> For the complete documentation index, see [llms.txt](https://levon.gitbook.io/agent-engineering/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://levon.gitbook.io/agent-engineering/06-gong-ju-ke-kao-xing.md).

# 第 6 课：Tool Reliability——幂等、Ledger 与故障恢复

前五课的助手只读材料、给出诊断。现在增加一项受限操作：获准后，把报告写到工作区的 `diagnosis.txt`。

假设文件已经写好，程序却在保存工具结果前中断。重启后，磁盘上有报告，对话里却只有一张没有回执的调用申请。文件没失忆，程序的记录落后了一步。

本课围绕这个中断窗口，区分记录、重放和重新执行。代码中的报告内容简化为 `done`，用于观察写入结果；实验只操作临时目录，不执行支付、发信等真实业务。

## 1. Ledger：记录执行状态与结果

Transcript 保存用户、模型和工具之间的消息。要恢复一次有副作用的操作，还需要知道它何时获准、是否已经开始、留下了什么结果。这份执行账本叫 **Tool Execution Ledger**，简称 **Ledger**。

对于已经获准的写文件操作，顺序应当是：

```
保存 Assistant Tool Call
-> 记 approved
-> 记 running，并确认账本记录写入成功
-> 执行 write_file
-> 把终态与 result 一起保存
-> 写回 Tool Result Message
-> 模型继续回答
```

如果用户拒绝，则记 `rejected` 并回传拒绝结果，不进入执行路径。账本由执行层维护，不能拿模型自己填写的“已批准”或“已成功”当作证据。

`running` 要先于文件变化，否则文件已经改了，本地记录却可能显示“还没开始”。执行结束后，状态和结果也要一起保存：

```json
{
  "execution_id": "exec_1",
  "tool_call_id": "call_report",
  "status": "succeeded",
  "result": {"path": "diagnosis.txt", "bytes_written": 4}
}
```

这是省略工具名、参数指纹等字段的示意记录。终态回答“执行结果是什么”，`result` 留下可以重新交给模型的回执。先存 succeeded、稍后再存结果，会制造另一种中断：账上说成功，却拿不出回执。

恢复时还要区分两个状态：

| 状态        | 已经知道什么        | 不能直接推出什么           |
| --------- | ------------- | ------------------ |
| `failed`  | 工具已经报告失败      | 不能保证失败前没有产生部分副作用   |
| `unknown` | 目前无法确认这次动作的结果 | 不能当作“肯定没执行，可以再来一次” |

确认旧执行者已经中断后，遗留的 running 应转为 unknown，旧记录保留。它可能在动作前中断，也可能在动作后中断。仅凭账本停在哪里，分不出这两种情况。

账本与对话可以存进同一份 JSONL，也可以分开。前者保留执行证据，后者组织会话，内部状态流水不必全部交给模型。写入失败、半条记录和落盘保证仍需处理，扩展名不会自动提供可靠性。

## 2. Idempotency：业务效果与请求身份

**幂等性（Idempotency）** 指同一个操作重复请求时，业务效果与执行一次相同。例如，把内容设为 `done`，连续覆盖两次仍是 `done`；每次追加一行 `done`，做两次就会多两行。

这不等于所有覆盖写都能放心重试。如果中间有人更新了文件，再用旧内容覆盖，就会抹掉他的修改。判断能否重试，必须同时看操作契约和当前证据。

因此，需要区分三种身份：

| 编号                | 区分什么         |
| ----------------- | ------------ |
| `tool_call_id`    | 模型提出的哪一次调用申请 |
| `execution_id`    | 哪一次真实执行尝试    |
| `idempotency_key` | 需要防重的同一个逻辑动作 |

在操作允许安全重试的前提下，两次尝试可以这样对应：

```
                 第一次尝试              第二次尝试
tool_call_id     call_report             call_report
execution_id     exec_1                  exec_2
idempotency_key  write_file:call_report   write_file:call_report
```

这里只用可读编号说明关系。新的执行尝试有新编号；只重放回执没有重新执行，不需要创建新的 execution\_id。真正的业务幂等键还要包含合适的作用域，不能让不同用户或任务碰巧共享一个 Key。

参数 Hash 用来检查同一个 Key 有没有被换了内容：同一键、同一组参数可以关联到旧操作；同一键、不同参数应报冲突。生成指纹时应使用一致的序列化规则，避免只因字典字段顺序不同就误判。反过来，也不要只因参数相同，就认定模型两次独立提出的调用是同一个动作。

**Key 和 Hash 本身不会阻止执行。** 执行器或下游服务必须识别它们并落实防重规则。给普通 Shell 追加命令贴上一个 Key，命令也不会因此知道“这行昨天写过”。

## 3. Recovery：回执重放与外部对账

恢复时，先关联旧调用与 Ledger，核对调用编号和参数，再看已有证据：

| 已有证据                         | 恢复动作             |
| ---------------------------- | ---------------- |
| 用户拒绝                         | 补回拒绝结果，不执行工具     |
| succeeded／failed 且 result 完整 | 重放保存的结果，不再执行工具   |
| 结果不明，但能取得可靠的外部证据             | 对账，记录确认的事实       |
| 仍然无法确认                       | 保持 unknown，不自动重跑 |
| 账本缺失、参数冲突或终态没有结果             | 停止恢复，检查记录        |

下面只展开身份与参数核对之后的“取回执”逻辑：

```python
def replay_result(state):
    status = state["status"]
    if status == "rejected":
        return {"status": "rejected"}
    if status == "unknown":
        return {"status": "unknown"}
    if status in {"succeeded", "failed"}:
        if "result" not in state:
            raise RuntimeError("终态存在，但结果缺失")
        return state["result"]
    raise RuntimeError("这个状态还不能生成回执")

saved = {
    "status": "succeeded",
    "result": {"path": "diagnosis.txt", "bytes_written": 4},
}
print(replay_result(saved))
print(replay_result({"status": "unknown"}))
```

输出为：

```
{'path': 'diagnosis.txt', 'bytes_written': 4}
{'status': 'unknown'}
```

代码没有调用 `write_file`。它拿出原来保存的结果，配上原 `tool_call_id` 写成 Tool Result，便能把对话补到：

```
User -> Assistant Tool Call -> Tool Result
```

本例先续完这一轮，再接新用户请求。缺少 Assistant Final，不能成为重做已经完成工具的理由；本书核验的 OpenClaw 恢复逻辑也会检查调用、匹配结果与潜在副作用。\[1]

对于 unknown 的报告写入，可以读取当前文件进行**对账（Reconciliation）**：字节与原请求完全相同，就记录“目标状态已满足”，不再写入；内容不同或文件不存在，则继续保持 unknown，不能擅自覆盖。

这个对账结果沿用原 execution\_id，并注明 `reconciled=true`，旧 unknown 记录仍然保留。它证明当前目标已满足，不证明文件由谁写成，更不证明旧进程执行过几次。

付款、发信等操作要查询对应下游的权威回执，不能套用文件内容比较。没有足够证据时，我会保留不确定性并请求处理意见，而不是靠再执行一次制造“成功”。

## 4. 并发去重：唯一约束与执行资格

单个串行执行者可以按顺序检查记录、决定动作。两个执行者同时恢复时，却可能都查到“没有”，随后各自开始执行。需要把执行资格交给真正写入时的原子约束裁决。

下面用 SQLite 的唯一主键演示。同一个幂等键尝试插入两次，只有第一次插入成功；`hash_A` 只是参数指纹的占位文字：

```python
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("""
    CREATE TABLE operations (
        idempotency_key TEXT PRIMARY KEY NOT NULL,
        args_hash TEXT NOT NULL
    )
""")
sql = """
    INSERT INTO operations VALUES (?, ?)
    ON CONFLICT(idempotency_key) DO NOTHING
"""

for attempt in range(2):
    with db:
        result = db.execute(sql, ("write_file:call_report", "hash_A"))
    print(result.rowcount)

db.close()
```

输出为 `1`、`0`。`rowcount` 是本次插入的行数，主键约束不允许相同 Key 占第二个位置。`:memory:` 表示数据库只在内存中，关闭连接后数据消失，真实恢复需要持久存储。

取得资格的一方才能执行动作；其他请求读取已有参数和状态，参数冲突就停止。插入零行不等于“工具失败，请重试”，因为它甚至不应该开始第二次工具执行。

这段代码只验证数据库内的资格裁决，没有执行写文件，也没有模拟真实多 Worker。实际系统还要把执行入口接到这些约束上，并处理取得资格后的中断。唯一约束不等于外部副作用恰好发生一次。

## 5. 事务一致性：事件历史与当前状态

Ledger 只追加事件，便于追查，但要找“现在仍然 unknown 的操作”，不能只搜索包含 unknown 的行。例如：

```
exec_1：running -> unknown
exec_2：unknown -> succeeded
```

当前只应返回 exec\_1。exec\_2 曾经结果不明，但后来已经确认成功。查询频繁时，可以同时维护事件历史 `execution_events` 和当前状态 `execution_state`：后者保存每次执行的最新状态。

追加事件与更新当前状态必须在同一事务里提交。否则历史写着 unknown，当前状态却可能仍停在 running。下面故意让第二次写入失败；表只保留状态列，以便观察回滚：

```python
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE execution_events (status TEXT)")
db.execute("CREATE TABLE execution_state (status TEXT NOT NULL)")

try:
    with db:
        db.execute("INSERT INTO execution_events VALUES ('unknown')")
        db.execute("INSERT INTO execution_state VALUES (?)", (None,))
except sqlite3.IntegrityError:
    print("更新失败，事务已回滚")

print(db.execute("SELECT COUNT(*) FROM execution_events").fetchone()[0])
print(db.execute("SELECT COUNT(*) FROM execution_state").fetchone()[0])
db.close()
```

输出为：

```
更新失败，事务已回滚
0
0
```

`NOT NULL` 拒绝空值，异常让整个事务回滚，第一条事件也没有留下。真实表还需要执行编号、参数和结果等字段；当前状态应能由完整事件重新构建。

事务只保护这两次数据库写入。已经写出的文件、已经发出的邮件，不会跟着数据库自动回滚。单进程、低频恢复时可以先用 JSONL；频繁查询、需要事务和唯一约束时，再考虑 SQLite。它的 WAL 模式可以缓解本机读写等待，但仍有写入并发与部署范围的限制。\[2]

本课的 Ledger 恢复与 SQLite 约束是可分别验证的教学部件，不是已经集成的生产级多 Worker 去重系统。下一课再检查另一条边界：即使操作可以恢复和防重，它究竟被允许访问哪些文件、运行哪些命令？

## 资料与配套实验

1. [OpenClaw：已核验版本的未完成 Turn 恢复](https://github.com/openclaw/openclaw/blob/1fb3e0ca33847b5827a21cf5cb132d3f90ff49ad/src/agents/embedded-agent-runner/run/incomplete-turn-recovery.ts)
2. [SQLite WAL](https://www.sqlite.org/wal.html)
3. [配套综合实践与故障验证](https://github.com/unix2dos/agent-engineering-book/tree/main/exercises/phase-1-capstone/README.md#第四关ledger-与幂等)
4. [SQLite 专项练习](https://github.com/unix2dos/agent-engineering-book/tree/main/exercises/session-storage-sqlite/README.md)
5. [已有参考实现](https://github.com/unix2dos/agent-engineering-book/tree/main/examples/lesson_06_tool_reliability.py)
6. [项目对照、固定源码及核验记录](https://github.com/unix2dos/agent-engineering-book/tree/main/research/06-tool-reliability-source-verification.md)
7. [正文代码检查](https://github.com/unix2dos/agent-engineering-book/tree/main/experiments/reading-pilot/check_lesson_06.py)：`python -B experiments/reading-pilot/check_lesson_06.py`，只验证教学代码和受控文件操作，不调用模型或真实业务接口。


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://levon.gitbook.io/agent-engineering/06-gong-ju-ke-kao-xing.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
