第 6 课:实战:给 harness 装上检查点与恢复
学习目标:
- 把「A 点存悬空调用、B 点清空悬空调用」的检查点方案,实际焊进本系列第 7 门课写的
runAgent循环里,而不只是停留在概念图上- 给
runToolUses接上副作用台账:工具执行成功后立刻落盘一条记录,让恢复时能分辨「这次工具到底跑没跑」- 写出
reconcile的三分流逻辑,并用一段可控的「模拟被杀死 +--resume」流程,亲眼验证恢复行为符合预期前置要求:读完第 1-5 课,手边能跑起本系列第 7 门课的 harness 循环 | 上一课 第 5 课 <<
先看它跑起来的样子
前五课把检查点、恢复、幂等、回退分叉拆开讲清楚了。这一课把它们焊进一个真能跑的 harness:还是那个熟悉的循环——带 messages 请求模型、stop_reason === "tool_use" 就执行工具再发一次——只是这次每一轮都往磁盘上落两次检查点,外加一本记录工具执行结果的台账。任务是「把销售笔记整理成报告」,会依次调用 read_notes、count_words、write_report 三个工具。先看它正常跑到第三轮、被硬生生杀掉是什么样:
第 1、2 轮都走完了完整的 save A → 执行 → save B 三步,一切正常。第 3 轮存了 save A(记下悬空调用是 write_report),工具也确实执行完了、结果也已经写进了台账——但下一步的 save B 还没来得及存,进程就被杀了。这正是这一课要死磕的窗口:checkpoint.json 里此刻还留着一个悬空的 pendingToolUse。带着这份现场,用 --resume 把它接起来:
恢复流程读到 turn=3 pending=write_report,去查台账——发现这次调用其实已经在被杀死之前跑完并落账了,于是直接复用那条记录,不重新执行 write_report,补上这一轮缺的 save B,再照常往下推进到模型收尾。整段任务没有从头重来,也没有把报告重复写一遍。
这两段终端输出不是手写的示例,是后面「验证桥段」一节里那段用固定响应队列驱动的 Node 脚本跑出来的真实输出,逐行照抄在这里。
逐块搭建
检查点的读写:saveCheckpoint / loadCheckpoint
检查点就是把 {version, task, turns, tokensUsed, messages, pendingToolUse} 这份现场序列化写盘。唯一讲究的地方是别把文件写坏:先写到一个临时文件,再用 fs.renameSync 原子替换——rename 在同一个文件系统内是不可分割的操作,不会出现「写到一半」的中间态:
读的时候要扛住两件事:文件不存在(还没跑过,或者本来就该从头开始),以及文件解析失败。第二种情况格外要小心——JSON.parse 失败,通常意味着上一次写入本身就被中断了(saveCheckpoint 理论上是原子的,但如果连 .tmp 文件都没写完整就被杀,或者磁盘本身出了问题,rename 之前的半成品也可能被误读)。这时候绝不能悄悄把状态重置为空、假装什么都没发生——那才是真正会丢任务的地方。正确做法是把错误明明白白地抛出来,告诉用户这份检查点已经不可信,该删掉重新开始,而不是让程序自己猜着补全:
version 字段也顺手校验一下:以后检查点结构改了,旧文件不该被当成新格式硬解出来,宁可拒绝加载也不要读出一份半对半错的状态。这两个函数都用真实的截断 JSON 测过:喂一段写了一半的 {"version":1,"turns":3,"pendingT 进去,loadCheckpoint 准确抛出上面那条「请删除重新开始」的错误,不会返回任何看似合理的默认值。
A 点与 B 点:接进 runAgent 循环
本系列第 7 门课写的循环骨架没变——while (response.stop_reason === "tool_use"),push assistant → 执行工具 → push tool_result → 重新请求模型。这一课往循环体里插两个检查点,插入的位置就是这一课的核心:
A 点插在拿到 response 之后、messages.push({ role: "assistant", ... }) 之前——这是模型「点了名但还没真正执行」的那一刻,pendingToolUse 就是把这次点名原样记下来。B 点插在 runToolUses 执行完、tool_result 也已经推进 messages 之后——这时候这一轮彻底翻篇,pendingToolUse 清成 null。两次落盘之间夹着的,正是工具真正执行的那一段代码;如果进程恰好死在这段代码执行期间或刚执行完,磁盘上留下的就是「A 点存过、B 点没存过」的现场——pendingToolUse 非空,这正是恢复逻辑要处理的信号。
为了让这套单一悬空调用(pendingToolUse 是一个对象而不是数组)的协议站得住,这一课把任务设计成每轮模型只点一个工具的名——这是刻意简化,「分寸」一节会说清楚它的边界。
副作用台账:接进 runToolUses
台账要解决的问题是:如果崩溃恰好夹在「工具真的执行完」和「结果落进 messages」之间,恢复时怎么知道这次调用是不是已经跑过、不能再跑一次。做法是在工具执行成功后,立刻把结果单独存进一本按 tool_use_id 索引的台账(同样用临时文件加 rename 原子写):
顺序不能颠倒:必须先拿到 toolImpls[block.name](block.input) 的真实结果,saveEffect 才能跟着落盘——先执行、后记录。台账记的是「这件事真的发生过,而且发生的结果是这个」,如果反过来先落账再执行,落进台账的就只能是个占位值,台账也就失去了「已完成」这个承诺的意义(Level 2 练习会让你亲手复现这个反面教材)。
正常的单次执行里,runToolUses 走的是「执行 → 记录」两步,因为每个 tool_use_id 都是第一次出现,无账可查。而恢复时要处理的那一个悬空调用,走的是更完整的「查台账 → 执行(如果需要)→ 记录(如果执行了)」三步——下面 reconcile 就是这三步的实现,两处遵循的是同一条纪律:不能在没有真实结果之前就往台账里写「已完成」。
reconcile:崩溃时悬空调用的三分流
恢复时要处理的就是检查点里那一个(如果有的话)pendingToolUse。它对应三种可能:
三条分支,对应三种真实测过的场景:
- 台账命中——就是本课开头那段崩溃演示:
write_report其实已经执行完、也已经落账,只是save B没赶上。恢复时直接复用台账里的结果,不重新执行,避免报告被写两遍。 - 台账未命中 + 只读工具——比如
read_notes这类不产生副作用的工具,落账之前就崩了也无所谓,直接重跑一次拿到结果,顺手把这次也补进台账: - 台账未命中 + 有副作用——比如
write_report这类会改动外部状态的工具,落账之前就崩了:不知道它到底有没有真的执行过(真实文件系统上,write_report的副作用完全可能已经发生,只是没来得及记进台账),这时候宁可不猜,用一条is_error: true的tool_result如实告诉模型「这次调用状态不明」,把判断权交回去:
这三段日志都是真实跑出来的,不是编的——reconcile 本身不需要知道任务是什么,只要给它一份 pendingToolUse 和对应的台账状态,三条分支各自独立可测。
入口:main() 里的 --resume
最后是入口。main() 只做一件判断:命令行有没有 --resume。有,就走 loadCheckpoint() 恢复;没有,就清掉上一次遗留的检查点和台账文件,从头开始——这条清理逻辑保证「不带 --resume 重新开始」永远是一次干净的开局,不会被上一次跑到一半的现场污染:
runAgent 内部对应地分两条路:opts.resume 为真就 loadCheckpoint()、跑 reconcile、把补齐的结果(如果有)推进 messages 并存一次 B 点,再照常向模型发请求;为假就 fs.rmSync 清掉旧的检查点与台账,从一条空 messages 开始。真实的 agent.js 里,模型客户端换成 @anthropic-ai/sdk 的 client.messages.create({ model, max_tokens, tools, messages }),其余结构不用动。
协议引用
这一课的两个设计决定都不是凭空定的:
reconcile 在台账缺失又拿不准状态时,选择补一条 is_error: true 的 tool_result 而不是沉默地跳过,依据的是协议对内容块配对的硬性要求——每一个 tool_use 都必须有一个对应的 tool_result 一起回传,用 tool_use_id 认领1。少存一次 A 点会让恢复流程连「有过这次调用」都不知道,也就没法满足这条配对要求;reconcile 存在的意义,正是不管台账命不命中,都保证这次悬空调用最终会有一个配对的 tool_result 补上。
选择「resume 接着跑」而不是「出错就从头重来」,呼应的是 Anthropic 工程团队在其研究系统复盘中描述的经验:出错时不能简单地从头重启,重启对长任务而言代价太大也太让人沮丧,应该让系统能从出错的那个位置继续2。同一篇复盘也提到,Agent 的适应力可以和「重试逻辑、定期检查点」这类确定性护栏配合起来,而不是二选一2——检查点负责兜住「进程死了」这种确定性的失败,模型的适应力负责处理「台账查不清」这种没法用代码硬判的情况。reconcile 那条 is_error 分支正是把这两者接起来的地方:把「状态不明」如实告诉模型,让它自己决定要不要核实或重试,其实往往能处理得不错2。
验证桥段
这一课的两段终端演示,靠的不是真的杀掉一个进程去看会发生什么——那样每次跑出来的崩溃时机都不受控制,没法针对性地断言「崩溃发生在第几次工具调用之后、恢复行为对不对」。做法是把模型客户端换成一个按固定顺序出牌的桩:一个响应队列,每调一次 messages.create 就按顺序吐出下一条预先写好的响应,队列耗尽还在调就直接报错——这样任务会在哪几轮调用哪个工具、模型什么时候收尾,全都是写死的常量,不会因为一次真实调用而变化。
「杀进程」则是一个受环境变量控制的 crashPoint(label):runToolUses 里每完成一次台账落盘,就把当前是第几次落盘拼进一个字符串标签,和 CRASH_AFTER 环境变量比对,一致就抛出一个专门的 SimulatedCrash 异常。这样「在第几次工具调用之后崩溃」就成了一个可以精确指定的整数,而不是听天由命的偶然事件。main() 只在最外层捕获这一种异常,打一行 [kill] 日志、用 137(约定俗成的「被 SIGKILL」退出码)退出,让演示读起来像一次真实的进程被杀,而不是一段难看的堆栈。
这套「响应队列钉死内容、崩溃点钉死次数」的方法,和本系列第 8 门课第 6 课验证上下文工程时用的是同一套思路:把原本不确定的东西(模型这次会说什么、进程这次会死在哪)先钉成固定的量,恢复行为才能被逐条断言,而不是每跑一次结果都不一样。这一课就是靠这套方法,把「台账命中不重跑」「只读工具台账缺失直接重跑」「有副作用工具台账缺失补 is_error」三条分支,还有 loadCheckpoint 对截断文件的容错,逐条用真实的 node 执行验证过一遍,而不是只在纸面上推理。
分寸
这一课焊上去的机制——两个检查点、一本台账、三分流的 reconcile——是给「会连续跑很多轮、中间还有副作用」的长任务准备的。一个几秒钟就能跑完、失败了重新点一下也无所谓的小任务,未必值得背上这一整套磁盘 I/O 和状态机;这里可以借用本系列第 3 门课引用过的同一条分寸:值得考虑的是只在复杂度确实能改善结果时才往上加3。这不是一条「必须这样做」的硬规定,更像是动手之前先问自己一句:这个任务真的长到、真的重要到值得为它维护一份检查点吗。
这一课的实现还有两处明确划了边界,值得说在明处,别当成「学完就能直接套进生产系统」:
- 每轮只处理一个悬空的
pendingToolUse,对应的是演示任务里模型每轮只点一个工具的名。真实场景里一轮模型响应完全可能带着好几个并发的tool_use块(本系列第 7 门课的runToolUses就是用Promise.all并发执行的),要把这一课的单一悬空调用协议扩展成一组悬空调用,pendingToolUse得从一个对象改成一个数组、reconcile也要挨个跑一遍——这一课刻意没有引入这层复杂度,是为了先把单个悬空调用的对账逻辑讲透。 - 这一课的检查点与台账,管的是「一个进程、跑一个任务」这一件事。多个会话之间怎么共享状态、多个进程同时碰同一份检查点会不会冲突、跨机器的一致性怎么保证——这些属于多会话并发与分布式一致性的范畴,不在这一课、也不在这门课程的范围内。
小结
- 检查点在一轮里存两次:A 点在拿到模型响应后记下悬空的
pendingToolUse,B 点在工具结果落进messages后把它清零;只存 B 点会让「模型点名工具」和「结果落账」之间的窗口在检查点里完全不可见,而每个tool_use都必须有配对的tool_result一起回传1,A 点正是为了让这段窗口内的悬空调用有据可查 - 副作用台账按
tool_use_id记录,纪律是「先执行、后记录」——落账的前提是已经拿到真实结果,反过来会把「还没跑」误记成「已完成」 reconcile三分流处理恢复时的悬空调用:台账命中就复用、不重跑;台账缺失但只读就直接重跑;台账缺失且有副作用就不猜,补一条is_error的tool_result把状态如实交还给模型——这呼应了「出错时不能从头重启、要能从出错处恢复」以及「让模型知道工具失败了、交给它自己适应,往往效果不错」这两点工程经验2,也和「确定性护栏配合模型适应力」的思路一致2- 检查点、台账这套机制不是白拿的,只在复杂度确实能改善结果时才值得往上加3;这一课的实现只管「一个进程跑一个任务」,多会话并发与分布式一致性不在其列,也不在这门课程的范围内
你已经走完这门课。从「Agent 是有状态的、错误会复利」这句判断出发,一路拆开检查点该存什么、什么时机落盘、恢复时怎么应对悬空调用、幂等性怎么给恢复兜底、检查点还能怎么用在回退与分叉上,到这一课亲手把它们焊进一个真能跑、真能被杀死、真能接着跑完的 harness——你现在手里的不只是一套概念,而是一段经过真实 node 执行验证过的代码。把它接到你自己的 harness 上,下一次它真的被杀掉的时候,会稳稳地从上次停下的地方接着干。
Footnotes
-
Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls ↩ ↩2
-
How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system ↩ ↩2 ↩3 ↩4 ↩5
-
Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents ↩ ↩2
练习
线上事故报告写着:「用户中断了一次任务,--resume 之后有一个工具被跳过了——日志里显示它『已经完成』,可实际上这个工具从来没有真正执行过,约定要写的文件根本不存在。」你翻出了当时线上跑的 runToolUses,发现和这一课的版本有一处不同:
Level 2:找出台账写反的顺序错误找出这处顺序错误,说清它为什么会导致「工具明明没跑却被当成已完成」,并改成正确的顺序。改完后,请照着这一课「验证桥段」里的方法,自己写一小段脚本复现:在 saveEffect 和 toolImpls[block.name](...) 之间插一个受环境变量控制的模拟崩溃点,用 node 实际跑一遍——错误顺序下,崩溃之前台账里就已经留下了一条 result: null 的记录;顺序修好之后,同样位置崩溃,台账里根本不会出现这个 tool_use_id。