Cron 调度的 Claude Agents:一份维护日志
让 20 个 Claude 智能体按 launchd 定时计划运行,很快就教会我一件事:调度器给出的保证,比预想的要弱得多。每个智能体在指定时间醒来,处理一小块工作,写入结果,然后继续睡,等下一个周期。模式很简单,但故障模式一点也不简单。三个月每晚跑下来,我遇到了 launchd 的定时抖动、会话结束时的失败,还有轮次数量悄然增长带来的压力。这篇算是一份坦诚的维护日志:什么坏了、为什么坏、我改了什么。
调度器不是魔法计时器
刚开始搭这套 cron 风格的任务时,我选择 launchd,是因为它跟 macOS 集成得很好,对启动时间、资源上限和重启策略都能做精细控制。第一个意外是:launchd 并不保证准确的启动时间。系统一忙,任务就可能被推迟好几秒,而这些秒数一天下来会越积越多。对单个智能体来说,这点漂移约等于没有;但 20 个智能体排在一起,累积延迟就可能把最后一轮运行挤出预期的时间窗口,导致任务重叠执行。
重叠运行的直接表现是:两个智能体同时尝试写入同一个 SQLite 文件。SQLite 写入时要锁文件,第二个智能体只能卡住等锁释放。在紧凑的调度下,这会引发一连串超时,最后 launchd 守护进程干脆把任务标记为失败。解决办法是:在每个智能体的 launchd plist 里,分配一个独立且固定的启动分钟。与其让多个智能体挤在同一个启动槽上,不如手动把一天里的启动时间错开,让每个智能体都有互不重叠的清晰时间窗,写锁导致的连锁超时也就随之消除了。
另一个隐蔽的问题是 launchd 对环境变量的处理。这些 agent 依赖 PATH 来找到 Python 解释器和几个辅助脚本。当 launchd 启动任务时,它继承的是一个最小化环境,不包含用户的 shell 配置文件。头几次运行因为找不到解释器而报 "command not found" 错误。解决办法是在 launchd 的 plist 里定义完整的 PATH,并用绝对路径引用解释器。这样任务就不再依赖任何交互式 shell 配置,"command not found" 错误也彻底消失了。
轮次预算是轮数,不是 token
这支 agent 舰队按每张工单的轮次上限运行,而不是 token上限集中保存在一个中央配置文件里,按工作类型分别设定:构建类工单 80 轮,评审类 30 轮,设计类 50 轮。这些数字是参照历史运行数据校准的,大约取实测均值的两倍,所以正常工单几乎不会逼近上限。
监控部分已经就位:每个 agent 会话旁边都跑着一个轮次监视器,达到上限时发出警告,达到 1.5 倍时再警告一次。还没就位的是硬性执行。轮次预算调控器目前仍处于校准模式——它会把警告呈现给 agent,但不会中止会话。实际结果就是:一个在异常复杂工单上运行过久的 agent 会看到警告,但还是会继续跑,直到它自然结束或撞上墙钟超时。
从警告走向强制,需要用更广泛的工单类型验证上限数值,并搞清这些数字如何随提示缓存的升温与降温而漂移。先埋点,后强制——等校准结果稳固了,再上硬性执行。教训是:尽早埋点,耐心校准,之后再强制执行。要是当初运行 agent 时完全没有轮次监控,就只能等模式已然成形,才看得出哪些工单类型在吞噬异常数量的轮次。
会话结束时的处理与数据归属
Claude 代理每次运行结束时,都会自动保存一份会话摘要。摘要写入项目目录下的本地 SQLite 文件。文件归用户所有,除非明确指示,代理不会把它上传到任何地方。这种设计让工程师完全掌控数据,但也意味着会话结束时如果出了问题,可能会留下孤立文件或半截写入的记录。
我之前写过一篇文章,讲代理会话之间丢失上下文带来的成本——那篇侧重的是 token 浪费。这次的问题在于结构性后果:写入丢失会污染下游数据。最常见的故障模式是 Python 进程因未捕获异常而突然终止。进程一死,SQLite 事务就悬在那里,下一次运行尝试写入时就撞上被锁定的数据库,报出"database is locked"错误。这个锁要等操作系统回收文件句柄才会释放,可能长达几分钟。在那段时间里,整个代理集群都卡住了。
解决办法是确保每个数据库连接在退出时都显式关闭——无论会话是正常结束还是异常中断。进程崩溃后遗留的连接会一直持有写锁,直到操作系统回收文件描述符。在错误处理路径中加上显式的关闭调用,而不是依赖垃圾回收,可以把锁的持有时间压到最短,让下一个定时任务干净利落地启动。
另一个隐蔽的问题是开放问题的处理。代理会尝试捕捉运行过程中出现的未解答事项,把它们作为独立行存下来。但这种捕捉是尽力而为:如果会话里信号不够充分,问题就不会被记录。早期我假设每个开放问题都会被保存,于是基于"缺失行"构建了下游告警。当捕捉静默失败时,告警就开始刷屏,慢慢消磨掉大家对监控系统的信任。
教训是:把未解决问题日志当作提示而非硬性约定。我加了一个状态标志位,用来标记数据捕获是否成功,下游流程在处理数据前会先检查这个标志。如果标志为假,流水线就跳过告警继续执行,避免误报。
三个月后:关于自主 AI 代理集群的几点心得
按计划运行一批 Claude 代理,并不是「配好就撒手不管」的事。环境在变,模型在升级,操作系统也会时不时闹点小脾气。这三个月下来,我的体会是:要把这个代理集群当成一套有生命的生产系统来对待,需要定期做健康检查,就像任何一个线上服务一样。
调度可靠性比原始速度更重要。把任务错开,给每个任务设定明确的启动时间,原来那个互相打架的不稳定问题,就变成了一种可预期的节奏。轮次预算(turn budget,即单次任务允许消耗的模型调用次数)必须先有数据、再设上限:只有知道哪些工单类型会跑得久,你才敢设上限,同时又不误伤正常任务。健壮的会话处理能防止级联故障——这种故障一旦发生,可能让整个集群停摆。另外,对自动数据捕获的预期也要贴合现实:推理本来就是「尽力而为」,按「某条数据可能缺失」来设计,你的下游流程会结实得多。
如果你是数据工程师或 AI 从业者,正在考虑搭建自己的自主代理集群,这些经验就是你的维护清单:先从清晰的调度开始,从第一天就盯着轮次使用量,再把数据持久化做成不怕崩溃的样子。在这些地基上花的功夫,会以更顺畅的运行和更少的突发故障回报你——这也正是这个系列值得追下去的原因。
本文是一个持续更新的维护日志系列的第一篇。后续我会继续记录新出现的故障模式和对应的修复方案。