作者运行 20 个 Claude Agent 定时任务三个月,总结出 launchd 定时不准、SQLite 并发写入冲突、session 残留导致 Agent 数量膨胀等问题及具体修复方案。
调度器并不是一个精准的定时器
最初搭建这些类 cron 任务时,我选用了 launchd,因为它与 macOS 集成良好,能对启动时间、资源限制和重启策略进行细粒度控制。第一个意外发现是:launchd 并不能保证精确的启动时间。如果系统正忙,一个任务可能被延迟数秒,而这些秒累积起来会产生漂移。对单个 agent 来说漂移可以忽略,但二十个 agent 的舰队,累计延迟可能把最后一次运行推到预定窗口之后,导致执行重叠。
重叠运行表现为两个 agent 同时尝试写入同一个 SQLite 文件。SQLite 会对写操作加锁,所以第二个 agent 会一直阻塞到锁释放。在紧凑的调度周期里,这会造成超时级联,最终 launchd 守护进程将该任务标记为失败。解决办法是为每个 agent 在其 launchd plist 中分配一个独立的、固定好的启动分钟。不允许多个 agent 共享同一个启动槽位,而是将调度错开分布在一天中,给每个 agent 一个清晰的无重叠窗口,从而消除了写锁级联。
另一个隐藏的怪癖是 launchd 对环境变量的处理。这些 agent 依赖的 PATH 包含 Python 解释器及几个辅助脚本。当 launchd 启动一个任务时,它继承的是一个最简环境,不包含用户的 shell 配置。前几次运行都失败了,报"command not found"错误,因为找不到解释器。解决方案是在 launchd plist 中定义完整的 PATH,并用绝对路径引用解释器。这样任务就独立于任何交互式 shell 配置了。
Turn 预算是一个计数,不是 token
这个舰队中的 Claude agent 运行在每张工单 turn 计数上限之下,而非 token 预算。上限保存在一个中心配置文件里,且根据工作类型不同而不同:构建工单的上限比审查工单高。这些数字是根据历史运行数据校准的,大约是实测均值的两倍,所以正常工作时很少会接近上限。
监控部分先实现了:每个 agent 会话都伴随一个 turn 监控器运行,在接近上限时触发警告。后来才加上强制执行。实践中的教训是:监控必须先于强制。没有弄清楚哪些工单类型运行时间长,任何设置的上限都可能让某些类型的正常工作被截断,又对另一些类型放得过松。先监控。根据真实数据校准。之后再强制。
会话结束处理与数据所有权
Claude agent 在每次运行结束时会自动保存一个会话摘要。最常见的故障模式是 Python 进程因未捕获的异常而突然终止。当进程崩溃时,SQLite 事务处于打开状态,下一次运行尝试写入被锁定的数据库。锁会一直持续到操作系统回收文件句柄,可能需要几分钟。在这整个窗口期内,整个舰队都卡住了。
确保每个数据库连接在退出时都被显式关闭——无论会话是正常结束还是异常终止——就是解决方案。被崩溃进程留下的打开连接会持有写锁,直到操作系统回收文件描述符。在错误路径中加入显式的 close 调用,而不是依赖垃圾回收,可以使锁窗口保持短小,下一次调度运行也能顺利进行。
我之前写过关于 agent 会话之间上下文丢失的代价——那篇文章侧重于 token 浪费。这里的问题是结构性后果:写入丢失会破坏下游数据。
另一个微妙的问题是未决问题的处理。Agent 会在运行期间尝试捕获任何出现的未回答事项。捕获是尽力而为的:如果会话中没有足够的信号,问题就不会被记录。早期我假设每个未决问题都会被保存,并在下游警报中基于缺失的行来构建。当捕获静默失败时,警报产生了噪音,破坏了监控系统的可信度。把未决问题日志当作一个有用的提示,而不是一个严格的契约来对待,同时设计下游流程以容忍缺失的条目。
三个月教会我的事
按照调度运行一个 Claude agent 舰队不是一个"设置后即可放手"的事情。调度可靠性比原始速度更重要。Turn 预算需要在强制执行之前先有监控。健壮的会话处理能防止级联故障——那种可能让整个舰队完全停摆的故障。
如果你是数据工程师或 AI 从业者,正在构建一个自主 agent 舰队,这些就是你的维护检查清单项。从一个定义清晰的调度开始,从第一天就监控 turn 计数,并且让你的数据持久化层对崩溃具有韧性。如果你想从零开始构建一个 agentic 数据管道,LangGraph 实现指南涵盖了那些在本文所涵盖的运维决策之前的架构决策。
本文是一份持续维护日志的第一篇。在 Labyrinth Analytics 博客上阅读完整文章,或者如果你已经有舰队在运行并希望有另一双眼睛来审视设计,欢迎联系。
PS -- 每周获取此类文章:订阅 Dispatches from the Labyrinth