第 4 课:副作用与幂等:恢复时哪些工具敢重跑
学习目标:
- 说清恢复重放为什么默认给你的是「至少一次」执行语义,而不是「恰好一次」
- 判断一个工具操作是否幂等,识别哪些副作用一旦重跑就会闯祸
- 设计并实现效果台账(effects ledger),用
tool_use_id 做幂等键,让恢复时的悬空调用先查台账、再决定要不要真的执行
前置要求:读过第 2、3 课,理解 checkpoint.json 里 pendingToolUse 悬空调用的对账规则(第 3 课);了解本系列第 7 门课的 HIGH_IMPACT 工具集合与执行前审批阀 | 上一课 第 3 课 << | 下一课 第 5 课 >>
恢复带来的「至少一次」:第 3 课把高影响工具的对账挂起了
第 3 课教你从 checkpoint.json 里读出 pendingToolUse,用它把「崩溃发生在工具执行与结果落账之间」的悬空调用接回循环。当时的对账规则是:只读工具直接重跑就好,高影响工具查不清楚就补一个 is_error 的 tool_result,先让循环别卡死,把这件事交回给人。这是个诚实的兜底,但也是个没解决的问题——查不清楚,意味着任务没法自动续上,每次崩溃在高影响工具上,都得有人来盯着。
问题的根子在于:恢复这件事,天生给你的是至少一次(at-least-once)的执行语义。进程可能在工具真的执行成功之后、结果还没来得及写回 messages 或落进检查点之前崩掉——这时候「这个工具到底跑没跑」这件事,单看 checkpoint.json 是分辨不出来的。对 read_file 这种只读工具,分辨不出来无所谓,大不了多读一次,结果一样。可对 send_email、create_ticket、转账这类操作,分辨不出来就是事故:重跑意味着收件人可能收到两封同样的邮件,系统里可能凭空多出一张工单。
这不是个新问题。本系列第 1 门课就说过,Agent 是有状态的、错误会复利1——重复执行一次本该只发生一次的副作用,正是复利的一种具体形态:错误不会停在「多跑了一次」,它会顺着这次多余的副作用继续往下游滚。这一课要把第 3 课挂起的问题解决掉:引入幂等性这个概念,再给恢复循环装上一本效果台账,让「查不清楚」变成「查得清楚」。
幂等的定义:重跑一次和重跑多次,效果得一样
幂等(idempotent)说的是:一个操作执行一次和执行多次,最终效果相同,就是幂等的。注意这说的是「效果」,也就是操作对外部世界(文件、数据库、收件箱)留下的最终状态,不是说每次调用返回的字面值必须一样。
判断一个工具幂不幂等,问自己一句话就够:「如果这个操作被悄悄多跑了一次,外部世界会不会因此多出点什么、或者变得不一样?」下面几个小例子,照着这句话过一遍就能看出差别。
readFileContent 天然幂等,因为它压根没有副作用——没有「留下点什么」这回事。setLine 也幂等,尽管它确实修改了状态,但修改的方式是覆盖:调用一次把第 42 行设成 X,调用十次还是设成 X,终态不随调用次数变化。appendRow 和 sendEmail 不幂等,原因都一样:它们的效果是累加的——每调用一次,外部世界就真的多出一份东西,调用次数直接体现在最终状态里。
记住这条分界线:覆盖式的写入通常幂等,追加式的写入通常不幂等;读操作和「先查重再决定要不要动手」的操作通常幂等,纯粹的「无条件新增」通常不幂等。下一节的效果台账,就是专门用来兜住那些不幂等、又不得不保留的操作。
效果台账:把「哪些副作用已经发生」也落盘
第 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_config、write_file)通常幂等,追加式写入(append_log、send_email)通常不幂等。
- 效果台账把「哪些副作用已经发生」单独落盘,以
tool_use_id 为键——这个 id 是模型点名工具时自带的唯一标识符2,同一次点名恢复重放时不会变,天然就是幂等键;写入要用 .tmp + rename 原子写,且要在工具执行成功后立刻落盘。
- 恢复对账升级为:
pendingToolUse.id 在台账里命中,直接复用存好的结果,绝不重跑;没命中,安全执行。
- 审批阀管「该不该做」,效果台账管「做没做过」,两道闸互补,都卡在工具真正执行之前,幂等闸要排在审批阀前面。
- 治本优于治标:能把工具设计成天然幂等(「确保存在」优于「创建」、覆盖优于追加),就不必事事依赖台账兜底;可靠性来自模型的适应力配合确定性护栏1,但护栏本身也是复杂度,只在它确实改善结果时才该加3。
>> 第 5 课:回退与分叉:检查点的第二重价值