最近在 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(host="127.0.0.1", port=6379, decode_responses=True) 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, nx=True, ex=ttl) 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_code=409, 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 自动对账。这几块都可以单独写一篇文章展开。