尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

没有Cron的Agent定时任务:用sleep/schedule/webhook组合实现可靠调度

没有Cron的Agent定时任务:用sleep/schedule/webhook组合实现可靠调度 上个月我在 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是因为一旦封装成 CronAgent 就失去了对该不该执行的判断权。它的哲学是你可以给 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(seconds30) 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( at2025-06-15T09:00:0008:00, task每日巡检, task_iddaily-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_tokenTrue # 开启鉴权 )注册之后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 流程里用循环加 sleepfrom 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(seconds600) 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, timezone08:00) agent.schedule( attomorrow, taskdaily_report, task_iddaily-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_tokenTrue )这个组合把所有定时状态都从 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_tokenTrue基本属于必开项。4.4 时区基准不统一9 点变成了 8 点这个坑比较隐蔽。我在 schedule 里写的是at2025-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(atnext_ts, taskdaily_confirm, task_idconfirm-chain) # 3) sleep 轮询等待外部数据同步完成 async def wait_data_ready(): for _ in range(30): if await agent.execute(is_data_synced): return True await agent.sleep(seconds30) return False def main(): # 启动时补偿检查是否错过了任务 last load_last_run() if not last or last today_start(): agent.schedule(atnow, taskrun_daily_report_if_needed) agent.serve()这段代码基本就是我目前在用的形态。说实话跑了两三周之后我反而有点庆幸 dsh 故意不做 Cron 了。因为它逼着我把定时和执行彻底拆开——定时交给最可靠的组件执行交给最聪明的组件。这套思路放到任何 Agent 平台都适用。你可以说这是一种将就但我更愿意把它看成一种取舍Agent 的能力用在判断和交付上而不是消耗在一个永远不会出错的时钟上。如果让我给一条建议收尾那就是下次再在 dsh 上想做定时任务的时候先别急着找 Cron先问自己三个问题——什么时候必须触发什么时候条件才成立谁来决定最终执行想清楚这三个三件套怎么组合自然就有了答案。
返回列表