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 前崩溃。 此时数据库只有“已认领”,没有“已成功”。如果恢复后直接补发请求,仍可能重复退款。

解决它必须依赖明确的下游能力:

  1. 下游支持幂等:在其契约允许的范围内复用同一个 Key。
  2. 下游支持可靠查询:按原业务操作 ID 或下游流水号核验结果。
  3. 两者都不支持:保持待对账,停止自动重试;本地 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,至少要回答三个问题:

  1. 是不是同一笔业务? 用稳定的业务操作 ID 识别。
  2. 是否已经执行? 用持久化状态与下游证据确认。
  3. 结果不确定怎么办? 依靠下游幂等或对账;无法确认就暂停,而不是再试一次。

把 Agent 杀在任意一次写操作的中间,重启后它能找回已经发生的事实,还是会把“未知”当成“失败”,再执行一遍?

幂等性的关键不只是生成一个 Key,而是让业务意图有稳定身份,让执行结果有可核验证据,让未知状态有明确出口。


参考资料

  1. Do agents survive a crash? 34 runs measured — Agent 故障注入与幂等策略对比(小规模实验)。
  2. Stripe — Idempotent Requests — API v1 幂等行为与有效期边界。
  3. LangGraph — Functional API;Checkpoint / Replay — 任务持久化与重放语义。
  4. Google ADK — Rewind Sessions — 外部副作用不随会话回退。
Logo

更多推荐