Agent 的幂等性灾难:重试一次,就可能重复扣款
Agent 的幂等性灾难:重试一次,就可能重复扣款
一句话观点:Agent 的重试不一定是原样重放,而可能是重新推理。同一个业务意图一旦被生成成不同的工具请求,传统去重机制就可能失效。
1. 一次超时,为什么会退两次款?
用户让 Agent 退款 100 元。Agent 调用 refund,支付系统已经退款成功,但响应在返回途中丢失。Agent 看到超时,于是重新调用。
Agent → refund(R1, 100元) → 下游退款成功
↓
响应丢失
↓
Agent 重新推理 → refund(R2, 100元) → 再次退款
假设原订单支持多次部分退款,且剩余可退金额充足:如果第二次调用使用新的退款申请 ID,下游就可能把它当作一笔合法的新退款。
问题不在“重试”本身,而在于:系统没有识别出 R1 和 R2 原本属于同一个业务意图。
这类事故有一个基本前提:超时只代表调用方没收到结果,不代表下游没有执行。
2. Agent 为什么比普通重试更棘手?
重复执行主要有三个入口:
- 编排层重试:工具已执行,但整个步骤在保存状态前失败,重跑步骤可能再调工具。
- 模型主动重试:模型看到超时或模糊结果,重新生成一次调用,参数可能已变化。
- Checkpoint 恢复:尚未完成或未持久化的工具任务,恢复时可能重新执行。
注意:单纯重试尚未产生工具调用的 LLM API 请求,并不必然造成工具重复执行。
传统服务通常重放保存好的请求;Agent 却可能重新推理,将同一笔退款改写成另一组参数。因此,Agent 的幂等性不仅要识别相同请求,还要识别相同业务操作。
3. 参数哈希不够,位置键也不是万能解
最直观的方案是对工具参数求哈希:
key = sha256(raw_args)
但模型重试时可能新增备注、调整参数格式,原始参数的哈希随之变化:
第一次:{"orderId":"A","amount":"100.00"}
第二次:{"orderId":"A","amount":"100.00","note":"retry"}
一项公开的 34 次故障注入实验展示过这种风险:参数发生改写时,原始参数哈希可能无法识别重复意图。[1] 但这是小规模模拟实验,不能把其中的结果视作通用去重率。
另一种方案是位置键:{workflow}:{step}:{tool}。它能跨越部分参数改写,却也有缺陷:同一个步骤内合法地退款两笔,会因为位置相同而碰撞;换一次 Workflow 再尝试同一笔退款,又可能绕过去重。
更可靠的设计是把两个身份分开:
- 业务操作 ID:例如
refund_request_id。由可信业务层生成并持久化;同一操作重试时必须复用。 - 关键参数指纹:对订单、金额(统一为整数分)、币种、收款对象等规范化后计算,检测同一操作 ID 下的参数漂移。
| 操作 ID | 关键参数 | 处理方式 |
|---|---|---|
| 相同 | 相同 | 查询原操作状态,不能直接再执行 |
| 相同 | 不同 | 拒绝并告警 |
| 不同 | 任意 | 按新操作处理,但仍须校验业务规则与唯一性 |
“系统重试一次”和“用户明确再做一次”,必须由业务层区分,不能交给模型或哈希函数猜。
4. 幂等层真正要解决的,是结果未知
幂等保护应该放在 Tool Gateway 或业务服务中,而不是只靠 Prompt 约束。基本链路是:
稳定业务操作 ID
↓
原子认领(数据库唯一约束)+ 校验参数指纹
↓
调用下游(尽可能传递相同幂等 Key)
↓
确认成功 ───→ 保存结果,后续重放直接返回
结果未知 ───→ 查询下游 / 对账,不能盲目重试
至少需要区分以下状态:
| 状态 | 含义 | 处理 |
|---|---|---|
PROCESSING | 已认领,可能正在执行 | 阻止并发重复调用 |
DONE | 已确认完成 | 返回保存的结果 |
UNKNOWN | 是否生效无法确定 | 查询下游流水;无法核实时暂停 |
实际实现还需记录确定失败等状态,并处理进程崩溃后遗留的 PROCESSING。
最危险的故障窗口是:下游已经退款成功,本地却在写入 DONE 前崩溃。 此时数据库只有“已认领”,没有“已成功”。如果恢复后直接补发请求,仍可能重复退款。
解决它必须依赖明确的下游能力:
- 下游支持幂等:在其契约允许的范围内复用同一个 Key。
- 下游支持可靠查询:按原业务操作 ID 或下游流水号核验结果。
- 两者都不支持:保持待对账,停止自动重试;本地 Claim 无法凭空消除跨系统的不确定性。
以 Stripe API v1 为例,幂等记录可能在至少 24 小时后被清理,且对已开始执行的请求可能复用包含 500 在内的原始响应。[2] 因此,“有幂等 Key”不等于“永久保证只执行一次”。
5. Checkpoint 能恢复 Agent,不能回滚真实世界
Checkpoint 保存的是 Agent 的内部执行进度,不会撤销外部已经发生的退款、邮件或订单。
LangGraph 对已完成且持久化的任务结果可以复用,但未完成的任务仍可能重跑;从旧 Checkpoint 主动 Replay,也不等于撤销外部副作用。[3] Google ADK 同样明确提醒:Rewind 不负责恢复外部依赖。[4]
因此,生产系统最好分开维护:
Checkpoint:Agent 执行到了哪里?
操作账本:这笔业务是否已经生效?证据是什么?
操作账本至少保存业务操作 ID、参数指纹、状态、下游流水和核验结果,不能随着 Agent 会话回退而丢失。
6. 如何验证系统真的安全?
不用真实支付。搭一个支持部分退款的假服务,在不同时间点主动制造超时或杀进程,观察实际退款流水,而不是只看 Agent 的回答。
| 故障场景 | 应有结果 |
|---|---|
| 下游成功,但响应丢失 | 不产生第二笔退款 |
下游成功,本地写 DONE 前崩溃 | 从下游幂等记录或可靠对账恢复 |
| 两个 Agent 并发执行同一操作 | 只有一个合法执行路径 |
| 同一操作 ID 的金额被模型改写 | 拒绝执行并告警 |
| 下游不支持幂等,也查不到权威流水 | 不盲目重试,转入待对账 |
同时比较原始参数哈希、位置键和业务操作 ID 三种策略,就能看到:防止请求重复,与保证业务动作不重复,是两件不同的事。
7. 结论
一个能操作真实世界的 Agent,至少要回答三个问题:
- 是不是同一笔业务? 用稳定的业务操作 ID 识别。
- 是否已经执行? 用持久化状态与下游证据确认。
- 结果不确定怎么办? 依靠下游幂等或对账;无法确认就暂停,而不是再试一次。
把 Agent 杀在任意一次写操作的中间,重启后它能找回已经发生的事实,还是会把“未知”当成“失败”,再执行一遍?
幂等性的关键不只是生成一个 Key,而是让业务意图有稳定身份,让执行结果有可核验证据,让未知状态有明确出口。
参考资料
- Do agents survive a crash? 34 runs measured — Agent 故障注入与幂等策略对比(小规模实验)。
- Stripe — Idempotent Requests — API v1 幂等行为与有效期边界。
- LangGraph — Functional API;Checkpoint / Replay — 任务持久化与重放语义。
- Google ADK — Rewind Sessions — 外部副作用不随会话回退。
更多推荐
所有评论(0)