状态管理与持久化:让长任务经得起中断 · 第 4 / 6 节

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

学习目标:

  • 说清恢复重放为什么默认给你的是「至少一次」执行语义,而不是「恰好一次」
  • 判断一个工具操作是否幂等,识别哪些副作用一旦重跑就会闯祸
  • 设计并实现效果台账(effects ledger),用 tool_use_id 做幂等键,让恢复时的悬空调用先查台账、再决定要不要真的执行

前置要求:读过第 2、3 课,理解 checkpoint.jsonpendingToolUse 悬空调用的对账规则(第 3 课);了解本系列第 7 门课的 HIGH_IMPACT 工具集合与执行前审批阀 | 上一课 第 3 课 << | 下一课 第 5 课 >>

恢复带来的「至少一次」:第 3 课把高影响工具的对账挂起了

第 3 课教你从 checkpoint.json 里读出 pendingToolUse,用它把「崩溃发生在工具执行与结果落账之间」的悬空调用接回循环。当时的对账规则是:只读工具直接重跑就好,高影响工具查不清楚就补一个 is_errortool_result,先让循环别卡死,把这件事交回给人。这是个诚实的兜底,但也是个没解决的问题——查不清楚,意味着任务没法自动续上,每次崩溃在高影响工具上,都得有人来盯着。

问题的根子在于:恢复这件事,天生给你的是至少一次(at-least-once)的执行语义。进程可能在工具真的执行成功之后、结果还没来得及写回 messages 或落进检查点之前崩掉——这时候「这个工具到底跑没跑」这件事,单看 checkpoint.json 是分辨不出来的。对 read_file 这种只读工具,分辨不出来无所谓,大不了多读一次,结果一样。可对 send_emailcreate_ticket、转账这类操作,分辨不出来就是事故:重跑意味着收件人可能收到两封同样的邮件,系统里可能凭空多出一张工单。

这不是个新问题。本系列第 1 门课就说过,Agent 是有状态的、错误会复利1——重复执行一次本该只发生一次的副作用,正是复利的一种具体形态:错误不会停在「多跑了一次」,它会顺着这次多余的副作用继续往下游滚。这一课要把第 3 课挂起的问题解决掉:引入幂等性这个概念,再给恢复循环装上一本效果台账,让「查不清楚」变成「查得清楚」。

幂等的定义:重跑一次和重跑多次,效果得一样

幂等(idempotent)说的是:一个操作执行一次和执行多次,最终效果相同,就是幂等的。注意这说的是「效果」,也就是操作对外部世界(文件、数据库、收件箱)留下的最终状态,不是说每次调用返回的字面值必须一样。

判断一个工具幂不幂等,问自己一句话就够:「如果这个操作被悄悄多跑了一次,外部世界会不会因此多出点什么、或者变得不一样?」下面几个小例子,照着这句话过一遍就能看出差别。

readFileContent 天然幂等,因为它压根没有副作用——没有「留下点什么」这回事。setLine 也幂等,尽管它确实修改了状态,但修改的方式是覆盖:调用一次把第 42 行设成 X,调用十次还是设成 X,终态不随调用次数变化。appendRowsendEmail 不幂等,原因都一样:它们的效果是累加的——每调用一次,外部世界就真的多出一份东西,调用次数直接体现在最终状态里。

记住这条分界线:覆盖式的写入通常幂等,追加式的写入通常不幂等;读操作和「先查重再决定要不要动手」的操作通常幂等,纯粹的「无条件新增」通常不幂等。下一节的效果台账,就是专门用来兜住那些不幂等、又不得不保留的操作。

效果台账:把「哪些副作用已经发生」也落盘

第 2 课教你把循环的执行现场——messages、计数器、还没落账的工具调用——存进检查点,好让崩溃后能从原地拉起来。但检查点回答的是「循环跑到哪一步了」,回答不了「这一步的副作用真的发生了吗」。这两件事在正常运行时几乎同步,可一旦崩溃发生在两者之间的缝隙里,它们就会对不上——这正是第 3 课挂起高影响工具对账的根本原因。

补上这道缝隙,靠的是一本效果台账(effects ledger):把「哪些副作用已经发生」单独落盘,不和检查点混在一起。它的结构很简单,一个以 tool_use_id 为键的映射:

写入台账的时机很讲究:工具函数真正执行成功、拿到结果的那一刻立刻写,而且要比第 2 课那次「B 点」检查点(工具结果被写进 messages 之后例行落盘的那个点)还要早一拍。原因很直接:如果崩溃恰好发生在「工具执行成功」和「B 点检查点写完」这段窄窗口里,B 点检查点根本来不及记录这件事发生过,恢复时你只能靠更早写完的台账去判断真相。台账写入本身也要用第 2、3 课那套 .tmp + rename 的原子写,道理相同——半成品的台账文件比没有台账更危险,它会让你误信一个从没真正完成的副作用。

有了台账,恢复时的对账规则就能从第 3 课的「查不清楚」升级成「查得清楚」:拿到 checkpoint.json 里的 pendingToolUse,用它的 id 去台账里查——命中,说明这个副作用已经真实发生过,直接取台账里存的 result 补一个 tool_result,绝不再执行一遍;没命中,说明这次调用要么从没开始、要么执行到一半就崩了还没来得及成功,安全执行就是。这条规则对所有工具都成立,只是对幂等工具而言「查不查」都无所谓——真正靠它兜底的,是那些一旦重复就会闯祸的操作。

这里有个关键洞见,值得单独说一句:tool_use_id 天然就是幂等键。模型每次点名一个工具,都会带上一个「针对这个 tool_use 块的唯一标识符」2——这是官方规范里 id 字段的逐字定义。同一次点名如果因为恢复重放而被再看见一次,这个 id 不会变;台账正是靠这一点,把「这次调用」和「上次那次调用」认成同一件事,而不需要你自己再发明一套去重逻辑。

分层防线:审批阀管「该不该做」,台账管「做没做过」

本系列第 7 门课给 runToolUses 装过一道审批阀:高影响工具在真正执行之前,先打印出即将做的事、等人确认,确认了才放行3。那道阀拦的问题是「这件事该不该做」。本课的效果台账拦的是另一个问题:「这件事做没做过」。两道闸问的问题不一样,但卡的位置一样——都堵在「模型点了名、工具还没真的跑」那一刻,谁都不准工具函数在没被自己检查过之前先执行。

把两道闸叠在一起,runToolUses 长这样:

顺序不能乱:幂等闸必须排在最前面。原因很直白——如果这次调用已经在台账里了,后面那道审批阀问「要不要做」根本没有意义,这件事已经做完了,再问一遍只会让人费解:系统明明已经做完了这件事,为什么还要重新确认。恢复时的悬空调用,第一件要问的事永远是「这事发生过没有」,问清楚了,再轮到「该不该做」登场。

工具设计侧的幂等:能治本就别只靠兜底

效果台账是 harness 侧的兜底——不管工具本身设计得幂不幂等,台账都能靠 tool_use_id 把重复执行拦下来。但兜底终究是兜底,更值得花心思的是治本:能改造的工具,尽量设计成天然幂等,让台账连出场的机会都没有。

最常见的一处改造是把「创建」换成「确保存在」:

ensureTicket 不管被调用一次还是十次,系统里都只会有一张标题匹配的工单——终态不随调用次数变化,这正是幂等的定义。同样的思路也适用于写文件:整体覆盖式的 write_file 天然幂等,反复调用留下的是同一份内容;追加式的 append_file 不幂等,调用几次文件就长几截。能选覆盖,就别选追加。

治本和治标不是二选一,是分工:能设计成天然幂等的工具,尽量在工具那一层解决,省得每次调用都要绕道台账查一遍;那些业务上本来就没法「查重合并」的操作——比如两次真实发生在不同时间的转账,本就该被认成两个不同的事件,没法靠幂等设计合并成一个——台账才是唯一的兜底。

呼应:护栏之一,分寸自己拿

本系列第 1 门课说过,可靠性来自把模型的适应力,和「重试逻辑、定期检查点」这类确定性护栏配合起来1。效果台账就是这样一道护栏:它不要求模型自己判断「我是不是已经做过这件事了」——那本来就超出了模型能感知的范围,它只能靠 harness 用一份写在磁盘上的确定证据,替模型把这个判断做掉。

分寸也要留意。如果你的 Agent 手里全是只读工具,那这一课讲的台账大概率用不上——效果台账本身也是一层复杂度,值得加,是因为它确实挡住了重复副作用这个实打实的风险;只在复杂度确实能改善结果时才该加3。判断标准和上一课一样:先看你的工具集里有没有不幂等的高影响操作,有,就值得装这道闸;没有,就先别急着写。

小结

  • 恢复给你的是「至少一次」执行语义:进程可能在工具真的执行成功之后、结果还没落账之前崩掉,单看检查点分不清这次悬空调用「跑没跑过」——第 3 课把高影响工具的对账挂起来,根子就在这里。
  • 幂等的定义:一个操作执行一次和执行多次,最终效果相同,就是幂等的。覆盖式写入(set_configwrite_file)通常幂等,追加式写入(append_logsend_email)通常不幂等。
  • 效果台账把「哪些副作用已经发生」单独落盘,以 tool_use_id 为键——这个 id 是模型点名工具时自带的唯一标识符2,同一次点名恢复重放时不会变,天然就是幂等键;写入要用 .tmp + rename 原子写,且要在工具执行成功后立刻落盘。
  • 恢复对账升级为:pendingToolUse.id 在台账里命中,直接复用存好的结果,绝不重跑;没命中,安全执行。
  • 审批阀管「该不该做」,效果台账管「做没做过」,两道闸互补,都卡在工具真正执行之前,幂等闸要排在审批阀前面。
  • 治本优于治标:能把工具设计成天然幂等(「确保存在」优于「创建」、覆盖优于追加),就不必事事依赖台账兜底;可靠性来自模型的适应力配合确定性护栏1,但护栏本身也是复杂度,只在它确实改善结果时才该加3

>> 第 5 课:回退与分叉:检查点的第二重价值

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

  3. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2 3

练习

01

下面六个工具,逐一判断:(1) 它是不是幂等的;(2) 如果恢复时把一次悬空调用对它重跑一遍,风险等级是高、中还是低,说明理由。

Level 1:给六个工具挑幂等性和风险等级
  • read_file(path) —— 读取文件内容
  • send_email(to, subject, body) —— 发一封邮件
  • ensure_ticket(title, body) —— 按标题查重,已存在就返回,不存在才新建工单
  • append_log(line) —— 在日志文件末尾追加一行
  • set_config(key, value) —— 把某个配置项设成给定的值(覆盖式)
  • delete_file(path) —— 删除一个文件
完成标准 · 本地勾选
02

上周你的 harness 在处理一个「客户报障 → 建工单」的任务时,执行完 create_ticket 拿到了成功结果,可容器恰好在这时被重启杀掉,结果还没来得及写回 tool_result。进程恢复后,harness 从 checkpoint.json 里读到 pendingToolUse 就是这个 create_ticket 调用,按下面这版没有台账的 runToolUses,它只能选择重跑——于是同一个客户报障,系统里凭空多出了一张重复工单。

Level 2:给一个没有台账的 runToolUses 补上效果台账

请你:(1) 写出 effects.json 的读写函数 loadEffects/saveEffects,要求原子写(先写 .tmprename);(2) 改造 runToolUses,补上「执行前查台账 → 没命中才执行 → 执行成功立刻记台账」这套逻辑;(3) 写一段 Node 脚本验证:对同一个 tool_use_id 调用两次改造后的 runToolUses(模拟恢复重放),第二次不会让 create_ticket 的真实实现被再执行一遍。

完成标准 · 本地勾选

我的笔记

记下想法、痛点、没懂的地方。只写进这门课的附录,正课文件不动。