状态管理与持久化:让长任务经得起中断 · Lesson 3 of 6

第 3 课:断点恢复:从检查点拉起循环

学习目标:

  • 说清「悬空调用」为什么必然出现在崩溃恢复场景里,它和一次普通的工具执行失败并不是同一回事
  • 写出从 loadCheckpoint() 到重新进入循环的完整恢复路径,包括版本校验与状态重建
  • 按工具性质给悬空调用分流对账——只读工具直接重跑,高影响工具先兜底,而不是无脑重跑或无脑删除

前置要求:完成第 2 课,理解 checkpoint.json 的字段与两个存档点 | 上一课 第 2 课 << | 下一课 第 4 课 >>

只能续跑,不能重启

Agent 执行到一半崩溃,第一反应往往是「重新跑一遍」。但对一个已经推进了十几轮、调用过好几次工具的长任务来说,重启既昂贵,又会让用户沮丧1。上一课把执行现场存成了 checkpoint.jsonversiontaskturnstokensUsedmessagespendingToolUse,在模型响应之后(存档点 A)和工具结果落账之后(存档点 B)各落一次盘。这一课要做的,是把这份存档重新变回一个能往前走的循环——构建一种能从出错处恢复的系统,而不是每次出错都从头开始1

恢复的主干:一半很简单

先看不难的那一半。恢复的主干就四步:读文件、JSON.parse、校验 version、把字段摊开塞回运行时状态。做完这四步,runAgent 不需要重新「构造初始 messages」——检查点里已经有一份完整的 messages 数组了,直接跳过初始化,进入循环。

有了这两个函数,runAgent 的开头就变成一次简单的分支:

恢复之后,循环做的第一件事和平时完全一样:拿着 state.messages 照常发起下一次 client.messages.create()。模型看到的 messages 和崩溃前一模一样——它根本不知道中间发生过一次进程重启。这也是为什么第 2 课要求 messages 必须原样进检查点:只要这份数组还原得准确,恢复对模型来说就是无感的。

难的一半:悬空调用怎么对账

真正麻烦的,是 state.pendingToolUse 不为 null 的那种检查点。回忆一下两个存档点的位置:存档点 A 在模型响应之后,此时 pendingToolUse 记的是这次响应里的 {id, name, input};存档点 B 在工具结果落账之后,pendingToolUse 被清回 null。如果进程恰好死在 A、B 之间——工具还没跑,或者跑完了但结果还没来得及塞进 messages——检查点里留下的就是一个不为 nullpendingToolUse

这时候 messages 的末尾是一条含 tool_use 内容块的 assistant 消息,却没有配对的 tool_result。这不是一个可以将就的状态:协议要求每一个 tool_use 都必须有对应的 tool_result,作为下一条 user 消息集中回传给模型2。少了这一条,恢复后根本没法正常发起下一次调用——模型看到的是一个自己发起了工具调用、却永远等不到结果的半截对话。这条悬空的调用,必须在重新进入循环之前处理掉。

三种处置方式,一个能留

面对这条悬空的 assistant 消息,直觉上有三种做法,但只有一种真的站得住。

方式一:把这条 assistant 消息从 messages 里删掉,当它没发生过。 看起来最干净——恢复出来的对话里不再有任何缺口。但代价有两层:一是模型忘了自己已经做出的决策,同一次探索完全可能重新走一遍,白白多花一轮;二是更危险的一层——如果那次工具调用其实已经执行完了,只是进程在记录结果之前死掉,删除这条消息并不会撤销已经发生的副作用,它只是让模型和后续的日志都不再知道这件事发生过。删除掩盖的是事实,不是风险。

方式二:直接重跑这个工具,把结果补成 tool_result 对只读工具(read_filegrep 这类)这完全正确——读两遍和读一遍没有区别,副作用为零。但对高影响工具(发邮件、写库这类)就危险了:工具很可能已经执行过一次,无条件重跑就是让它执行第二次。这正是第 4 课要专门讨论的幂等性问题,本课先立一条能落地的规则:只读工具直接重跑;高影响工具必须先确认是否已经执行过,才能决定要不要重跑。

方式三:补一条 is_error: truetool_result,内容写「执行状态未知,请重新评估」,把决定权交还给模型。 这是查不清「有没有执行过」时的保守兜底——is_error 字段本就是为了告诉模型这次工具调用出了状况2。让模型知道工具出了状况、并让它自己去适应,这件事在工程上的效果好得超出预期1:模型会重新读一遍上下文,判断要不要换个方式确认结果,而不是被一个静默的、可能重复的动作坑到。

三种方式摆在一起,方式一出局;方式二和方式三分别对应「查得清」和「查不清」两种情况,组合起来才是完整的对账规则。

reconcile(cp):把对账写成代码

把上面的规则落成一个函数:按工具名判断是不是只读,只读就重跑;不是只读,就去查「效果台账」确认这次调用是否已经执行过——效果台账本课还没有,先用注释占位,第 4 课会给出真正的实现。查不清的时候,落到 is_error 兜底。

reconcile() 做完之后,cp.messages 末尾已经补上了配对的 tool_resultcp.pendingToolUse 也归位成 null。这份 cp 现在和一份正常落在存档点 B 的检查点没有区别,可以直接交给 while 循环继续往前走。

恢复之后:turns 和 tokensUsed 怎么算

有两个计数器容易被恢复流程带偏,需要单独说清楚。

turns 恢复后不清零。它记的是这个任务从开始到现在的总轮次,不是「本次进程运行了几轮」——检查点里的 turns 就应该原样接着往上加,第 2 课定的最大轮次阀 MAX_TURNS 才会继续起作用。如果恢复时把 turns 归零,一个反复崩溃又反复恢复的任务就能绕开轮次上限,无限跑下去。

tokensUsed 同理,也从检查点接续,不重新计算。第 8 门课讲上下文压缩时,tokensUsed 的语义就是「当前窗口的用量」,而检查点里存的正是崩溃那一刻这份窗口的用量——两者语义是一致的,恢复时直接拿来接着用即可,不需要额外换算。

小结

断点恢复的主干并不难:读检查点、校验版本、把字段摊回运行时状态、跳过初始化直接进循环,模型甚至感觉不到中间发生过崩溃。真正需要设计的是悬空调用的对账——删除会丢决策、掩盖已发生的副作用;只读工具可以放心重跑;高影响工具在查不清是否已执行时,is_error 兜底是比无脑重跑更安全的选择。但这条规则里还留着一个没解决的问题:高影响工具「是否已经执行过」到底怎么查?本课只是把它兜底成了「查不清」,真正查得清需要一份效果台账——这正是下一课要解决的。

>> 第 4 课:副作用与幂等:恢复时哪些工具敢重跑

Footnotes

  1. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3

  2. Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls 2

Exercises

01

下面是三份在恢复时读到的检查点(为了阅读方便,messages 里省略了具体内容)。针对每一份,写出 runAgent 恢复时应该做什么、为什么。

Level 1:三份检查点,三种恢复动作
Done criteria · checked locally
02

运维事故报告:「进程在 send_email 工具调用后、结果落账前被 OOM Killer 杀掉重启。重启后 harness 自动 --resume,几分钟后用户反馈收到了两封内容完全相同的邮件。」

Level 2:找出重复发信的根因

事发时线上跑的 reconcile() 是这样写的:

定位根因,并把这个 reconcile() 改成按工具性质分流的版本(提示:本课定的规则是「只读直接重跑,高影响工具没有台账时用 is_error 兜底」)。改完之后用 node 跑一遍,验证 send_email 这类高影响工具不会再触发 executeTool()

Done criteria · checked locally

My note

Jot down thoughts, sticking points, things you didn't get. Written to this course's appendix only — the lesson file is never touched.