AI Agent也是分布式系统:用幂等与状态机根治重复退款
2026/9/7 15:35:22 网站建设 项目流程

最近在 AI Engineer 相关的分享里有一个观点很值得聊:AI Agent 本质上是分布式系统。这个说法最早听到会觉得“夸张”,但放到真实业务里仔细一想,确实如此——LLM 调用、工具调用、记忆读写、任务队列、状态持久化,这些东西拆开之后,每一个环节都跑在不同的组件里,错误处理方式也和传统分布式系统高度相似。

这篇文章不聊概念,直接从工程师视角拆解三件事:

  1. 为什么说 AI Agent 是分布式系统;
  2. “重复退款”这类问题到底是怎么产生的;
  3. 用幂等、状态机、分布式锁把重复退款问题从根上按死。

如果你正在做 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 自动对账。这几块都可以单独写一篇文章展开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询