
最近在 AI Engineer 相关的分享里有一个观点很值得聊AI Agent 本质上是分布式系统。这个说法最早听到会觉得“夸张”但放到真实业务里仔细一想确实如此——LLM 调用、工具调用、记忆读写、任务队列、状态持久化这些东西拆开之后每一个环节都跑在不同的组件里错误处理方式也和传统分布式系统高度相似。这篇文章不聊概念直接从工程师视角拆解三件事为什么说 AI Agent 是分布式系统“重复退款”这类问题到底是怎么产生的用幂等、状态机、分布式锁把重复退款问题从根上按死。如果你正在做 Agent 应用、AI 工具调用平台或者负责给 LLM 应用接支付、退款、订单类接口这篇文章可以直接收藏。1. 核心能力速览维度说明问题背景AI Agent 在工具调用、任务重试、并发调度中产生重复退款核心观点AI Agent 本质上是一个分布式系统需要分布式的可靠性设计关键手段幂等键、状态机、唯一约束、分布式锁、对账补偿适用场景Agent 调用支付/退款接口、批量任务调度、多 Agent 协作技术栈参考FastAPI、Redis、PostgreSQL、任务队列Celery / 消息队列工程目标让 Agent 重试 100 次也不会造成重复退款这里先给出结论只要 Agent 需要调用外部业务接口它就必须遵守分布式系统的“铁律”。否则LLM 一旦超时重试或者多 Agent 并发处理同一条任务重复退款只是第一个炸的雷。2. 为什么说 AI Agent 本质是分布式系统很多人在初学 Agent 时会把 Agent 理解成一个“大模型加提示词”的线性流程用户提问LLM 回答输出结果。但一旦 Agent 进入生产环境它的运行形态完全不是这样。一个业务 Agent 的典型调用链是用户请求 - 意图识别 - 任务规划 - 工具调用 - 结果校验 - 状态持久化 - 响应 | | | | v v v v 记忆服务 API网关 外部服务 数据库/消息队列这条链路里任何一个环节都可能超时、失败、被重复调度。更麻烦的是LLM 规划器本身具备非确定性同一句话问两遍它可能调用两次同一个工具。这种“重复”不是 bug而是系统设计的一部分。如果把这个调用链拆开你会看到它和传统分布式系统没有任何区别组件Agent 中的角色控制节点LLM 规划器、编排器决定下一步做什么Worker工具调用器、代码执行器、API 调用器状态存储记忆、数据库、KV 存储通信协议HTTP/gRPC、消息队列故障类型超时、网络抖动、下游服务不可用、重复投递这里的核心问题是Agent 的“大脑”在规划但“手脚”遍布多个服务。大脑发出一个指令手脚执行了但结果回传时超时了。大脑认为没执行于是再发一次指令。如果手脚执行的是“退款”这种操作重复执行就是事故。这就是为什么说 AI Agent 需要按分布式系统来设计。它不是“要不要”的问题而是“当你把 Agent 部署到真实业务里它就已经是了”。2.1 Agent 的“幻觉”与分布式失败叠加还有一层容易被忽略LLM 的“幻觉”会放大分布式系统的不可靠性。普通接口调用失败只会返回错误码但 Agent 可能“编造”一个成功的结果或者在工具返回异常时自行决定降级策略。这带来一个很棘手的问题系统不光要处理基础设施失败还要处理模型层面的语义失败。语义失败不是靠重试能解决的但工程上我们至少要保证一点——无论 Agent 怎么“理解”底层的数据一致性不能被破坏。换句话说允许模型犯错但绝不允许模型犯“重复扣款”这种不可逆的错。这就是后面要讲的幂等和状态机存在的意义。3. 重复退款问题从现象到根因先还原一个典型的重复退款现场。假设一个客服 Agent 收到用户消息“帮我退掉刚才那笔订单”Agent 调用退款接口退款请求已经到达支付系统钱也退了。但支付系统的响应在网络传输中丢失Agent 没收到成功响应。此时 Agent 有几种可能按照重试策略再次调用退款接口LLM 自动补全后认为“可能需要再确认一次”再次调用多个并发 Agent 实例同时处理同一会话各自调用一次消息队列重复投递同一个退款事件被消费两次。任何一个分支发生都会导致同一笔订单被退款两次。这类问题的根因可以归纳为四类根因说明超时重试调用方设置自动重试但第一次实际已成功并发调度多个 Agent 或线程同时处理同一个任务消息重复投递MQ 不保证恰好一次消费者需要自行去重状态不一致Agent 没有持久化任务状态重启后从旧状态继续执行所以避免重复退款不能靠“让 Agent 别乱重试”而是要在接口层和数据层做兜底。4. 避免重复退款的核心方案幂等、状态机与分布式锁在 Agent 业务链路里防重复不是某一个点的事而是三条防线配合。4.1 第一道防线幂等键幂等键是解决重复退款最直接的手段。简单说就是每次退款请求带上一个全局唯一的业务请求 ID支付系统记住这个 ID遇到相同 ID 直接返回第一次的结果。幂等键的设计要点是必须以业务实体维度生成比如“订单 ID 退款类型”不能每次调用重新生成幂等键要在 Agent 侧生成并随请求传递而不是由支付系统生成。4.2 第二道防线状态机幂等键解决了“同一请求重复提交”但 Agent 场景里多个不同请求可能指向同一个订单。比如“退款”和“撤销退款”同时到达。这时候需要给订单定义清晰的状态机已支付 - 退款中 - 已退款 - 退款失败 - 已撤销状态机的作用是非法状态迁移直接拒绝。如果订单已经处于“已退款”再来一个“退款”请求不管是不是幂等键都必须被拦截。在数据库层面用“状态字段 条件更新”来实现效果比代码里写 if/else 更可靠。4.3 第三道防线分布式锁幂等键和状态机适合“单实例”场景。但 Agent 系统往往是多实例部署多个 worker 同时消费同一个任务单靠数据库检查可能仍有竞态窗口。分布式锁解决的是并发互斥同一个订单的退款请求同一时间只允许一个 worker 处理。通常用 Redis 的 SETNX 或者数据库行锁实现。4.4 第四道防线对账与补偿前三道防线用于事前和事中但对账是兜底。即使所有机制都失效只要存在一个离线的对账任务定期扫描“退款中”状态超过 N 分钟的订单去支付系统查询真实状态就能发现漏退或错退。对账发现重复退款后执行补偿操作原路退回或人工介入。对账机制在涉及资金操作的 Agent 系统里属于必选项。5. 代码落地幂等退款接口实现下面给出一套可运行的参考实现。以 FastAPI Redis PostgreSQL 为例重点演示三道防线怎么落到代码里。5.1 数据库表结构CREATE TABLE refund_order ( id BIGSERIAL PRIMARY KEY, order_id VARCHAR(64) NOT NULL, refund_request_id VARCHAR(64) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT PENDING, amount NUMERIC(10, 2) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW(), CONSTRAINT uk_refund_request UNIQUE (refund_request_id), CONSTRAINT uk_order_refund UNIQUE (order_id), CONSTRAINT chk_status CHECK (status IN (PENDING, SUCCESS, FAILED, CANCELLED)) );这里的两个唯一约束很关键uk_refund_request保证同一个幂等键只能插入一次uk_order_refund保证同一笔订单只能有一条退款记录防止并发生效。5.2 幂等退款接口from fastapi import FastAPI, HTTPException from pydantic import BaseModel import redis import uuid app FastAPI() redis_client redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) LOCK_PREFIX refund:lock: TTL 30 # 锁有效期单位秒按实际接口耗时调整 class RefundRequest(BaseModel): order_id: str amount: float refund_request_id: str | None None # 外部传入不传时自动生成 def acquire_lock(key: str, token: str, ttl: int) - bool: Redis SETNX 加锁避免并发重复处理 return redis_client.set(key, token, nxTrue, exttl) def release_lock(key: str, token: str) - None: 释放锁先校验 token 再删除防止误删他人锁 script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end redis_client.eval(script, 1, key, token) def create_refund_record(refund_request: RefundRequest) - dict: 核心退款逻辑使用 PostgreSQL 的条件更新兜底 # 这里用一个假的 SQL 行为表示 # INSERT ... ON CONFLICT DO NOTHING # 如果插入影响行数为 0说明已处理过直接返回已有结果 # 如果订单状态不是 PENDING直接拒绝 return { refund_request_id: refund_request.refund_request_id, status: SUCCESS, } app.post(/api/refund) def refund(req: RefundRequest): if req.refund_request_id is None: req.refund_request_id str(uuid.uuid4()) lock_key LOCK_PREFIX req.order_id lock_token str(uuid.uuid4()) # 第一道分布式锁防止并发 if not acquire_lock(lock_key, lock_token, TTL): raise HTTPException(status_code409, detail订单正在处理中请勿重复提交) try: # 第二道幂等键 唯一约束 状态机 result create_refund_record(req) return {code: 0, data: result} finally: release_lock(lock_key, lock_token)这套实现的核心思路是Redis 分布式锁负责并发互斥refund_request_id的唯一约束负责幂等订单表status字段的检查约束负责状态机合法性。即使 Agent 层连续调用三次退款最终生效的也只有一次。5.3 无 Redis 时的简化方案如果不想引入 Redis也可以直接用 PostgreSQL 的行锁或条件更新实现互斥UPDATE refund_order SET status SUCCESS, updated_at NOW() WHERE order_id :order_id AND status PENDING RETURNING id;如果返回 0 行说明订单不在可退款状态直接拒绝。这种方式依赖数据库事务隔离适合中小规模场景。6. Agent 状态机设计避免“脑内重试”代码层的幂等只能兜住“重复请求”但 Agent 的规划器还可能出现另一个问题它在自己的“思考”里决定再次执行退款。比如 LLM 把“退款可能没成功”作为前提主动发起了第二次退款调用。这个问题不能靠接口层解决必须靠 Agent 的状态管理约束。6.1 给 Agent 的任务加上持久化状态Agent 的每一次工具调用都应该记录在任务状态表里CREATE TABLE agent_task ( task_id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64) NOT NULL, status VARCHAR(20) NOT NULL, current_step VARCHAR(64) NOT NULL, plan TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE TABLE agent_tool_call ( call_id BIGSERIAL PRIMARY KEY, task_id VARCHAR(64) NOT NULL, tool_name VARCHAR(64) NOT NULL, tool_input JSONB NOT NULL, result JSONB, status VARCHAR(20) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() );Agent 在每次执行工具调用前先查询当前任务状态如果某一步已经标记为“completed”则不允许再次执行。6.2 把“重复判断”放入 Agent 提示词状态管理之外可以通过提示词约束减少 LLM 的“主观重试倾向”你是一个客服退款助手。每条退款请求只能执行一次判断依据是工具调用历史中 是否存在相同 order_id 的成功调用。如果已成功退款直接回复用户禁止再次 调用退款工具。提示词不是可靠保证但它能显著降低触发次数。工程兜底仍然以幂等接口为准。7. 批量任务与消息队列重复消费的解决思路Agent 系统通常会用消息队列做异步任务比如批量退款、批量通知。消息队列最常见的坑是重复消费同一个消息被 worker 处理了两遍。解决方案是在消费端做幂等而不是依赖 MQ 的“恰好一次”。常见做法有两种7.1 消费记录去重CREATE TABLE msg_consume_log ( msg_id VARCHAR(64) PRIMARY KEY, task_id VARCHAR(64) NOT NULL, consume_time TIMESTAMP NOT NULL DEFAULT NOW() );消费消息前先插入msg_id冲突则说明已经处理过。7.2 Redis 布隆过滤器去重消息量极大时用布隆过滤器判断消息是否消费过可以减少数据库查询压力。但注意布隆过滤器有误判风险适合放在数据库校验前做前置过滤不能作为唯一去重手段。批量退款的任务队列设计可以这样约束{ batch_id: batch_20250101_001, refund_items: [ {order_id: A001, amount: 199.00, refund_request_id: req_A001_001}, {order_id: A002, amount: 59.00, refund_request_id: req_A002_001} ] }每个退款事项都必须有独立的refund_request_id不能在批量任务里共用同一个幂等键。8. 可观测性Agent 调用的排查基础重复退款这类问题光靠本地复现很难查。更现实的路径是先靠日志和链路追踪定位哪一次调用真正生效了再针对性修复。建议 Agent 系统至少记录以下信息每次工具调用的 request_id调用的 agent_task_id 和 session_id工具入参与返回结果的 JSON 快照重试次数和触发重试的原因幂等键和最终落库状态。用 OpenTelemetry 采集 trace把 LLM 规划、工具调用、数据库写入串成一条链路User Session - Agent Planner - Refund Tool - Payment API - DB Write trace_id -------------- 同一 trace_id -----------------------当用户反馈“我被退了两笔钱”时通过 session_id 找到 trace_id再顺着链路看哪几个请求最终写入了数据库。这样能快速判断是 Agent 规划重复还是网络重试导致还是并发消费导致。9. 常见问题与排查方法问题现象可能原因排查方式解决方案同一订单退款成功两次缺少幂等键查 refund_order 表是否存在两条记录增加 refund_request_id 唯一约束并发时两个请求都成功分布式锁失效或未加锁检查锁过期时间和释放逻辑缩短 TTL 或改用数据库行锁Agent 反复调用退款工具状态未持久化LLM 误判查看 agent_tool_call 表的调用历史执行前查询任务状态增加提示词约束消息队列重复消费消费者未处理重复消息查看 msg_consume_log 是否存在重复 msg_id消费端增加去重表Agent 重启后任务重复执行任务状态未持久化检查 agent_task 表状态增加任务状态恢复逻辑接口超时但业务已成功响应丢失查支付系统真实退款状态增加对账任务扫描 PENDING 状态幂等键在批量任务中重复批量任务共用同一个 key检查批量任务 JSON 配置每个子任务单独生成幂等键10. 最佳实践与合规提醒给正在做 Agent 业务接入的团队几点建议。第一退款接口必须从第一天就设计成幂等。前端传不传幂等键是客户端的事但服务端必须保证没有幂等键时也能根据订单 ID 做出唯一处理。哪怕 Agent 层不传 key服务端也能基于业务 ID 生成内部幂等 ID。第二状态机要写在数据库里不要写在 LLM 提示词里。提示词可以引导模型但数据库约束才是最终防线。第三先灰度再全量。涉及资金操作的 Agent 功能上线前先用小流量订单测试。建议准备一套独立的测试支付环境专门验证“超时重试 同订单并发 批量任务”三个场景。第四退款记录必须留存完整的审计链路。谁发起的退款、哪个 Agent 实例处理的、模型是怎么规划的、工具返回了什么全部记录。出了问题才能回溯。第五合规边界要提前确认。在真实业务中退款操作必须由被授权的账户发起数字人 / Agent 代操作时需要确认用户本人授权和平台规则允许。涉及支付、个人数据、肖像、声音等敏感场景务必要确认授权范围、数据合规和平台审核要求。测试环境不要使用真实用户数据和真实支付凭证。11. 总结回到标题里那个观点AI Agent 本质是分布式系统。这句话的真正意思是Agent 的工程化难度不在模型而在可靠性。模型可以再训练提示词可以再调但“重复退款”这种问题根因通常在系统设计。只要把幂等、状态机、分布式锁、对账补偿这四件事做扎实Agent 重试一百次也不会出大问题。这篇文章里给的代码是一个最小可行的参考骨架实际落地时按你们自己的支付系统、消息队列和数据存储做适配即可。建议先跑通“同订单并发请求”这个测试场景它能同时验证幂等键、锁和状态机三层防线是否生效。后续可以继续扩展的方向多 Agent 协作时的分布式事务方案、Agent 工具调用的超时与熔断策略、基于 trace 的 Agent 自动对账。这几块都可以单独写一篇文章展开。