Agent 重试如何避免重复退款与下单
工具调用可能已经成功却因响应超时被 Agent 重试,导致业务操作重复执行。文章提出结合稳定幂等键、请求校验、持久化执行记录、并发控制与下游支持,并在结果不明时先核对状态。
AI 深度解读
Agent 调用退款工具,接口超时,接着重试,客户却收到了两笔退款。问题出在:调用方没收到结果,不代表业务没执行成功。只要 Agent 开始操作订单、支付或云资源,这种故障就必须认真处理。
原文的核心判断是:幂等不能靠模型记忆,必须由执行架构保证。它给出了一套围绕稳定操作标识、数据库记录、下游去重和故障核对的设计。文章没有提供实测数据,但把「加一个幂等键就安全了」背后的缺口讲得比较清楚。
超时之后,为什么不能直接再调一次
作者用一笔 $250 退款说明风险:支付服务已经退款,响应却在返回途中超时。Agent 看到错误后再次调用,如果没有重复保护,就可能再退一次。
这里要区分两个对象:请求尝试和业务操作。同一笔退款可以有多次请求尝试,但它们应当指向同一个业务操作。
请求失败,不等于业务操作失败。
这类重复也不只来自模型。工作进程执行后崩溃、框架自动重试、工作流恢复、队列重复投递、多个 worker 同时消费,都可能再次触发工具。模型没收到上一次结果,重新生成调用,只是其中一种来源。
重试策略决定「什么时候再试」,幂等机制决定「再试会不会重复产生业务效果」。只配置退避重试,解决不了后一个问题。
幂等键(idempotency key)就是给一次逻辑操作分配的稳定标识。原文示例中,operation_id 和 idempotency_key 都是 refund_4821,目标支付为 pay_901,amount_minor_units 为 25000。对于每个主货币单位包含 100 个最小单位的货币,这对应 $250。
重试时要复用这个键。系统据此返回已保存结果、报告处理中,或者进入结果核对,而不是创建另一笔退款。
键应由应用或编排层生成,例如 idempotency_key = f"{workflow_run_id}:{tool_operation_id}",然后持久化。会话 ID 不够细,因为一次会话可以合法申请多笔退款;每次重试生成新 UUID,又会让去重失效。
有了幂等键,数据库还要检查什么
作者建议在 Agent 和敏感外部 API 之间增加确定性的工具执行层:网关负责参数、权限和业务规则,协调器负责操作身份与去重,持久化存储记录状态,worker 执行操作,核对服务处理不确定结果。
这里的「确定性」很具体:相同操作遇到相同状态,应遵循固定规则处理,不能临时交给模型猜测。
PostgreSQL 示例使用 agent_tool_operations 表,通过 PRIMARY KEY (tenant_id, idempotency_key) 防止同一租户内重复建记录,再通过 UNIQUE (tenant_id, operation_id) 约束业务操作身份。result_json 保存结果,external_ref 保存外部引用,便于重复请求直接取回结果或向下游查询。
但键相同还不够。有人把退款金额改了,却沿用原键,系统必须拒绝。因此还需要请求指纹,将租户、工具名和参数绑定起来。
下面这段代码解决参数比对问题,让符合这些序列化规则的等价输入得到相同指纹:
import hashlib
import json
def request_fingerprint(
tenant_id: str,
tool_name: str,
arguments: dict,
) -> str:
payload = {
"tenant_id": tenant_id,
"tool_name": tool_name,
"arguments": arguments,
}
canonical = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
)
return hashlib.sha256(
canonical.encode("utf-8")
).hexdigest()
要看清 sort_keys=True 和 separators=(",", ":"):它们固定序列化方式;allow_nan=False 则拒绝 NaN 等值。时间戳、小数仍需提前规范化,所有会改变业务含义的字段都必须参与计算。
创建操作记录时,原文用数据库唯一约束处理并发竞争:
INSERT INTO agent_tool_operations (
tenant_id,
operation_id,
idempotency_key,
request_hash,
status
)
VALUES (
:tenant_id,
:operation_id,
:idempotency_key,
:request_hash,
'PENDING'
)
ON CONFLICT (tenant_id, idempotency_key)
DO NOTHING;
关键是 ON CONFLICT ... DO NOTHING:冲突时不重复插入。它只负责占位,不代表已经安全取得执行权。后续还要在妥善管理的事务中读取记录、比较操作 ID 和指纹,并处理条件状态迁移、worker 所有权及租约到期。
匹配且成功的请求返回旧结果,仍在执行的请求报告处理中,指纹不同的请求返回冲突。不能为了绕过冲突自动换一个新键,否则可能真的多执行一次。
最难处理的是「可能已经成功」
原文定义了五种状态:PENDING 表示已接收并记录,IN_PROGRESS 表示 worker 已领取,SUCCEEDED 表示成功已确认并保存,FAILED 表示失败已确认,UNKNOWN 表示外部效果可能发生,但结果未确定。复杂流程还可以增加 RECONCILING 或 CANCELLED。
最危险的窗口,是外部服务成功之后、本地保存结果之前。
例如支付服务已经退款,worker 随即崩溃。替补 worker 看到的仍是 IN_PROGRESS,仅凭本地数据库无法判断款项是否退过。此时应转入 UNKNOWN,使用外部交易引用或下游幂等机制核对:确认成功就补存结果,确认未执行才继续尝试,仍有歧义就升级处理。
租约到期只能帮助发现失联任务,不能证明旧 worker 已经停止执行。
如果下游支持幂等,应继续传递同一个键,并核实它的保留窗口、参数匹配规则和重复请求行为。如果只支持唯一业务标识或条件写入,也应利用这些约束。
如果下游既不能去重,也无法可靠查询,本地事务就无法把远端副作用一起原子提交。任意故障下的「恰好一次」(exactly-once)效果通常无法保证,高影响操作可能需要人工复核或业务补偿。
其他组件也有各自边界:Redis 适合快速协调和短期去重,但要考虑过期、持久化、故障切换和旧 worker;事务发件箱(transactional outbox)能把数据库变更与事件一起记录,消费者仍需去重;持久化工作流能恢复流程,也不会自动保证外部工具只执行一次。
我的判断
我认同作者把幂等放进执行层。模型上下文不是可靠账本,多 worker、多 Agent 共用的业务操作身份,也不能靠各自理解对齐。相比单纯禁用重复调用或限制模型重试次数,这套设计能覆盖更多故障来源。
不过,原文是架构建议和示例代码,没有给出吞吐量、故障恢复成功率,也没有 PostgreSQL 与 Redis 的对照数据。选型部分应当按工程经验理解,不能据此认定某个方案在所有团队都更好。
示例也还不是可直接投产的实现。worker 如何领取任务、租约如何续期、哪些失败允许重试、核对如何调度,都没有完整展开。尤其是下游能否查询、幂等键保存多久,会直接决定本地方案能提供多强的保证。
安全边界同样不能混在一起:模型参数要按严格 schema 校验,权限和资源归属要单独检查。拿到一个幂等键不代表有操作权限;租户隔离也不能替代逐次鉴权。记录、日志中的敏感参数和结果还需要访问控制、脱敏与保留策略。
能拿来做什么
- 给 Node 服务中的高影响工具先接入完整链路,例如退款、创建订单或开通资源。先定义稳定操作 ID、指纹字段和状态迁移,再把同一身份传到队列、worker 与下游 API。
- 在 Agent 工具结果中明确区分成功、处理中、已确认失败和结果未知。遇到
UNKNOWN时进入查询或核对流程,避免模型把所有错误都理解为「可以重新执行」。 - 把故障测试放在关键边界:用独立数据库连接发起并发请求,并模拟外部成功后进程崩溃、响应超时、worker 重启、跨租户重放,以及记录过期后的迟到重试。
- 监控重复请求率、指纹冲突、未知结果、操作积压时间和核对延迟。指标标签保持有限取值,具体操作 ID 放入受控日志或 trace;保留周期则覆盖重试、队列重投、工作流恢复和核对窗口。