Agent 可靠性:工具执行、幂等与故障恢复

版本说明:本文保留为已发布快照。持续更新、经过当前源码核验和故障注入验证的版本,见 《Agent 工程实践》第 7 课。后续完整正文只在书籍仓库维护。

1. 文件写完了,Agent 却不知道

上一篇完成了 Session JSONL、上下文预算和自动 Compaction。Agent 已经能调用 read_filerun_bashwrite_file,但工具循环里还藏着一个故障窗口:

1
2
3
4
5
保存 Assistant Tool Call

执行 write_file

保存 Tool Result

假设文件已经替换成功,程序却在保存 Tool Result 前崩溃。重启后的 Session 只有 Tool Call,没有回执。Agent 无法判断工具根本没运行,还是已经运行但丢了结果。

这不是普通的 failed

1
2
failed  = 已确认工具执行失败
unknown = 无法确认副作用是否发生

如果把 unknown 当成失败并自动重跑,邮件可能发两次,付款可能扣两次,echo x >> audit.log 也会追加两行。本章要解决的就是这段空白:让工具执行留下可恢复的证据,再用证据完成原来的 Agent Turn。

2. 一份 Session,两条记录线

模型协议和工具执行关心的不是同一件事。

Session Message 记录 User、Assistant 与 Tool Result,供模型继续对话。Tool Execution Ledger 记录每次真实执行尝试走到了哪一步,供执行器恢复和审计。

当前单进程示例没有为两者创建两个文件。session_file 是当前会话 JSONL 的磁盘路径:

1
2
3
session_file = (
workspace / ".agent_state" / f"session-{session_id}.jsonl"
)

同一个文件用 type 区分记录:

1
2
3
4
session-demo.jsonl
├─ type=message User、Assistant、Tool Result
├─ type=compaction 上下文压缩记录
└─ type=tool_execution Tool Execution Ledger

workspacesession_file 都是路径,但职责不同:

1
2
workspace    = 工具被允许操作哪些业务文件
session_file = Agent 把本次会话记录写到哪里

Ledger 不应进入模型上下文。build_prompt_view() 在没有 Compaction 时只选择 message

1
2
3
4
5
return [
entry["message"]
for entry in entries
if entry.get("type") == "message"
]

因此,JSONL 中即使有 9 条 Message 和 6 条 Ledger,模型也只看到 9 条 Message。Ledger 没有被删除,它仍在磁盘上等恢复程序读取。

最短的区分是:

1
2
Session 记录模型经历了什么
Ledger 记录工具实际上走到了哪一步

3. 三个 ID 各管一层

恢复程序必须分清 “ 模型下的订单 “” 执行器的尝试 “ 和 “ 同一个业务动作 “。三个 ID 正好对应这三层。

ID回答的问题何时变化
tool_call_idTool Result 属于哪次 Assistant Tool Call?模型产生新的工具请求时
execution_id哪次真实尝试可能产生了副作用?每次执行或重试时
idempotency_key多次尝试是否属于同一个逻辑调用?Tool Call 变化时

同一个 Tool Call 如果经过两次执行尝试,会形成:

1
2
3
4
5
tool_call_id = call_7
idempotency_key = write_file:call_7

第一次尝试:execution_id = exec_1 → unknown
第二次尝试:execution_id = exec_2 → succeeded

两次尝试都在完成同一个模型请求,所以 tool_call_id 不变;第二次是真实的新尝试,所以必须创建新的 execution_id

如果模型完成写入后又调用 read_file 验证内容,那是一张新工具订单,需要新的 tool_call_id

1
2
call_write → write_file → write result
call_read → read_file → read result

一个订单号只能配对自己的回执。复用旧 tool_call_id 会让写入和读取共用一个订单号,API 无法判断两张 Tool Result 分别回答哪次调用。

3.1 幂等键不是防重开关

idempotency_key 只是同一逻辑调用的稳定身份。当前代码使用 工具名:tool_call_id;两张参数完全相同的新 Tool Call 仍会得到不同 Key,因为它们可能是用户有意发起的两次操作。真正防重的必须是执行端:数据库唯一约束、外部 API,或者工具自己的原子检查。

1
2
3
本地数据库     UNIQUE(idempotency_key)
HTTP API Idempotency-Key Header
结构化工具 在一个事务中检查 Key 并执行

给任意 Bash 命令附上 Key 没有用。echo sent >> audit.log 不会读取这个 Key,每执行一次仍会多写一行。

参数 Hash 单独回答 “ 同一个 Key 的内容有没有变化 “。Ledger 保存规范化参数的 SHA-256:

1
2
3
4
5
6
7
8
def arguments_sha256(arguments: dict) -> str:
canonical = json.dumps(
arguments,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

sort_keys=True 消除了字段顺序差异。恢复时重新计算 Hash;只要与 Ledger 不同,就停止执行和结果复用。否则旧批准可能被错误地用于新路径或新内容。

1
2
3
4
5
{"a":1,"b":2} 与 {"b":2,"a":1}
→ 规范化后相同,Hash 相同

{"content":"hello"} 与 {"content":"goodbye"}
→ 业务参数不同,Hash 必须不同

4. 先记账,再执行

可靠执行的核心不是增加更多状态,而是固定落盘顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Assistant Tool Call 已保存

用户拒绝 → Ledger: rejected
↓ 用户批准
Ledger: approved

Ledger: running + fsync

执行工具

Ledger: succeeded / failed / unknown + result

Session: Tool Result

Assistant Final

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 重建。succeededfailed 必须保存可重放 Result;unknown 至少要保存原因和对账线索,恢复时再构造不确定回执。大输出仍应放进 Artifact,Ledger 只留引用。

参考实现复用 Session 的追加函数写 Ledger:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def append_execution_state(
session_file: Path,
execution_id: str,
tool_call_id: str,
status: str,
**details,
) -> dict:
if status not in VALID_EXECUTION_STATUSES:
raise ValueError(f"未知执行状态:{status}")

entry = {
"type": "tool_execution",
"execution_id": execution_id,
"tool_call_id": tool_call_id,
"status": status,
**details,
}
append_entry(session_file, entry)
return entry

append_entry()flush()fsync()。内存投影只能在追加成功后更新。崩溃会清空内存字典,却不会删除已经落盘的 JSONL。

扫描时,同一个 execution_id 的后记录覆盖内存中的前记录:

1
2
3
4
5
6
def latest_execution_states(entries: list[dict]) -> dict[str, dict]:
states = {}
for entry in entries:
if entry.get("type") == "tool_execution":
states[entry["execution_id"]] = entry
return states

这里覆盖的只是内存字典。磁盘仍完整保留 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
2
3
4
5
6
7
8
目标 Byte 与请求 content 完全相同
→ 不重新写文件
→ 沿用原 execution_id 追加 succeeded
→ result 标记 reconciled=true

目标不存在或内容不同
→ 保持 unknown
→ 等用户确认或更权威的外部回执

reconciled=true 只表示逻辑请求要求的最终状态已经满足,不能证明旧进程是否真正写过,也不能证明它执行了几次。旧 unknown 仍留在 Ledger 中,Session 只发布一条最终 Tool Result。

5.2 补写 Result 后,Turn 还没结束

Tool Result 只把工具输出交还模型。模型可能直接生成结论,也可能继续调用另一个工具。只有出现不含 tool_calls 的 Assistant Final,当前 Turn 才算完成。

1
2
3
4
5
6
7
8
run_agent()
→ 校验旧 Session 已完成
→ 追加新 User Message
→ continue_agent_turn()

recover_missing_tool_results()
→ 补写缺失 Tool Result
→ continue_agent_turn()

恢复路径不能调用会追加新 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
2
3
4
5
6
7
8
9
recovered = recover_missing_tool_results(workspace, session_file)
history = build_prompt_view(load_entries(session_file))

if history and (
history[-1].get("role") != "assistant"
or history[-1].get("tool_calls")
):
answer = continue_agent_turn(...)
print("Agent>", answer)

6. 从上下文管理到可靠执行,代码增加了什么

lesson_07_tool_reliability.py 保留了第 5 课的 Session、Compaction、上下文预算与三个本地工具,再增加可靠执行需要的部分。最新的分步实现位于 第一阶段综合实践第 4~5 关

增量关键入口用途
执行身份arguments_sha256()防止旧批准复用到新参数
Ledgerappend_execution_state()latest_execution_states()追加状态并恢复最新投影
孤立调用扫描pending_tool_calls()latest_execution_by_tool_call()找缺少 Tool Result 的调用
可靠 Routerexecute_tool(..., session_file, ...)在副作用前后写 Ledger
崩溃恢复recover_missing_tool_results()重放终态或处理 running
可续接循环continue_agent_turn()不追加新 User,继续完成旧 Turn

03 的 Router 只执行工具并返回 JSON,所以不需要 session_file。04 要在执行前后写 Ledger,必须知道当前会话日志的磁盘路径:

1
2
3
4
5
6
7
def execute_tool(
workspace: Path,
session_file: Path,
tool_call: object,
ask=input,
) -> str:
...

完整可运行代码见 lesson_07_tool_reliability.py

对照 03 与 04:

1
2
3
4
5
6
git clone https://github.com/unix2dos/agent-engineering-book.git
cd agent-engineering-book

git diff --no-index --color=always \
examples/lesson_05_context_compaction.py \
examples/lesson_07_tool_reliability.py | less -R

推荐阅读顺序:

1
2
3
4
5
6
execute_tool()
→ Ledger 辅助函数
→ recover_missing_tool_results()
→ continue_agent_turn()
→ interactive_main() 的启动恢复
→ self_check()

先运行最小自检:

1
python examples/lesson_07_tool_reliability.py --self-check

预期输出:

1
self-check passed

7. 两次真实实验

代码自检能证明分支,但 JSONL 更适合建立直觉。下面保留一次正常写入和一次崩溃恢复。

7.1 正常写入:7 行完成一个 Turn

启动 Agent:

1
2
cd agent-engineering-book
python examples/lesson_07_tool_reliability.py

输入:

1
请调用 write_file 工具,在当前 Workspace 创建 ledger-demo.txt,内容必须恰好是 hello ledger。不要使用 run_bash。

批准预览:

1
2
3
4
5
6
{
"action": "create",
"path": "ledger-demo.txt",
"bytes": 12,
"preview": "hello ledger"
}

输入 y 后,Session 最后 7 行依次是:

1
2
3
4
5
6
7
1. message / user
2. message / assistant + write_file Tool Call
3. tool_execution / approved
4. tool_execution / running
5. tool_execution / succeeded + result
6. message / tool + Tool Result
7. message / assistant + Final

第 2 至第 6 行使用同一个 tool_call_id。三条 Ledger 与 Tool Result 使用同一个成功 execution_id。磁盘中的 ledger-demo.txt 正好是 12 字节,没有额外换行。

1
2
3
4
5
6
7
{"type":"message","message":{"role":"user","content":"请调用 write_file 工具,在当前 Workspace 创建 ledger-demo.txt,内容必须恰好是 hello ledger。不要使用 run_bash。"}}
{"type":"message","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_7d0dea1500df4e77ad998b2d","type":"function","function":{"name":"write_file","arguments":"{\"path\": \"ledger-demo.txt\", \"content\": \"hello ledger\"}"}}]}}
{"type":"tool_execution","execution_id":"exec_ef1a380b3439469ea9677bbd74c2e8a3","tool_call_id":"call_7d0dea1500df4e77ad998b2d","status":"approved","tool_name":"write_file","idempotency_key":"write_file:call_7d0dea1500df4e77ad998b2d","arguments_sha256":"316b7a5c54fa80f28f7af606784c9af906d06657a1fd35b6094fed239dca6966"}
{"type":"tool_execution","execution_id":"exec_ef1a380b3439469ea9677bbd74c2e8a3","tool_call_id":"call_7d0dea1500df4e77ad998b2d","status":"running","tool_name":"write_file","idempotency_key":"write_file:call_7d0dea1500df4e77ad998b2d","arguments_sha256":"316b7a5c54fa80f28f7af606784c9af906d06657a1fd35b6094fed239dca6966"}
{"type":"tool_execution","execution_id":"exec_ef1a380b3439469ea9677bbd74c2e8a3","tool_call_id":"call_7d0dea1500df4e77ad998b2d","status":"succeeded","result":{"status":"succeeded","execution_id":"exec_ef1a380b3439469ea9677bbd74c2e8a3","path":"ledger-demo.txt","bytes_written":12},"tool_name":"write_file","idempotency_key":"write_file:call_7d0dea1500df4e77ad998b2d","arguments_sha256":"316b7a5c54fa80f28f7af606784c9af906d06657a1fd35b6094fed239dca6966"}
{"type":"message","message":{"role":"tool","tool_call_id":"call_7d0dea1500df4e77ad998b2d","content":"{\"status\": \"succeeded\", \"execution_id\": \"exec_ef1a380b3439469ea9677bbd74c2e8a3\", \"path\": \"ledger-demo.txt\", \"bytes_written\": 12}"}}
{"type":"message","message":{"role":"assistant","content":"文件已成功创建:**ledger-demo.txt**,内容为 `hello ledger`。"}}

这次实验没有设置独立 AGENT_SESSION_ID,记录追加到了已有的 session-demo.jsonl。隔离实验时应检查启动日志中的 [session] 路径。

7.2 崩溃恢复:先检查文件,不自动重写

综合实践第五关构造了这样的现场:Assistant Tool Call 和 approved → running 已经保存,目标文件也已经变成请求内容,但 Tool Result 尚未写入。

恢复后的关键顺序是:

1
2
3
4
5
6
7
1. 原 execution:approved
2. 原 execution:running
3. 原 execution:unknown
4. 读取目标文件,发现 Byte 与请求内容完全一致
5. 原 execution:succeeded + reconciled=true
6. message / tool:发布对账后的 Tool Result
7. message / assistant:完成旧 Turn

没有创建第二个 execution_id,也没有再次调用 write_file。同一条执行流水保留了 “ 旧进程结果不明 “ 和 “ 重启后确认目标状态已满足 “ 两个事实:

1
2
3
4
{"type":"tool_execution","execution_id":"exec_interrupted","tool_call_id":"call_write","status":"running","idempotency_key":"write_file:call_write","arguments_sha256":"..."}
{"type":"tool_execution","execution_id":"exec_interrupted","tool_call_id":"call_write","status":"unknown","message":"副作用无法确认"}
{"type":"tool_execution","execution_id":"exec_interrupted","tool_call_id":"call_write","status":"succeeded","result":{"status":"succeeded","execution_id":"exec_interrupted","path":"answer.txt","bytes_written":4,"reconciled":true}}
{"type":"message","message":{"role":"tool","tool_call_id":"call_write","content":"{\"status\":\"succeeded\",\"reconciled\":true}"}}

如果文件不存在或内容已经不同,恢复程序不会覆盖它,只会发布 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
2
3
批准 = 用户是否同意执行
权限 = 当前进程有没有能力执行
沙箱 = 即使执行,也只能触及允许的边界

cwd=workspace 只改变命令起点,不能阻止 Bash 访问 Workspace 外的文件。当前示例适合个人本机学习:run_bashwrite_file 每次人工批准,并明确说明 run_bash 不是沙箱。接收不可信输入、自动执行任务或对外提供服务前,仍需容器、低权限用户、文件系统与网络隔离。

8.3 JSONL 适合单进程教学,不负责并发事务

一个 Session JSONL 足以展示追加历史、Prompt 过滤和崩溃恢复。进入多进程执行、跨 Session 调度或大量状态查询后,需要 SQLite 或数据库约束,并补上锁、租约或事务 Outbox。

如果外部服务能把副作用、终态与待发布回执放进同一个原子事务,独立 Ledger 的必要性也会下降。本文的方案不是 “Exactly Once” 魔法,它只是把不确定窗口显式记录下来,并为不同工具选择可证明的恢复动作。

主动回忆自测

  1. failedunknown 有什么区别?
  2. tool_call_idexecution_ididempotency_key 分别标识什么?
  3. 为什么相同幂等键还必须核对 arguments_sha256
  4. 为什么 running 必须在副作用前写盘并 fsync()
  5. 为什么终态与 Result 要先于 Session Tool Result 落盘?
  6. rejectedfailedunknown 分别能确定什么?
  7. 为什么相同 unknown 状态下,不同 Tool 的重试策略不同?
  8. 恢复程序补写 Tool Result 后,为什么还不能显示新的 You>
  9. 内存最新状态投影与磁盘完整 Ledger 有什么区别?
  10. 为什么文件内容符合预期仍不能证明某次 Execution 成功?
展开查看简答
  1. failed 是确认失败;unknown 是无法确认副作用是否发生。
  2. Tool Call ID 配对模型订单与回执;Execution ID 区分每次真实尝试;Idempotency Key 关联同一逻辑调用。
  3. Key 相同但参数不同是冲突;规范化 Hash 既忽略字段顺序,又能发现业务参数变化。
  4. 否则 Tool 可能已经执行,磁盘却仍显示尚未开始,恢复程序会误重跑。
  5. 若两次写入间崩溃,恢复程序可以从 Ledger 重放 Result,不必重新执行 Tool。
  6. rejected 确定未执行;failed 确认失败但可能已有部分副作用;unknown 无法确认副作用。
  7. 是否能继续处理来自工具契约:只读工具可重放,文件写入先核对目标状态,外部 API 依赖 Key,任意 Bash 保持 unknown
  8. role="tool" 只把结果交还 Model;还要继续旧 Turn,直到 Assistant Final。
  9. 内存只保留每个 Execution 的最后状态用于决策;磁盘追加历史保留全部状态变化用于审计。
  10. 内容可能原本相同或由其他进程写入;只有能归属于该 Execution 的权威回执才能证明。

9. 结语

工具可靠性真正增加的不是几个状态名,而是一条可核对的因果链:

1
2
3
4
5
6
Tool Call 已保存
→ 执行前写 running
→ 终态和 Result 先落盘
→ Tool Result 后发布
→ 崩溃时按 ID、证据和工具契约恢复
→ Assistant Final 完成原 Turn

Session 让模型接着说,Ledger 让执行器知道自己做过什么。两条记录线分开后,Agent 才能在工具已经影响外部世界、对话却没来得及写完时,恢复到一个说得清、查得到的状态。