1. 文件写完了,Agent 却不知道 上一篇完成了 Session JSONL、上下文预算和自动 Compaction。Agent 已经能调用 read_file、run_bash 与 write_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
workspace 和 session_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 重建。succeeded、failed 和 unknown 则需要保存真实 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()防止旧批准复用到新参数 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 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 laigit 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
预期输出:
7. 两次真实实验 代码自检能证明分支,但 JSONL 更适合建立直觉。下面保留一次正常写入和一次崩溃恢复。
7.1 正常写入:7 行完成一个 Turn 启动 Agent:
1 2 cd laipython 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] 路径。
第二次实验预先构造 4 行崩溃现场:User Message、Assistant Tool Call,以及旧执行的 approved → running。目标文件和 Tool Result 都不存在。
1 2 cd laiAGENT_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_bash 与 write_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 才能在工具已经影响外部世界、对话却没来得及写完时,恢复到一个说得清、查得到的状态。