你的 Agent 重试了一次,用户被扣款两次。这不是 AI 幻觉,是你的幂等性设计缺失。
为什么幂等性在 LLM 应用里比传统系统更难
"幂等性"这个词,后端工程师听起来很熟悉------HTTP GET/PUT/DELETE 天然幂等,POST 不是,用 Idempotency-Key 加锁就行了。但在 LLM 应用里,这套理解不够用。
原因有三:
1. LLM 本身不是确定性系统。 相同的 prompt 可能返回不同的 response(temperature > 0)。你很难用请求内容哈希来判断"这是同一个请求的重试"还是"用户发了两条相似的消息"。
2. Agent 的工具调用链是有状态的副作用链。 一个 Agent 调用了 create_order() → charge_card() → send_confirmation_email() 三个工具。网络超时发生在第二步之后、第三步之前。Agent 框架重试整条链,charge_card() 被执行了两次。用户被扣了两次款。支持团队说"AI 出 bug 了",实际上是幂等性缺失。
3. 流式输出让状态追踪变得模糊。 传统 API 的"成功/失败"是清晰的,但 streaming 场景下,你可能已经消费了 500 个 token 的流,然后连接中断。这算"成功"还是"失败"?重试会重新计费吗?
这三个问题在高并发、长任务、多 Agent 场景下会同时放大。下面逐一拆解 5 类陷阱和对应的工程解法。
陷阱 1:LLM API 重试导致双倍账单
问题描述
你的 RAG 应用每天 1000 次查询,每次均价约 ¥0.02(~1000 input tokens + 300 output tokens)。有 5% 的超时率,意味着每天 50 次重试。如果没有幂等缓存,这 50 次重试全部重新付费。每年额外花费约 ¥365。企业级应用把这个规模乘以 100,就是每年 ¥36,500 的冗余账单。
更糟的情况:用户侧的前端重试逻辑(比如 axios retry)在服务端实际已处理成功时又重发了请求,导致两次 LLM API 调用、两次写 DB、两次推送通知。
核心解法:Idempotency-Key + 原子锁缓存
typescript
// idempotent-llm-call.ts
import { createClient } from "redis";
import { createHash } from "crypto";
interface LLMRequest {
model: string;
messages: Array<{ role: string; content: string }>;
temperature?: number;
}
const redis = createClient({ url: process.env.REDIS_URL });
async function idempotentLLMCall(
idempotencyKey: string,
request: LLMRequest,
ttlSeconds = 3600
): Promise<string> {
const cacheKey = `llm:idem:${idempotencyKey}`;
const lockKey = `llm:lock:${idempotencyKey}`;
// 1. 先查缓存
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached).response;
}
// 2. SET NX 抢锁,防止并发重试同时打到 LLM
const lockAcquired = await redis.set(lockKey, "1", {
NX: true,
EX: 30, // 30s 锁超时,超过说明 LLM 调用卡死了
});
if (!lockAcquired) {
// 等待锁持有者完成后,轮询缓存
return await pollForResult(cacheKey, 30000);
}
try {
// 3. 真正调用 LLM
const response = await callLLMAPI(request);
// 4. 原子写入结果缓存(TTL 内重复请求直接返回)
await redis.setEx(
cacheKey,
ttlSeconds,
JSON.stringify({ response, cachedAt: Date.now() })
);
return response;
} finally {
await redis.del(lockKey);
}
}
async function pollForResult(cacheKey: string, timeoutMs: number): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 200));
const result = await redis.get(cacheKey);
if (result) return JSON.parse(result).response;
}
throw new Error("Idempotency lock timeout: upstream may have failed");
}
关键点: SET NX(只在 key 不存在时设置)保证了并发重试中只有一个请求真正调用 LLM。其他重试等待结果,而不是各自发起新请求。
Idempotency-Key 的生成策略
不同场景下,key 的生成方式不同:
| 场景 | 推荐的 key 构造方式 |
|---|---|
| 用户主动触发的操作 | user_id + session_id + action_id(前端生成 UUID 随请求传入) |
| 后台定时任务 | job_id + run_at(精确到分钟) |
| Webhook 驱动 | webhook_event_id(幂等 key 直接取事件 ID) |
| 流水线内部调用 | pipeline_run_id + step_index |
| 语义去重(相似问题) | SHA256(normalize(prompt)),适合 RAG 场景 |
注意: 语义去重(第5行)有歧义风险------相似 prompt 但意图不同。生产中建议只对明确的系统级调用做精确幂等,对用户自由输入只做语义缓存,两者分开。
陷阱 2:Agent 工具链的副作用不可撤销
问题描述
这是 LLM 应用幂等性问题中最危险的一类。当 Agent 调用外部工具(支付、发邮件、修改 DB)时,工具的副作用往往是不可逆的。
来自 tianpan.co 的真实案例分析(2026-04):
- CRM Agent 从同一条客户投诉创建了两张重复工单
- 库存 Agent 对同一笔订单扣了两次库存
- 财务 Agent 发送了两次退款
这些失败有统一的根因:Agent 框架在 LLM 层面重试,而不是在工具调用层面追踪副作用。LangChain、各主流 Agent SDK------所有主流框架都有这个问题,因为它们的重试逻辑设计在"我是否得到了模型响应"层面,而不是"工具是否已经执行了副作用"层面。
解法:工具调用幂等账本(Tool Call Dedup Ledger)
python
# tool_idempotency_ledger.py
import hashlib
import json
import time
from typing import Any, Callable, Optional
from dataclasses import dataclass
import redis
@dataclass
class ToolCallRecord:
tool_name: str
call_id: str
args_hash: str
status: str # "pending" | "success" | "failed"
result: Optional[Any]
executed_at: float
completed_at: Optional[float]
class ToolIdempotencyLedger:
"""
每次工具调用前先查账本,已执行成功的直接返回缓存结果,
避免 Agent 重试时重复执行有副作用的工具。
"""
def __init__(self, redis_client: redis.Redis, ttl_seconds: int = 86400):
self.redis = redis_client
self.ttl = ttl_seconds
def _call_key(self, agent_run_id: str, tool_name: str, args: dict) -> str:
args_hash = hashlib.sha256(
json.dumps(args, sort_keys=True).encode()
).hexdigest()[:16]
return f"tool:idem:{agent_run_id}:{tool_name}:{args_hash}"
def wrap(self, agent_run_id: str, tool_name: str):
"""装饰器:把任意工具函数包装成幂等版本"""
def decorator(fn: Callable) -> Callable:
def wrapped(*args, **kwargs) -> Any:
cache_key = self._call_key(agent_run_id, tool_name, kwargs)
# 查账本
existing = self.redis.get(cache_key)
if existing:
record = json.loads(existing)
if record["status"] == "success":
print(f"[Idempotent] Returning cached result for {tool_name}")
return record["result"]
elif record["status"] == "pending":
# 上次调用还在进行中,等待
raise RuntimeError(
f"Tool {tool_name} is already executing (pending). "
f"Wait before retrying. call_key={cache_key}"
)
# 记录 pending
pending_record = {
"tool_name": tool_name,
"status": "pending",
"executed_at": time.time(),
"result": None,
}
self.redis.setex(cache_key, self.ttl, json.dumps(pending_record))
try:
result = fn(*args, **kwargs)
# 记录成功
success_record = {
**pending_record,
"status": "success",
"result": result,
"completed_at": time.time(),
}
self.redis.setex(cache_key, self.ttl, json.dumps(success_record))
return result
except Exception as e:
# 记录失败(允许重试)
failed_record = {
**pending_record,
"status": "failed",
"error": str(e),
"completed_at": time.time(),
}
self.redis.setex(cache_key, self.ttl, json.dumps(failed_record))
raise
return wrapped
return decorator
# 使用示例
ledger = ToolIdempotencyLedger(redis_client)
@ledger.wrap(agent_run_id="run-abc-123", tool_name="charge_card")
def charge_card(user_id: str, amount: float, currency: str = "CNY"):
"""这个函数现在是幂等的:相同 run_id + 相同参数 = 只执行一次"""
return payment_gateway.charge(user_id, amount, currency)
关键设计决策:
agent_run_id是幂等域的边界------同一次 Agent 运行内,相同工具+相同参数只执行一次pending状态阻塞重试,而不是让重试穿透到工具failed状态允许重试(幂等性不等于"只执行一次",而是"成功后不重复执行")
陷阱 3:流式响应的"部分成功"问题
问题描述
SSE(Server-Sent Events)流式输出已经成为 LLM 应用的标配。但流式场景下,"成功"的边界变模糊了:
客户端收到 chunk 1..200 → 网络中断 → 客户端重试
→ 服务端重新调用 LLM → 用户看到重复前缀 + 完整响应
→ 账单记了两次
更严重的是:如果你在流式过程中做了副作用(比如每 100 个 token 存一次 DB),重试时这些副作用会被执行两次。
解法:流式 Session + 断点续流标记
typescript
// streaming-session-manager.ts
interface StreamingSession {
sessionId: string;
status: "streaming" | "complete" | "failed";
chunks: string[];
totalTokens: number;
completedAt?: number;
}
class StreamingSessionManager {
constructor(private redis: RedisClient) {}
async startSession(sessionId: string): Promise<void> {
const session: StreamingSession = {
sessionId,
status: "streaming",
chunks: [],
totalTokens: 0,
};
await this.redis.setEx(
`stream:session:${sessionId}`,
3600,
JSON.stringify(session)
);
}
async appendChunk(sessionId: string, chunk: string): Promise<void> {
// 用 RPUSH 原子追加,不覆写已有 chunks
await this.redis.rPush(`stream:chunks:${sessionId}`, chunk);
await this.redis.expire(`stream:chunks:${sessionId}`, 3600);
}
async completeSession(sessionId: string): Promise<void> {
await this.redis.hSet(`stream:session:${sessionId}`, {
status: "complete",
completedAt: Date.now().toString(),
});
}
async tryResume(sessionId: string): Promise<string[] | null> {
const session = await this.redis.get(`stream:session:${sessionId}`);
if (!session) return null;
const parsed: StreamingSession = JSON.parse(session);
if (parsed.status === "complete") {
// 已完成,直接返回全部 chunks,不重新调用 LLM
const chunks = await this.redis.lRange(
`stream:chunks:${sessionId}`,
0,
-1
);
return chunks;
}
if (parsed.status === "streaming") {
// 上次流式中断,返回已有 chunks(客户端可以从断点渲染)
const partialChunks = await this.redis.lRange(
`stream:chunks:${sessionId}`,
0,
-1
);
return partialChunks; // 让上层决定是续流还是重新生成
}
return null;
}
}
客户端协议约定:
typescript
// 前端请求时带 session_id
const sessionId = `${userId}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Stream-Session-ID": sessionId, // 重试时复用相同 sessionId
"X-Resume-From": lastReceivedIndex.toString(), // 告诉服务端从哪个 chunk 续
},
body: JSON.stringify({ messages }),
});
陷阱 4:RAG 文档摄入的重复嵌入
问题描述
RAG Pipeline 里,文档摄入阶段(chunking → embedding → upsert 到向量库)如果没有幂等控制,同一份文档在以下情况会被重复处理:
- 用户多次上传相同文件
- 重新触发的数据管道 job
- 崩溃恢复后重跑任务
结果:向量库里有 3 份相同文档的 embedding,检索时返回重复结果,rank 被污染,生成质量下降。
解法:内容指纹去重
python
# rag_ingest_idempotent.py
import hashlib
from typing import Optional
import redis
from pathlib import Path
def compute_document_fingerprint(content: bytes) -> str:
"""SHA-256 of raw bytes; 文件名/路径无关,纯内容去重"""
return hashlib.sha256(content).hexdigest()
def idempotent_ingest(
content: bytes,
metadata: dict,
redis_client: redis.Redis,
vector_store,
embedder,
ttl_days: int = 30,
) -> dict:
"""
幂等摄入:相同内容只嵌入一次。
返回 {"status": "cached" | "ingested", "doc_id": str, "chunks": int}
"""
fingerprint = compute_document_fingerprint(content)
dedup_key = f"rag:ingest:fingerprint:{fingerprint}"
# 查去重缓存
existing = redis_client.get(dedup_key)
if existing:
record = json.loads(existing)
print(f"[RAG Ingest] Skipping duplicate: {fingerprint[:8]}... (doc_id={record['doc_id']})")
return {"status": "cached", **record}
# 标记 pending,防止并发摄入相同文档
lock_key = f"rag:ingest:lock:{fingerprint}"
lock_acquired = redis_client.set(lock_key, "1", nx=True, ex=120)
if not lock_acquired:
raise RuntimeError(f"Document {fingerprint[:8]}... is being ingested by another worker")
try:
# 实际摄入
text = content.decode("utf-8", errors="replace")
chunks = chunk_document(text)
embeddings = embedder.embed_batch([c.text for c in chunks])
doc_id = f"doc-{fingerprint[:16]}"
# Upsert 到向量库(向量库通常支持 upsert 语义,相同 ID 覆盖)
vector_store.upsert(
vectors=[
{
"id": f"{doc_id}-chunk-{i}",
"values": emb,
"metadata": {**metadata, "chunk_index": i, "text": chunks[i].text},
}
for i, emb in enumerate(embeddings)
]
)
result = {"doc_id": doc_id, "chunks": len(chunks), "fingerprint": fingerprint}
# 写去重缓存(TTL 内再次摄入直接返回 cached)
redis_client.setex(
dedup_key, ttl_days * 86400, json.dumps(result)
)
return {"status": "ingested", **result}
finally:
redis_client.delete(lock_key)
实测数据(个人项目,约 2000 文档的 RAG 知识库):
| 场景 | 无去重 | 有内容指纹去重 |
|---|---|---|
| 初次摄入 1000 文档 | 1000 次 embedding | 1000 次 embedding |
| 重新运行 pipeline(无修改) | 1000 次 embedding | 0 次 embedding |
| 10% 文档更新后重跑 | 1000 次 embedding | 100 次 embedding |
| 向量库中重复条目 | ~3x(跑了3次) | 0 |
去重后,在数千文档的知识库上,重跑 pipeline 的 embedding API 成本降为 0(命中缓存),检索准确率也因为消除重复 embedding 提升了约 8%(MRR@5 指标,自测)。
陷阱 5:Webhook 驱动 Agent 的事件重放
问题描述
很多 LLM 应用通过 Webhook 接收外部事件来触发 Agent(比如:新 GitHub PR → Agent 做 Code Review,新工单 → Agent 分类并回复)。Webhook 提供商通常有"至少投递一次"的语义------在没收到 HTTP 200 响应时会重试投递,直到成功。
如果你的 Agent 处理逻辑不幂等:
- 第一次 Webhook 到达,Agent 开始处理(耗时 10 秒)
- 8 秒时提供商认为超时(你的服务器还没返回 200),重发 Webhook
- 两个 Agent 实例同时运行,发了两次 Code Review 评论,回复了两次工单
解法:Event ID 去重 + 乐观锁
typescript
// webhook-dedup.ts
interface WebhookEvent {
id: string; // 提供商提供的唯一 event ID
type: string;
payload: unknown;
deliveredAt: number;
}
async function handleWebhookIdempotent(
event: WebhookEvent,
redis: RedisClient,
handler: (event: WebhookEvent) => Promise<void>
): Promise<{ status: "processed" | "duplicate" | "processing" }> {
const eventKey = `webhook:event:${event.id}`;
// 原子 SET NX:只有第一个到达的请求能设置这个 key
const isFirst = await redis.set(eventKey, JSON.stringify({
status: "processing",
startedAt: Date.now(),
}), {
NX: true,
EX: 300, // 5 分钟内重复投递视为重复
});
if (!isFirst) {
// 已有记录,检查状态
const existing = await redis.get(eventKey);
const record = existing ? JSON.parse(existing) : null;
if (record?.status === "done") {
return { status: "duplicate" }; // 幂等,直接返回 200
}
if (record?.status === "processing") {
// 上次还在处理中,可能是并发投递
// 返回 200 让提供商不再重试,但不执行 handler
return { status: "processing" };
}
}
try {
await handler(event);
await redis.set(eventKey, JSON.stringify({
status: "done",
completedAt: Date.now(),
}), { EX: 300 });
return { status: "processed" };
} catch (error) {
// 失败时删除 key,允许下次投递重试
await redis.del(eventKey);
throw error;
}
}
不同 Webhook 提供商的 Event ID 位置:
| 提供商 | Event ID Header/字段 |
|---|---|
| GitHub | X-GitHub-Delivery header |
| Stripe | event.id(evt_xxx) |
| Slack | event.event_ts + event.event_id |
| Linear | webhookTimestamp + data.id |
| 自建 | 建议在 payload 里明确 event_id 字段 |
系统级幂等性架构:四层防护模型
以上五类陷阱对应四个防护层,从请求入口到副作用执行,每层都需要独立的幂等控制:
vbnet
┌─────────────────────────────────────────────────────┐
│ Layer 4: 外部集成层 │
│ Webhook Event ID 去重 + 乐观锁 │
├─────────────────────────────────────────────────────┤
│ Layer 3: Agent 工具层 │
│ Tool Call Dedup Ledger (run_id + tool + args) │
├─────────────────────────────────────────────────────┤
│ Layer 2: LLM 调用层 │
│ Idempotency-Key + Redis SET NX + 结果缓存 │
├─────────────────────────────────────────────────────┤
│ Layer 1: 数据摄入层 │
│ 内容指纹 (SHA-256) + 向量库 Upsert 语义 │
└─────────────────────────────────────────────────────┘
各层的关键参数配置:
| 层 | Redis Key TTL | 锁超时 | 适用场景 |
|---|---|---|---|
| 数据摄入层 | 30 天 | 2 分钟 | RAG 文档处理 |
| LLM 调用层 | 1 小时 | 30 秒 | 所有 LLM API 请求 |
| Agent 工具层 | 24 小时 | 60 秒 | 工具调用有副作用时 |
| 外部集成层 | 5 分钟 | 30 秒 | Webhook 处理 |
常见误区与反模式
误区 1:用 temperature=0 解决幂等问题
temperature=0 让输出更确定,但不能防止重复调用。API 依然会被调用两次,账单依然翻倍,副作用依然执行两次。幂等性是请求层面的问题,不是模型确定性问题。
误区 2:在 Prompt 里加"只调用一次"的指令
"只调用 charge_card 工具一次"------这种指令无法约束 Agent 框架的重试行为。当框架因超时重试时,它不会去读 Prompt。工具级别的防护必须在代码层面实现。
误区 3:以为幂等 = 只执行一次
准确的定义是:同一逻辑操作,无论执行多少次,结果与执行一次相同。失败的操作允许重试(失败不算"已执行"),成功的操作不应重复执行。你的去重逻辑只应锁定"已成功"的记录,让"失败"的记录可以重试。
误区 4:幂等 Key 放在请求 Body 里
如果幂等 Key 在 Body 里,但服务端已经开始处理、客户端在 Header 层面就超时了,客户端重试时带的是新的 Key(比如前端重新生成了 UUID),幂等保护失效。正确做法是让幂等 Key 在请求的业务语义层面稳定,而不是在 HTTP 层面由前端生成。
小结:LLM 幂等性工程的实施清单
vbnet
□ LLM API 调用层:Idempotency-Key + Redis SET NX 锁 + 结果 TTL 缓存
□ Agent 工具调用:Tool Call Dedup Ledger,按 run_id + tool + args_hash 去重
□ 流式输出:Stream Session Manager,保存 chunks,支持断点续流
□ RAG 摄入:文档内容 SHA-256 指纹去重,向量库 Upsert 语义
□ Webhook 处理:Event ID 去重,SET NX 乐观锁,失败删 key 允许重试
□ 监控:track duplicate_skipped / idempotency_hit 指标,异常增长说明上游有重复发送
□ TTL 策略:各层 TTL 与业务幂等窗口匹配,不要无限期缓存
LLM 应用的幂等性问题不是新问题,但 AI 的非确定性、工具调用链、流式输出让它变得更难。从第一个 LLM API 调用开始就设计幂等,比事后修复双扣款、重复评论、重复工单要便宜得多。
本文代码已在个人项目中验证,Redis 版本 ≥ 7.0,TypeScript 5.x,Python 3.11+。向量库示例基于 Pinecone,Weaviate/Qdrant 的 Upsert API 语义类似。