☰
智能体重试引发重复扣款?幂等设计实战指南
2026/10/4 5:58:45 网站建设 项目流程

1. 智能体入场之后,重试问题为什么首当其冲

1.1 从传统接口调用到智能体自主决策:重试的代价变大了

先看一个我最近处理的线上问题:一个负责自动续费扣款的智能体,在调用支付网关时发生了网络超时。支付网关那边其实已经成功扣款,但智能体因为没收到响应,按照预设的重试策略又调了一次。结果用户银行卡里被扣了两次钱,客诉直接打到业务侧。

这不是罕见事故。传统单体系统里,接口调用链是固定的,开发者在某个服务里写死重试逻辑,出问题范围相对可控。但智能体的行为模式完全不同:任务拆解由模型动态决定,工具调用由模型自主发起,步骤之间还可能跨多个服务、多个上下文。同一操作一旦重放,影响面不只是某一个接口,而是整条决策链。

更麻烦的是,LLM本身有不确定性。同一个意图,模型在不同轮次可能生成略有差异的请求参数;同一个函数,可能在规划里被重复选择;同一笔订单,可能在推理链路里被编排两次。传统的「超时重试」逻辑直接搬到智能体上,会产生很多传统系统里不太会出现的问题。重试不只是网络层的重试,还包括推理层面的重试、任务执行的整个重放。

1.2 智能体特有的几个重复执行场景

相比普通API调用,智能体场景里,重复执行至少来自四个层面:

  • 网络层重试:调用工具超时后自动重发,这是最常见也没得商量的情况。任何分布式系统都有网络抖动,智能体的工具调用也不会例外。
  • 模型层重复决策:同一个用户问题触发多次规划,模型在前后两次推理里都决定「调用扣款工具」,即使没有网络故障,也会产生两个真实请求。
  • 任务重放:智能体某个中间步骤失败后,框架常常会「退回到上一步重新来」。这一步如果设计得粗暴,很容易把已经执行成功的操作再执行一遍。
  • 多智能体协作中的消息重复投递:在编排多个子智能体时,协调者的消息队列如果用了 at-least-once 投递,下游收到重复指令几乎是必然事件。

这四个层面叠加,重复执行的概率远高于普通后端服务。

所以「智能体重试一次,扣款两次」这类题目的本质,不是问「要不要重试」,而是问:既然重试不可避免,我们靠什么来保证业务结果只发生一次?答案不是「别重试」,而是把「只执行一次」这件事,从靠运气的期望,变成靠机制的设计。

2. 先别急着写代码:把 exactly-once 的语义扣清楚

2.1 三种投递语义到底分别承诺什么

在分布式系统里聊「只执行一次」,绕不开三种基本语义:

语义含义代价
at-most-once最多一次,可能丢消息不重试,损失可用性
at-least-once至少一次,可能重复重试导致重复风险
exactly-once精确一次,不丢不重端到端极难实现

很多团队在面对智能体扣款问题时,第一种反应是「把超时重试关掉不就行了」。这等于选择了 at-most-once:如果请求真的丢了,用户付不了款,业务直接失败。

于是大家退而求其次选 at-least-once,也就是「失败了一定要重试」。这时候重复扣款的风险就来了。问题的焦点变成:能不能既享受重试带来的可靠性,又消除重复执行带来的副作用。

答案是在语义层加一层东西:幂等。系统实际承诺的,是「对外表现为只执行一次」。这就是行业里常说的 effectively-once(有效一次),它不是语义层面的 exactly-once,而是业务结果层面的 exactly-once。

2.2 端到端的 exactly-once 在分布式系统里几乎不存在

我理解很多人第一次接触 exactly-once 这个概念,是在消息队列或者数据库事务里。但请注意,这些系统声称的 exactly-once,大多只在单个组件边界内成立。比如 Kafka 的 exactly-once 语义,靠的是 producer 与 consumer 的协调机制,但它不能保证「这个消息触发的业务操作」在另一个远端系统里也只执行一次。

放到智能体场景里,链路是这样的:

用户指令 -> 智能体推理 -> 工具调用 -> 支付网关 -> 银行扣款

模型的推理没有事务保障,网络调用没有统一协调器,支付网关和银行也不参与你的分布式事务。所以在整条链路上谈真正的 exactly-once,就像要求两个将军隔着不可靠的信道同时进攻一样,理论上是无解的。

这不是消极,而是帮大家认清现实:与其追求无法实现的全局 exactly-once,不如在每个可能产生副作用的环节,都让它具备幂等能力。只要每个环节能安全重试,整个链路就能表现为「只执行一次」。

2.3 正确的目标:effectively-once 与幂等

幂等的定义很直观:一个操作无论执行一次还是执行 N 次,最终结果都一样。拿扣款举例,如果扣款接口是幂等的,那重复调用时,第二次就会返回「这笔请求已经处理过了」,不会再次扣钱。

设计目标就清晰了:

  • 传输层允许重试,保证请求不丢;
  • 业务层保证幂等,让重复请求不产生新副作用;
  • 最终对外表现:一次成功且唯一一次生效。

理解这个目标之后,剩下的问题就是技术细节:幂等键怎么生成?存储层怎么判断是不是重复请求?状态怎么流转才不会被乱序回调搞乱?

3. 让「重复执行」看不出来:幂等设计的完整拆解

3.1 幂等键怎么生成才算靠谱

幂等设计的核心是一个唯一标识符,业界通常叫 Idempotency-Key 或者 Biz-Id。所有重复请求都会携带同一个幂等键,服务端靠这个键区分「新请求」和「重放请求」。

幂等键有三个生成原则:

第一,必须由请求发起方生成,并且全局唯一。我强烈建议用 UUID 或 ULID,而不是用「用户ID + 订单号」这种拼接逻辑。拼接过长容易出错,而且如果业务上两个订单号其实指向同一笔交易,反而会误判。

第二,必须在智能体编排层生成,不能让模型自由发挥。这是智能体场景最容易被忽视的一点。有些团队在 system prompt 里写「请为每次请求生成一个唯一的订单号」,然后让 LLM 自己填。结果模型两次重试时生成了不同的订单号,幂等形同虚设。正确的做法是:编排框架拦截工具调用,在入参里强制注入 request_id,模型没有能力改动它。

第三,一个幂等键只能对应一个业务意图。同样是调用扣款工具,「从余额扣」「从银行卡扣」是两种意图,要区分开。不能让两个逻辑上不同的操作因为共享一个 request_id 而互相覆盖。

在智能体场景里,我常用的拼接方式是:

request_id = task_id + ":" + tool_call_id

task_id 是这一次智能体任务的全局ID,tool_call_id 是模型某次具体工具调用的ID。即便同一任务里的模型重复发起了两次调用方法,tool_call_id 不同,幂等键也不同;而网络重试同一个调用时,tool_call_id 相同,幂等键也就相同。这样能把「模型重新规划」和「网络重试」天然区分开。

3.2 存储层怎么保证「第一个请求说话算数」

有了幂等键之后,关键问题是服务端怎么保证两个相同键的请求,只有第一个能落库执行。

最可靠的办法是数据库唯一约束。建一张请求记录表,把幂等键设为唯一索引。两个请求同时进来,数据库层会保证只有一个 INSERT 成功,另一个因为主键或唯一键冲突直接失败。这是原子性的保证,不存在「检查到一半另一个线程插进来了」的窗口。

我见过很多团队一开始用 Redis SETNX 做去重:

if redis.set(key, "1", nx=True, ex=300): do_payment() else: print("duplicate request") return previous_result

说实话这套方案在并发不高、允许少量漏网的情况下确实能跑,但它有两个硬伤:一是 Redis 缓存会过期,一旦 key 过期,同一个幂等键的请求又能进来了;二是「先写缓存再执行业务」之间如果进程 crash,缓存有了但业务没执行,后续请求全被当成重复请求拦掉。所以我的建议是:Redis 只做前置拦截,数据库唯一索引做最终仲裁。判断重复,以数据库为准。

一个简单的实践:

CREATE TABLE payment_req ( request_id VARCHAR(128) PRIMARY KEY, task_id VARCHAR(64) NOT NULL, tool_call_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, amount_cents INT NOT NULL, status VARCHAR(16) NOT NULL, -- CREATED / PAYING / SUCCESS / FAILED created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_task_tool (task_id, tool_call_id) );

这里即使用了request_id主键,我仍然加一个task_id + tool_call_id的唯一索引,因为如果再上一层编排框架没传 request_id,我们还能靠任务维度的组合键兜底。

3.3 状态机与版本号:让回调乱序也翻不了天

幂等键解决了「同一请求重复进入」的问题,但还有一个场景要处理:请求第一次执行失败,第二次重试时成功了;或者支付网关的回调乱序,把先发生的成功通知后发过来了。

状态机的价值就在这里。给每个请求定义明确的状态流转:

CREATED -> PAYING -> SUCCESS CREATED -> PAYING -> FAILED SUCCESS/FAILED 是终态,不可被覆盖

回调到达后,先查状态,再决定是否更新。比如一个请求已经是 SUCCESS,就不会因为一个迟到的 FAILED 回调被改成失败;反过来也一样。

有些系统用乐观锁版本号来做这件事,每次更新前比较版本号:

updated = ( update(payment_req) .where( request_id == request_id, status == "PAYING", ) .values(status="SUCCESS", version=version + 1) .execute() ) if updated.rowcount == 0: # 状态已被修改,说明有并发/乱序,重新查询并返回当前状态 return query_status(request_id)

这里用更新行数判断有没有竞态:如果更新影响行数为0,说明当前状态已经不是我期望的 PAYING,那就以当前状态为准。这是分布式系统里很经典且很朴素的乐观并发控制,不需要引入额外组件。

4. 实战:给智能体扣款工具做防重方案

4.1 场景还原与设计约束

假设我们的智能体负责处理用户的「自动续费」请求,工具定义如下:

{ "name": "charge_user", "description": "对指定用户发起扣款", "parameters": { "type": "object", "properties": { "user_id": { "type": "string" }, "amount": { "type": "number" } } } }

这个工具在生产上暴露的问题就是:没有幂等参数,模型每次调用的参数几乎一样,服务端无法区分「这是新请求」还是「这是刚才那个请求的重试」。

我给出的设计约束有三条:

  1. 工具签名里不加引导模型生成的幂等参数,因为模型不可控;
  2. 编排层在调用真实工具前,自动注入 request_id,模型只关心业务参数;
  3. 真实支付服务侧,以 request_id 为主键做幂等处理,支付回调也必须携带这个 request_id。

4.2 表结构与核心处理逻辑

真实支付服务侧的伪代码看起来是这样:

from datetime import datetime import uuid def handle_charge_request(req): """ req 里面包含 request_id, task_id, tool_call_id, user_id, amount """ # 第一步:尝试插入请求记录,利用主键冲突做原子去重 try: insert_payment_req( request_id=req.request_id, task_id=req.task_id, tool_call_id=req.tool_call_id, user_id=req.user_id, amount_cents=req.amount_cents, status="CREATED", created_at=datetime.now(), updated_at=datetime.now(), ) except DuplicateKeyError: # 幂等键已存在,说明之前处理过,直接返回已有结果,不重新扣款 return get_status_and_result(req.request_id) # 第二步:更新状态为 PAYING update_status(req.request_id, "CREATED", "PAYING") # 第三步:调用真实支付网关 try: result = payment_gateway.charge( merchant_id=req.merchant_id, out_trade_no=req.request_id, # 把 request_id 透传给支付网关,网关侧也做唯一约束 user_id=req.user_id, amount=req.amount_cents, ) update_status(req.request_id, "PAYING", "SUCCESS", payment_result=result) return result except PaymentGatewayTimeout: # 重点:超时并不代表失败,绝对不能在本处直接重试扣款 # 先把请求标记为 PAYING,然后进入状态探测逻辑 probe_payment_status(req.request_id) raise except PaymentGatewayRejected: update_status(req.request_id, "PAYING", "FAILED", fail_reason="gateway_rejected") raise

这里最关键的一步是:在 PaymentGatewayTimeout 分支,绝不重试扣款。因为在网络超时的情况下,你根本不知道支付网关内部是否已经扣款成功。直接重试,就是题目的原话——重试一次,扣款两次。

正确做法是主动去支付网关查询这笔out_trade_no的真实交易状态,再根据查询结果更新状态。支付网关是否收到这笔请求、是否扣款成功,查询接口会给出确定答案。

查询逻辑:

def probe_payment_status(request_id): for _ in range(3): status = payment_gateway.query(request_id) if status in ("SUCCESS", "REFUNDED", "CLOSED"): update_status(request_id, "PAYING", status) return status elif status in ("NOT_EXIST", "FAILED"): # 网关侧确实没有这笔扣款,才允许重新发起 # 如果智能体编排层决定重试,这里会把状态先改回 CREATED update_status(request_id, "PAYING", "FAILED") return status else: # 状态不明确,比如 PROCESSING,间隔后重查,不能盲目重试 time.sleep(0.5 * (retry_count + 1) + random.uniform(0, 0.2)) raise ProbeTimeout()

很多团队只做了「请求去重」,但漏了「超时后的状态探测」,导致真正该重试的没重试,不该重试的瞎重试。状态探测本质上就是「用查询代替重放」,是扣款这类高风险操作的标准解法。

4.3 智能体编排层如何强制注入幂等键

在智能体这一侧,关键是拦截工具调用。实现方式取决于框架,但核心逻辑不变:模型只输出业务参数,编排层拿到参数后补上 request_id,再去调真实工具。

伪代码:

def dispatch_tool_call(task_id, tool_name, tool_params, tool_call_id): if tool_name == "charge_user": augmented_params = { **tool_params, "request_id": f"{task_id}:{tool_call_id}", "task_id": task_id, "tool_call_id": tool_call_id, } return call_payment_service(augmented_params) else: return call_tool(tool_name, tool_params)

注意,request_id应该在编排框架里生成,而不是让大模型生成。原因很简单:如果模型在第一次超时后的重试里生成了一个新的 UUID,那幂等机制就完全失效了。实测下来,LLM即使给定了格式,也无法保证重试时沿用同一个 UUID,所以这个参数必须从模型边界之外注入。

对于有调试需求的情况,可以在给模型的工具调用结果里带上 request_id,帮助模型理解上下文,但绝不能让它自己生成。

4.4 超时重试的完整时序处理

把重试策略也规范化,我建议遵循三个原则:

第一,重试次数有限。无限重试是灾难的温床。普通网络错误建议最多重试3次,且使用指数退避加抖动:

def retry_delay(attempt): base = 1.0 backoff = base * (2 ** attempt) jitter = random.uniform(0, 0.3 * backoff) return backoff + jitter

第二,每两次重试之间必须做状态探测。重试前先查一次当前请求的真实状态,而不是直接重发。

第三,重试与状态探测协同。当探测结果是「网关侧没这笔请求」,将状态置为 FAILED 或直接更新为 CREATED 允许重发;当探测结果是「网关侧已扣款成功」,将状态置为 SUCCESS,绝对不重发。

一句话总结完整顺序:收到超时异常,先插幂等记录并标记状态;再探测网关真实状态;根据探测结果决定是「重试」还是「返回已有结果」。

5. 复盘:反复踩过的坑与排查思路

5.1 五类高频问题实录

问题一:扣款成功但响应丢了,客户端重复调用。

这是最典型的「重试一次,扣款两次」现场。排查思路不是去看有没有重复调用,而是去确认:两次请求的 request_id 是否一致?不一致的话,说明幂等键生成逻辑有漏洞;一致的话,说明服务端幂等没生效。看服务端日志里两次 INSERT 的记录,重点看第二次 INSERT 是否命中了 DuplicateKeyError。

我实际排查过一个案例,第一次插入请求记录后,还没来得及更新状态进程就崩了。第二次请求进来,发现幂等键已存在,返回了「已处理」,但真实支付其实没执行。这种情况状态机要兜底:如果发现状态是 CREATED 且创建时间超过阈值,允许业务方人工介入或将状态置为可重试。

问题二:LLM 自主生成的参数每次重试都不一样。

如果你让模型自己生成交易号或订单号,大概率会在重试时生成新的。现象是:服务端收到了两笔请求,request_id 不同,但 user_id 和 amount 相同,重复扣款。根因就是把不该交给模型的参数交出去了。

排查办法:比较两次请求的 tool_call_id 和 request_id,如果不是同源,就是模型层新建了调用。修复手段就是我在 4.3 里说的,编排层把 request_id 作为框架参数注入,模型无权控制。

问题三:同一次任务里,模型连续多次调用同一个扣款工具。

这不是网络重试,而是模型规划层面的重复。LLM 可能在一个计划里生成两个几乎一样的 charge_user 调用,或者在执行完一次后又「确认一下是否扣款成功」而再次调用。这种重复最难查,因为它不是简单的请求重放。

我的做法是在编排层加任务级调用去重:维护该任务内已经完成过的 tool_call 摘要,如果新调用参数与之前某次完全一致,直接返回上次的结果,不进入真实工具调用。这个机制不是幂等,而是「避免无意义的二次执行」,与幂等互为补充。

问题四:支付回调与主动查询并发,顺序颠倒,状态被回退。

状态机设计不够严格就会出现这个坑。比如请求先收到主动查询结果「SUCCESS」,还没落库;随后迟到的回调「PAYING」覆盖了被更新的状态,把 SUCCESS 改回了 PAYING。

解法就是 3.3 里的乐观锁:更新时带上当前状态条件,只能单向流转,终态不可逆。后端在代码 review 时要重点检查每一个 update 语句,是否都带上了状态条件。

问题五:Redis 去重觉得没问题,线上被穿透。

我见过一个团队用 Redis SETNX 做幂等,设置了5分钟过期。支付网关处理偶发超过5分钟,第一次扣款确实成功,但因超时未回;第二次重试时 Redis key 恰好过期,于是又扣了一次。这个事故告诉我们,任何基于时间的去重方案都有窗口期。唯一可靠的还是数据库侧持久化的幂等记录。

5.2 问题速查表

症状可能的根因优先排查项
扣款两次,两边 request_id 不同幂等键由模型生成或每次新建编排层是否注入 request_id
扣款两次,request_id 相同服务端幂等未生效唯一索引是否存在,事务是否包裹
请求未扣款但显示已处理INSERT 成功但业务未执行状态机是否区分 CREATED 与终态
同一工具被连续调用多次模型层重复决策任务级调用去重是否开启
回调到达后状态错乱终态被覆盖更新是否带状态条件
日志显示只有一个请求但扣款两次网关侧透传标识缺失out_trade_no 是否等于 request_id

5.3 审计与对账:最后一道防线

哪怕前面所有机制都做对了,我还是建议保留对账与审计。这不是防御过度,而是因为幂等机制本质上依赖各个组件的合作,任何一环出了 bug,都需要靠对账及时发现。

对账方案可以很简单:每天跑一个定时任务,把支付网关流水和本地 payment_req 状态做比对。凡是网关侧有成功扣款但本地状态不是 SUCCESS 的记录,自动告警。这能在用户发现之前发现问题,而不是等着客诉上来再去翻日志。

审计日志方面,至少记录三类信息:

  • 请求链路:request_id、task_id、tool_call_id、模型参数原文、注入后的实际请求参数;
  • 重试动作:什么时候发起的、触发原因、间隔了多少、返回了什么;
  • 状态变更:每一次状态从什么变成什么、由哪个回调或探活触发的。

有了这些,排查问题的时候就不需要从一堆日志里大海捞针。

6. 一些技术之外的体会

每次跟团队复盘这类事故,我都会强调一件事:别指望智能体能「自己判断出来」它是不是在重试。模型没有事务概念,也没有全局状态。所有「只执行一次」的保障,必须由系统层面去承载,而不是靠模型自觉。

具体到落地,我个人的排序是这样的:先保证数据库层面有幂等约束,再保证编排层强制注入幂等键,再保证超时后走状态探测而不是盲目重试,最后补上对账机制。四层都齐了,才能比较自信地说这个智能体的扣款链路是稳的。

这个方案不止适用于扣款,智能体里的发消息、锁库存、创建订单、写数据库、调用第三方API,凡是会产生外部副作用的工具,都可以拿同一套思路去套。说穿了道理很简单:智能体越能自主行动,我们越要在系统层面给它划好边界——可以重试,但结果只能生效一次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询