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

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多次尝试是否属于同一个业务动作?业务动作变化时

一次 write_file 在崩溃后安全重试,会形成:

1
2
3
4
5
tool_call_id = call_7
idempotency_key = write_file:<arguments hash>

第一次尝试: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 只是同一业务动作的稳定身份。真正防重的必须是执行端:数据库唯一约束、外部 API,或者工具自己的原子检查。

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

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

同一个 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 不同,就停止执行和结果复用。否则旧批准可能被错误地用于新路径或新内容。

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无法确认工具是否产生副作用

rejected 的标准回执可以由状态与 execution_id 重建。succeededfailedunknown 则需要保存真实 Result、受限错误或核对线索。终态的共同要求不是 “ 复制一份完整输出 “,而是 “ 以后能够重放 Tool Result”。大输出仍应放进 Artifact,Ledger 只留引用。

04 版复用 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 / unknown从 Ledger 重放已保存 Result

所有分支都先核对参数 Hash。Hash 不同就报冲突,不执行,也不复用旧结果。

5.1 running 之后能不能重试

状态机只能告诉程序 “ 结果不确定 “,不能决定重试是否安全。这个判断属于工具契约。

工具恢复策略理由
read_file可以重新读取没有副作用;当前代码也不写 Ledger
write_file相同参数可安全重试原子替换完整内容,最终文件状态相同
run_bash保持 unknown,人工核对任意命令可能产生不可重复的副作用
支持幂等的外部 API携带原 Key 查询或重试执行端真正按 Key 防重

自然幂等的 write_file 重试时,第一次尝试仍要记为 unknown,第二次使用新 execution_id

1
2
3
4
exec_1: approved → running → unknown
exec_2: approved → running → succeeded

共同:tool_call_id、idempotency_key、arguments_sha256

最终 Tool Result 仍与原 tool_call_id 配对,但内容里的 execution_id 指向真正产生结果的 exec_2。Ledger 保留两次尝试,Session 只发布一个最终回执。

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()。它必须从旧历史继续循环,直到 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. 从 03 到 04,代码增加了什么

04_tool_reliability.py 保留了 03_context.py 的 Session、Compaction、上下文预算与三个本地工具,只增加可靠执行需要的部分。

增量关键入口用途
执行身份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:
...

完整可运行代码见 04_tool_reliability.py

对照 03 与 04:

1
2
3
4
5
6
git clone https://github.com/unix2dos/lai.git
cd lai

git diff --no-index --color=always \
03_context.py \
04_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 04_tool_reliability.py --self-check

预期输出:

1
self-check passed

7. 两次真实实验

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

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

启动 Agent:

1
2
cd lai
python 04_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 字节,没有额外换行。

展开查看正常写入的 7 行原始 JSONL
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:316b7a5c54fa80f28f7af606784c9af906d06657a1fd35b6094fed239dca6966","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:316b7a5c54fa80f28f7af606784c9af906d06657a1fd35b6094fed239dca6966","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:316b7a5c54fa80f28f7af606784c9af906d06657a1fd35b6094fed239dca6966","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 崩溃恢复:一次 Tool Call,两次 Execution

第二次实验预先构造 4 行崩溃现场:User Message、Assistant Tool Call,以及旧执行的 approved → running。目标文件和 Tool Result 都不存在。

1
2
cd lai
AGENT_SESSION_ID=lesson05-recovery-01 python 04_tool_reliability.py

恢复后的 Session 有 12 行:

1
2
3
4
5
6
7
8
9
10
11
12
1.  message / user
2. message / assistant + write_file Tool Call
3. 旧 execution / approved
4. 旧 execution / running
5. 旧 execution / unknown
6. 新 execution / approved
7. 新 execution / running
8. 新 execution / succeeded + result
9. message / tool:发布 write_file 最终结果
10. message / assistant:产生 read_file Tool Call
11. message / tool:返回 recovered
12. message / assistant:最终结论

恢复程序先把旧尝试记为 unknown,再为自然幂等的 write_file 创建新 execution_id。第 9 行用原 tool_call_id 发布第二次尝试的成功结果。

模型拿到 Tool Result 后没有立即 Final,而是新建 tool_call_id 调用 read_file 验证内容。read_file 是只读工具,所以第 10、11 行之间没有 Ledger。

展开查看崩溃恢复后的 12 行原始 JSONL
1
2
3
4
5
6
7
8
9
10
11
12
{"type":"message","message":{"role":"user","content":"请把 recovery-demo.txt 的完整内容设置为 recovered"}}
{"type":"message","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_recovery_demo","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"recovery-demo.txt\",\"content\":\"recovered\"}"}}]}}
{"type":"tool_execution","execution_id":"exec_interrupted_demo","tool_call_id":"call_recovery_demo","status":"approved","tool_name":"write_file","idempotency_key":"write_file:8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe","arguments_sha256":"8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe"}
{"type":"tool_execution","execution_id":"exec_interrupted_demo","tool_call_id":"call_recovery_demo","status":"running","tool_name":"write_file","idempotency_key":"write_file:8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe","arguments_sha256":"8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe"}
{"type":"tool_execution","execution_id":"exec_interrupted_demo","tool_call_id":"call_recovery_demo","status":"unknown","result":{"status":"unknown","execution_id":"exec_interrupted_demo","message":"进程在 running 状态中断,副作用无法确认"},"tool_name":"write_file","idempotency_key":"write_file:8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe","arguments_sha256":"8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe"}
{"type":"tool_execution","execution_id":"exec_abe36d1afcf74bcaaf736105eec7f9f1","tool_call_id":"call_recovery_demo","status":"approved","tool_name":"write_file","idempotency_key":"write_file:8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe","arguments_sha256":"8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe"}
{"type":"tool_execution","execution_id":"exec_abe36d1afcf74bcaaf736105eec7f9f1","tool_call_id":"call_recovery_demo","status":"running","tool_name":"write_file","idempotency_key":"write_file:8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe","arguments_sha256":"8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe"}
{"type":"tool_execution","execution_id":"exec_abe36d1afcf74bcaaf736105eec7f9f1","tool_call_id":"call_recovery_demo","status":"succeeded","result":{"status":"succeeded","execution_id":"exec_abe36d1afcf74bcaaf736105eec7f9f1","path":"recovery-demo.txt","bytes_written":9},"tool_name":"write_file","idempotency_key":"write_file:8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe","arguments_sha256":"8889280b9146b06f667dd62a1bdb2f6bcb763b656a18214926a39dba97dc18fe"}
{"type":"message","message":{"role":"tool","tool_call_id":"call_recovery_demo","content":"{\"status\": \"succeeded\", \"execution_id\": \"exec_abe36d1afcf74bcaaf736105eec7f9f1\", \"path\": \"recovery-demo.txt\", \"bytes_written\": 9}"}}
{"type":"message","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_831bbb27cf6f496b9526c757","type":"function","function":{"name":"read_file","arguments":"{\"path\": \"recovery-demo.txt\"}"}}]}}
{"type":"message","message":{"role":"tool","tool_call_id":"call_831bbb27cf6f496b9526c757","content":"{\"status\": \"completed\", \"content\": \"recovered\", \"path\": \"recovery-demo.txt\", \"truncated\": false, \"next_offset\": null}"}}
{"type":"message","message":{"role":"assistant","content":"已完成。`recovery-demo.txt` 的完整内容已被设置为 `recovered`。"}}

8. 这套设计的边界

8.1 状态不决定重试,工具契约才决定

同样是 unknown,不同工具需要不同动作:

可靠性来源例子能做什么
自然幂等原子覆盖完整文件用相同参数重新设置目标状态
Keyed Idempotency支付、发信服务执行端按原 Key 防止第二次业务动作
Reconciliation按交易 ID 查询获取第一次动作的权威状态

看到目标文件内容相同,并不能证明第一次执行成功;文件可能原本就是这个内容,也可能由其他进程写入。Reconciliation 需要能归属于本次执行的权威收据。

当前示例没有通用 reconcile()write_file 依赖自然幂等重试,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” 魔法,它只是把不确定窗口显式记录下来,并为不同工具选择可证明的恢复动作。

9. 结语

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

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

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