给 Agent 加定时任务:dsh 只给了三个工具,而且故意不做 Cron
最近在跟一个 Agent 项目较劲,项目本身用了一套叫 dsh 的 Agent 编排框架。上手第一感觉是:干净,真的干净,框架只给了 Agent 三个工具,三件套就完事了。但做到“定时任务”这个需求的时候,我翻了半天文档没找到 Cron 相关的东西,去社区一问,人家说得很直接:“dsh 故意不做 Cron”。
我当时就有点懵——一个 Agent 框架不做定时任务,这不是逼着开发者自己造轮子吗?后来仔细想了两天,想明白了:这恰恰是 dsh 最有价值的设计决策之一。Agent 和传统定时任务根本不是一个物种,把 Cron 内置进 Agent 框架,反而会引入一堆乱七八糟的状态问题。但问题是,业务就是需要定时能力啊:每天 9 点自动复盘、每小时抓一次行情、每周日生成数据分析报告。这需求绕不过去,怎么办?只能自己动手。
这篇就把我给 dsh Agent 加定时任务的完整思路、踩坑过程和最终方案整理出来。里面包括为什么 dsh 故意不做 Cron、我为什么最终选择了“外部 Cron 唤醒 + Agent 自主轮询”这套组合拳、以及具体怎么把 cron 表达式、任务状态管理、失败重试这些事落到 dsh 的插件体系里。不管你是刚开始接触 Agent 开发,还是已经拿着 dsh 在折腾项目,这篇都应该能给你省下几天的摸索时间。
1. 为什么 dsh 敢“故意不做 Cron”
1.1 三个工具的本质:入口、能力、记忆
dsh 只给了 Agent 三个工具,乍一看少得有点寒酸,但把这三个工具拆开看,其实覆盖的是智能体最核心的三个生命周期环节:
第一个是会话入口工具,负责接收外部指令,把用户的一个请求转成 Agent 内部的执行上下文。第二个是能力执行工具,Agent 要调用外部 API、执行某个命令、写日志,全靠这个工具。第三个是状态与记忆工具,让 Agent 能把中间结果、历史行为、环境信息沉淀下来。
这三个工具的组合把 Agent 变成了一个“可以被调用、可以做事情、可以记住事情”的闭环单元。注意,这里没有任何“时间”维度。也就是说 dsh 压根不关心任务什么时候触发,它只关心任务一旦来了,Agent 能不能接得住、做得好、记得牢。
不关心时间,不是没想到,而是刻意取舍。我后来翻 dsh 的源码梳理过,框架内部确实没有 scheduler 相关模块,所有和“时间”相关的能力都被设计成外部注入。这种设计说白了就是:Agent 内核只管推理和行动,事情到底该什么时候做,是编排层(Orchestration Layer)的责任。
1.2 定时任务和 Agent 的本质差异
为什么 Cron 这种成熟的东西不能直接用?我用自己的话概括一下:Cron 是“时钟驱动”的,Agent 是“状态驱动”的。
Cron 的逻辑极其简单粗暴:到点了,执行命令,执行完了,结束。它不关心上次执行的结果是什么,不关心任务之间的依赖,更不会主动判断“这次是不是不该跑”。传统服务器上一堆定时脚本就是这么跑的,可靠、高效,但也笨。
而 Agent 不一样。Agent 每次执行任务前通常要结合当前状态做判断:比如定时抓数据,抓之前它得确认数据源是不是可用;比如定时发报表,发之前它得先检查数据更新完了没有。如果直接把 Cron 嵌进 Agent 框架,就会出现一个尴尬的打架场景:到点了 Agent 刚准备开始干活,但它的状态机可能还在某个任务的中途,这时候调度器硬塞一个新任务进来,Agent 是打断当前任务,还是排队等着?这根本就不是框架层面该替 Agent 做决定的事。
dsh 不做 Cron,本质上是把“要不要现在做、能不能现在做”这个决策权完全交还给开发者。框架只负责保证:只要任务进入 Agent 的执行上下文,它就能处理好。至于任务怎么进来的,是用户敲一句指令,还是外部 Cron 触发一次唤醒,还是 Agent 自己睡醒了决定干点活,那是编排层的事。这个边界划得很清晰,也很聪明。
2. 给 dsh Agent 加定时任务,先想清楚四种触发场景
我开头准备动手的时候,以为不就是加个 Cron 吗?结果做着做着发现,所谓“给 Agent 加定时任务”至少可以拆出四种完全不同的触发场景,每种场景的实现路径都不一样。如果直接照搬传统的 Cron,一定会翻车。
2.1 场景一:外部调度,Cron 唤醒式
这是最传统、也最稳妥的方案:定时任务完全由 Agent 外部的一个 Cron 进程(通常是系统 crontab 或者 Jenkins 这类调度平台)掌握,到点了 Cron 发起一个请求,把 Agent 唤醒,Agent 收到指令后开始干活。
这种模式的好处是 Agent 本身根本不需要感知时间,它只处理“突然收到一个要干活的指令”这件事就行。dsh 的三个工具在这种模式下只需要会话入口工具被外部调用,其他两个工具在任务执行过程中按需使用即可。
适合的场景很典型:每天早上 8 点自动汇总前一天的销售数据,每周一上午发周报,每小时抓一次行情并且附上异常标记。这些任务有一个共同点:执行逻辑相对固定,不需要 Agent 自己判断“现在是不是该做”。
2.2 场景二:常驻进程,Agent 自主轮询式
外部 Cron 虽然稳,但它解决不了一个灵魂问题:Agent 自己没有一个“时间观”。如果任务的要求不是“每天几点做”,而是“有条件就做,没条件就等”,外部 Cron 调度就很笨重了。
这时候可以让 dsh Agent 以常驻服务的形式跑起来,在 Agent 的循环里加一个“定期检查当前环境状态”的步骤。说白了,Agent 睡一会儿,醒过来看看事情发生了没有,没发生就继续睡,发生了就开始干活。这是很多 Agent 框架推荐的“伪定时”方案,实际体验也不错,因为执行 Agent 本身就是一轮一轮跑的。
这种模式下的定时粒度不适合太精确,至少秒级的精确调度绝对别这么做。Agent 的自主轮询天然适合分钟级以上、条件触发的场景,比如“当某个目录出现了新文件就处理”“当 API 返回的数据量超过阈值就开始分析”。
2.3 场景三:事件驱动,消息队列触发式
如果定时任务不是时间触发,而是业务事件触发——比如用户下单、支付回调、新的数据文件上传——那用 Cron 其实并不合适,事件驱动的场景应该走消息队列。
给 dsh Agent 配一个中间队列(Redis、RabbitMQ、Kafka 都行),外部系统把任务写进队列,Agent 这边挂一个消费者,一旦队列里有任务就取出来执行。比如数据同步任务,上游系统跑完数据之后推送一条消息,Agent 收到后开始做数据校验和清洗。
2.4 场景四:框架扩展,插件注入式
这个场景是最贴合 dsh 原生哲学的。dsh 的插件机制允许开发者往 Agent 里注入自定义逻辑,虽然框架没给你 Cron,但给了你一个 hook 的入口。你完全可以写一个定时检查插件,挂在 Agent 的循环里,替代前面的自主轮询方案,并且做得更规范。
用插件方式的好处是可以让 Agent 具备一个属于自己的“小小的调度器”,既能按时间触发,又能结合 Agent 的状态做判断。比如“如果当前 Agent 空闲超过 5 分钟,就自动去检查一遍数据更新”。这种逻辑用外部 Cron 写非常别扭,用插件注入就很顺手。
3. 实操:用外部 Cron 给 dsh Agent 加定时任务
3.1 准备一个可被调用的入口
外部 Cron 要唤醒 Agent,核心是让 Agent 暴露一个可以被 HTTP 调用的入口。dsh 本身没有内置 Web Server,但可以通过 dsh web 这个相关组件(或者你自己给 Agent 挂一个轻量级服务)把 Agent 的会话入口包一层。
我这边直接用 Python 起了一个最小的 Flask 服务,里面调 dsh 的会话入口工具,代码大致长这样:
from flask import Flask, request, jsonify from dsh import create_agent, session_tool agent = create_agent("my_timed_agent", tools=[session_tool, ...]) app = Flask(__name__) @app.route("/trigger", methods=["POST"]) def trigger(): payload = request.get_json() task = payload.get("task", "") result = agent.execute(task) return jsonify({"status": "ok", "result": result}) if __name__ == "__main__": app.run(host="0.0.0.0", port=8765)这里有个细节:不要每次都重建 Agent 实例。Agent 是有记忆的,如果你每次定时任务过来都重新 create_agent,它的状态和记忆全部丢光,Agent 就退化成一个每次从零开始的普通脚本了。正确做法是把 Agent 实例常驻,HTTP 服务起来之后保持一个单例,只接收任务、执行任务。
而且建议把会话入口工具的响应时间设一个超时上限。外部 Cron 通常不能容忍一个任务跑几小时,很容易中途被杀掉。如果 Agent 的某个任务确实要跑很久,把任务拆成两步:Cron 触发后先入队,然后由常驻进程慢慢消化。
3.2 写一个 Shell 脚本,作为 Cron 的壳
接着,写一个脚本,专门负责调用 Agent 的 HTTP 入口。这个脚本充当 Cron 和 Agent 之间的翻译官。
#!/bin/bash # /opt/agent-tasks/hourly_report.sh AGENT_URL="http://127.0.0.1:8765/trigger" TASK_DESC="$1" LOG_FILE="/var/log/agent-task.log" echo "[$(date '+%Y-%m-%d %H:%M:%S')] Start task: $TASK_DESC" >> "$LOG_FILE" curl -s -X POST "$AGENT_URL" \ -H "Content-Type: application/json" \ -d "{\"task\": \"$TASK_DESC\"}" \ --max-time 600 >> "$LOG_FILE" 2>&1 echo "[$(date '+%Y-%m-%d %H:%M:%S')] Task finished" >> "$LOG_FILE" curl --max-time 10 -s -X POST "http://127.0.0.1:8765/health" > /dev/null脚本里加了一个 health 探活,是为了防止 Agent 进程已经挂了 Cron 还在往死里跑任务的情况。探活失败时,脚本应该发告警而不是继续执行,这个逻辑你在生产环境必须补上。
3.3 配置 crontab 和 cron 表达式
脚本写好后,配 crontab 就很简单了。用crontab -e直接编辑:
# 每天 9 点生成昨日复盘报告 0 9 * * * /opt/agent-tasks/hourly_report.sh "生成昨日数据复盘报告,包含关键指标变化和异常点" # 每小时整点抓取行情数据 0 * * * * /opt/agent-tasks/hourly_report.sh "抓取当前行情数据,对比上一个小时的变化" # 每周一 8:30 发送周报 30 8 * * 1 /opt/agent-tasks/hourly_report.sh "生成周报,总结上周进展和问题"cron 表达式就是五个字段:分、时、日、月、周。0 9 * * *表示每天 9 点 0 分;0 * * * *表示每小时整点。这个表达式本身的语法很简单,但真正容易踩坑的是时区问题。
3.4 时区、日志与执行状态确认
时区这个坑,我几乎每次写定时任务都会遇到。crontab 用的是系统时区,不是浏览器时区,更不是你想当然的时区。如果你的 Agent 服务部署在一台 UTC 时区的机器上,你写0 9 * * *想的是北京时间上午 9 点,实际执行的是 UTC 上午 9 点——也就是北京时间的下午 5 点。所以写完 crontab 之后,先date看一下系统时区,必要时把 cron 服务重启或显式在脚本开头加时区环境变量。
日志这块,我在脚本里已经把输出打到了/var/log/agent-task.log,但 Cron 的执行环境里 PATH 经常是精简过的,很多命令直接执行会找不到路径。建议脚本开头显式申明 PATH:
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"执行状态确认比执行本身更重要。怎么确认 Agent 真的把任务干完了而不是只是“接收到了”?我的做法是让 Agent 在任务结束时回写一个状态标记,比如一个 done 文件、一条数据库记录,或者调用一个回写接口。Cron 脚本里 curl 的返回只能说明 HTTP 请求成功了,不能说明 Agent 逻辑成功,这是外部调度方案最容易让大家产生误判的地方。
我实际用下来,外部 Cron + HTTP 唤醒的组合,最适合那种“定时逻辑稳定、Agent 任务执行时间可控”的场景。优点是 Agent 完全无状态地接收任务即可,调度逻辑全部收敛在系统层,出问题好排查;缺点是 Agent 缺乏对时间的感知能力,做不到“执行完 A 之后等 10 分钟再执行 B”这种相对时间编排。
4. 实操:在 Agent 内部实现“伪定时任务”
如果业务确实需要 Agent 自己掌握时间节奏,外部 Cron 就不够用了。这时只能回到 dsh 的体系内做文章。我试过两种在 Agent 内部实现定时能力的方式,分享下效果和代码。
4.1 常驻任务循环 + sleep 实现最朴素的定时
最简单也最容易理解的方案:让 Agent 常驻运行,在一个循环里执行任务、然后 sleep 一段时间,再醒来继续执行。
import time from dsh import create_agent agent = create_agent("r2d2_agent", tools=[...]) # 每 300 秒被唤醒一次,检查是否有待办 while True: agent.execute("检查当前是否有待处理任务,有就按优先级处理") time.sleep(300)这个方案我愿称之为“面向资源的伪定时”。原理不复杂,就是让 Agent 自己维护一个时间感,到点就自我唤醒。它和外部 Cron 的区别在于:执行时机不是由外部强推的,而是 Agent 在循环中自己感知的。
但这方案有几个明显的坑。首先,sleep 的时间越短,CPU 空转浪费越严重。Agent 循环每 300 秒醒一次其实还好,如果你要做到秒级唤醒,那还不如用外部 Cron。其次,单进程常驻下如果 Agent 执行一个任务卡住了,整个循环都会被卡死,后续的定时执行全部失效。我建议循环里加 try-except,单次任务异常不要拖垮整个 Agent。还有一点,sleep 方案做不了精确调度,比如“每天 9 点执行”,这个用 sleep 实现就很别扭,因为它无法对齐到具体时间点。
4.2 用 dsh 插件机制注入一个“定时检查模块”
dsh 虽然没给 Cron,但它给了插件工具,这个口子比想象中要大。我后来把方案从 sleep 循环升级成了插件注入:写一个 dsh 插件,挂到 Agent 的循环流程里,每次 Agent 执行完一个任务之后,插件自动检查当前时间和预定义的时间表,如果发现到了一个时间点了,就生成一个“定时任务指令”注入 Agent 的执行队列。
class ScheduleChecker: def __init__(self, schedule_table): self.schedule_table = schedule_table # {"09:00": "生成日报", "14:30": "发送周报提醒"} def check(self, now): current_time = now.strftime("%H:%M") return self.schedule_table.get(current_time, None)挂到 Agent 里之后,每次 Agent 跑完手头的事,就自动看一眼时间表,发现到点了就触发新任务。这种模式的好处是:定时任务不再是外部强推的,而是 Agent 自己“想起来”的。它可以在触发定时任务之前先做一个状态检查,比如“数据文件是不是已经就绪了”,再决定要不要执行。
插件模式比 sleep 循环的高级感强很多,但它依然有自身的限制:如果 Agent 长时间没有活动,循环不转,时间检查也不会触发。这就意味着插件注入的定时任务必须配合至少一个常驻心跳循环才有意义。实际操作中,我一般把心跳时间设成 60 秒一次,代价很小,又能保证定时检查的及时性。
4.3 最小可用的时间表达式引擎
既然要模拟 Cron,那就绕不开 cron 表达式。但真的没必要引一个重量级 Cron 解析库,dsh 插件里只需要一个最小可用的时间匹配判断。
def match_cron_expression(expr, dt): parts = expr.split() if len(parts) != 5: raise ValueError("Invalid cron expr, must have 5 fields") minute, hour, day, month, week = parts if minute != "*" and dt.minute != int(minute): return False if hour != "*" and dt.hour != int(hour): return False if day != "*" and dt.day != int(day): return False if month != "*" and dt.month != int(month): return False if week != "*" and dt.isoweekday() != int(week): return False return True这段代码支持的就是最基础的0 9 * * *这类精确时间表达式,不支持*/5、1-10这种区间和步长语法。实际需求里如果真需要这些复杂规则,我建议就别在 Agent 内部写了,老老实实用外部 Cron。
4.4 把定时检查插件真正挂到 Agent 循环里
插件写好了,怎么挂进 Agent 的执行循环?我在 dsh 的框架设置中把定时检查放进了一个中间件钩子里,相当于每个循环轮次结束后自动调用,而定时检查触发的任务则会拼接到 Agent 的待办事项中。实际效果就是:Agent 只要在跑,它就会自动按时间表自我安排任务,像一个被设定好“生物钟”的机器人。
这种自我安排式的定时任务,最大的优点是我们不用再去写 HTTP 接口、不用配 crontab、也不用担心外部服务和 Agent 之间通信失败。一切停留在 dsh 进程内部,逻辑更内聚,调试也更方便。缺点就是 Agent 一旦停了,时间表就完全失效。如果你用单机跑的部署模式,Agent 进程挂在服务器上了,定时任务可能就再也不会执行了,这一点在生产环境里要尤其小心。
5. 外部方案和内部方案怎么选,我总结了三条判断标准
写到这里,其实很多朋友会纠结一个问题:既然外部 Cron 能搞定,内部模拟定时又是什么鬼,到底哪个方案好?我个人的判断标准有三条。
第一条,看任务的时间粒度。如果定时是精确到分钟级别的,外部 Cron 是唯一正确选择。企业内部大多数定时任务都是分钟级以上的,比如日报、周报、月度汇总。Agent 内部模拟的定时精度相对粗糙,而且容易受 Agent 占用状态的影响。
第二条,看任务是否需要结合上下文。如果定时任务不是单纯“到点了执行一个动作”,而是要基于之前的执行结果做推理决策,那必须放在 Agent 内部来触发。最简单的例子,每天下班前让 Agent 检查“今天的工作收尾了没有”,这个 Agent 需要同时知道今天的执行情况,才能决定该提醒什么,外部 Cron 无论如何给不出这种上下文。
第三条,看你能否容忍 Agent 进程挂掉之后定时失灵。如果定时任务的核心价值在于稳健可靠,一定选外部 Cron。如果定时任务只是辅助性的,Agent 挂了重来也无所谓,可以选内部模拟方案。
6. 实操:dsh Agent 定时任务的常见问题与排错技巧
6.1 遇到的 5 个典型问题
第一个问题:脚本里 curl 请求到了,但 Agent 好像没在执行任务。这多半是 Agent 进程本身已经卡死或者启动失败。定期给 Agent 加一个健康检查接口,并把健康检查状态单独记录到日志里,别把所有信息混在一起。
第二个问题:Cron 执行时间跟预期差了好几个小时。这个就是时区问题,排查方法很简单,在脚本第一行加date命令,把系统时间打出来,立刻就能看到问题。解决方式是改系统时区,或者在 Cron 表达式里手动换算成系统时区的时间。
第三个问题:Agent 任务执行时间超过 Cron 的间隔,导致多个任务同时堆积。我在实际项目中遇到过 Cron 每 5 分钟触发的任务,上一次没跑完下一次又进来了。解决方案是给脚本加一个文件锁,flock命令就可以实现:
flock -n /tmp/agent_task.lock /opt/agent-tasks/hourly_report.sh "xxx"第四个问题:日志文件越来越大,磁盘爆了。定时任务长期运行,日志增长比想象中快很多。处理方式是按天归档,用 logrotate 或者脚本自己切割。我一般习惯让 Agent 任务日志只保留最近 30 天,超过的就自动删除。
第五个问题:任务本身失败了,但 Cron 完全没有感知。这是最危险的问题。Cron 只保证“到点了执行命令”,从来不保证“执行成功”。必须在脚本里把每次任务的退出码记录下来,并配合一个告警机制。如果连续失败 N 次,发一条消息到运维群。
6.2 定时 Agent 任务排查速查表
| 现象 | 原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 任务完全没执行 | crontab 语法错误 | 先跑crontab -l确认配置存在 | 手敲参数,别复制粘贴 |
| 任务执行了但 Agent 没反应 | HTTP 入口探活失败 | 检查 Flask 服务端口是否监听 | 先重启 dsh Agent 再手动 curl 测试 |
| 执行时间偏差 | 系统时区不一致 | 脚本头部加date输出实际时间 | 切换时区或在命令行里加TZ参数 |
| 任务重复触发 | 上次任务没跑完 | 查看进程列表,确认是否有残留 | 给脚本加文件锁 |
| Agent 常常无响应 | 并发任务太多 | 查看 Agent 进程的日志和报错 | 限制并发或者拆分任务队列 |
6.3 一些你可能没想过的经验细节
我一开始只写 Cron 任务,完全没考虑 Agent 的状态独立性问题。后来发现一个特别隐蔽的问题:Agent 任务之间如果共享状态,比如都往同一个文件里写内容,会出现数据互相覆盖的问题。给每个定时任务分配独立的输出文件,或者给 Agent 建独立的工作目录,能够避免很多跨任务污染的问题。
还有一个小技巧,Cron 任务里不要直接用sudo。Cron 环境是很受限的,sudo 权限在 crontab 里经常会导致各种诡异问题。如果你确实需要管理员权限来执行某个命令,最好单独学习如何配置 command 的权限,而不是在脚本里写 sudo 绕来绕去。
再补充一个排查技巧:所有定时任务必须能“手动执行”。如果一个定时任务不能从命令行里手动跑通,那在 Cron 里大概率也跑不通。所以每次写脚本,我一定先在终端手动跑一遍,确认正常了再写进 crontab。手动执行通过仅代表环境 OK,crontab 里执行仍可能因为 PATH 环境、工作目录、用户权限等原因失败,所以配好后要观察前几次的执行日志。
7. 折腾完之后的个人体会
给 dsh Agent 加上定时任务,这个过程让我重新理解了 Agent 框架的设计边界。dsh 故意不做 Cron,我不是一开始就理解的,真的自己动手设计调度方案、写完外部 Cron、又写了内部插件之后,才明白这种克制的意义:它把一个复杂决策完全留给了开发者,而不是替你做选择。Agent 的定时任务从本质上来讲就不该由框架层死板地定义成“到点就做”,因为 Agent 需要在每个时间点上结合自己的运行状态、外部环境、上下文信息动态做判断。
回到最初的标题,dsh 只给了三个工具而且故意不做 Cron,这句话我现在理解了。三个工具是 Agent 的生存基础,不做 Cron 是留给开发者的自由空间。自由往往意味着责任,你必须为自己的调度方案承担所有失败的后果。但反过来说,Agent 这个形态,本来就不该像一台老式闹钟一样被时间推着走。它应该更像一个拥有自己节奏的协作者——这就需要编排层充分理解业务、理解状态、理解时间,把该推的推一下,该等的等一下,该结合上下文的结合一下。这恰好就是 Agent 开发里最考验架构能力和全局观的地方。