← 回到知识图谱
设计模式持续完善

可靠执行:状态、幂等与恢复

本文总结从一次工具超时讲起:状态存在模型外面,重试带上同一个幂等键,结果不明时先对账再决定下一步。

创建 2026-10-31更新 2026-11-02阅读约 11 分钟
Agent工程实践扩展学习

问题:超时意味着没有执行吗

报销 Agent 调用 create_reimbursement_ticket 为 INV-102 建单,等了 10 秒没有响应,连接断了。工单可能已经建好,只是响应在回来的路上丢了。如果 Agent 直接再调用一次,员工就可能有两张一样的报销单,财务也可能付两次钱。

工具调用的结局要分三种:

  • 调用失败:请求根本没被处理,比如连接都没建立,或者服务端明确返回 503、429 表示拒收。
  • 业务失败:请求被处理了,但业务上不成立,比如服务端返回“发票 INV-102 已报销过”。
  • 结果未知:发出去了,但不知道远端做没做。超时就是这一种。

日志里写了一行 timeout,只能说明客户端没等到响应,证明不了远端没有副作用。

可靠执行状态机:意图先落盘,调用后核对结果,未知状态进入对账而非盲目重试可靠执行状态机:意图先落盘,调用后核对结果,未知状态进入对账而非盲目重试 · 点击放大

把状态放在模型之外

模型的上下文会被截断、会话会中断、进程会重启,所以任务进行到哪一步,要写进数据库,不能靠模型记得。下面是一个教学用的状态模型,具体用什么数据库和队列由项目决定。

状态 持久化内容 可执行的下一步
planned 任务 ID、动作 ID、参数摘要、版本 检查权限与预算
approved 授权对象、范围、有效期 写入执行意图
running 幂等键、请求指纹、开始时间 调用工具
succeeded 远端对象 ID、结果与证据 更新任务状态
unknown 超时原因、查询线索 先查询远端、对账
failed 明确失败原因、是否可重试 修复参数、退避或人工处理

审批要和这一次动作的参数、对象绑定在一起。比如审批记录写的是“INV-102,金额 118.5 元,超标 38.5 元,主管同意提交”。之后 Agent 想把金额改成 180 元,参数变了,原来的审批就不再适用,要重新判断授权。员工“同意过一次”不能当成后续所有动作的通行证。

一次超时后的对账

完整走一遍。时间都是示意:

时间 事件 状态
10:00:00.000 生成动作 act-3:为 INV-102 建单,金额 118.5,超标 38.5;幂等键 t-7f31:act-3,参数指纹 sha256:9f3a… planned
10:00:00.040 权限检查通过,记录“创建待审批工单”的授权范围 approved
10:00:00.060 把意图、幂等键、指纹写入数据库,提交事务 running
10:00:00.080 调用 create_reimbursement_ticket,请求头带幂等键,超时设为 10 秒 running
10:00:03.400 远端实际建好工单 R-55821,但响应包在网络上丢失 running(本地不知道)
10:00:10.080 客户端超时 unknown
10:00:10.100 不重试创建,先用 query_tickets(idempotency_key="t-7f31:act-3") 查询 unknown
10:00:10.350 查到 R-55821,金额 118.5,创建时间 10:00:03.4,指纹一致 succeeded
10:00:10.360 写 checkpoint,告诉员工“工单 R-55821 已提交,待主管审批” succeeded

如果 10:00:10.350 的查询没查到呢?还不能断定远端没做。请求可能还在服务端排队,或者查询走的是延迟同步的从库。稳妥的做法是隔一会儿再查一两次,仍然没有,再用同一个幂等键重试创建。只要服务端按键去重,即使第一次其实已经执行,这次重试也只会返回 R-55821,不会建第二张。

如果服务端没有按幂等键查询的接口,就用业务唯一键查:同一员工、同一张发票、处于未完成状态的报销单最多一张。查不清楚的,把任务挂到人工对账队列,不要让模型猜。

幂等键与事务边界

幂等键对应一次业务意图,和发了几次网络请求无关。“为 INV-102 建一张报销单”是一个意图,因网络问题重试三次,三次请求都带同一个键 t-7f31:act-3。如果每次重试都新生成一个 UUID,服务端看到的就是三个不同的意图,去重没有用。

服务端除了认键,还要核对参数指纹。同一个键第一次带的是金额 118.5,第二次带的是 180,说明调用方出了 bug 或者被篡改,应该返回冲突错误。这时既不能把第一次的结果当作这次的结果返回,也不能按新参数再建一张。

认领键必须是原子操作。两次重试几乎同时到达,如果服务端的逻辑是“先查这个键有没有,没有就处理”,两个请求可能同时查到“没有”,然后各建一张。正确做法是用数据库唯一约束一步完成认领:

INSERT INTO idem_keys(key, fingerprint, status)
VALUES ('t-7f31:act-3', 'sha256:9f3a…', 'processing')
ON CONFLICT (key) DO NOTHING

插入成功的那个请求去建单;插入失败的请求读出已有记录,指纹一致就等待或返回已有结果,指纹不一致就报冲突。处理完成后把 R-55821 写回这条记录。

客户端一侧的流程:

intent = persist(action_id, arguments_hash, idempotency_key)
result = execute(intent)
if result.is_unknown:
    result = reconcile_by_key(intent.idempotency_key)
checkpoint(task_state, result)

远端支持幂等接口,就直接用;不支持,就先按业务唯一键查询,再配合串行化或互斥锁,并保留人工对账。光靠“先查再写”仍然有并发竞态。

跨两个系统的操作,通常没有一个共同的事务。例如建单成功后还要给主管发审批通知。如果先建单再发消息,中间进程崩了,通知就丢了。outbox 做法是:建单和“待发送通知”这条记录写在同一个数据库事务里,另一个进程负责把 outbox 里的记录发出去,发送进程崩溃重启后可能把同一条再发一遍,所以消费方要按事件 ID 去重。这样通知至少会发出一次,去重后只生效一次。

还有一种情况是事后补偿。工单建好了,但附件上传失败,业务要求整单撤回,就调用撤单作为补偿动作。撤单也可能失败,所以它要有自己的状态,比如 compensation_pending,并且可以重试和人工介入。

退避不是无限重试

对于明确可重试的失败(503 服务暂时不可用、429 限流),或者带了幂等键、可以安全重试的请求,重试前要等一会儿,而且每次等得更久:

dn=min⁡(dmax⁡,d02n)+U(0,j)d_n = \min(d_{\max}, d_0 2^n) + U(0,j)
符号 含义 示例取值
nn 第几次重试,从 0 开始数 0, 1, 2, …
dnd_n 第 n 次重试前要等的时间 秒
d0d_0 初始等待 1 秒
2n2^n 每多一次失败,等待时间翻一倍 1, 2, 4, 8, …
dmax⁡d_{\max} 等待上限 8 秒
min⁡(x,y)\min(x, y) 取两者中较小的,这里让等待不超过上限
U(0,j)U(0,j) 在 0 到 jj 之间均匀随机取一个数,叫抖动 j=0.5j=0.5 秒

不加抖动时,等待序列是:

d0: 1,d1: 2,d2: 4,d3: min⁡(8,8)=8,d4: min⁡(8,16)=8d_0{:}\ 1,\quad d_1{:}\ 2,\quad d_2{:}\ 4,\quad d_3{:}\ \min(8,8)=8,\quad d_4{:}\ \min(8,16)=8

n=4 是第 5 次重试,按指数应该等 16 秒,被上限压回 8 秒。从 n=0 到 n=4 这 5 次重试,等待总计 1 + 2 + 4 + 8 + 8 = 23 秒。

一次具体的重试时间线(抖动是随机抽到的示意值):

时间(秒) 事件 下一次等待
0.0 第 1 次请求
0.2 收到 503 n=0:min(8, 1) + 0.3 = 1.3
1.5 第 2 次请求
1.7 收到 503 n=1:min(8, 2) + 0.1 = 2.1
3.8 第 3 次请求
4.0 收到 429,带 Retry-After: 5 n=2:公式给 4 + 0.4 = 4.4,服务端要求 5,取 5
9.0 第 4 次请求,带同一个幂等键
9.4 成功,返回 R-55821

整个过程设了两条硬上限:总截止时间 30 秒,最多 6 次尝试(首次请求加 5 次重试)。任意一条先到就停止重试,任务转为 failed 或 unknown,交给对账或人工。服务端给了 Retry-After 的,以服务端为准。

为什么要加抖动?设想发票系统抖了一下,1000 个 Agent 同时收到 503。如果都严格按 1、2、4 秒重试,1000 个请求会在同一毫秒再次打过来,系统刚恢复又被打垮。加上 0 到 0.5 秒的随机量,这 1000 个请求就分散到半秒里,大约每毫秒 2 个。另一种常见做法叫 full jitter,直接在 0 到 min⁡(dmax⁡,d02n)\min(d_{\max}, d_0 2^n) 之间随机取等待时间,分散得更开。

退避只适合“再试一次可能就好了”的失败。参数校验失败,比如金额字段传了 "118.5元",重试一百次也是失败,要修参数。权限不足,要停下来或者请求授权,不能让模型自己去找一个权限更大的工具。超时这种结果未知的情况,按上一节先对账。

恢复测试:专门在最坏的时刻断开

用一个假的工单服务,故意在三个时刻让进程崩掉,重启后检查结果:

中断时刻 重启后应该看到
写入意图之前 数据库里没有 act-3 的记录,重新规划,工单只建一张
远端已建好 R-55821,本地还没保存结果 状态停在 running,恢复程序按幂等键查到 R-55821,转为 succeeded,不再建单
等待主管审批期间 审批记录仍绑定 INV-102、118.5 元;参数如被改动,要求重新审批

最后再让两个重试请求同时到达,带同一个幂等键,验证服务端只建了一张单,另一个请求拿到的是同一个 R-55821。这一条测的就是认领键是否真的原子。

这是一套待执行的练习方案,还没有线上成功率可以报告。接着读 可观测性:追踪一条任务的代价,给每次恢复保留关联 ID,排查时才能把重启前后的 span 串起来。审批环节的设计见 人在回路。

依据与延伸

AWS Builders’ Library:幂等 API 与安全重试 解释了客户端请求标识如何表达“同一个意图”,以及服务端如何处理重复请求。LangGraph 持久化文档 区分了 checkpoint 和跨会话的 store。本文的状态表和故障练习是按这些原则整理的工程设计示例,不代表任何框架默认就能保证这些行为。

这是一篇可以继续生长的笔记。沿着关系继续阅读 ↗