☰
没有Cron的Agent定时任务:用sleep/schedule/webhook组合实现可靠调度
2026/10/2 6:32:37 网站建设 项目流程

上个月我在 dsh 上给一个内部运营 Agent 排定时任务,翻遍整个工具面板,只看到三个和"时间"沾边的工具:sleep、schedule、webhook。没有 Cron,没有分布式调度,连个 cron 表达式都没看到。我当时的第一个反应是:这平台怕不是功能没做完。后来把文档翻完、在社区里蹲了几天,才慢慢理解——这一步是故意的,而且背后的设计哲学,跟我之前在 Java 项目里写 xxl-job、配 cron 表达式的路子完全是两个方向。

这篇文章不打算写成产品说明书,我想把我对"给 Agent 加定时任务"这整件事的理解讲透:dsh 为什么只给三个工具,每个工具的能力边界到底在哪,怎么用它们组合出够用的定时方案,以及真实跑了两三周之后踩到哪些坑。如果你也正在折腾 Agent 的定时执行,这些经验多少能帮你省点时间。

1. 先说我怎么发现 dsh 没给我 Cron 的

1.1 一次日报需求引发的"工具搜查"

当时的需求其实特别简单:每天早上 9 点让 Agent 把前一天的运营数据整理成日报,发到工作群里。这个需求放在传统后端项目里,我闭着眼都能写:要么直接上系统 Cron,要么在 Java 项目里集成 XXL-Job,配一个0 0 9 * * ?之类的 cron 表达式,到点就跑。所以我很自然地打开 dsh 的工具面板,想找定时相关的配置入口。

结果翻了一圈,时间相关的工具就三个:

  • sleep:让当前 Agent 流程挂起指定秒数。
  • schedule:安排一个一次性任务,在指定时间点触发,触发后即焚。
  • webhook:注册一个 HTTP 回调入口,外部请求来了才唤起对应处理函数。

没有 Cron,没有周期执行,没有 Quartz,也没有 UI 界面上那种"每天几点几分执行"的配置页。我当时挺懵的,在社区里问了一圈,有人甩给我一句"dsh 不打算做 Cron,你拿这三个组合一下就行"。组合一下?我心想这三个玩意儿怎么拼也不是一个 Cron 啊。事实证明,是我理解浅了。

那几天我认真读了一下 dsh 的架构说明,核心意思大概是:Cron 是操作系统层面的定时原语,它假设任务是无状态、静默、可随时启动的;而 Agent 是有上下文、有记忆、需要决策的执行体。把一个有决策能力的 Agent 硬塞进 Cron 的"到点就执行"模型里,等于让一个人每天在固定时刻被闹钟吓醒,然后不带脑子地去干活——这不是 Agent,这是机器人客服。dsh 想要的,是让"时间"成为 Agent 可感知的信息源之一,而不是把 Agent 变成一个被动执行器。

1.2 设计理念里藏着答案:Cron 是时间的奴隶,Agent 要的是意图

我把这个逻辑再展开说两句。

先说 Cron 为什么不适合 Agent。Cron 的执行模型是"时间到、立即执行",它不关心执行时的环境状态、不关心任务是不是真的应该做、不关心上一次执行的结果。这在传统后端里没问题,因为任务本身是确定性的:临时文件清理、日志归档、数据同步,到了点跑就行,跑错了大不了下次再跑。但换成 Agent 就不一样了。Agent 每次执行都意味着一次完整的"感知-规划-行动"循环,最少也要消耗几千 token,而且它还要读取上下文、更新记忆、输出结果。如果一个定时任务在凌晨每隔 5 分钟触发一次 Agent,而每次触发前后都没有实质变化,Agent 也会一本正经地分析一遍"当前没有新数据",然后把这段思考写进上下文。几次下来,上下文窗口里全是垃圾,真正有用的业务信息反而被挤掉了。

再说 dsh 的三个工具为什么是"刻意为之"。你仔细看就会发现,这三个工具对应的其实是时间触发的三个正交维度:

  • sleep 管的是"持续时长",让 Agent 在流程里等多久;
  • schedule 管的是"绝对时刻",在某一个时间点把某个流程唤醒;
  • webhook 管的是"外部事件",由 Agent 之外的系统决定何时触发。

三者互相独立、不可替代,组合起来又能覆盖绝大多数定时需求。但 dsh 不把它封装成一个开箱即用的 Cron,是因为一旦封装成 Cron,Agent 就失去了对"该不该执行"的判断权。它的哲学是:你可以给 Agent 提供时间信号,但执行与否、如何执行,必须由 Agent 根据当前上下文决定。

我承认,这套逻辑我是一边写代码一边体会到的。当我真正开始用这三个工具组合定时方案的时候,才发现这种"半成品"设计反而逼着我把任务想清楚:你到底是要一个精确到秒的触发,还是要一个"条件合适时才执行"的能力?这两个需求在传统 Cron 里被混为一谈,但在 Agent 场景下必须分开。

2. 三件套拆解:sleep、schedule、webhook 的定位与边界

在讲组合方案之前,我先把三个工具逐个说清楚。很多坑其实不是工具本身的问题,而是没搞清楚工具的边界。

2.1 sleep:只能"原地等",不能"定点醒"

sleep 在 dsh 里的用法非常简单,就是让当前 Agent 流程暂停一段时间。比如:

from dsh import agent async def main(): await agent.execute("check_inbox") # 等 30 秒再去查一次收件箱 await agent.sleep(seconds=30) await agent.execute("check_inbox")

它解决的问题只有一个:轮询节奏控制。你需要反复检查某个条件是否成立,但又不希望高频空转,用 sleep 把两次检查的间隔拉长。

sleep 的边界也很明显。第一,它只能作用于当前会话,不能后台执行。也就是说,一个 Agent 正在 sleep 的时候,会话是被占住的,如果此时会话里来了新的用户消息,Agent 是没法响应的。第二,sleep 是"原地等",不是"定时器"——它不保证在某个绝对时刻唤醒你,只保证"从调用时刻起等这么多秒"。所以在需要"每天固定 9 点执行"这种场景下,光靠 sleep 没法做,你必须结合 schedule 或者外部触发。

我见过有人想用sleep(86400)实现"每天跑一次"的效果,从数学上没错,但实际操作里有个致命问题:如果 Agent 会话在夜间被平台回收(dsh 对长时间空闲的会话有回收机制),这个 sleep 就直接没了。本质原因是 sleep 是会话级的状态,不是平台级的状态。你把它当成一个进程里的暂停指令没问题,但别指望它能在会话之外继续生效。

2.2 schedule:一次性的锚点任务

schedule 是三个工具里最接近 Cron 的一个。它的语义是:在某个绝对时间点,唤起一个指定的处理函数。

from dsh import agent async def 每日巡检(): await agent.execute("run_daily_check") # 安排一个一次性任务,触发时间务必带时区偏移 agent.schedule( at="2025-06-15T09:00:00+08:00", task=每日巡检, task_id="daily-20250615" )

注意 schedule 的两个特点。

第一,它是"一次性"的。触发一次之后,这个调度就会被删掉,不会自动续期。这跟 Cron 的0 9 * * *这种周期表达式有本质区别。你当然可以在任务执行完之后再给自己排一次,形成"自我重排"的任务链,但要记得在代码里显式做这一步,平台不会帮你续。

第二,它是"任务级"的,不依赖某个具体会话。这意味着 schedule 被触发时,dsh 会新建一个上下文来跑这个任务,而不是复用发起调度时的那个会话。这点比 sleep 强,也是它能做"准点执行"的基础。

边界在于:schedule 触发后跑任务需要平台资源,如果任务执行时没有可用的运行环境,平台会重试几次,重试仍然失败就丢弃。所以 schedule 适合执行那些"即使晚几分钟也没关系"的幂等任务,不适合做"必须精确到秒"的强实时任务。另外,schedule 的持久化也是需要你自己保证的——如果平台重启,已注册但未触发的 schedule 会丢失。这个坑我在后面会详细展开。

2.3 webhook:把触发权交给外部世界

webhook 是三件套里最"不定时"的一个,因为它本身根本没有时间概念。它的作用是给 Agent 开一个 HTTP 入口,外部系统通过请求来触发它:

from dsh import agent async def report_handler(payload): # payload 里可以带上调用方给的数据 await agent.execute("generate_report", payload) agent.on_webhook( "/hooks/daily-report", report_handler, require_token=True # 开启鉴权 )

注册之后,dsh 会为这个 Agent 暴露一个 HTTP 端点。你在外部随便用什么方式向这个端点发一个 POST 请求,Agent 的处理函数就会被唤起。

为什么在定时任务的话题里要放一个 HTTP 回调?因为 dsh 想得很清楚:定时任务的触发源不一定要来自 Agent 内部,更可靠的方案是让操作系统级的 Cron 或外部调度器来触发,Agent 只负责"接单干活"。这样"定时"这个职责回到了它最擅长的地方,而 Agent 依然保留了对任务执行的自主判断。

webhook 的坑也是三个工具里最多的:触发端点需要鉴权、请求可能重复发送、外部系统可能重试导致重复执行。这些我在第四部分会详细讲。但从设计上说,webhook 其实是 dsh 给定时任务留的"正门"——你想要真正可靠的定时,绕不开它。

把三个工具放在一起看,它们的边界其实用一句话就能说清:sleep 管"等多久",schedule 管"何时启动",webhook 管"谁来叫醒"。唯一共同点是:它们都不承诺"到点自动周期执行"。这正是 dsh 刻意留白的地方——周期执行的语义应该由业务自己定义,平台不替你决定。

3. 三个组合方案,覆盖九成定时需求

现在进入到标题的核心:怎么用这三个工具把"定时任务"拼出来。我按需求类型分场景讲,每个场景都是我自己实际跑过的。

3.1 轮询组合:sleep 循环实现"每隔多久跑一次"

需求:Agent 需要每隔一段时间检查一次某个状态,比如每 10 分钟看一眼行情,或者每 30 分钟检查一下有没有新工单。

方案:在单个 Agent 流程里用循环加 sleep:

from dsh import agent async def watch_market(): for round_no in range(48): # 最多跑48轮(8小时),防止无限循环 data = await agent.execute("fetch_market_price") # 只输出值得关注的变化,避免把上下文塞满 if data.should_alert: await agent.execute("send_alert", data) # 静默等待,不要打日志 await agent.sleep(seconds=600) await agent.execute("notify_timeout")

这个方案的优点显而易见:实现简单、逻辑直白,特别适合"条件满足就动作"的轮询型任务。sleep 每次只做一件事:控制节奏。

缺点也有两个。第一是占会话:这个流程一旦跑起来,这个会话在 8 小时内基本只能干这一件事。如果你需要 Agent 同时响应用户消息,这个方案就不合适。第二是上下文消耗:虽然我在代码里刻意做了静默,但每轮循环 Agent 执行产生的结果多少还是会写入上下文,跑久了开销依然不小。所以我坚持给循环加了上限,本质是让这个流程有始有终,而不是挂一条不归路。

什么时候用这个方案最合适?答案是"等你打算手动盯着的那段时间"。比如你在做一个持续数小时的监控,人就在电脑前等着处理结果,让 Agent 每 5 分钟轮询一次,发现问题提醒你。这种场景用 sleep 循环非常顺手。但如果你只是想让 Agent"默默在后台挂着",我建议还是用外部触发。

3.2 锚点组合:schedule 自我重排实现"每天固定时刻跑"

需求:每天固定时间执行任务,比如早上 9 点生成日报,或者每晚 11 点做数据整理。

方案:用 schedule 做一次性触发,然后在任务执行完成之后,把自己"下一跳"的 schedule 再注册一次。这就是最经典的任务链。

from dsh import agent async def daily_report(): report = await agent.execute("generate_daily_report") await agent.execute("send_report", report) # 重排明天的任务 tomorrow = next_day_at("09:00", timezone="+08:00") agent.schedule( at=tomorrow, task=daily_report, task_id="daily-report-chain" )

这里有两个细节值得强调。一是next_day_at这个函数需要你自己实现,dsh 没有提供现成的"下一个 9 点"计算。在实现时务必在 UTC 和本地时区之间做明确转换,这个坑我后面细说。二是每一个 schedule 都要带一个稳定的task_id,并记录在案。因为 Agent 会话可能随时重启,重启之后你需要扫描已注册的 schedule 的 task_id,来判断任务链是不是断了。

这个方案的优点是:只在任务执行的瞬间占用资源,其他时候 Agent 会话完全空闲,可以同时服务用户消息。缺点是:任务链本质上是"接力棒"式的,任何一环没续上——比如执行到一半平台崩溃、或者会话被回收导致重排代码没跑——整条链就断了。可靠性完全取决于"重排那一步是否被执行"。

为了降低风险,我在项目里增加了"持久化待办队列":把下次触发时间写到一个文件或 dsh 的记忆存储里。每次 Agent 启动时,先扫描这个队列,发现"应该触发但还没触发的任务",立即补执行。这一层补偿机制是我那几周里最值得的一笔投入。

3.3 外部组合:系统 Cron + webhook 实现"准点、可靠、无残留"

需求:需要真正可靠的准点执行,同时不想在 Agent 内部维护任何调度状态。

方案:在外部随便找一台常驻机器配一个系统 crontab,到点发一个 HTTP 请求给 webhook 入口,Agent 收到请求后干活。

crontab 那一侧长这样:

0 9 * * * curl -s -X POST http://127.0.0.1:8787/hooks/daily-report -H "X-Token: ${DASH_HOOK_TOKEN}"

Agent 那一侧长这样:

from dsh import agent async def report_handler(payload): await agent.execute("generate_daily_report") agent.on_webhook( "/hooks/daily-report", report_handler, require_token=True )

这个组合把所有"定时状态"都从 Agent 里挪出去了:Cron 负责到点发请求,webhook 负责接请求,Agent 的常态就是"等活儿干"。无论 Agent 会话是否重启、平台是否回收资源,都不影响 Cron 侧触发——因为外部 Cron 根本不在乎 Agent 在不在线,它只管发请求,Agent 在线就把活干了,不在线就等下一次。

说白了,这是我从传统后端迁移过来时最容易接受的一种模式,也是我现在跑生产任务最依赖的一种。唯一的问题是,你需要一台常驻的外部设备来运行 Cron。如果你不想维护额外机器,也可以用 GitHub Actions 的 schedule 之类的服务来当这个"外部触发器"。重点是:把"定时"这个确定性极强的活儿,交给确定性最强的组件,Agent 不掺和。

4. 没有 Cron 的日子里,我踩过的五个坑

组合方案听着挺美,实际跑起来,我踩了不少坑。这一节按踩坑时间顺序记录,方便你遇到类似问题时能快速定位。

4.1 无限轮询把上下文塞满了

最开始跑 sleep 轮询方案时,我习惯性地在每次循环后打一条日志:"检查完毕,未发现变化"。跑了一天,第二天打开 Agent 的上下文一看,几千行全是这种记录。上下文窗口被无意义的轮询内容塞得满满当当,真正的业务数据反而被挤掉了。

原因很简单:Agent 的每次 execute 结果都会被写进上下文,你让它在循环里反复执行检查任务,这些检查结果天然会累积。处理办法有三层。第一,代码层面把日志输出降到最低,只在状态变化时记录;第二,流程层面给循环加一个上限,让流程有终态;第三,如果你确实需要无限轮询,那就别把"检查"做成 Agent 的 execute,改用外部 Cron 和 webhook 来驱动,把轮询从 Agent 上下文里摘出去。

4.2 会话一回收,schedule 全灭

我一度以为 schedule 是平台级的,注册了就安稳存在。直到有一天我发现第二天早上任务没跑,翻日志根本没有触发记录。排查之后才明白:schedule 存在会话的上下文中,Agent 会话长时间空闲被平台回收后,所有未触发的 schedule 一并消失。

这就是 3.2 里说的"任务链断掉"的真实案例。解决办法是引入一个外部状态源:把任务链的下一跳时间持久化到一个文件、数据库或 dsh 的记忆存储中。每次 Agent 启动时先做一次"补触发检查",对比当前时间和记录的下次触发时间,如果发现已经过期就立即执行,然后再重排。这套补偿逻辑其实比 schedule 本身还重要——因为你没法假设平台永远不会回收你的会话。

4.3 webhook 被重复触发,任务执行了两遍

webhook 方案稳定是稳定,但有一次早上群里收到了两份日报。排查后发现,外部 Cron 那台机器网络抖动,curl 请求重试发送,Agent 的 webhook 入口收到两个一样的请求,而两个请求都成功触发了任务。

这暴露了 webhook 天生的弱点:HTTP 请求不带"只执行一次"的语义。所以在写 webhook 处理函数时,幂等是必须设计的。我的做法是在处理函数开头检查"上次执行时间",如果距上次执行小于某阈值,直接忽略;或者要求请求头里带一个任务相关 ID,处理时校验是否已经处理过。另一个细节:dsh 的 webhook 如果不开启鉴权,外部任何人知道地址都可以触发你的 Agent,所以require_token=True基本属于必开项。

4.4 时区基准不统一,9 点变成了 8 点

这个坑比较隐蔽。我在 schedule 里写的是at="2025-06-15T09:00:00",没带时区偏移。结果任务在早上 8 点就跑了。dsh 的 schedule 默认按 UTC 解析,而我在配置时脑海里想的是本地时间——差了整整 8 个小时。更坑的是,外部 Cron 写0 9 * * *时,Cron 用的是系统时区,这时候又是本地时区了。

两边时区基准不一致,导致我一度以为是平台 bug。最终的规范是:所有跨组件的时间统一用 UTC 时间戳,展示时再转目标时区。具体到代码里,所有 schedule 的at参数都显式带时区偏移,比如+08:00;外部 Cron 那边则把触发时间换算成 UTC 再写表达式。这个规范虽然啰嗦,但能省掉大量排查时间。

4.5 需求里的"早上 9 点"未必是字面意思

最后一个坑不是技术坑,是需求坑。客户说"每天早上 9 点发日报",我按字面实现了,结果一周后客户反馈:"我周一早上 8:40 到公司,那时候没看到日报,不太行。"

这让我意识到:Agent 场景下的定时任务,重点不该是"到点执行",而是"在用户预期的时间范围内交付结果"。如果把"9 点发日报"理解成"9 点整生成并发送",那正好卡在用户到公司的时间边缘,一有延迟就会出问题。更好的做法是把触发点提前——比如 7:30 生成好、8:00 发出去、9:00 前完成重试检查。这也是 dsh 不给 Cron 的用意所在:它希望你思考"什么时候交付最合适",而不是"什么时候执行最精确"。

5. 我的最终落地:三层混合调度,各管一段

踩完这些坑之后,我最终实践下来的一套方案是三层混合调度。不是选一个,而是三个工具各管一段。我最后再把这套东西完整讲一遍。

5.1 分层思路:分钟级、天级、条件级的归宿

第一层是外部 Cron。它负责最可靠的"天级"触发:每天固定时刻给 webhook 发请求。这一层不依赖 Agent 的存活状态,也不占任何 Agent 资源。触发之后任务到底要不要执行、怎么执行,Agent 自己判断。

第二层是 schedule 任务链。它负责"小时内"的连续任务:比如生成了日报初稿之后,10 分钟后再检查一次发送状态,1 小时后再汇总反馈。这些短链路不需要外部介入,用 schedule 一次性触发就够了,记得在任务里写持久化补偿。

第三层是 sleep 轮询。它负责"条件型"任务:等某个外部条件满足。比如等待一个大文件下载完成、等待某个服务端口就绪。这种任务没有固定触发时刻,只有"条件成立时刻",用 sleep 循环去轮询最自然。

三层的关系可以概括成一句话:外部 Cron 负责"命运的闹钟",schedule 负责"接力棒",sleep 负责"探头探测"。每一层都有自己的失效模式,但把它们叠起来,彼此的缺点刚好被互相弥补。

5.2 完整示例:一个能跑的每日日报 Agent

最后给一个完整的伪代码,假设整个方案已经搭好:

from dsh import agent from mylib import is_workday, get_next_9am, load_last_run, save_next_run # 1) 外部 Cron 每天 0:30 (UTC) 调 webhook,对应本地 8:30 async def on_webhook_trigger(payload): if await is_workday(): await run_daily_report_if_needed() # 2) schedule 自我重排队列:每天 9:00 (本地) 的确认任务 async def daily_confirm(): await agent.execute("confirm_report_sent") next_ts = get_next_9am() save_next_run(next_ts) agent.schedule(at=next_ts, task=daily_confirm, task_id="confirm-chain") # 3) sleep 轮询:等待外部数据同步完成 async def wait_data_ready(): for _ in range(30): if await agent.execute("is_data_synced"): return True await agent.sleep(seconds=30) return False def main(): # 启动时补偿:检查是否错过了任务 last = load_last_run() if not last or last < today_start(): agent.schedule(at="now", task=run_daily_report_if_needed) agent.serve()

这段代码基本就是我目前在用的形态。说实话,跑了两三周之后,我反而有点庆幸 dsh 故意不做 Cron 了。因为它逼着我把"定时"和"执行"彻底拆开——定时交给最可靠的组件,执行交给最聪明的组件。这套思路放到任何 Agent 平台都适用。你可以说这是一种将就,但我更愿意把它看成一种取舍:Agent 的能力用在判断和交付上,而不是消耗在一个永远不会出错的时钟上。

如果让我给一条建议收尾,那就是:下次再在 dsh 上想做定时任务的时候,先别急着找 Cron,先问自己三个问题——什么时候必须触发?什么时候条件才成立?谁来决定最终执行?想清楚这三个,三件套怎么组合自然就有了答案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询