ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

dsh 不做 Cron 的真相:给 Agent 补上定时任务能力的三种落地实践

dsh 不做 Cron 的真相:给 Agent 补上定时任务能力的三种落地实践 我接过不少 Agent 项目发现很多人都卡在同一道坎上对话里明明聊得好好的一到“让它定时跑起来”就抓瞎。你让 Agent“每天早上九点拉取行情、生成简报、发到群里”它当场答应第二天你再看什么都没有。不是你调教得不好是框架这一层压根就没打算替你兜这件事。dsh 就是这种故意“留白”的框架——它只给 Agent 三个元工具而且官方明确不做 Cron。这篇文章就聊聊 dsh 为什么这么设计以及我们自己怎么在它上面补出一套可靠可用的定时任务能力。1. 先搞清楚 dsh 的定位三个工具与它刻意划下的边界1.1 三个元工具到底承担什么职责dsh 默认暴露给 Agent 的严格来说是三个元工具。很多第一次接触的人会困惑“就这么点东西连个 Cron 都没有怎么干活”但我的理解是dsh 不是在偷懒它是在把 Agent 的扩展面收敛到最小。这三个元工具分别是 invoke、load_skill 和 register_tool。invoke 负责把一段自然语言请求翻译成对已安装技能包的调用它相当于 Agent 的“手”所有实际动作最后都靠它落到具体的技能上。load_skill 则是从本地目录或者 dsh market 拉取技能包——你搜到的“dsh plugin --profile web add dshmarket”这条命令本质就是在往某个 profile 里追加技能来源走的就是这个口子。而 register_tool 更底层它允许你把任意的本地脚本、命令行程序、内部 API 暴露成 Agent 可感知的工具并且声明参数结构。这三个工具的分工逻辑很清楚load_skill 管“装什么”register_tool 管“能把什么交给 Agent 用”invoke 管“怎么调度”。装、暴露、执行三件事刚好闭环。至于定时任务、消息推送、复杂的编排流程dsh 统统不管它把这些问题留给外部系统。这种设计一开始会让人不太适应但用久了你会发现它避免了那种“框架里什么都有、什么都很弱”的尴尬局面。1.2 最小工具集背后的防呆思维只给三个工具不是能力不足而是有意为之。我自己的体会是Agent 的工具越多出错的维度就越多。你在工具清单里塞一个“执行任意 shell 命令”和一个“读取数据库”看起来威风实际上推理模型每次都在做高风险选择稍微抽风一次就是事故。dsh 用三个元工具把 Agent 的技术边界框死了但同时又支持你通过技能包按需扩展既防呆又留了活口。具体到 register_tool它其实是最值得研究的一个。比如你想让 Agent 能拉取行情可以写一个脚本然后注册成工具# tool-market-pull.yaml name: market_pull description: 拉取指定市场的实时行情与涨跌数据 command: python3 /opt/scripts/pull_market.py parameters: symbol: type: string required: true description: 股票或资产代码注册完成后Agent 看到工具列表里就多了一个 market_pull它会根据你的指令自动填入 symbol 参数。这一步的意义在于你不需要改造 Agent 本体只需要把新能力“插”进去就行。整个设计风格就是“插件化思考”一切皆可挂载一切皆可卸载。理解了这三个元工具你才能明白 dsh 为什么敢不做 Cron——它预设的管理哲学是调度这类“元能力”应该由你来接管而不是让 Agent 自己胡来。2. dsh 为什么故意不做 Cron一场架构理念上的取舍2.1 Cron 模型和 Agent 模型是两种相反的东西讨论“为什么不做 Cron”之前得先看清 Cron 和 Agent 的运行模型有多不一样。Cron 是典型的确定性调度器到点触发、执行脚本、拿退出码、写日志整个过程是短平快的适合那些秒级到分钟级就能结束的任务。Agent 则完全不是这个形态。一个 Agent 任务从加载技能、组织上下文、调用工具到生成最终结果可能要跑好几分钟中间还依赖外部 API 的响应速度。如果你用传统 Cron 硬套会遇到一个很致命的问题任务还没跑完cron 的等待窗口就已经把进程掐了或者产生了重复触发。更麻烦的还不是超时而是上下文。Cron 执行脚本时是“无记忆”的跑完就结束下次重新开始。Agent 不一样它天然依赖会话状态上次聊到哪儿、昨天保存了什么结论、用户偏好是什么这些东西构成了它干活的前提。dsh 如果内置 Cron它就要同时解决会话恢复、状态持久化、失败恢复这一整套问题这已经不是在做一个调度器了而是在做一整个任务编排系统。所以 dsh 的取舍很聪明不做把问题留给更擅长的人。2.2 “人要在环上”拒绝无人值守的自动执行我看过 dsh 的不少设计讨论发现它骨子里非常在意一件事Agent 执行的每一步都应该有人类介入的窗口。比如 dsh 的工作流里普遍存在 checkpoint 确认机制Agent 在执行关键动作之前先把计划列出来等你点头再继续。这种理念下Cron 这种“到了时间闷头就跑”的机制是天然冲突的——它在设计上就把人排除在外了。这不是保守而是一个很实际的防跑飞策略。Agent 再强也是概率模型同一个任务今天跑得好好的明天可能因为 prompt 措辞变化就执行了完全不同的操作。如果这时候再叠上一层无人值守的定时调度出了问题你连拦截的机会都没有。我见过太多人把 Agent 直接挂上定时任务然后撒手不管结果某一天 Agent 在循环里反复调用付费 API等发现时账单已经很难看了。dsh 故意不做 Cron某种意义上是在帮你守住最后一道人工审核的闸门。2.3 职责分离调度是宿主的事不是 Agent 的事从工程架构上讲定时调度本来就应该是“宿主层”的责任。你想让某个 Python 脚本每天凌晨备份数据库你会直接写一个 while 循环在脚本里自己 sleep 吗不会你会上 cron、systemd timer 或者 GitHub Actions。Agent 也一样它本质上是一个被调用方它的职责是“把派下来的活干好”而不是“自己决定什么时候该干活”。dsh 把调度的担子完全推给外部我认为是特别清醒的决策。这样做的好处非常多调度器可以被你任意替换今天用 cron 明天换 APScheduler 后天迁到分布式调度平台Agent 这边完全无感同时Agent 的执行入口只有一个“run”语义外部系统的重试、补偿、通知机制都能干净地套上去。理解这个哲学之后我们自己动手加定时任务就有了明确方向不要试图教会 Agent 自己管理时间而是在它外面罩一层调度壳。3. 自己动手给 Agent 加定时任务的三种可落地方案3.1 方案一外部 cron dsh CLI 触发最稳定的起步姿势先说我最推荐的方案也是我在生产环境里用得最多的完全绕过 Agent 内部用系统的 cron 在固定时间唤起 dsh 命令执行单次任务。这个方案的思路非常简单——把 Agent 当成一个“可以被命令行召唤的员工”cron 负责当闹钟dsh 负责干活。第一步你需要给定时任务准备一个干净的 profile。所谓 profile就是 dsh 的一种运行配置里面定义了 Agent 用什么模型、挂哪些技能、工具白名单是什么。我们可以单独建一个 profile专门给定时任务用避免和日常对话的配置混在一起dsh profile create morning_task \ --base-model deepseek-chat \ --skill-dir /opt/agents/skills \ --allowed-tools invoke,market_pull,web_search这条命令创建了一个名为 morning_task 的 profile限定了可用的工具防止 Agent 在无人监督时动用危险能力。第二步写一个执行脚本把任务描述固化进去#!/bin/bash # /opt/agents/run_morning_task.sh /usr/local/bin/dsh run --profile morning_task \ --task 拉取昨日行情数据整理成简报发送到指定的群机器人地址 \ --session-id morning_$(date \%Y\%m\%d) \ --log-file /var/log/agent/morning_$(date \%Y\%m\%d).log注意这里的 session-id 我故意用了日期来生成保证每天都是全新的会话不会串上下文。第三步把它挂到系统 crontab 里15 9 * * 1-5 /opt/agents/run_morning_task.sh我测试下来这个方案最稳的点在于状态隔离每次触发都是全新进程、全新会话上次跑挂了也不影响下一次。而且排查问题非常清爽——直接看当天的日志文件就知道发生了什么。3.2 方案二Agent 内部自循环做一个 wait_until 工具如果你不想依赖外部 cron也可以在 Agent 会话内部做“软定时”。思路是给 Agent 一个 wait_until 工具它的作用就是阻塞到某个时间点再继续Agent 先调用工具“睡到目标时间”醒来之后立刻执行后续动作。你可以在 register_tool 里注册这样一个脚本#!/bin/bash # wait_until.sh TARGET_TS$(date -d $1 %s) CURRENT_TS$(date %s) DELAY$((TARGET_TS - CURRENT_TS)) if [ $DELAY -gt 0 ]; then sleep $DELAY fi echo ready at $(date)然后在 yaml 里注册这个工具并告诉 Agent“当用户要求定时执行时先调用 wait_until 等到指定时间再继续执行任务。” Agent 就会把这个工具当作“闹钟”来用。这个方案的好处是灵活Agent 可以在一次长会话里完成“等两个小时再查一次数据”这种临时需求完全不需要外部系统介入。但我要泼一盆冷水内部自循环只适合短周期、低频率的临时任务千万别拿它做长期定时。原因有三点。第一Agent 的上下文窗口有限你让它睡到半夜再干活中间那段等待期会白白占据 token 开销。第二推理模型对时间语义的理解不够稳定我遇到过 Agent 把“明天”解析成“今天”的情况。第三会话一旦被关闭或者网络中断整个定时链就断了没有任何恢复机制。所以这个方案我会定位成“应急小工具”而不是基建能力。3.3 方案三skill 内嵌 APScheduler把调度器变成 Agent 的能力如果你要的是正式的、可管理的定时任务体系我建议走第三种方案做一个调度技能包内部用 Python 的 APScheduler 管理任务表暴露 register、list、cancel 三个操作给 Agent。这样 Agent 能通过自然语言自己注册定时任务但背后真正跑的是经过验证的调度引擎。下面是我的 skill 核心部分# scheduler_skill/worker.py from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.cron import CronTrigger import subprocess def execute_task(task_id: str): result subprocess.run( [dsh, run, --profile, task_id], capture_outputTrue, textTrue, timeout600 ) # 执行成功或失败都要写结构化日志便于追踪 print(f[{task_id}] exit{result.returncode} err{result.stderr[:200]}) def main(): scheduler BlockingScheduler() jobs load_task_registry() # 从 tasks.json 读取注册表 for task in jobs: trigger CronTrigger.from_crontab(task[cron]) scheduler.add_job(execute_task, trigger, args[task[id]]) scheduler.start()这个 worker 进程常驻后台任务注册表 tasks.json 记录了每条任务的 cron 表达式和执行 profile。你只需要再给 Agent 注入一个 skill让它可以解析用户指令里的时间描述转化成 CronTrigger 能认的表达式然后写入 tasks.json。比如用户说“每个工作日早上九点半执行早报”Agent 会调 register 接口把 cron 转成30 9 * * 1-5存进去。这其实就是把 Cron 重新造了一遍但这次造出来的东西是 Agent 自己能读写、能在对话里确认、能把任务列表打印出来给你看的。我之所以把方案三放在“进阶”位置是因为它同时涉及常驻进程管理、持久化和 Agent 工具编排三件事对新手来说门槛不低。但如果你已经跑通了前两种方案再上手这个会非常顺——前两种攒下的调试经验全都能复用。4. 定时任务接入后的真实踩坑记录与排查方法4.1 时区不一致导致的“幽灵提前执行”我先讲一个我自己踩过的大坑。当时配了一个每天早上八点的定时任务在服务器本地测试怎么调都对部署上去之后却发现 Agent 总是七点就跑了。排查了一整天才发现问题不在 dsh而是容器时区和主机时区不一致dsh 进程内看到的系统时间是 UTC而我的 cron 写的是主机本地时间两个时间基准差了八个小时。更隐蔽的是Agent 在解析“早上八点”这个概念时用的可能是它模型自带的时区直觉和系统 timezone 又对不上。我的建议是在做任何定时任务之前先统一时间基准。cron 里明确用服务器本地时间dsh 容器里挂载时区文件然后在传给 Agent 的 prompt 里显式写清楚“当前时间是某年某月某日某时UTC8”。只有系统时间、调度器时间、Agent 感知时间三者对齐才不会出现“提前跑”“延迟跑”这类看上去很玄学的问题。4.2 会话污染上一轮的聊天记忆串到了定时任务里Agent 和普通程序最大的区别就是它“记性太好”。我做过一次实验白天在对话里跟 Agent 聊了一堆周末旅行计划晚上定时任务触发做日报统计结果日报里赫然写着一句“明天记得预定酒店”。原因就是我没有给定时任务指定新的会话Agent 把之前对话的上下文带进了这次执行。解决方案其实很简单就是我在方案一里写的 session-id 策略每次定时执行都用一个新的、带业务标识的会话 ID比如daily_report_20250106。如果你需要让 Agent 在定时任务里使用长期记忆那就单独建一个“记忆库”技能让它主动去查而不是直接沿用对话上下文。我强烈建议把这个策略固化成规范因为定时任务的执行场景里上下文干净比能力强更重要。4.3 重复触发与并发重入一个任务跑出了三个进程cron 本身是可靠的但叠加了网络超时、Agent 执行时间过长、人工手动补跑这些因素之后就会出现同一个任务被触发多次的情况。有一次我排查任务重复执行发现原因特别无语cron 认为脚本执行超时自动启动了新实例但旧实例其实还活着两个进程同时去调同一个 Agent profile导致报告被推了两次。这种问题要靠两层防护来解决。第一层是加执行锁#!/bin/bash exec 9/var/lock/morning_task.lock if ! flock -n 9; then echo another instance is running, exit exit 0 fi # 实际任务逻辑第二层是给任务本身加幂等键。你在任务描述里带上一个唯一 ID比如日期让 Agent 在执行时先检查当天是否已经产出过结果有就直接复用。锁保证进程层面不重入幂等键保证结果层面不重复两层都加上才算真正稳了。4.4 Agent 执行失败时的告警盲区普通脚本跑挂了cron 会发邮件或者你查日志就能发现。Agent 跑挂了你可能完全无感因为它“失败”的形式非常多样有可能是工具调用超时报错信息你可能搜到过 “agent execution terminated due to error”这个就是 dsh 在执行工具时遇到了未捕获的异常被强制终止也有可能是 Agent 自己觉得结果没问题但推导过程跑偏了。如果定时任务没有告警机制这些失败都会无声无息地过去。我的做法是在任务执行脚本末尾加一个结果探针if [ -f /tmp/agent_task_done.flag ]; then echo task completed else curl -s -X POST https://你的告警服务地址 \ -H Content-Type: application/json \ -d {message:定时任务未生成结果文件} fi更推荐的做法是让 Agent 执行完主动写一个结构化结果文件包含任务 ID、执行状态、结果摘要。外层脚本只需要检查这个文件是否存在、内容是否完整就能判断成功还是失败然后决定要不要告警。有了这道探针我基本不会再遇到“任务跑挂了但我隔了一周才发现”的尴尬情况。4.5 工具执行超时长任务和短超时的矛盾定时任务里最常见的报错就是工具执行到一半被掐断。dsh 对单个工具调用模型做了超时限制这是有道理的防止 Agent 卡在一个坏工具上无限浪费 token。但很多真实任务本身就超时比如生成一个完整报告可能要调十几次接口、汇总大量数据如果全程只有一个工具调用极大概率被打断。有两种解法。第一种是把大任务拆成多个小工具调用让 Agent 分步执行比如“先拉数据落盘再读数据生成报告”每一次调用都控制在超时阈值之内。第二种是给耗时操作一个“异步化”外壳注册一个工具它启动后台进程后立即返回一个 job_id再提供查询接口Agent 过一会儿再轮询结果。这样虽然多写一点代码但能让 dsh 在超时限制下也能跑长时间任务。顺便提一嘴真的不要在定时任务里让 Agent 去做“读取超大 PDF 并全文总结”这种动作先预处理文本再喂给它性价比高得多。我个人在项目推进过程中的体会是给 Agent 加定时任务本质上是想清楚一件事你把“决策权”留给 Agent把“控制权”牢牢攥在自己手里。dsh 不做 Cron 不是缺陷反而逼着我们把调度层做得更扎实。刚开始不要想着一步到位先从外部 cron 触发单次执行开始跑稳一个任务再慢慢把注册、取消、状态查询这些能力交给 Agent 自己管理。最后再分享一个我实战中很受益的小技巧给每个定时任务起一个清晰的名字在 session-id、日志文件、告警内容里统一带上这个名字出了问题搜索起来你会感谢自己的。
返回列表