AI Agent 工具调用幂等与防重入生产事故复盘:从重复下单到有界副作用

AI Agent 工具调用幂等与防重入生产事故复盘:从重复下单到有界副作用

技术主题:AI Agent(人工智能代理)
内容方向:生产环境事故的解决过程(故障现象、根因分析、解决方案、预防措施)

引言

Agent 上线后,我们遭遇了令人心跳加速的一次事故:在网络抖动与模型重试叠加的场景下,Agent 对“创建订单”工具产生了重复调用,导致少量用户被重复下单与扣款。本文从现场入手,复盘故障、定位根因,并沉淀出一套围绕“幂等、防重入、可观测与补偿”的工程化改造方案。

一、故障现象与影响

  • 现象:
    • 部分会话在 30~90 秒后出现“补发”行为;
    • 同一用户、同一请求内容,被多次调用 create_order 工具;
    • 日志中存在同一 trace_id 下的多条工具调用记录。
  • 影响:
    • 重复下单率峰值 0.3%(分钟级);
    • 造成小额重复扣款与库存冻结异常;
    • 事故窗口约 17 分钟,涉及请求 ~2.3k。

二、排查步骤

  1. 还原调用链:按 trace_id → 回溯 LLM 输出 → 工具执行日志 → 订单服务;
  2. 识别触发条件:高延迟网络 + LLM 超时重试 + 工具侧无去重;
  3. 核对执行语义:模型输出中确有“确认再次下单”的误导 token;
  4. 数据指纹:同一用户/商品/金额/时间窗高度相似;
  5. 对照非幂等型工具:转账、扣款、发货也存在潜在风险。

三、根因分析

  • 幂等缺失:工具层未要求 idempotency_key;
  • 防重入缺失:同一会话并行分支可能同时触发有副作用的工具;
  • 重试语义不区分:LLM/网关的超时重试带来“多次到达”;
  • 缺观测:缺少“副作用工具调用账本”,难以及时止损。

四、解决方案(总体思路)

  • 协议层:强制所有具副作用工具携带 idempotency_key(基于业务主键或稳定哈希);
  • 执行层:在工具网关实现去重存档(SETNX/唯一索引)+ TTL;
  • 并发层:会话内的工具调用加围栏令牌(fencing token)与排他锁,防止并行重入;
  • 重试层:区分“重放”与“重试”,确保幂等返回旧结果;
  • 观测与补偿:建立调用账本与 Saga 补偿,支持回滚/对账。

五、关键代码(Python)

以下示例展示:

  • 生成幂等键并在请求链路中透传;
  • 用 Redis 实现去重(SETNX + TTL)与结果缓存;
  • 围栏令牌(单调递增版本号)避免并发重入;
  • 工具网关统一封装与观测。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# pip install redis==5.*
import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional, Callable
import redis

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

@dataclass
class ToolRequest:
name: str
args: Dict[str, Any]
user_id: str
session_id: str
idem_key: Optional[str] = None
fencing_token: Optional[int] = None

# 1) 幂等键生成:稳定序列化 + 业务主键

def make_idem_key(tr: ToolRequest) -> str:
payload = {
"name": tr.name,
"user": tr.user_id,
"args": tr.args,
}
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]

# 2) 围栏令牌:会话维度的单调计数,防止并发重入

def next_fencing_token(session_id: str) -> int:
return int(r.incr(f"agent:session:{session_id}:fencing"))

# 3) 工具网关:去重 + 结果缓存 + 观测

def tool_gateway(executor: Callable[[Dict[str, Any]], Dict[str, Any]],
tr: ToolRequest, ttl_sec: int = 3600) -> Dict[str, Any]:
idem_key = tr.idem_key or make_idem_key(tr)
tr.fencing_token = tr.fencing_token or next_fencing_token(tr.session_id)

# 去重键与结果缓存键
key_seen = f"agent:idem:seen:{tr.name}:{idem_key}"
key_result = f"agent:idem:result:{tr.name}:{idem_key}"

# 3.1 首到达判定(SETNX)
first = r.set(name=key_seen, value=tr.fencing_token, nx=True, ex=ttl_sec)
if not first:
# 非首到达:尝试返回历史结果(幂等返回)
cached = r.get(key_result)
if cached:
return json.loads(cached)
# 没有缓存则降级为“已处理”提示
return {"status": "dedup", "message": "duplicate suppressed"}

# 3.2 执行工具(必须保证幂等)
start = time.time()
try:
result = executor(tr.args)
result_payload = {
"status": "ok",
"result": result,
"latency_ms": int((time.time() - start) * 1000),
"fencing": tr.fencing_token,
}
# 结果缓存:后续重复到达直接返回
r.set(key_result, json.dumps(result_payload, ensure_ascii=False), ex=ttl_sec)
# 观测打点(这里只是示意,实际应写入日志/时序库)
print({
"tool": tr.name,
"idem": idem_key,
"fencing": tr.fencing_token,
"latency_ms": result_payload["latency_ms"],
})
return result_payload
except Exception as e:
# 失败:清理首到达标记,允许重试
r.delete(key_seen)
raise

# 示例:创建订单工具(副作用)

def create_order(args: Dict[str, Any]) -> Dict[str, Any]:
# 这里应调用真实业务:库存预占、订单入库、支付预授权等
# 要求自身也具备幂等:例如以 business_id 作为唯一索引
order_id = f"ORD-{int(time.time()*1000)}"
return {"order_id": order_id}

# 调用示例
tr = ToolRequest(
name="create_order",
args={"business_id": "U123-ITEM456-20250826", "sku": "ITEM456", "amount": 2},
user_id="U123",
session_id="S-abcdef",
)
resp1 = tool_gateway(create_order, tr)
resp2 = tool_gateway(create_order, tr) # 将被幂等返回
print(resp1, resp2)

要点:

  • 去重用 SETNX(或数据库唯一索引)比客户端内存判断可靠;
  • 结果缓存让重复到达“拿旧结果”,避免再次产生副作用;
  • 围栏令牌可与下游资源协调(例如队列/锁)避免并发重入;
  • 工具自身也要基于业务主键幂等(双保险)。

六、验证与观测

  • 压测:30% 丢包 + 高延迟条件下,重复到达被 100% 去重;
  • 观测:暴露指标——重复抑制率、幂等返回比例、平均延迟、首到达失败率;
  • 回放:基于调用账本可以重放单次调用并校验返回稳定性;
  • 对账:将 create_order 与支付、库存对齐,发现并自动补偿异常。

七、预防清单

  • 工具分类:副作用型工具必须启用幂等键;
  • 协议约束:LLM 输出 Schema 中强制 idempotency_key 字段;
  • 超时与重试:超时-重试-幂等语义成套联动;
  • 并发与锁:会话内串行化或围栏令牌 + 排他锁;
  • 账本与补偿:建立调用账本与 Saga 补偿队列,异常自动对账。

总结

本次事故的根源不是“LLM 乱说话”,而是工程约束缺位:缺幂等、无防重入、无账本。将幂等键、去重存档、围栏令牌、结果缓存与观测补齐后,Agent 的副作用被严格约束在可控范围内,重复到达也能稳定“拿旧结果”。这套改造不仅止血于下单,也适用于转账、发货、通知等所有具副作用的工具调用场景。