可靠执行:状态、幂等与恢复
本文总结从一次工具超时讲起:状态存在模型外面,重试带上同一个幂等键,结果不明时先对账再决定下一步。
问题:超时意味着没有执行吗
报销 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 限流),或者带了幂等键、可以安全重试的请求,重试前要等一会儿,而且每次等得更久:
| 符号 | 含义 | 示例取值 |
|---|---|---|
| 第几次重试,从 0 开始数 | 0, 1, 2, … | |
| 第 n 次重试前要等的时间 | 秒 | |
| 初始等待 | 1 秒 | |
| 每多一次失败,等待时间翻一倍 | 1, 2, 4, 8, … | |
| 等待上限 | 8 秒 | |
| 取两者中较小的,这里让等待不超过上限 | ||
| 在 0 到 之间均匀随机取一个数,叫抖动 | 秒 |
不加抖动时,等待序列是:
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 到 之间随机取等待时间,分散得更开。
退避只适合“再试一次可能就好了”的失败。参数校验失败,比如金额字段传了 "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。本文的状态表和故障练习是按这些原则整理的工程设计示例,不代表任何框架默认就能保证这些行为。