ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI Agent的分布式本质:如何用幂等保证退款只发生一次

AI Agent的分布式本质:如何用幂等保证退款只发生一次 一次 AI Agent 在生产环境里的退款事故往往不是模型答错了而是同一个退款请求被执行了两次。TikTok 工程师在一次 AI Engineer 主题分享中提出过一个很值得反复思考的判断AI Agent 本质上是一个分布式系统。这句话初看有点反直觉因为大多数人对 Agent 的第一印象来自一个聊天窗口模型读问题、调工具、给答案看起来像一个单线程程序。但在生产环境里一次 Agent 决策要穿越多个网络边界、多个服务、多次超时重试还要处理用户侧和消息队列的重复投递。此时单机程序思维会失效。本文围绕一个最典型的高危场景展开当 AI Agent 被用来做退款决策和执行时如何用分布式系统的工程手段保证退款只发生一次。你会看到幂等键、状态机、工作流编排、Saga 补偿和验证压测这些方法如何逐层兜住风险。文末给出可复用的排查清单和上线检查清单。1. 为什么说 AI Agent 本质上是分布式系统1.1 从一条 Agent 调用链看系统边界很多团队做 AI Agent 时第一版代码通常是这样的一个 Python 进程里循环调用 LLM把用户问题拼进 prompt解析模型输出发现模型想调用某个函数就在同一进程里执行这个函数。这种写法在演示 Demo 时没有任何问题。问题在于一旦 Agent 要处理真实业务比如退款、下单、改密、发券函数调用就不再是本地函数了。一次退款动作实际会经过以下节点用户输入 - Agent Orchestrator编排服务 - LLM API模型推理节点 - Tool Registry工具注册与参数校验 - Refund Service退款服务 - Payment Gateway支付网关 - 账务/积分/通知等下游系统这些节点往往是不同的进程、不同的机器、甚至不同的团队维护。每次调用都要经过网络而网络会超时、会抖动、会重放。聊天窗口给用户的感受是一次提问一次回答但从系统角度看一次 Agent 决策会产生多次跨服务调用。只要出现一次超时重试下游就可能收到两笔内容相同的退款请求。这就是AI Agent 本质是分布式系统的第一层含义Agent 不是一个算法而是一组通过网络协作的服务的集合。1.2 Agent 生产环境要面对的三个分布式问题把 Agent 当作分布式系统看待后很多问题就变得眼熟了。第一个问题是网络不可靠。模型调用工具时工具服务可能因为网络超时而没有返回结果。调用端不知道工具到底执行了没有只能重试而重试可能造成重复执行。第二个问题是重试无法避免。客户端有超时重试消息队列有重投机制Agent Orchestrator 自己也通常会加 retry 策略。三层重试叠加同一个请求就会以多个副本进入退款服务。第三个问题是部分失败。退款请求可能已经打到支付网关但通知 Agent 的那次响应丢了。Agent 认为失败实际已经成功于是发起第二次退款。第四个问题是并发。横向扩容后同一个订单可能被两个 Agent 实例同时处理。如果没有互斥和状态约束两笔退款会同时通过校验。把 Agent 当单机程序写的人会在业务逻辑里假设这个函数只执行一次把 Agent 当分布式系统写的人会在每一处有外部副作用的地方假设这个函数可能被重复执行而且可能部分失败。1.3 单机思维和分布式思维的差异下面这张表可以用来快速判断团队目前是哪种思维方式。维度单机程序思维分布式系统思维网络调用默认一次成功默认可能超时、重放、部分失败重试尽量不加必须加且重试必须是幂等的状态存在内存变量里存在 DB 中支持并发读取和状态约束执行顺序代码顺序即执行顺序依赖状态机和工作流保证顺序一致性单事务保证幂等 补偿 最终一致日志打点即可全链路 traceId 串联如果项目已经出现同一个订单被退款两次模型重复调用工具重试导致重复发券这类问题通常就是还停留在单机思维。2. 重复退款从哪来一条退款请求的四次重放路径2.1 先定义一个最小退款工具为了让后面的方案具体化先定义一个标准的退款工具接口。Agent 通过工具调用发起退款时入参通常包含订单号、退款金额、退款原因和业务来源。{ tool: apply_refund, args: { order_id: ORD202501010001, amount: 199.00, reason: user_request, operator: agent_001 } }简化后退款服务的核心逻辑是校验订单是否可退 - 调用支付网关执行退款 - 更新退款单状态 - 返回退款结果。2.2 四条常见的重复路径路径一RPC 超时重试。Agent Orchestrator 调用退款服务时发生超时随即重试。第一次请求其实已经到达并执行成功只是响应丢失。第二次请求再次到达退款服务如果没有任何去重措施就会再退一次。路径二模型重复调用。LLM 在生成本次工具调用后因为推理过程中出现重复 token 序列或者 Agent 推理循环的上下文里工具执行结果不明确模型再次生成了一次一模一样的apply_refund调用。这不是程序员写错了代码而是模型输出的概率特性。路径三上游消息重复投递。很多 Agent 系统通过消息队列异步驱动比如用户提交工单后MQ 把消息投递给 Agent 服务。消费者处理超时后MQ 会重新投递同一条消息。重复投递在新消费实例上会被当成新任务处理。路径四多实例并发。同一订单被两个消费者线程或两个 Agent 实例同时处理。两边都查询到订单可退都执行了退款数据库的普通条件更新无法阻止这一情况。重放路径触发原因重复请求特征RPC 超时重试响应丢失网络层重放时间间隔短模型重复调用LLM 输出不确定两次调用参数完全相同消息重复投递消费超时或 ack 丢失消息 offset 相同并发竞争多实例同时处理同一订单并发请求2.3 为什么不能只靠让模型别重复有人会觉得给 prompt 加一句退款前先检查是否已退款不要重复退款就能解决。这个思路可以降低重复概率但不能作为工程保证。原因在于LangChain、LlamaIndex 这类 Agent 框架返回的 tool call 是模型生成的文本结构化结果。模型可能因为上下文截断、输出采样策略、工具返回结果描述不清在下一步再生成一次相同的调用。这是概率行为不是可以枚举的确定性行为。工程上有两种态度一种是尽量让模型少犯错另一种是无论模型怎么犯错底层系统都能兜住。重复退款问题必须用第二种态度处理。模型的输出只能作为决策信号不能作为系统一致性的依据。3. 第一层防线给退款调用加幂等键3.1 幂等键应该在哪里生成幂等键是解决同一个业务动作被重复执行最基础的手段。退款场景的关键原则是幂等键必须在意图产生的时候生成而不是在执行工具的时候生成。Agent 在分析完用户需求、决定执行退款的那一刻就应该生成一个全局唯一的idempotency_key。它由本次 Agent 运行 ID、决策步骤 ID、订单号和动作类型组合而成。import hashlib def build_idempotency_key(agent_run_id: str, step_id: str, order_id: str) - str: raw f{agent_run_id}:{step_id}:{order_id}:REFUND return hashlib.sha256(raw.encode(utf-8)).hexdigest()这样做的好处是同一个 Agent 运行的同一个决策步骤无论重试多少次、模型重复调用多少次生成的幂等键都一样。退款服务只要看到这个键就能判断之前是否处理过。不要用纯随机 UUID 作为幂等键因为每次重试会生成不同 UUID幂等判断就失效了。3.2 用 Redis 做快速幂等闸门在高并发场景可以先在 Redis 里做一次快速判断避免大量重复请求直接打到数据库。import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def try_acquire_idempotency(key: str, payload: str, ttl: int 1800) - bool: # NX: key 不存在时才写入 # EX: 设置过期时间防止 key 永久占用 ok r.set(key, payload, nxTrue, exttl) return ok is Trueset nx ex是原子操作多个并发请求同时进来时只有一个能写入成功。写入成功者作为首次请求继续执行退款写入失败者作为重复请求读取已有结果返回。但这里有一个前提Redis 不能保证强一致主从切换或内存淘汰可能导致 key 丢失。所以 Redis 只能作为第一道快速闸门真正兜底必须靠数据库的唯一约束。3.3 用数据库唯一约束做最终保证退款服务侧维护一张幂等记录表idempotency_key加上唯一索引。这样即使 Redis 被穿透数据库也能挡住重复请求。CREATE TABLE refund_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, idempotency_key VARCHAR(64) NOT NULL, order_id VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT CREATED, request_payload TEXT NOT NULL, result_payload TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_idempotency_key (idempotency_key), KEY idx_order_id (order_id) ) ENGINEInnoDB;服务层的处理逻辑是先插入幂等记录插入成功则说明是首次请求插入时发生唯一键冲突则读取已有结果直接返回。以 Spring Boot 的常见写法为例Transactional public RefundResult applyRefund(RefundRequest request) { try { refundRequestMapper.insertIdempotency(request); } catch (DuplicateKeyException ex) { // 幂等键已存在说明之前处理过直接返回旧结果 return refundRequestMapper.selectResultByIdempotencyKey( request.getIdempotencyKey()); } // 只有首次插入成功才执行真正退款 RefundResult result refundGateway.refund( request.getOrderId(), request.getAmount()); refundRequestMapper.updateResult( request.getIdempotencyKey(), result); return result; }第一次插入成功后执行外部退款退款成功后把结果写回记录。后续任何重复请求都会撞上唯一索引直接返回历史结果不会再向支付网关发起第二次退款。3.4 幂等键设计参数速查参数含义推荐做法幂等键标识一次业务意图agent_run_id step_id order_id 动作类型的哈希key 有效期Redis 中 key 的存活时间建议 30 分钟到 24 小时覆盖整个退款流程冲突处理重复请求返回什么返回首次请求的结果而不是报错唯一性保证最终兜底数据库唯一索引不能只依赖 Redis注意幂等记录表要和业务执行结果绑定。不能只判断这个订单退过款因为同一订单可能因不同原因多次退款必须用幂等键精确到同一次退款意图。4. 第二层防线用状态机约束 Agent 的动作边界4.1 退款单状态机设计幂等键解决的是同一个请求不能被重复执行的问题。但 Agent 场景还有另一种风险模型可能在一个订单已经退款成功后又发起金额不同、原因不同的新退款。这时幂等键不同必须靠退款单状态机来约束。退款单的状态设计如下CREATED - REFUNDING - SUCCESS 正常退款链路 \- FAILED 退款失败可人工重试或走补偿 \- NEED_MANUAL 异常状态转人工只有处于REFUNDING状态的退款单才允许推进到终态。已经进入SUCCESS或FAILED的退款单Agent 发起的任何新动作都必须被拒绝。4.2 用乐观锁保证状态流转安全状态流转不能写成先查状态再更新因为并发下两个请求可能同时读到旧状态。推荐用条件更新把状态作为更新条件的一部分。UPDATE refund_order SET status SUCCESS, refund_no #{refundNo}, updated_at NOW() WHERE id #{id} AND status REFUNDING这条 SQL 只有一行被更新说明状态流转成功如果更新行数为 0说明状态已经不是REFUNDING本次动作应该终止。在 Agent 工具层调用退款工具前应该先拿到退款单当前状态。工具返回结果里把状态暴露给模型模型看到状态后就不会继续发起无效调用。{ order_id: ORD202501010001, current_status: SUCCESS, action_allowed: false, message: 该订单已完成退款不能重复发起退款动作 }4.3 给工具输出加结构化校验模型解析工具返回结果时经常因为字段命名模糊而误解。推荐把动作是否被允许这种关键判断放在一个明确字段里同时对工具输出做 JSON Schema 校验。{ name: check_refund_permission, output_schema: { type: object, required: [order_id, current_status, action_allowed], properties: { order_id: { type: string }, current_status: { type: string, enum: [CREATED, REFUNDING, SUCCESS, FAILED, NEED_MANUAL] }, action_allowed: { type: boolean }, message: { type: string } } } }Agent 在调用任何有副作用的工具前先走一次权限检查。action_allowed为 false 时无论模型生成什么参数工具执行层都直接短路。5. 第三层防线用工作流编排代替模型自由调工具5.1 为什么要把执行权从模型手里拿走前面两层的思路是允许模型调用但调用必须经过幂等和状态校验。更保守的做法是模型不直接调用退款工具只负责生成退款意图然后把意图交给一个确定性的工作流引擎去执行。这样做有三个理由。第一LLM 的输出不稳定不适合承担必须保证恰好执行一次的执行责任。模型适合做决策不适合做事务。第二退款链路通常需要多个步骤权限校验、退款执行、结果通知、异常补偿。这些步骤用工作流描述比靠模型在复杂上下文中自由调用更可控。第三工作流天然具备重试、超时、回退和审计能力这些都是分布式系统需要的。5.2 一个可重试的退款工作流示例下面用 YAML 描述一个简化退款工作流。idempotency_key由上游意图层生成工作流引擎根据 key 去重确保整个流程只执行一次。workflow: agent_refund_workflow version: 1.0 input: order_id: $.order_id amount: $.amount idempotency_key: $.idempotency_key steps: - id: check_refund_permission type: service retry: 0 next: create_refund_order - id: create_refund_order type: service retry: 0 next: call_payment_gateway error: mark_failed - id: call_payment_gateway type: service timeout: 5s retry: max_attempts: 3 backoff: exponential next: mark_success error: mark_failed - id: mark_success type: service retry: 2 end: true - id: mark_failed type: service retry: 2 end: true这里的call_payment_gateway允许重试 3 次但每次重试都必须带上同一个idempotency_key。支付网关侧对这一 key 去重所以重试不会产生第二笔退款。5.3 Saga 补偿与最终一致分布式系统里一个跨多个服务的流程不可能用本地事务保证强一致。退款流程可能已经调用支付网关成功但在mark_success阶段失败。此时需要补偿动作而不是简单重试。以退款为例常见的补偿设计如下。已执行动作后续步骤失败补偿动作更新退款单为 REFUNDING调用支付网关失败标记 FAILED不做资金操作支付网关退款成功通知 Agent 失败重试通知不重复退款退款成功且已通知积分回补失败单独执行积分回补任务用户已收到退款后续人工审核失败转 NEED_MANUAL人工确认补偿动作也必须幂等。比如积分回补要带上退款单号数据库建立唯一索引避免补偿任务被重复执行。6. 生产环境还要补齐的分布式设施6.1 事务消息与 Outbox 模式Agent 决策层在同一个数据库事务里写入退款意图和 Outbox 记录再由独立 Worker 把记录发给下游。这样不会出现意图已经发给支付服务但本地没记录的状态。Agent 决策事务 INSERT INTO refund_order (...); INSERT INTO outbox_event (event_id, payload, status); COMMITWorker 扫描status PENDING的 Outbox 记录发送到消息队列收到确认后把状态改成SENT。这条链路避免了本地事务和远程调用不一致的问题。6.2 全链路追踪Agent 场景的排查难点是调用链太长一条退款路径要经过 Agent、工具、退款服务、支付网关。没有 traceId工程师很难确认重复请求来自哪一层。建议在入口生成trace_id通过 HTTP Header 和 MQ 消息字段向下游传递所有日志都带上这个字段。trace_id6753a12f9c34 stepapply_refund order_idORD202501010001 actionstart trace_id6753a12f9c34 stepapply_refund order_idORD202501010001 idempotencyhit trace_id6753a12f9c34 steppayment_gateway order_idORD202501010001 resultsuccess出现重复退款时凭 trace_id 就能还原完整链路判断重复请求是从哪一层重放的。6.3 学习环境与生产环境的差异同样的 Agent 退款流程在本地跑通不等于上线后不会出问题。两张环境面临的问题完全不同。对比项学习环境生产环境网络本地调用几乎不超时跨机房调用必须处理超时重试手动触发客户端、SDK、MQ 三层自动重试数据一致性单库单表多个服务多份数据需最终一致幂等可有可无必须有否则必然产生重复资金操作监控看控制台需要日志、指标、告警、链路追踪回滚重启进程需要版本发布、开关和补偿任务7. 验证与压测如何证明不会重复退款7.1 核心测试用例上线前至少覆盖以下用例。测试场景操作方式预期结果用户重复点击用户连续提交两次退款申请只创建一笔退款单RPC 超时重试模拟第一次响应丢失服务端自动重试支付网关只收到一次退款模型重复调用同一上下文连续生成两次相同 tool call第二次幂等命中直接返回MQ 重复投递手动重新投递同一消息消费端去重不重复执行并发竞争同一订单 10 个线程同时发起退款只有 1 个成功其余返回已有结果部分失败支付成功但通知失败退款状态正确通知任务可重试7.2 验证通过标准验证不能只看程序没崩。建议用以下指标判断系统是否达标。重复退款率任意重放测试下为 0。幂等命中率重复请求全部命中已有结果。状态终态率退款单最终都进入 SUCCESS、FAILED 或 NEED_MANUAL不存在卡在 REFUNDING 的僵尸单。补偿执行率所有补偿任务都成功执行没有漏补偿。100 次并发重放测试中支付网关侧收到退款请求次数为 1而不是 0 或 2。7.3 上线前检查清单[ ] 幂等键在意图创建时生成不在执行时生成。[ ] 幂等表有唯一索引Redis key 有合理过期时间。[ ] 退款状态机所有流转都用条件更新。[ ] 工具输出包含action_allowed字段并通过 Schema 校验。[ ] 工作流中重试都复用同一幂等键。[ ] 补偿任务幂等有唯一索引保护。[ ] 全链路日志携带 traceId。[ ] 完成了并发重放测试和部分失败测试。注意只验证能启动没有意义。退款这类资金场景必须验证输入、输出、异常分支、重复分支和补偿分支都符合预期。8. 常见问题排查幂等失效、状态卡死、补偿漏执行8.1 高频问题对照表问题现象常见原因检查方式处理建议同一个订单被退款两次幂等键每次重试都不同对比两次请求的 idempotency_key改为在意图层生成固定幂等键Redis 幂等闸门失效key 过期或主从切换丢失查看 Redis 慢日志和主从状态依赖数据库唯一索引兜底退款单卡在 REFUNDING流程中断且没有超时恢复查updated_at和超时任务增加超时巡检重新推进或补偿重复请求直接报错幂等冲突按异常返回查看日志中 DuplicateKey 处理分支冲突时读已有结果而不是抛异常补偿任务反复执行补偿任务没有幂等保护查补偿记录表是否唯一索引补偿动作绑定退款单号并加唯一约束Agent 重复生成 tool call模型上下文不清检查工具返回结果是否包含状态工具输出加action_allowed明确字段8.2 从现象倒推根因的排查链路遇到疑似重复退款时按下面顺序排查。第一步确认重复请求的幂等键是否相同。如果不同问题在意图层去看 Agent 生成幂等键的代码是不是把随机 UUID 当幂等键。第二步确认幂等判断是否真的执行。看日志里有没有idempotencyhit或唯一键冲突捕获记录。如果没有说明请求跳过了幂等逻辑比如走了别的接口。第三步确认状态机是否生效。查退款单的当前状态如果已经是 SUCCESS后续请求应该被状态拒绝看代码里条件更新是否写对。第四步确认补偿任务是否具备幂等性。检查补偿任务执行表的唯一索引和重试逻辑避免补偿变重复。第五步确认是模型层重复还是框架层重试。用 traceId 还原调用链看两次退款来自同一 Agent 运行实例的两次 tool call还是来自消息队列的重复投递。这套链路适用于大多数外部副作用被重复执行的问题。顺序是先看标识再看去重逻辑再看状态约束最后看补偿和重试。9. 最佳实践与进阶方向9.1 可以直接落地的工程实践清单把 AI Agent 当分布式系统来设计不是一句理念而是一组可以落地的约束。第一所有会产生外部副作用的工具必须支持幂等。退款、发券、改库存、发验证码统一要求传入幂等键服务端统一去重。第二LLM 只负责生成决策意图不直接执行副作用操作。真实资金类动作交给工作流引擎模型的结果只作为输入信号。第三状态机是 Agent 动作的最后防线。所有业务实体都要有明确状态任何状态流转都要用条件更新不能先查再改。第四把 Outbox、重试、补偿当作 Agent 基础设施的一部分。不要把流程跑了一半视为不可能事件。第五从第一天就接 traceId 和结构化日志。Agent 天然难排查没有全链路观测出事后只能靠猜。9.2 对 AI Engineer 的能力启示这次分享最大的价值是把 AI Engineer 的能力要求从会写 prompt、会调模型拉回到会设计分布式系统。一个合格的 AI Engineer不能只会问模型能不能做这件事。还要能回答如果模型调用重复了怎么办如果工具服务超时了怎么办如果退款成功但通知失败了怎么办。这些问题的答案几乎都来自分布式系统的经典方法幂等、状态机、事务消息、补偿、观测。如果现在刚开始学习 AI Agent建议练习路径这样安排先跑通一个带工具调用的最小 Agent然后把工具改成真实数据库操作再加一个 RPC 接口模拟超时最后自己实现幂等和补偿。等你能回答为什么同一个退款请求不会执行两次时你对 AI Agent 的理解就已经超过了大多数只聊 prompt 的人。退款只是起点。下单、转账、库存扣减、数据删除凡是动作重复会造成严重后果的场景都可以套用这套模型。理解了 Agent 的分布式本质才算真正理解了生产环境的 AI 应用。
返回列表