ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

智能体重试引发重复扣款?幂等设计实战指南

智能体重试引发重复扣款?幂等设计实战指南 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_idtask_id 是这一次智能体任务的全局IDtool_call_id 是模型某次具体工具调用的ID。即便同一任务里的模型重复发起了两次调用方法tool_call_id 不同幂等键也不同而网络重试同一个调用时tool_call_id 相同幂等键也就相同。这样能把「模型重新规划」和「网络重试」天然区分开。3.2 存储层怎么保证「第一个请求说话算数」有了幂等键之后关键问题是服务端怎么保证两个相同键的请求只有第一个能落库执行。最可靠的办法是数据库唯一约束。建一张请求记录表把幂等键设为唯一索引。两个请求同时进来数据库层会保证只有一个 INSERT 成功另一个因为主键或唯一键冲突直接失败。这是原子性的保证不存在「检查到一半另一个线程插进来了」的窗口。我见过很多团队一开始用 Redis SETNX 做去重if redis.set(key, 1, nxTrue, ex300): 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(statusSUCCESS, versionversion 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 } } } }这个工具在生产上暴露的问题就是没有幂等参数模型每次调用的参数几乎一样服务端无法区分「这是新请求」还是「这是刚才那个请求的重试」。我给出的设计约束有三条工具签名里不加引导模型生成的幂等参数因为模型不可控编排层在调用真实工具前自动注入 request_id模型只关心业务参数真实支付服务侧以 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_idreq.request_id, task_idreq.task_id, tool_call_idreq.tool_call_id, user_idreq.user_id, amount_centsreq.amount_cents, statusCREATED, created_atdatetime.now(), updated_atdatetime.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_idreq.merchant_id, out_trade_noreq.request_id, # 把 request_id 透传给支付网关网关侧也做唯一约束 user_idreq.user_id, amountreq.amount_cents, ) update_status(req.request_id, PAYING, SUCCESS, payment_resultresult) return result except PaymentGatewayTimeout: # 重点超时并不代表失败绝对不能在本处直接重试扣款 # 先把请求标记为 PAYING然后进入状态探测逻辑 probe_payment_status(req.request_id) raise except PaymentGatewayRejected: update_status(req.request_id, PAYING, FAILED, fail_reasongateway_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_id5.3 审计与对账最后一道防线哪怕前面所有机制都做对了我还是建议保留对账与审计。这不是防御过度而是因为幂等机制本质上依赖各个组件的合作任何一环出了 bug都需要靠对账及时发现。对账方案可以很简单每天跑一个定时任务把支付网关流水和本地 payment_req 状态做比对。凡是网关侧有成功扣款但本地状态不是 SUCCESS 的记录自动告警。这能在用户发现之前发现问题而不是等着客诉上来再去翻日志。审计日志方面至少记录三类信息请求链路request_id、task_id、tool_call_id、模型参数原文、注入后的实际请求参数重试动作什么时候发起的、触发原因、间隔了多少、返回了什么状态变更每一次状态从什么变成什么、由哪个回调或探活触发的。有了这些排查问题的时候就不需要从一堆日志里大海捞针。6. 一些技术之外的体会每次跟团队复盘这类事故我都会强调一件事别指望智能体能「自己判断出来」它是不是在重试。模型没有事务概念也没有全局状态。所有「只执行一次」的保障必须由系统层面去承载而不是靠模型自觉。具体到落地我个人的排序是这样的先保证数据库层面有幂等约束再保证编排层强制注入幂等键再保证超时后走状态探测而不是盲目重试最后补上对账机制。四层都齐了才能比较自信地说这个智能体的扣款链路是稳的。这个方案不止适用于扣款智能体里的发消息、锁库存、创建订单、写数据库、调用第三方API凡是会产生外部副作用的工具都可以拿同一套思路去套。说穿了道理很简单智能体越能自主行动我们越要在系统层面给它划好边界——可以重试但结果只能生效一次。
返回列表