Agent 可靠性:工具执行、幂等与故障恢复
版本说明:本文保留为已发布快照。持续更新、经过当前源码核验和故障注入验证的版本,见 《Agent 工程实践》第 7 课。后续完整正文只在书籍仓库维护。
1. 文件写完了,Agent 却不知道
上一篇完成了 Session JSONL、上下文预算和自动 Compaction。Agent 已经能调用 read_file、run_bash 与 write_file,但工具循环里还藏着一个故障窗口:
1 | 保存 Assistant Tool Call |
假设文件已经替换成功,程序却在保存 Tool Result 前崩溃。重启后的 Session 只有 Tool Call,没有回执。Agent 无法判断工具根本没运行,还是已经运行但丢了结果。
这不是普通的 failed:
1 | failed = 已确认工具执行失败 |
如果把 unknown 当成失败并自动重跑,邮件可能发两次,付款可能扣两次,echo x >> audit.log 也会追加两行。本章要解决的就是这段空白:让工具执行留下可恢复的证据,再用证据完成原来的 Agent Turn。
2. 一份 Session,两条记录线
模型协议和工具执行关心的不是同一件事。
Session Message 记录 User、Assistant 与 Tool Result,供模型继续对话。Tool Execution Ledger 记录每次真实执行尝试走到了哪一步,供执行器恢复和审计。
当前单进程示例没有为两者创建两个文件。session_file 是当前会话 JSONL 的磁盘路径:
1 | session_file = ( |
同一个文件用 type 区分记录:
1 | session-demo.jsonl |
workspace 和 session_file 都是路径,但职责不同:
1 | workspace = 工具被允许操作哪些业务文件 |
Ledger 不应进入模型上下文。build_prompt_view() 在没有 Compaction 时只选择 message:
1 | return [ |
因此,JSONL 中即使有 9 条 Message 和 6 条 Ledger,模型也只看到 9 条 Message。Ledger 没有被删除,它仍在磁盘上等恢复程序读取。
最短的区分是:
1 | Session 记录模型经历了什么 |
3. 三个 ID 各管一层
恢复程序必须分清 “ 模型下的订单 “” 执行器的尝试 “ 和 “ 同一个业务动作 “。三个 ID 正好对应这三层。
| ID | 回答的问题 | 何时变化 |
|---|---|---|
tool_call_id | Tool Result 属于哪次 Assistant Tool Call? | 模型产生新的工具请求时 |
execution_id | 哪次真实尝试可能产生了副作用? | 每次执行或重试时 |
idempotency_key | 多次尝试是否属于同一个逻辑调用? | Tool Call 变化时 |
同一个 Tool Call 如果经过两次执行尝试,会形成:
1 | tool_call_id = call_7 |
两次尝试都在完成同一个模型请求,所以 tool_call_id 不变;第二次是真实的新尝试,所以必须创建新的 execution_id。
如果模型完成写入后又调用 read_file 验证内容,那是一张新工具订单,需要新的 tool_call_id:
1 | call_write → write_file → write result |
一个订单号只能配对自己的回执。复用旧 tool_call_id 会让写入和读取共用一个订单号,API 无法判断两张 Tool Result 分别回答哪次调用。
3.1 幂等键不是防重开关
idempotency_key 只是同一逻辑调用的稳定身份。当前代码使用 工具名:tool_call_id;两张参数完全相同的新 Tool Call 仍会得到不同 Key,因为它们可能是用户有意发起的两次操作。真正防重的必须是执行端:数据库唯一约束、外部 API,或者工具自己的原子检查。
1 | 本地数据库 UNIQUE(idempotency_key) |
给任意 Bash 命令附上 Key 没有用。echo sent >> audit.log 不会读取这个 Key,每执行一次仍会多写一行。
参数 Hash 单独回答 “ 同一个 Key 的内容有没有变化 “。Ledger 保存规范化参数的 SHA-256:
1 | def arguments_sha256(arguments: dict) -> str: |
sort_keys=True 消除了字段顺序差异。恢复时重新计算 Hash;只要与 Ledger 不同,就停止执行和结果复用。否则旧批准可能被错误地用于新路径或新内容。
1 | {"a":1,"b":2} 与 {"b":2,"a":1} |
4. 先记账,再执行
可靠执行的核心不是增加更多状态,而是固定落盘顺序:
1 | Assistant Tool Call 已保存 |
running 必须在副作用前落盘。否则进程可能已经修改外部状态,磁盘上却还显示 approved,恢复程序会误以为工具从未开始。
工具完成后,终态和 Result 也必须先于 Tool Result 落盘。如果程序在两次写入之间崩溃,Ledger 已有可重放结果,恢复程序只需补写 Tool Result,不必重新执行工具。
当前状态含义如下:
| 状态 | 已知事实 |
|---|---|
approved | 用户已经批准,Ledger 尚未记录工具开始 |
rejected | 用户拒绝,工具没有执行 |
running | 工具已经进入执行窗口,结果尚未确认 |
succeeded | 工具确认成功,终态应保存 Result |
failed | 工具确认失败,但不代表副作用已回滚 |
unknown | 无法确认工具是否产生副作用 |
例如 Bash 已追加一行日志,随后以退出码 1 结束,状态是 failed,但追加副作用已经发生。只有 rejected 能确定 Tool 完全没有执行;unknown 则表示连副作用是否发生都无法确认。
rejected 的标准回执可以由状态与 execution_id 重建。succeeded 和 failed 必须保存可重放 Result;unknown 至少要保存原因和对账线索,恢复时再构造不确定回执。大输出仍应放进 Artifact,Ledger 只留引用。
参考实现复用 Session 的追加函数写 Ledger:
1 | def append_execution_state( |
append_entry() 会 flush() 并 fsync()。内存投影只能在追加成功后更新。崩溃会清空内存字典,却不会删除已经落盘的 JSONL。
扫描时,同一个 execution_id 的后记录覆盖内存中的前记录:
1 | def latest_execution_states(entries: list[dict]) -> dict[str, dict]: |
这里覆盖的只是内存字典。磁盘仍完整保留 approved → running → unknown 三行历史。
5. 重启时怎样恢复
恢复从 “ 孤立 Tool Call” 开始:Session 中存在 Assistant Tool Call,却找不到相同 tool_call_id 的 Tool Result。
程序启动后先扫描孤立调用,再查看对应 Ledger 的最后状态:
| 最后状态 | 当前代码的恢复动作 |
|---|---|
| 没有 Ledger | 重新进入工具 Router;副作用工具仍需批准 |
approved | 重新进入 Router;当前实现会再次确认批准 |
running | 先把原 execution_id 追加为 unknown,再核对外部状态 |
rejected | 确定性重建拒绝回执 |
succeeded / failed | 从 Ledger 重放已保存 Result |
unknown | 发布不确定回执;只有取得新证据后才更新状态 |
所有分支都先核对参数 Hash。Hash 不同就报冲突,不执行,也不复用旧结果。
5.1 running 之后能不能重试
状态机只能告诉程序 “ 结果不确定 “,不能决定重试是否安全。这个判断属于工具契约。
| 工具 | 恢复策略 | 理由 |
|---|---|---|
read_file | 可以重新读取 | 没有副作用;当前代码也不写 Ledger |
write_file | 先比较目标 Byte | 内容相同只说明目标状态已满足;不同则不能自动覆盖 |
run_bash | 保持 unknown,人工核对 | 任意命令可能产生不可重复的副作用 |
| 支持幂等的外部 API | 携带原 Key 查询或重试 | 执行端真正按 Key 防重 |
当前实现不会自动重试 unknown write_file。它先读取目标文件:
1 | 目标 Byte 与请求 content 完全相同 |
reconciled=true 只表示逻辑请求要求的最终状态已经满足,不能证明旧进程是否真正写过,也不能证明它执行了几次。旧 unknown 仍留在 Ledger 中,Session 只发布一条最终 Tool Result。
5.2 补写 Result 后,Turn 还没结束
Tool Result 只把工具输出交还模型。模型可能直接生成结论,也可能继续调用另一个工具。只有出现不含 tool_calls 的 Assistant Final,当前 Turn 才算完成。
1 | run_agent() |
恢复路径不能调用会追加新 User 的 run_agent()。补写后历史最后一条仍是 role="tool":Model 尚未看到这个 Result,当前 User Turn 也尚未结束。补写的 Tool Result 使用原 tool_call_id;再次请求 Model 不需要复用这个 ID,若 Model 再调用新 Tool,会生成新的 tool_call_id。恢复程序必须从旧历史继续循环,直到 Assistant Final,然后才显示新的 You>。
启动入口因此放在用户输入循环之前:
1 | recovered = recover_missing_tool_results(workspace, session_file) |
6. 从上下文管理到可靠执行,代码增加了什么
lesson_07_tool_reliability.py 保留了第 5 课的 Session、Compaction、上下文预算与三个本地工具,再增加可靠执行需要的部分。最新的分步实现位于 第一阶段综合实践第 4~5 关。
| 增量 | 关键入口 | 用途 |
|---|---|---|
| 执行身份 | arguments_sha256() | 防止旧批准复用到新参数 |
| Ledger | append_execution_state()、latest_execution_states() | 追加状态并恢复最新投影 |
| 孤立调用扫描 | pending_tool_calls()、latest_execution_by_tool_call() | 找缺少 Tool Result 的调用 |
| 可靠 Router | execute_tool(..., session_file, ...) | 在副作用前后写 Ledger |
| 崩溃恢复 | recover_missing_tool_results() | 重放终态或处理 running |
| 可续接循环 | continue_agent_turn() | 不追加新 User,继续完成旧 Turn |
03 的 Router 只执行工具并返回 JSON,所以不需要 session_file。04 要在执行前后写 Ledger,必须知道当前会话日志的磁盘路径:
1 | def execute_tool( |
完整可运行代码见 lesson_07_tool_reliability.py。
对照 03 与 04:
1 | git clone https://github.com/unix2dos/agent-engineering-book.git |
推荐阅读顺序:
1 | execute_tool() |
先运行最小自检:
1 | python examples/lesson_07_tool_reliability.py --self-check |
预期输出:
1 | self-check passed |
7. 两次真实实验
代码自检能证明分支,但 JSONL 更适合建立直觉。下面保留一次正常写入和一次崩溃恢复。
7.1 正常写入:7 行完成一个 Turn
启动 Agent:
1 | cd agent-engineering-book |
输入:
1 | 请调用 write_file 工具,在当前 Workspace 创建 ledger-demo.txt,内容必须恰好是 hello ledger。不要使用 run_bash。 |
批准预览:
1 | { |
输入 y 后,Session 最后 7 行依次是:
1 | 1. message / user |
第 2 至第 6 行使用同一个 tool_call_id。三条 Ledger 与 Tool Result 使用同一个成功 execution_id。磁盘中的 ledger-demo.txt 正好是 12 字节,没有额外换行。
1 | {"type":"message","message":{"role":"user","content":"请调用 write_file 工具,在当前 Workspace 创建 ledger-demo.txt,内容必须恰好是 hello ledger。不要使用 run_bash。"}} |
这次实验没有设置独立 AGENT_SESSION_ID,记录追加到了已有的 session-demo.jsonl。隔离实验时应检查启动日志中的 [session] 路径。
7.2 崩溃恢复:先检查文件,不自动重写
综合实践第五关构造了这样的现场:Assistant Tool Call 和 approved → running 已经保存,目标文件也已经变成请求内容,但 Tool Result 尚未写入。
恢复后的关键顺序是:
1 | 1. 原 execution:approved |
没有创建第二个 execution_id,也没有再次调用 write_file。同一条执行流水保留了 “ 旧进程结果不明 “ 和 “ 重启后确认目标状态已满足 “ 两个事实:
1 | {"type":"tool_execution","execution_id":"exec_interrupted","tool_call_id":"call_write","status":"running","idempotency_key":"write_file:call_write","arguments_sha256":"..."} |
如果文件不存在或内容已经不同,恢复程序不会覆盖它,只会发布 unknown Tool Result。随后 Model 可以告诉用户先检查文件或外部系统。这个结果更保守,但不会把崩溃后的新修改当作旧任务的一部分抹掉。
8. 这套设计的边界
8.1 状态不决定重试,工具契约才决定
同样是 unknown,不同工具需要不同动作:
| 可靠性来源 | 例子 | 能做什么 |
|---|---|---|
| 本地状态核对 | 比较目标文件 Byte | 确认请求要求的最终状态是否已经满足 |
| Keyed Idempotency | 支付、发信服务 | 执行端按原 Key 防止第二次业务动作 |
| Reconciliation | 按交易 ID 查询 | 获取第一次动作的权威状态 |
看到目标文件内容相同,并不能证明第一次执行成功;文件可能原本就是这个内容,也可能由其他进程写入。当前代码只把它解释为 “ 逻辑请求要求的最终状态已经满足 “,并用 reconciled=true 保留证据边界。若要证明某次 Execution 确实发生,仍需查询带交易 ID、请求 ID 或幂等键的权威外部回执。
当前示例只实现 write_file 的目标 Byte 核对,不实现通用 reconcile()。文件内容不同和 run_bash 都保持 unknown。支付、部署等外部工具应各自提供查询能力,不能让模型凭结果外观猜状态。
8.2 批准不是沙箱
批准、权限和沙箱解决不同问题:
1 | 批准 = 用户是否同意执行 |
cwd=workspace 只改变命令起点,不能阻止 Bash 访问 Workspace 外的文件。当前示例适合个人本机学习:run_bash 与 write_file 每次人工批准,并明确说明 run_bash 不是沙箱。接收不可信输入、自动执行任务或对外提供服务前,仍需容器、低权限用户、文件系统与网络隔离。
8.3 JSONL 适合单进程教学,不负责并发事务
一个 Session JSONL 足以展示追加历史、Prompt 过滤和崩溃恢复。进入多进程执行、跨 Session 调度或大量状态查询后,需要 SQLite 或数据库约束,并补上锁、租约或事务 Outbox。
如果外部服务能把副作用、终态与待发布回执放进同一个原子事务,独立 Ledger 的必要性也会下降。本文的方案不是 “Exactly Once” 魔法,它只是把不确定窗口显式记录下来,并为不同工具选择可证明的恢复动作。
主动回忆自测
failed与unknown有什么区别?tool_call_id、execution_id与idempotency_key分别标识什么?- 为什么相同幂等键还必须核对
arguments_sha256? - 为什么
running必须在副作用前写盘并fsync()? - 为什么终态与 Result 要先于 Session Tool Result 落盘?
rejected、failed、unknown分别能确定什么?- 为什么相同
unknown状态下,不同 Tool 的重试策略不同? - 恢复程序补写 Tool Result 后,为什么还不能显示新的
You>? - 内存最新状态投影与磁盘完整 Ledger 有什么区别?
- 为什么文件内容符合预期仍不能证明某次 Execution 成功?
展开查看简答
failed是确认失败;unknown是无法确认副作用是否发生。- Tool Call ID 配对模型订单与回执;Execution ID 区分每次真实尝试;Idempotency Key 关联同一逻辑调用。
- Key 相同但参数不同是冲突;规范化 Hash 既忽略字段顺序,又能发现业务参数变化。
- 否则 Tool 可能已经执行,磁盘却仍显示尚未开始,恢复程序会误重跑。
- 若两次写入间崩溃,恢复程序可以从 Ledger 重放 Result,不必重新执行 Tool。
rejected确定未执行;failed确认失败但可能已有部分副作用;unknown无法确认副作用。- 是否能继续处理来自工具契约:只读工具可重放,文件写入先核对目标状态,外部 API 依赖 Key,任意 Bash 保持
unknown。 role="tool"只把结果交还 Model;还要继续旧 Turn,直到 Assistant Final。- 内存只保留每个 Execution 的最后状态用于决策;磁盘追加历史保留全部状态变化用于审计。
- 内容可能原本相同或由其他进程写入;只有能归属于该 Execution 的权威回执才能证明。
9. 结语
工具可靠性真正增加的不是几个状态名,而是一条可核对的因果链:
1 | Tool Call 已保存 |
Session 让模型接着说,Ledger 让执行器知道自己做过什么。两条记录线分开后,Agent 才能在工具已经影响外部世界、对话却没来得及写完时,恢复到一个说得清、查得到的状态。