Agent 恢复执行时,如何避免重复退款
文章通过稳定操作 ID、执行前持久化意图、传递幂等键及记录执行结果,构建工具调用的恢复协议。遇到外部系统不支持去重且执行结果未知的情况,应先核对结果或转人工处理。
AI 深度解读
Agent 发出退款请求,支付平台已经处理成功,后面的模型调用却报错了。此时从检查点重跑,退款工具可能再执行一次。原文讨论的就是这类恢复事故:怎样让一次业务动作在重试、进程重启和模型重新生成调用之后,仍然被识别为同一件事。
作者给出的办法是稳定的操作 ID、持久化执行账本,以及服务商支持的幂等机制。它不能保证任意外部系统都只执行一次,但能让已完成的动作被复用,让结果不明的动作进入核对流程。思路值得采用,不过原文的 Python 演示有明显问题,不能直接照搬。
重试之前,先弄清究竟哪一步失败了
原文区分了四类容易混在一起的情况:请求尚未发出就失败;请求发出后连接超时;工具成功后模型调用失败;工作流恢复时重新执行未完成节点。
它们对应的处理方式不同。尤其是网络超时,只能证明调用方没有拿到响应,不能证明服务商没有执行。退款、邮件、数据库写入都有可能已经完成。
请求可能已经发出时,默认状态应该是「结果未知」,而不是「执行失败」。
原文引用的 Agno issue 展示了一个具体案例:工具执行成功,后续模型报错,整个运行重试后重复调用工具。这是问题报告和复现,不是事故发生率统计。文中提到的研究也强调了相同边界:Agent 运行时和外部工具通常没有共享事务,单一的成功或失败信号不足以判断副作用是否发生。
这里还要区分两种 ID。模型的 tool-call ID 标识某条消息里的调用;业务 operation ID 标识一次明确的业务意图,例如「给 invoice-17 执行第 2 次退款调整」。
恢复后,模型可能为同一个意图生成新的 tool-call ID;反过来,只拿客户 ID 去重,又可能吞掉这个客户后续的合法退款。原文给出的业务键示例是:
workflow-81:invoice-17:refund-2:v1
它包含工作流、业务对象、动作序号和版本。关键是由工作流边界生成并持久化,同一次意图始终复用。每次重试随机生成一个键,无法达到去重目的;把整段提示词做哈希也不可靠,因为措辞变化不等于业务意图变化。
执行账本要记录未知状态,不能只有 done
作者建议用四个基本状态描述一次动作:
prepared:业务意图已经接受,操作 ID 已经确定。in_flight:请求可能已经开始发送。succeeded:服务商结果已经持久化。unknown:执行器无法证明成功,也无法证明没有执行。
只有确定副作用未发生,例如本地校验在发送前拒绝请求,才适合使用额外的 failed 状态。
单个 done=true 补不上这个缺口。发送前写,可能标记完成后进程崩溃,动作实际没发出;发送后写,可能服务商已经完成,进程却在落库前崩溃,恢复后再次发送。
账本还需要保存 run ID、operation ID、工具名称及版本、规范化参数哈希、服务商、状态、尝试次数、创建与更新时间、服务商请求 ID、结果引用和脱敏错误分类。它应当能定位权威结果,不必再复制一份客户数据库。
下面这段原文代码展示了两个关键检查:同一操作 ID 的参数是否变化,以及成功结果能否直接复用。
row = db.execute(
"SELECT params_hash, status, result FROM action_ledger "
"WHERE operation_id = ?", (operation_id,)
).fetchone()
if row and row[0] != digest:
raise ValueError("operation ID reused with changed parameters")
if row and row[1] == "succeeded":
return json.loads(row[2])
要看的是参数冲突那一行:退款金额或收款人变了,不能继续沿用原操作的身份,也不能悄悄返回旧结果,应当先明确这是一次新动作。
执行顺序同样重要:先验证和授权,再创建或读取账本;成功记录直接返回,处理中或未知记录先核对;只有新的 prepared 动作才能被领取发送。服务商确认后,必须先保存结果,再让模型继续。
并发恢复还需要数据库唯一约束和原子领取,例如带旧状态条件的更新,或带 fencing token 的租约。两个 worker 都读到 prepared 后分别发送,进程内的 Python set 挡不住。
外部幂等键能缩小崩溃窗口,但有有效期
本地数据库和远程 API 是两个系统。即使本地事务写得再严谨,也存在「远程已提交,本地还没记录成功」的窗口。
服务商幂等键的作用,是让接收方识别重复请求。同一次业务意图重试时,发送相同键和相同参数,接收方按约定复用结果。Stripe 文档中的细节尤其重要:键可能在至少 24 小时后被清理,清理后再次使用可能产生新请求。自己的恢复窗口必须与服务商保留周期匹配。
如果服务商不支持去重,就需要按请求 ID、业务引用或其他稳定标识查询结果,并核对收款人、金额、币种、发票、目标和状态。查询暂时返回不存在,也可能只是索引尚未更新,需要轮询窗口、退避安排和最终升级条件。既不能安全重试,也无法查询时,就得人工处理。
原文用 SQLite 模拟服务商,数据库文件是 agent-replay-demo.sqlite3,连接启用 PRAGMA journal_mode=WAL,运行命令是 python demo.py。示例对 invoice-17 发起 2500 cents 退款,试图演示首次响应丢失、相同键恢复、重复读取相同结果。
但下面这段实现并没有可靠模拟它声称的行为:
db.execute("INSERT INTO provider_keys VALUES (?, ?)",
(key, json.dumps(response)))
if lose_first_response:
raise TimeoutError("simulated lost response after provider commit")
return response
注意抛出 TimeoutError 的位置:它还在 with connect() as db: 事务上下文中,异常退出会导致当前事务回滚,代码没有先提交。此外,完整函数先查询 provider_keys,随后才建表,在空目录首次运行会先遇到表不存在。
这属于静态阅读即可发现的问题。原文演示不能作为「服务商已经提交,响应随后丢失」的有效验证;修复时需要先初始化表,并把服务商提交与响应丢失分成两个阶段。
我的判断
把恢复决策交给确定性的执行器,而不是交给模型回忆,是这篇最值得采用的结论。
对话记录可能被压缩、丢弃,或者不在恢复出的检查点里。模型可以提出调用,执行器必须根据账本判断这是新动作、已完成动作,还是待核对动作。
但原文没有提供重复执行率、吞吐损耗或不同框架的对照数据。这是一套有工程依据的设计建议,不能据此认定某种 Agent 架构已经得到生产验证。演示代码的缺陷也进一步限制了它的证明力度。
与常见做法相比,增加重试次数只能提高部分瞬时故障的成功率;限制最大迭代数、函数调用数和运行时长可以约束工作量,却不能识别已经完成的退款。原文提到 Microsoft 的相关限制是尽力执行、在请求批次之后检查,也不应把它当成防重复机制。NIST 的 Govern、Map、Measure、Manage 可以帮助分配责任,但其 Playbook 建议是自愿采用的,并不规定幂等实现。
还要区分本地与远程:如果业务修改和账本就在同一个数据库,可以放进同一事务,保证更强;跨支付、邮件或 SaaS API 时,不能把本地事务宣传成端到端 exactly-once。
能拿来做什么
- 给 Node 服务中的退款、发信、创建外部工单等工具增加持久化 operation ID,并核对服务商的幂等键范围、参数比较规则和保留周期;每次恢复都先读账本。
- 把多动作工作流拆到副作用粒度。发送邮件后再更新 CRM,如果第二步出错,应从第一个未解决的动作继续,已发送的邮件直接复用记录。
- 增加「远程已完成、本地未保存」「响应已保存、后续模型失败」「两个 worker 同时恢复」的故障验证,并监控长期
in_flight、参数哈希冲突、重复回放和超期unknown。 - 在面向用户的状态中区分失败与未知。结果不明时显示「暂时无法确认是否完成」,同时保留原操作 ID 进入核对,避免用户或 Agent 再创建一次相同动作。