ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

智能体重试引发重复扣款?分布式幂等设计实战指南 凌晨一点我盯着手机里的扣款短信后背有点发凉。同一笔订单同一个商家十分钟内收到两条消费提醒金额分毫不差。更离谱的是这事的罪魁祸首是我写的一个自动化交易智能体——它在下单时遇到了网络超时然后自作主张地重试了一次。结果就是用户被扣了两次款我的第一反应从完了变成为什么会这样。这个场景在近几年特别常见。随着智能体AI Agent从聊天窗口走向生产环境它们开始替我们下单、转账、调API、改配置甚至操作数据库。一旦切割到资金、库存、积分这类敏感业务重试一次就不再是简单的技术行为而是实打实的资损风险。今天这篇文章我想把这个问题拆透为什么智能体一重试就容易出双份账单只执行一次到底有没有可能保证如果有可能靠的是什么本文适合正在开发智能体应用的后端工程师、AI应用架构师以及所有准备让智能体碰真实业务系统的同学。下面这些内容既有基础理论也有我在生产环境里踩过坑之后沉淀下来的实操方案希望能帮你在设计阶段就避开重复扣款这类事故。1. 一场重试引发的双重扣款事故链路还原要理解为什么重试会导致扣款两次首先得还原一次完整的扣款调用链路。别急着怪智能体太傻这口锅其实是分布式系统底层机制送来的。1.1 智能体扣款动作的时间线假设你的智能体收到用户指令替我买一张火车票它内部大概会执行这么几步大模型理解意图决定调用下单支付工具。智能体框架通过 HTTP/RPC 请求调用支付服务携带订单号、金额、用户ID。支付服务收到请求先扣用户账户余额再更新订单状态最后返回扣款成功。智能体收到响应向用户汇报搞定。看起来顺理成章。但如果第 3 步之后、第 4 步之前网络上突然抖动了一下——支付服务的扣款其实已经完成了但响应报文在回传途中丢失或者网关超时提前断开了连接。此时智能体侧看到的是请求超时。于是基于再试一次更稳妥的朴素逻辑智能体或它背后的重试组件重新发起了一次扣款请求。这一次网络通畅了支付服务又执行了一次扣款逻辑。你在数据库里就会看到同一订单号对应两条资金流水。1.2 超时到底是没到还是没回来这是核心矛盾很多人会把超时误解为请求没送达这是重试出问题的根源。实际上一次请求从发出到超时可能有三种完全不同的状态状态服务端实际执行情况直接重试的后果请求在传输途中丢失未执行无风险重试即可请求到达服务端处理中响应超时可能已执行高风险重试可能重复执行请求到达服务端已执行响应丢失已执行极高风险重试必然重复执行现实中的超时大部分落在第二、第三种状态上。但客户端的直觉判断却是可能没送达。这种信息不对称就是重试一次、扣款两次最简单的底层解释。1.3 智能体让这个问题升级了以前我们写传统程序重试逻辑是程序员手写或框架配置的至少可以统一控制。但到了智能体场景重试的来源变得复杂可能是大模型觉得刚才工具没调用成功主动再调一次可能是Agent框架内置的自动重试策略也可能是底层HTTP客户端的三次重连机制。多层重试叠在一起重复放大的概率呈指数级上升。所以与其问智能体为什么会重复扣款不如问我们为智能体做了哪些防止重复执行的设计。如果答案是没有,那出事只是时间问题。2. 从消息队列到智能体调用为什么保证只执行一次这么难先泼一盆冷水在分布式系统里exactly-once精确一次从来不是零成本能做到的。我们能做到的是通过技术手段让系统对外表现像只执行了一次。2.1 三种投递语义先搞清楚到底在谈什么分布式通信中消息投递语义通常分三种At-most-once最多一次发送方尽力发送丢了就不再管。最省事但数据会丢。At-least-once至少一次发送方不确认收到就一直重发。不丢数据但可能重复。Exactly-once精确一次既不丢也不重是理想状态也是成本最高的状态。几乎所有基础通信设施包括HTTP、TCP、消息队列默认提供的都是at-least-once。这是物理世界和网络协议天然决定的——发送方永远无法确切知道接收方到底收到没有所以只能不成功就重试。这就引出一个关键结论exactly-once从来不是通信链路能保证的而是业务逻辑自己兜住的。2.2 消息队列里的经典解法智能体完全可以借鉴在消息队列领域解决at-least-once下重复消费的通用手法是消费幂等。经典做法长这样生产者给每条消息生成全局唯一IDMessage ID。消费者在处理前先查一下这个ID是否处理过。处理过就直接忽略没处理过就执行并把ID标记为已处理。这套逻辑跟大模型有什么关系关系大了。智能体的工具调用本质上跟消息消费是一模一样的模型智能体是消费者下单扣款是业务处理工具请求就是消息。只要把幂等机制从消息队列迁移到智能体的工具调用层很多问题都能解决。2.3 智能体工具调用的Exactly-once意味着什么对智能体而言实现exactly-once有三个层面的要求调用方智能体每次真正的业务操作都需要携带一个唯一的操作标识俗称幂等键。执行方业务系统需要能识别重复的幂等键并在第二次收到时直接返回第一次的执行结果。双方协作超时后重试时智能体必须使用同一个幂等键而不是重新生成的键。如果这三点没对齐谈exactly-once就是纸上谈兵。下面我重点展开第二步和第三步的实操设计因为第一步相对简单但后两步藏了很多细节坑。3. 幂等设计实操给每个操作配一把唯一钥匙要根治重复扣款最核心的手段就是幂等性设计。一句话解释幂等同一个操作无论你调一次、两次、十次产生的效果都和调一次完全相同。就像你去便利店买东西无论手机支付弹窗出来多少次只要扣款成功一次商店不会把同一瓶水卖你两次。3.1 幂等键从哪来业务唯一标识是唯一正确答案很多团队在幂等设计上翻车一上来就问用UUID行不行。UUID完全可以但关键问题是这个UUID是谁生成的什么时机生成的正确的做法是幂等键必须在业务动作发生时由操作发起方生成并伴随整个业务生命周期。拿前面的买票场景举例正确流程是这样的用户说买一张早上8点从北京到上海的高铁票。智能体在第一次调用下单工具时生成一个幂等键比如ticket_order:user_123:20240520_0800也可以直接用UUID它的生命周期绑定这趟行程。如果第一次调用超时智能体重试时仍然携带这个一模一样的幂等键。支付服务看到同一个幂等键再次到来识别出这个单子我已经处理过了直接返回之前的处理结果不再扣款。如果用UUID看起来也行但要格外小心业务场景。比如帮用户买一张明天去上海的高铁票这句话智能体可能今天说一次、明天又说一次两句话内容相同但意图完全不同。如果幂等键基于LLM生成的UUID两次请求拿到不同UUID就绕过了幂等保护。所以更稳妥的做法是把幂等键跟业务维度绑定例如import hashlib def build_idempotency_key(user_id, business_type, biz_params): raw f{user_id}:{business_type}:{biz_params} return hashlib.sha256(raw.encode()).hexdigest()注意biz_params必须是真正决定业务唯一性的字段。下单场景可以用订单号扣款场景可以用用户ID金额用途如果用订单号前提是订单号已经先创建好了。3.2 服务端处理幂等键的三重防线仅仅靠客户端传一个幂等键还不够服务端必须能防住三类情况重复请求并发到达、重复请求但参数不一致、以及处理了一半失败后的重复请求。我的做法是设计一张幂等记录表核心字段大概长这样CREATE TABLE idempotency_records ( idempotency_key VARCHAR(128) PRIMARY KEY, request_hash VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL, -- PROCESSING / SUCCESS / FAILED response_payload JSONB, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() );处理逻辑分三重查重入口处先查idempotency_key。如果存在且状态是SUCCESS直接返回response_payload不执行业务。并发控制如果多个重试请求同时到达光查重是不够的因为两个请求可能同时查到不存在。这时需要一条带锁的插入语句INSERT ... ON CONFLICT DO NOTHING。插入成功的请求才有资格执行业务插入失败的请求说明已有其他请求在处理只需等待结果。参数校验request_hash要记录第一次请求的参数摘要。如果第二次请求携带的幂等键一样但参数不同比如金额变了要果断拒绝并报警。这种情况要么是客户端生成逻辑有bug要么是有人恶意构造请求。这套逻辑写完基本上扛得住90%的重复扣款问题。3.3 状态机推进让处理中的请求有明确的归宿有一个细节特别容易踩坑——业务处理中途挂了怎么办。比如支付服务扣款成功了但更新订单状态时数据库锁表了进程抛异常。此时幂等记录状态是PROCESSING业务实际已经产生效果。如果没有状态机重试请求会被卡在PROCESSING判断上导致用户明明扣了款却永远无法拿到结果。解决方案是引入一个明确的中间态机制。我的推荐做法是第一阶段插入幂等记录状态置为PROCESSING。第二阶段执行业务扣款、更新订单。这两步必须在同一本地事务里。第三阶段事务成功状态置为SUCCESS事务失败状态置为FAILED。对于PROCESSING状态的请求重试时不能简单地拒绝而应该触发查询处理结果的下游动作比如去支付渠道查单。这也是为什么支付行业总是强调以查询结果为准、以回调为准的原因。4. 智能体的三重重试困局LLM、框架与底层协议叠加的不确定性传统系统的重试链路相对单纯但智能体一旦接入生产重试请求会来自三个完全不同的角色。这三个角色如果各自为政幂等键形同虚设。4.1 三层重试来源具体是谁在发起重试来源触发机制技术特征LLM自身决策模型认为工具调用失败重新规划并再次调用不可控、不可预测可能还会换参数Agent框架策略框架内置的重试机制如超时重试、错误重试可配置但默认时常开启底层HTTP/RPC层网络库自带的连接重试、超时重发最隐蔽最容易被忽略拿我最熟悉的一个案例说某个订单查询智能体底层用了常见的HTTP客户端默认开启了自动重试。结果用户问我订单支付了吗智能体查了两次造成支付状态接口的QPS翻倍。这只是查询还好如果哪天这套机制落到扣款工具上后果不敢想。4.2 更隐蔽的风险LLM把成功误判为失败这是我认为智能体场景里最危险的现象——大模型不是传统的确定性代码它可能在工具已经成功执行后因为响应解析问题、上下文长度超限或者推理中断产生这次调用失败了的判断然后在下一个推理轮次里重新发起调用。这类重试有几个特征属于LLM自主决策框架层的重试配置管不到。可能修改参数。比如第一次下单用的是A班次模型重试时理解出偏差改成查B班次这在业务上会产生完全不同的结果。几乎没有传统的重试日志可查只有通过agent行为审计才能发现。所以只靠客户端给一个幂等键就期待LLM重试时乖乖用同一个参数是不够的。必须在服务端设置更严密的防线其中最重要的就是业务参数校验。4.3 这个困局的解法把决策重试权收回代码层我的建议是在智能体架构里无感知自动重试的权限不能直接给LLM必须经过代码层的重试裁决器。具体做法智能体框架层把所有工具调用的结果成功、失败、异常、超时先交给代码裁决而不是直接喂给LLM。当检测到超时或网络错误时裁决器自动用原始参数与原始幂等键重试一次当检测到参数异常变化时裁决器宁可停下来向用户确认也不放行。换句话说让LLM做决策但让代码守底线。这个设计实施之后即便LLM产生幻觉想重试业务系统也能依靠幂等键和参数校验把重复请求挡在资金操作之外。5. 端到端防御体系从超时语义到人工兜底的完整设计聊完幂等键和智能体重试裁决接下来把它们拼成一套完整的防御体系。这套体系我建议每个接资金类业务的智能体都配备不只是扣款还包括发优惠券、改库存、发送短信等所有有副作用的外呼操作。5.1 第一层给超时重试定义业务语义在智能体工具设计的协议层面需要定义清楚重试语义。我的建议是分三类响应超时不得自动重试只能标记为结果未知进入人工确认队列。连接拒绝/网络不可达请求大概率未送达可以自动重试但必须用同一幂等键。业务明确失败如余额不足、库存不足不允许重试直接把错误返回给LLM让它调整策略。有一个需要注意的点即便连接拒绝看起来像请求没到也不能保证之前没有半路请求抵达。所以即使在这种明确网络错误下幂等键依然要带上。5.2 第二层用事务性Outbox模式保证幂等记录和业务动作同生共死我在前面说过扣款和更新幂等记录必须在同一个本地事务里。这里我再深入一步如果你的业务本身还依赖下游渠道比如接入第三方支付网关本地事务只能保本地账保不了第三方。标准解法是事务性发件箱Transactional Outbox模式本地事务里先写业务数据和一张待外呼消息表事务提交后再异步推送外呼消息。如果外呼失败定时任务扫到待处理消息追加重试重试依然携带同一幂等键。这样做的价值在于本地业务和幂等记录要么一起成功要么一起失败不会出现扣款成功了但幂等记录丢了的可怕状态。5.3 第三层Saga模式下的人工确认与自动对账智能体如果涉及跨多个服务的操作下单扣款通知建议引入Saga模式。即每个子操作都对应一个补偿动作。扣款成功但订单创建失败就发起退款退款也失败就转人工。除此之外对账任务是最后一道保险丝。我见过的所有稳定系统都离不开定时对账每5分钟扫描一次扣款成功但订单状态异常的流水。每日与支付渠道核对交易清单。一旦发现对不上立刻给值班人员发告警。对账不是为了防止每一笔重复扣款而是为了兜住那些幂等设计还没来得及覆盖的长尾风险。有对账兜底你至少不用在凌晨被用户电话叫醒——告警会先把你叫醒。5.4 智能体行为审计出了事能查清谁在什么时候做了什么这一点太重要了。智能体不是传统代码它的动作链路是动态的、跨多个推理步骤的。如果没有行为审计真出了重试扣款两次的事故你连复现都困难。我建议从第一天起就做全量工具调用审计日志至少记录每条用户消息对应的完整Agent执行轨迹。每次工具调用的参数、发出的时间、返回的状态。重试是被谁触发的LLM决策/框架策略/HTTP层。每次重试携带的幂等键和参数摘要。有了这套日志当用户投诉重复扣款时你能快速还原第一次请求到了哪一步、超时在哪里发生、第二次重试是谁发起的、两个请求的幂等键是否一致。这在排障和定责时价值千金。6. 验证与收敛如何测试真的只扣了一次款设计完了不等于万事大吉。我见过太多系统代码review时觉得应该没问题一上线就被真实流量教做人。幂等性这事必须靠系统性的测试来验证。6.1 故障注入把超时和重复请求当常态来测平时开发环境网络太顺畅很多问题根本暴露不出来。所以测试智能体的工具调用层时要有意制造故障。我的测试清单如下故障场景注入方式预期行为请求到达后响应超时在支付服务侧sleep超过客户端超时时间客户端重试应携带同一幂等键服务端不重复扣款响应丢失代理层拦截响应模拟丢包重试后应返回第一次执行结果我们做为开发人员遇到这种问题必须从根上解决。这次我把整套排查链路和防御设计写下来既是给自己复盘也希望能给正在做智能体生产落地的同行们一些参考。重复并发请求模拟用户连点两次下单幂等记录表只允许一条成功LLM误判成功为失败人为构造响应解析异常重试裁决器应识别为结果未知不自动重试这里有一套我自己常用的故障注入脚本思路在支付服务前挂一个代理设置20%的比例把成功响应丢弃。跑一轮回归测试统计用户实际扣款次数与订单数是否一一对应。如果出现1:2及以上就说明幂等设计有漏洞需要修复后再跑。6.2 用AgentDojo类框架做智能体专属安全测试传统API测试测的是接口但智能体测试的核心是LLM决策链路的稳健性。最近社区里比较热门的AgentDojo这类框架专门测智能体在受攻击/异常环境下的表现。我在实际使用中发现它很适合用来构造诱导重试的提示词与状态组合观察模型是否会擅自发起重复调用。具体做法构造一轮对话在工具返回成功但content格式异常时观察Agent是否还会再次调用扣款工具。这类对抗性测试比写一堆正常用例更能发现LLM场景下的重复执行风险。6.3 灰度与实时监控指标上线不能一把梭。即使测试全过也要在灰度阶段紧盯三个监控指标幂等命中率duplicate_hit_count / total_request_count如果这个比率为0很可能说明幂等键没有被正确传递。重复资金流比率同一业务单号多条资金流水的比例一旦0就要告警。工具重试触发率按触发来源LLM/框架/HTTP分层统计了解哪个环节重试最多。我自己在项目里对这三个指标做了看板阈值定在幂等命中率小于万分之五要告警重复资金流水为0一个都不允许工具重试触发率超过10%要人工review。靠这套指标我成功在用户发现问题之前拦截过好几次隐患。写在最后的个人体会别把重试当免费午餐踩过这次扣款双份的坑之后我最大的感受是**重试是人类对不确定性的本能反应但也是分布式系统里最昂贵的免费午餐。**每一次重试背后都可能藏着一个你不知道的其实已经成功执行的真相。而智能体带着大模型的不确定性出场把这个问题的复杂度又往上抬了好几层。我现在的工程习惯是接任何带副作用的工具调用第一件事不是写功能而是写幂等设计。给所有重试行为无论来自LLM还是框架强制打上幂等键的烙印。行为审计日志从第一行代码就开始写绝不事后补。如果你也正在把智能体推向生产环境希望这篇文章能帮你少踩一个坑。下次再看到重试一次这种逻辑先别急着加重试先想想如果它真的重复执行了你能承受吗如果你的答案是不能那就先把防御体系建起来再谈重试。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进