智能体工具重试,如何避免重复扣款退款
网络超时、模型重复调用及崩溃恢复,都可能让同一工具动作执行多次。文章建议为会产生副作用的工具引入幂等键,防止重复退款、发送邮件或创建工单。
AI 深度解读
Agent 调用退款工具超时,框架重试,客户却收到两笔退款。问题可能不在模型:第一次请求已经成功,只是结果没传回来。作者的核心主张是,把后端早已使用的幂等键带进工具层,让同一次业务操作重试多次,也只产生一次副作用。
这篇值得读的地方,是把网络重试、模型重复调用、任务崩溃恢复放到一起看。做 Agent 时,除了管模型调用哪个工具,还得管工具执行两次会发生什么。
为什么换个提示词挡不住重复退款
作者列出了四条重复执行的路径:
- HTTP 客户端或 Agent 框架在超时后重试,但服务端第一次已经执行成功。
- 模型再次请求同一个工具。原文援引 OpenAI 文档:一次响应可以包含零个、一个或多个调用,某个 gpt-4.1-nano 快照开启并行调用后,还可能重复调用同一工具。
- Worker 在完成副作用后、保存工具结果前崩溃,恢复时重放步骤。
- 工具输出被截断、出错或丢失,模型没看到结果,于是再次尝试。
这些路径不都受提示词控制。模型甚至可能做出了合理决定:它不知道退款已经成功。
作者建议先把工具分成读取、幂等写入、非幂等写入三类。读取通常可以重复;幂等写入重复执行后仍保持相同目标状态;退款、发邮件、建工单这类非幂等写入,则需要额外保护。
该去重的是同一次业务操作,而不是所有参数相同的请求。
幂等键怎样让重试拿到第一次结果
幂等键(idempotency key)相当于一次业务操作的凭证。客户端重试时继续提交同一个凭证,服务端识别后返回已保存的结果,避免重新执行。
作者以 Stripe 为例说明具体约定:
- 请求通过
Idempotency-Key请求头携带键,建议使用 V4 UUID 或其他高随机性字符串,最长 255 个字符。 - 对 API v1,请求开始执行后,Stripe 保存首次请求的状态码和响应体。后续同键请求拿到保存的响应,包括
500错误。 - 同一个键携带不同参数会报错,防止它被悄悄用于另一件事。
- 键至少保留 24 小时后可以清理;清理后再次使用,会被当成新请求。
- 键里不要放邮箱、个人标识等敏感信息。
这里最容易写错的是生成时机:每次 HTTP 尝试都生成新 UUID,完全起不到去重作用。同一次意图的重试必须沿用原键。
作者给出的来源包括 Agent run ID 加步骤编号、OpenAI 返回的工具 call_id,以及业务键,例如 refund:{order_id}。最后一种有明确前提:业务规则确实是每个订单最多退款一次。
最小封装能说明思路,但还不能直接上线
下面这段代码用运行标识、工具名和排序后的参数生成键,并尝试复用保存的结果:
import hashlib, json
def idem_key(run_id: str, tool: str, args: dict) -> str:
payload = json.dumps(args, sort_keys=True)
return hashlib.sha256(f"{run_id}:{tool}:{payload}".encode()).hexdigest()
def run_side_effect(store, run_id, tool, args, fn):
key = idem_key(run_id, tool, args)
saved = store.get(key) # e.g. a Postgres table with a UNIQUE key
if saved is not None:
return saved # replay: return the first result, don't redo it
result = fn(**args, idempotency_key=key) # pass through if the downstream API supports it
store.put(key, result)
return result
要盯住的是 store.get(key) 到 fn(...) 之间的空隙。两个并发调用可能都查不到记录,随后各执行一次。作者因此补充:应先插入 pending 记录,用数据库唯一约束拒绝重复,而不能只做「先查再写」。示例没有实现这部分。
另一个空隙发生在下游操作成功之后:如果本地 store.put 失败,下次仍可能重新调用下游。作者建议把键继续传给支持幂等的服务商。本地存储负责复用结果,下游的幂等能力负责挡住这类重复请求。
重放结果也要让模型看得懂,例如:
{"status": "already_done", "refund_id": "re_123", "note": "This refund was already issued for this order. Do not retry."}
注意 status 和 refund_id:它们明确表达操作已完成,并给出已有结果。含糊的错误更容易引发再次尝试,但这种返回格式也不能保证模型一定停止。
我的判断
我赞成把幂等性放进工具接口设计。它直接处理执行层问题,比继续叠加「不要重复退款」的提示词更切题。但原文没有给出事故统计或对照实验,应把它看作工程经验建议。
键的作用范围还需要自己判断。call_id 适合同一个调用的重放;模型重新发起一次调用时,原文没有说明它会沿用旧标识。示例的哈希方案也会把同一 run 内、同工具同参数的操作合并,若用户本来就想执行两次,便可能误去重。
另外,唯一约束只解决并发争抢,不会自动消除外部副作用与本地落库之间的崩溃窗口。下游不支持幂等时,原文没有给出完整方案。pending 记录如何恢复、等待者何时重试,也没有展开。
对特定模型设置 parallel_tool_calls=false 可以减少一种重复来源,网络重试和崩溃恢复仍然需要处理。
能拿来做什么
- 给日常 AI 编程项目里的工具补上读取、幂等写入、非幂等写入分类,优先检查退款、邮件和工单等会改变外部状态的工具。
- 在 Node 工具层统一传递业务操作键,让框架重试和任务恢复沿用它;采用
refund:{order_id}前,先确认是否允许分次退款。 - 保存执行状态和首次结果,用数据库唯一约束处理并发,并将键传给支持幂等的下游 API。
- 按作者建议做一次故障测试:在副作用完成后立即终止 Worker,再重放运行,检查是否只产生一次退款,以及工具是否返回已有结果。