一次 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(host="127.0.0.1", port=6379, decode_responses=True) def try_acquire_idempotency(key: str, payload: str, ttl: int = 1800) -> bool: # NX: key 不存在时才写入 # EX: 设置过期时间,防止 key 永久占用 ok = r.set(key, payload, nx=True, ex=ttl) 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) ) ENGINE=InnoDB;服务层的处理逻辑是:先插入幂等记录,插入成功则说明是首次请求;插入时发生唯一键冲突,则读取已有结果直接返回。
以 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); COMMIT;Worker 扫描status = PENDING的 Outbox 记录,发送到消息队列,收到确认后把状态改成SENT。这条链路避免了本地事务和远程调用不一致的问题。
6.2 全链路追踪
Agent 场景的排查难点是调用链太长:一条退款路径要经过 Agent、工具、退款服务、支付网关。没有 traceId,工程师很难确认重复请求来自哪一层。
建议在入口生成trace_id,通过 HTTP Header 和 MQ 消息字段向下游传递,所有日志都带上这个字段。
trace_id=6753a12f9c34 step=apply_refund order_id=ORD202501010001 action=start trace_id=6753a12f9c34 step=apply_refund order_id=ORD202501010001 idempotency=hit trace_id=6753a12f9c34 step=payment_gateway order_id=ORD202501010001 result=success出现重复退款时,凭 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 当幂等键。
第二步,确认幂等判断是否真的执行。看日志里有没有"idempotency=hit"或唯一键冲突捕获记录。如果没有,说明请求跳过了幂等逻辑,比如走了别的接口。
第三步,确认状态机是否生效。查退款单的当前状态,如果已经是 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 应用。