Agent 执行到一半崩溃了:用故障实验讲清恢复、重试与幂等

假设一个 Agent 调用工具创建工单。工单服务已经写入数据库,也返回了编号,但 Agent 进程在保存执行结果前崩溃。重启后,它的本地记录仍然是“这个步骤没完成”。

再调一次,可能创建第二张工单;直接跳过,又可能把一次根本没成功的调用当成成功。问题卡在这里:调用方没有保存到结果,无法单凭本地状态判断外部操作有没有发生。

本文用两个独立进程、HTTP 和两个 SQLite 数据库复现这个窗口,然后对比普通重试与幂等重试。代码只用 Python 标准库,可以直接下载运行。

验证范围:2026 年 9 月 27 日,macOS arm64,Python 3.14.7、SQLite 3.53.4。 我们实际退出并重启了子进程,验证了十个故障或并发场景。工单是本地临时数据库里的记录;执行器使用固定步骤,没有调用大模型,也没有接入 LangGraph 或 Temporal。结果说明的是工具执行阶段的恢复行为。

下载完整源码与原始实验结果(ZIP)。其中 evidence/results.json 保存了数据库快照、HTTP 结果和进程退出码。

1. Checkpoint 保存的是调用方已经知道的事

Checkpoint 可以理解为执行进度存档,例如“计划已保存”“工单创建结果已保存”。它帮助进程重启后找回状态。LangGraph 的持久化文档也将 checkpointer 用于保存线程的图状态,包括中断恢复与容错。LangGraph 持久化文档。

然而,外部系统有自己的提交时刻。看一次调用的时间线:

执行器 worker                         工单工具服务
    │                                    │
    ├─ 保存 intent:准备创建工单           │
    ├─ POST /tickets ────────────────────>│
    │                                    ├─ 数据库提交:工单 #1
    │<────────────────────────────── 返回 │
    × 进程退出                            │
    │                                    │
    └─ 本地 checkpoint 仍然是 pending     └─ 工单 #1 已经存在

这不是只有手写执行器才会遇到的情况。Temporal 文档明确描述了 Activity 已经执行成功、Worker 却在报告完成前崩溃的窗口,并建议由被调用的服务落实幂等键。Temporal Activity 幂等说明。

因此可以把两个问题分开看:checkpoint 负责保存执行进度;工具幂等负责让同一次业务操作被重复请求时,仍然对应同一份业务结果。两者配合,才能处理上图里的空隙。

2. 实验刻意使用两份数据库

源码的结构很小:

agent-recovery-lab/
├── worker.py          # 保存意图、调用工具、保存 checkpoint
├── tool_service.py    # 只监听 127.0.0.1 的工单 HTTP 服务
├── verify.py          # 自动制造故障、重启并检查结果
└── evidence/          # 实际验证结果

临时目录中的数据:
worker.db              # 调用方:steps 表
tool.db                # 工具侧:tickets 表 + receipts 表

两个进程通过 HTTP 通信。它们没有共享数据库事务,也没有把工单写入和 worker 进度更新包进同一个事务。这样才能保留实际系统里调用方与工具之间的边界。

worker 的 steps 表保存以下字段:

字段作用
run_id本例中稳定的业务任务标识,重启时沿用
payload已决定执行的参数,本例是工单标题
operation_key本次业务操作的幂等键
status / responsepending 或 done,以及已保存的返回结果

同一次恢复会读取已经保存的参数和键。如果重启时传入另一份参数,执行器会报错,保留原来的意图。这对应 Agent 中的一个实际要求:已经决定执行的工具参数要持久化,恢复时不能悄悄把它换成重新生成的另一份计划。

解压后,在目录中执行:

# 无需 pip 安装,不需要模型 API key。
python3 verify.py

# 查看实际快照、返回值、退出码和运行版本。
python3 -m json.tool evidence/results.json
python3 -m json.tool evidence/versions.json

验证脚本会为每个场景创建独立临时目录,启动本地工具服务,完成实验后关闭服务并清理数据库。成功完成全部检查后,evidence/ 会更新为这一次运行的记录。

脚本使用 sys.executable 启动子进程,因此 worker 和工具服务使用同一个 Python 解释器。不同机器的 SQLite 版本可能不同,以 versions.json 的实际记录为准。

3. 先让普通重试真的重复一次

普通模式也会保存 pending 和 done,只是请求没有附带幂等键。故障注入点位于 HTTP 响应之后、保存结果之前。

下面是 worker.py 中对应流程的节选,省略了参数解析与其他故障点:

with urlopen(request, timeout=5) as response:
    body = response.read().decode()
    replayed = response.headers.get("Idempotent-Replayed") == "true"
result = json.loads(body)

if args.crash == "after-call":
    os._exit(72)

conn.execute("BEGIN IMMEDIATE")
conn.execute(
    "UPDATE steps SET status='done', response=? WHERE run_id=?",
    (body, args.run_id),
)
conn.execute("COMMIT")

这里使用 os._exit(),让子进程直接退出,跳过 Python 的正常清理处理,而不是抛出一个随即被恢复逻辑捕获的普通异常。Python os._exit 文档。

本次普通模式的数据库快照如下,数值取自 naive_after_call 场景:

{
  "before_resume": {
    "checkpoint": "pending",
    "receipts": 0,
    "tickets": 1
  },
  "after_resume": {
    "checkpoint": "done",
    "receipts": 0,
    "tickets": 2
  }
}

第一次 worker 以实验约定的退出码 72 结束。此时工单已有一条,checkpoint 仍是 pending。新 worker 读取存档后再次调用工具,得到工单编号 2,然后把状态保存为 done。

它确实恢复到了“执行完成”,但业务结果已经重复。单看最终的 done,发现不了这个问题。

4. 幂等键跟随业务操作,不能随重试改变

幂等模式会在第一次调用之前保存操作键。本例只有一个固定步骤,使用下面的形式:

key = args.run_id + "/create_ticket/v1"

这个键与参数一起写入 steps 并提交。随后 worker 从存档中取出键,放进 HTTP 请求:

headers = {"Content-Type": "application/json"}
if row[2] == "idempotent":
    headers["Idempotency-Key"] = row[1]
request = Request(
    args.url + "/tickets",
    data=row[0].encode(),
    headers=headers,
)

源码默认的 demo-task-001 只适合一次性实验数据库。实际系统需要先确定业务操作的身份,例如租户、业务任务、稳定的步骤 ID 和动作版本,并保证这一标识在失败重试之间保持不变。同名工具如果在一个任务里被合法调用两次,应有两个不同的步骤标识。

一次新的网络尝试可以有新的请求追踪 ID,幂等键则继续指向同一个业务操作。每次超时后重新生成幂等键,工具侧就会把它当作新操作。

本次实验专门验证了这一点:同样的工单标题,分别使用 operation-a 和 operation-b,工具返回了两个不同编号,数据库里出现两条工单。相同参数不能自动决定它们是不是同一次业务操作。

5. 工具侧要把工单和回执一起提交

工具服务增加了 receipts 表,保存操作键、请求参数和首次成功结果:

CREATE TABLE receipts (
    operation_key TEXT PRIMARY KEY,
    payload TEXT NOT NULL,
    response TEXT NOT NULL
);

收到带键请求后,服务端在同一个 SQLite 写事务中完成查重、创建工单和保存回执。下面是逻辑示意;完整异常处理与实际代码见 tool_service.py:

BEGIN IMMEDIATE
  按 operation_key 查回执
  ├─ 已有回执且参数一致:结束事务,返回原结果
  ├─ 已有回执但参数不同:结束事务,返回 HTTP 409
  └─ 没有回执:
       INSERT tickets
       INSERT receipts(键、参数、工单编号)
       COMMIT
       返回结果

为什么要同一事务?如果先提交工单、再保存回执,中间又出现一个窗口:工单已存在,去重记录却没有。恢复后的请求仍可能创建第二条。

本例通过 BEGIN IMMEDIATE 提前申请写事务,使并发请求的检查和写入串行完成。SQLite 同一时刻只允许一个写事务,等待超时仍可能产生忙错误;这里使用了十秒数据库等待时间,没有测吞吐上限。SQLite 事务文档。

将普通模式的故障再执行一次,这次附带稳定键,结果变成:

{
  "before_resume": {
    "checkpoint": "pending",
    "receipts": 1,
    "tickets": 1
  },
  "after_resume": {
    "checkpoint": "done",
    "receipts": 1,
    "tickets": 1
  }
}

这是 idempotent_after_call 场景的字段节选。重试实际发生了,服务端返回原工单编号 1,验证客户端记录 tool_replayed: true。最终只有一条工单,worker 也补上了缺失的结果存档。

这个事务的保证有明确范围:工单记录和回执都由工具服务写入同一个数据库。如果工具内部再调用外部发信、支付或云资源 API,本地 SQLite 事务无法回滚那些外部操作。幂等约束还要落实到真正产生副作用的系统,或依赖它提供的业务唯一约束与结果查询能力。

6. 响应丢失时,重启后会怎样

前面的故障发生在 worker 已收到响应之后。更难判断的情况是:工具已经提交了事务,但还没写出 HTTP 响应就退出。

tool_service.py 的第二个故障点就在这里:

conn.execute("COMMIT")
if args.crash_after_commit:
    os._exit(87)

本次 worker 观察到的错误是:

RemoteDisconnected: Remote end closed connection without response

同样的错误,也出现在工具事务提交前退出的场景中。但数据库状态不同:

工具退出位置重启前工单数重启前回执数带原键重试后的行为
工单和回执已插入,事务尚未提交00创建工单,最终 1 条
事务已提交,HTTP 响应尚未写出11返回原工单,最终仍是 1 条

这两行都来自实际子进程实验。它们说明,调用方看到“连接断开”,并不能判断业务写入是否成功。带原键重试,让工具服务根据自己的持久化记录解决这份不确定性。

这里验证的是进程直接退出与数据库重新打开。没有做断电、磁盘损坏、网络分区或跨地域复制实验;不能将结果扩展成这些故障下的承诺。SQLite 的原子提交机制及其环境假设另见官方说明。

7. 十个场景分别验证了什么

本次 verify.py 的实际输出:

PASS naive_after_call
PASS idempotent_after_call
PASS worker_before_call
PASS worker_after_checkpoint
PASS tool_before_commit
PASS tool_after_commit_before_response
PASS same_key_changed_payload
PASS eight_concurrent_requests
PASS same_payload_different_keys
PASS changed_persisted_intent

其中还有几项容易遗漏的检查:

  • 调用工具之前退出:本地已保存意图,工单为零;恢复后才创建第一条工单。
  • 保存结果之后退出:直接停掉工具服务,再启动 worker,仍能从 checkpoint 返回原结果。这一步验证已完成步骤无需再次访问工具。
  • 同一个键更换参数:服务端返回 409,没有修改原工单,也没有创建第二条。
  • 八个并发请求使用同一个键:八个响应都指向工单 1,其中七个是回执重放,数据库只增加一条工单。
  • 恢复时更换已保存参数:worker 直接拒绝执行,工单数保持为零,避免恢复过程偷偷改变操作意图。

“八个并发请求”只是这个去重场景的检查数量,不是并发能力或性能结论。测试也没有覆盖完整的多步骤 Agent 调度、任务租约和旧 worker 的隔离机制。

8. 放进实际 Agent 系统时,要保留这些约束

最容易出问题的是恢复时重新规划。模型再次看到同一段对话,可能选择另一个工具或生成不同参数。执行器可以把“已决定执行的动作”保存为明确的步骤记录,恢复时先处理这条记录,再继续规划。本例通过拒绝修改已保存的参数来演示这条边界。

其次,幂等回执的保存期限要覆盖允许的重试窗口。本地例子没有清理回执,也只保存成功结果。外部服务的约定可能不同:例如 Stripe 文档说明会保存首次执行的状态码与响应体,同键异参会报错,键被清理后再次使用可能被当作新请求。接入时应按服务自己的契约处理。Stripe 幂等请求文档。

如果某个外部工具既不支持幂等键,也没有可查询的业务唯一标识,执行器就缺少判断未知结果的可靠依据。可以把这类步骤留在 unknown,由结果核对或人工处理继续推进,避免把所有超时都自动归类为“可以直接重做”。本文的最小执行器没有实现这个分支。

最后,实际的重试策略还需要限制次数、设置退避,并区分权限错误、参数错误、冲突与暂时故障。当前脚本由测试程序明确启动恢复流程,没有实现自动后台重试,也没有测模型选择工具的正确率。

这套实验能支持的结论是:在稳定操作键、相同参数、工具侧原子提交且回执保留的条件下,本文验证的重复请求只产生一条工单记录。后续扩展到多步骤流程时,可以继续把故障放在每个外部提交和本地存档之间,用数据库结果验证恢复行为。

相关阅读