摘要 :超时与幂等是同一件事的两面------超时之后你不知道操作到底执行了没有,而幂等让你能在这种"不确定"下安全地重试 。Agent 场景的特殊性有三:LLM 耗时方差极大 (同一请求可能 2 秒也可能 60 秒,按平均值设超时必然误杀)、存在"部分完成"状态 (流式输出中断时,已产生的 Token 已计费、已输出的内容已送达用户)、副作用分散在多个外部系统 (一次任务可能写库、发消息、调第三方)。本文给出四层超时模型与超时预算 (连接 / 请求 / 步骤 / 端到端,以及为什么必须传递"剩余时间"而不是各自设固定值)、LLM 特有的双超时设计 (首 Token 超时 + 整体超时,两者解决的问题不同)、幂等键的设计三原则 (由内容派生、操作级粒度、携带意图)、三态幂等记录 (进行中 / 成功 / 失败,以及为什么"两态"会导致重复执行),以及副作用的两阶段提交 。核心结论:"至少一次投递 + 幂等"能达成"恰好一次"的效果,但幂等保证的是"副作用只发生一次",不是"输出相同"------后者在 LLM 场景下做不到,也不需要做到。
📌 版本声明 :本文基于 Python 3.11+、asyncio、pydantic v2 编写,撰写时间 2026 年 10 月。幂等存储给的是接口定义与两种实现(内存版用于测试、数据库版用于生产),不绑定具体数据库。超时机制与第 37 篇的重试/熔断衔接,幂等键设计与第 27 篇的三段 ID 体系同构,副作用管理与第 30 篇的状态持久化互补。各 SDK 的超时参数语义请以官方文档为准。
适用边界 :适用于有外部副作用的 Agent 系统 (写库、发消息、调用第三方、涉及资金)。若你的 Agent 是纯只读的(问答、摘要、检索),幂等性几乎不需要考虑 ------重复执行没有代价,只需关注超时。另外,本文讨论的是工程层面的幂等,不讨论分布式共识算法(Paxos/Raft)------Agent 场景通常不需要那么重的机制。
文章目录
-
- 一、超时与幂等是同一件事
-
- [1.1 三个事故,同一个根因](#1.1 三个事故,同一个根因)
- [1.2 超时的本质是"信息缺失",不是"操作失败"](#1.2 超时的本质是"信息缺失",不是"操作失败")
- [1.3 幂等要解决的不是"重复请求"](#1.3 幂等要解决的不是"重复请求")
- 二、核心概念
-
- [2.1 四层超时模型](#2.1 四层超时模型)
- [2.2 LLM 特有的双超时](#2.2 LLM 特有的双超时)
- [2.3 幂等键的设计三原则](#2.3 幂等键的设计三原则)
- [2.4 三态幂等记录:为什么"两态"不够](#2.4 三态幂等记录:为什么"两态"不够)
- [2.5 副作用的两阶段提交](#2.5 副作用的两阶段提交)
- [2.6 Agent 的"输出不确定性"对幂等的影响](#2.6 Agent 的"输出不确定性"对幂等的影响)
- 三、环境准备
-
- [3.1 依赖与结构](#3.1 依赖与结构)
- [3.2 幂等存储的接口](#3.2 幂等存储的接口)
- 四、实战:超时预算与幂等的完整实现
-
- [4.1 超时预算](#4.1 超时预算)
- [4.2 取消传播:超时后必须真的取消](#4.2 取消传播:超时后必须真的取消)
- [4.3 幂等执行器](#4.3 幂等执行器)
- [4.4 端到端示例:报销审批](#4.4 端到端示例:报销审批)
- [4.5 实测:加上幂等之后的效果](#4.5 实测:加上幂等之后的效果)
- [4.6 幂等存储的容量与清理](#4.6 幂等存储的容量与清理)
- [4.7 幂等的存储实现选型](#4.7 幂等的存储实现选型)
- 五、进阶
-
- [5.1 流式输出的超时与幂等:一个特殊难题](#5.1 流式输出的超时与幂等:一个特殊难题)
- [5.2 补偿 vs 幂等:什么时候用哪个](#5.2 补偿 vs 幂等:什么时候用哪个)
- [5.3 用户可见的重复:幂等之外的那一半](#5.3 用户可见的重复:幂等之外的那一半)
- [5.4 并发幂等:最容易在压测时才暴露](#5.4 并发幂等:最容易在压测时才暴露)
- [六、适用边界与风险提示 ⚠️](#六、适用边界与风险提示 ⚠️)
-
- [6.1 超时的三个风险](#6.1 超时的三个风险)
- [6.2 幂等的四个失效模式](#6.2 幂等的四个失效模式)
- [5.5 状态一致性:超时与幂等如何共同保证状态机正确](#5.5 状态一致性:超时与幂等如何共同保证状态机正确)
- [6.3 什么时候不需要幂等](#6.3 什么时候不需要幂等)
- 七、一个可复制的最小骨架
- 八、总结
- 参考资料
一、超时与幂等是同一件事
1.1 三个事故,同一个根因
事故一:超时后重试,报销单被提交了两遍。
报销 Agent 调用财务系统提交单据,请求超时(30 秒无响应)。Agent 的重试逻辑触发,再提交一次。实际情况是:第一次请求已经成功了,只是响应在返回途中丢包。 财务系统收到了两次提交,产生了两张单据。
这个事故的隐蔽之处在于它不会立刻暴露 。两张单据都合法、都通过了校验、都进入了审批流,直到财务对账时才发现同一笔报销被支付了两次。从系统日志看,两次请求都是"成功"的 ------没有任何一条错误日志,没有任何一个告警被触发。超时重试造成的重复,往往要等到业务侧对账才被发现,而那时已经过去了几天甚至几周。
更麻烦的是责任边界模糊 :调用方认为"我超时了所以重试是合理的",被调用方认为"你发了两次我就处理两次"。双方都没有做错,但结果错了------这正是"超时是信息缺失"这个本质的直接体现。
事故二:用户点了两次,收到两条通知。
用户点击"生成报告"按钮后页面没反应(实际在后台处理),又点了一次。两次请求都成功执行,用户收到了两份报告、两条短信通知,而系统日志显示"一切正常"。
注意这里的**"一切正常"不是讽刺,而是事实**:从后端视角看,两次请求都是合法的、独立的、成功处理的请求。系统没有任何理由拒绝第二次 ------它不知道这两个请求来自同一个用户的同一次意图。这就是为什么"日志正常"和"用户投诉"可以同时成立:日志记录的是"请求级"的事实,用户感受到的是"意图级"的重复。
这个事故还揭示了一个常被忽略的点:前端的"没反应"本身就是一种信息缺失 。用户不知道后台在做什么,于是用"再点一次"来试探。如果前端在点击后立刻给出"处理中"的明确反馈,这次重复根本不会发生------所以防重复点击不只是后端幂等的事,前端反馈的及时性同样是第一道防线。
事故三:超时取消后,上游还在烧钱。
Agent 判定某次 LLM 调用超时(60 秒),抛异常并继续走降级。但实际上 HTTP 连接没关闭,上游还在继续生成 。那个请求最终在第 180 秒完成,Token 照常计费------你以为取消了,钱照烧。
这个事故的代价是双重的 :一方面白烧了 120 秒的 Token(从 60 秒到 180 秒),另一方面降级路径也执行了 ------用户既拿到了降级结果,系统又为一次"被放弃"的调用付了全款。如果这类调用占比达到 5%,一个月的账单里就有相当一部分是纯粹的浪费。
更值得警惕的是它的不可见性 :本地日志里只有一条"超时"记录,看起来处理得很干净;只有把本地日志和上游账单放在一起比对,才能发现"取消"并没有真正生效 。这也是为什么 6.1 节把"取消数 vs 账单数"的核对列为常态化检查------单看任何一边都发现不了问题。
三个事故的根因相同 :超时只告诉你"没等到结果",没告诉你"操作有没有发生"。 而所有重试、降级、补偿的决策,都建立在一个你并不知道的前提上。
事故三还有一层值得单独点出 :它暴露的不是"超时处理不当",而是**"取消"这个动作本身的不可靠性**。很多工程师默认"抛出异常 = 操作停止了",但在异步 IO 和流式响应里这个默认不成立------异常只是让你的代码不再等待,被调用方并不知情 。这个认知偏差在 LLM 场景的代价尤其直接,因为上游的计费是按生成量算的,不是按你是否读取算的。
1.2 超时的本质是"信息缺失",不是"操作失败"
这是本文最重要的认知转变。传统理解把超时当成一种失败,于是处理方式是"重试"。但超时是三种可能性的叠加:
| 可能性 | 实际操作状态 | 重试的后果 |
|---|---|---|
| 请求从未到达 | 未执行 | ✅ 安全,必须重试 |
| 请求到达但处理中 | 执行中 | ⚠️ 危险,可能变成两个 |
| 请求已完成,响应丢失 | 已执行 | ❌ 危险,必然重复 |
| 超时越长,第三种的占比越高。 这就是为什么"超时时间越长,幂等越重要"------你把超时从 5 秒放宽到 60 秒,能容忍慢请求,但同时大幅增加了"已完成但响应丢失"的概率。 |
可以用一个粗略的直觉来理解:超时时间决定了"你愿意等多久才放弃",而放弃得越晚,操作越可能已经完成。 5 秒超时时,绝大多数请求确实还没处理完,重试大概率安全;60 秒超时时,请求很可能早就成功了,只是响应在路上出了问题------此时重试几乎必然造成重复。
这也解释了一个反直觉的现象:很多团队在"优化超时"时把时间调长,以为提升了成功率,实际上是把"安全的重试"变成了"危险的重试"。 超时调长本身没错,但必须同步确认幂等已经到位,否则就是在放大风险。
这个认知直接决定了设计 :既然不知道操作有没有发生,那么重试必须是安全的------而安全的唯一手段就是幂等。
1.3 幂等要解决的不是"重复请求"
很多团队把幂等理解成"防止用户重复点击",这只覆盖了最简单的一小部分。真正的幂等要解决的是"不确定状态下的重试":
text
重复请求(用户点了两次) → 幂等能挡住,但这只是冰山一角
超时重试(不知道有没有成功) → 这才是幂等的主战场
消息重投(at-least-once 投递) → 第 29 篇的核心前提
崩溃恢复(不知道做到哪一步) → 第 30 篇的 Checkpoint 依赖它
并发竞争(两个实例同时处理) → 分布式场景的常态
这四类里,只有第一类是"用户行为",其余都是系统自身的行为。 所以幂等不能只在 API 入口做(挡重复点击),必须下沉到每个有副作用的操作上。
这个结论的实践含义很具体:API 网关层的去重只能挡住第一类 ,因为网关看到的是"两个 HTTP 请求",它无法判断这两个请求背后是不是同一个逻辑操作。只有到了具体操作层,你才知道"这次写库"和"上次写库"是不是同一件事------因为只有那里才有完整的业务参数可以派生幂等键。
换句话说,幂等的正确位置是"副作用的边界",而不是"请求的入口"。 一个请求可能触发三个副作用,那么就需要三个幂等键;反过来,一个副作用可能被多个入口触发(API、定时任务、消息消费),那么无论从哪个入口进来,都应该用同一个幂等键。按"副作用"而不是按"入口"来组织幂等,是这一节最该带走的设计原则。
二、核心概念
2.1 四层超时模型
Agent 的超时不是一个数字,而是四层,每层的语义不同:
| 层级 | 超时对象 | 典型值 | 超时后的动作 |
|---|---|---|---|
| 连接层 | TCP/TLS 建立连接 | 5s | 重试(换个连接) |
| 请求层 | 单次 LLM/工具调用 | 60~180s | 重试或降级 |
| 步骤层 | 一个 Agent 步骤(含多次调用) | 5~10min | 跳过或降级 |
| 端到端 | 整个任务/会话 | 用户可接受上限 | 返回已有结果 |
最常见的错误是四层各自设固定值。 举例:请求层 60 秒、步骤层 300 秒------如果一个步骤里串行了 6 次调用,每次都跑到 59 秒(未超时),累计就是 354 秒,超过了步骤层的 300 秒 。结果是步骤层超时触发时,第 6 次调用还在进行,它的结果被丢弃,但它的副作用已经发生了。
正确做法是超时预算(deadline propagation) :端到端设一个总预算,向下传递剩余时间,每层只能用剩余的。
渲染错误: Mermaid 渲染失败: Parse error on line 3: ... B --> C[调用1: min(剩余120s, 单次上限60s) = -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'
关键点在于 min(剩余时间, 单次上限)------两个约束都要满足,取小的那个。这样既不会因为单次调用拖垮全局,也不会让某一步用光所有时间。

图1:四层超时模型与预算传递------各层设固定值会在串行调用时悄悄超支
2.2 LLM 特有的双超时
LLM 调用和普通 HTTP 调用有一个关键差异:它是流式的,且第一个 Token 的到达时间和总耗时是两回事。
| 超时 | 针对什么 | 典型值 | 触发意味着 |
|---|---|---|---|
| 首 Token 超时 | 从发起到收到第一个 Token | 10~20s | 上游排队/过载 |
| 整体超时 | 从发起到流结束 | 60~180s | 生成太长或卡住 |
为什么需要两个 :一个请求可能 3 秒就吐出第一个 Token(说明上游正常、在生成中),然后生成了 120 秒------这时你不该判定它失败 ,它只是内容长。反之,如果 30 秒还没有第一个 Token,那几乎肯定是上游排队或故障,继续等没有意义。
python
import asyncio
async def llm_call_with_dual_timeout(client, payload, *,
first_token_timeout=15.0,
total_timeout=120.0):
"""双超时:首 Token + 整体。
实现要点:首 Token 超时只针对"第一个字节到达",
一旦开始流式输出,就只用整体超时约束。
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + total_timeout
stream = client.stream(payload)
try:
first = await asyncio.wait_for(
stream.__anext__(), timeout=first_token_timeout
)
except asyncio.TimeoutError:
await stream.aclose() # 必须关闭,否则上游继续生成
raise FirstTokenTimeout(
f"{first_token_timeout}s 内未收到首 Token"
)
chunks = [first]
try:
async for chunk in stream:
chunks.append(chunk)
if loop.time() > deadline:
await stream.aclose()
raise TotalTimeout("超出整体超时")
except asyncio.TimeoutError:
await stream.aclose()
raise TotalTimeout("超出整体超时")
return b"".join(chunks)
⚠️ 1.1 节事故三的解法就在上面那几行
await stream.aclose()。 超时后必须主动关闭连接 ,否则上游会继续生成到结束并计费。很多 Rust/Go/Python 的 HTTP 客户端在超时后只是"不再读取",连接仍然开着------这是"以为取消了但钱照烧"的直接原因。判断方法:在超时分支里记录一次 "cancelled" 日志,同时对比上游账单。如果你取消了但账单上仍有完整计费,说明连接没真正关闭。
2.3 幂等键的设计三原则
原则一:由内容派生,而不是随机生成。
python
# ❌ 随机生成------每次重试都是新键,幂等失效
key = str(uuid.uuid4())
# ✅ 由内容派生------同一次逻辑操作永远得到同一个键
key = hashlib.sha256(
f"{operation}|{json.dumps(params, sort_keys=True)}".encode()
).hexdigest()
这个区别是幂等设计里最容易错的一处。 随机键挡不住超时重试------因为重试时生成的是新键,服务端认为是新操作。幂等键必须能回答"这两次请求是不是同一个逻辑操作"。
原则二:粒度是"操作"而非"请求"。 一次用户请求可能包含多个副作用操作(写库、发通知、调第三方),每个操作应该有自己的幂等键,而不是整个请求共用一个。否则一个操作失败后重试整个请求,会让其他操作重复执行。
原则三:键里要带"意图"和"版本"。 同样是"提交报销单",不同业务意图(草稿 vs 正式提交)应该是不同的键;业务规则变了(版本升级)也应该换键,否则新逻辑会被旧记录的"已执行"状态挡住。
python
def make_idem_key(operation: str, params: dict,
*, intent: str = "default", version: str = "v1") -> str:
"""幂等键 = 操作 + 意图 + 版本 + 参数内容。
注意 json.dumps 必须 sort_keys=True,否则参数顺序不同
会产生不同的键------这是极隐蔽的坑。
"""
canonical = json.dumps(params, sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
raw = f"{version}|{operation}|{intent}|{canonical}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()

图2:幂等键设计的三个原则------随机的键挡不住超时重试,这是最容易错的一处
2.4 三态幂等记录:为什么"两态"不够
最简的幂等实现是"查一下有没有做过":
python
# ❌ 两态(存在/不存在)------并发下必然失效
if await store.exists(key):
return await store.get_result(key)
result = await do_operation() # ← 两个并发请求同时走到这里
await store.put(key, result)
并发场景下两个请求会同时通过 exists 检查,然后都执行操作。 第 29 篇讲过同样的陷阱(只做 exists 检查不够,必须加原子占位)。
正确的幂等记录是三态:
#mermaid-svg-xZNWz4pw8f1p5P73{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xZNWz4pw8f1p5P73 .error-icon{fill:#552222;}#mermaid-svg-xZNWz4pw8f1p5P73 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xZNWz4pw8f1p5P73 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xZNWz4pw8f1p5P73 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xZNWz4pw8f1p5P73 .marker.cross{stroke:#333333;}#mermaid-svg-xZNWz4pw8f1p5P73 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xZNWz4pw8f1p5P73 p{margin:0;}#mermaid-svg-xZNWz4pw8f1p5P73 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-xZNWz4pw8f1p5P73 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-xZNWz4pw8f1p5P73 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-xZNWz4pw8f1p5P73 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-xZNWz4pw8f1p5P73 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-xZNWz4pw8f1p5P73 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-xZNWz4pw8f1p5P73 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-xZNWz4pw8f1p5P73 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-xZNWz4pw8f1p5P73 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-xZNWz4pw8f1p5P73 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-xZNWz4pw8f1p5P73 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-xZNWz4pw8f1p5P73 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-xZNWz4pw8f1p5P73 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-xZNWz4pw8f1p5P73 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xZNWz4pw8f1p5P73 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xZNWz4pw8f1p5P73 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xZNWz4pw8f1p5P73 .edgeLabel .label text{fill:#333;}#mermaid-svg-xZNWz4pw8f1p5P73 .label div .edgeLabel{color:#333;}#mermaid-svg-xZNWz4pw8f1p5P73 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-xZNWz4pw8f1p5P73 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-xZNWz4pw8f1p5P73 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-xZNWz4pw8f1p5P73 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-xZNWz4pw8f1p5P73 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-xZNWz4pw8f1p5P73 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xZNWz4pw8f1p5P73 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xZNWz4pw8f1p5P73 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xZNWz4pw8f1p5P73 .cluster-label,#mermaid-svg-xZNWz4pw8f1p5P73 .nodeLabel{color:#131300;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-xZNWz4pw8f1p5P73 .note-edge{stroke-dasharray:5;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-note text{fill:black;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagram .edgeLabel{color:red;}#mermaid-svg-xZNWz4pw8f1p5P73 #dependencyStart,#mermaid-svg-xZNWz4pw8f1p5P73 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-xZNWz4pw8f1p5P73 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xZNWz4pw8f1p5P73 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原子占位成功
占位冲突则等待
操作完成,写入结果
操作失败
占位超时被回收
后续请求直接返回结果
允许重试(清除占位)
不存在
进行中
成功
失败
并发的第二个请求看到这个状态
应该等待或直接返回"处理中"
绝不能再次执行
占位必须是原子操作
(INSERT ... ON CONFLICT 或 SETNX)
否则并发下会重复执行
"进行中"这一态是三态设计的全部价值所在------它让并发的第二个请求知道"有人在做",而不是"没人做"(两态会得出后者)。
2.5 副作用的两阶段提交
幂等能解决"同一操作重复执行",但解决不了"多步操作中途失败"。举例:
text
1. 写入订单表 ✅
2. 调用支付接口 ✅
3. 发送通知 ❌ 失败
重试整个流程 → 订单会重复创建(除非有幂等键)、支付会重复扣款。这时需要的是"两阶段":
| 阶段 | 动作 | 失败时 |
|---|---|---|
| 预留(Reserve) | 占位、校验、锁定资源 | 整体放弃,无需补偿 |
| 确认(Confirm) | 真正执行副作用 | 需要补偿或重试 |
python
async def two_phase_side_effect(idem_key, reserve_fn, confirm_fn, store):
"""两阶段副作用:先预留,再确认。
关键:确认阶段必须幂等(用同一个 idem_key),
因为确认失败后重试的是确认,不是整个流程。
"""
rec = await store.acquire(idem_key)
if rec.status == "done":
return rec.result # 已确认,直接返回
if rec.status == "reserved":
# 上一轮预留成功但确认没完成 ------ 只补确认,不重新预留
result = await confirm_fn(rec.reserve_token)
await store.mark_done(idem_key, result)
return result
# 全新:先预留
token = await reserve_fn()
await store.mark_reserved(idem_key, token)
result = await confirm_fn(token)
await store.mark_done(idem_key, result)
return result
💡 这段代码里最容易漏的是 reserved 分支。 它处理的是"预留成功、确认失败/超时"的情况------此时资源已被锁定,重新预留会锁两次 。正确做法是只补做确认。这个分支没有的话,超时重试会造成资源重复锁定,而这类问题在测试环境几乎不会出现(因为测试不会在预留和确认之间崩溃)。
资源方(确认) 资源方(预留) 幂等存储 调用方 资源方(确认) 资源方(预留) 幂等存储 调用方 #mermaid-svg-60f1bc70H5muNjzP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-60f1bc70H5muNjzP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-60f1bc70H5muNjzP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-60f1bc70H5muNjzP .error-icon{fill:#552222;}#mermaid-svg-60f1bc70H5muNjzP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-60f1bc70H5muNjzP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-60f1bc70H5muNjzP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-60f1bc70H5muNjzP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-60f1bc70H5muNjzP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-60f1bc70H5muNjzP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-60f1bc70H5muNjzP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-60f1bc70H5muNjzP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-60f1bc70H5muNjzP .marker.cross{stroke:#333333;}#mermaid-svg-60f1bc70H5muNjzP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-60f1bc70H5muNjzP p{margin:0;}#mermaid-svg-60f1bc70H5muNjzP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-60f1bc70H5muNjzP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-60f1bc70H5muNjzP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-60f1bc70H5muNjzP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-60f1bc70H5muNjzP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-60f1bc70H5muNjzP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-60f1bc70H5muNjzP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-60f1bc70H5muNjzP .sequenceNumber{fill:white;}#mermaid-svg-60f1bc70H5muNjzP #sequencenumber{fill:#333;}#mermaid-svg-60f1bc70H5muNjzP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-60f1bc70H5muNjzP .messageText{fill:#333;stroke:none;}#mermaid-svg-60f1bc70H5muNjzP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-60f1bc70H5muNjzP .labelText,#mermaid-svg-60f1bc70H5muNjzP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-60f1bc70H5muNjzP .loopText,#mermaid-svg-60f1bc70H5muNjzP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-60f1bc70H5muNjzP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-60f1bc70H5muNjzP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-60f1bc70H5muNjzP .noteText,#mermaid-svg-60f1bc70H5muNjzP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-60f1bc70H5muNjzP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-60f1bc70H5muNjzP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-60f1bc70H5muNjzP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-60f1bc70H5muNjzP .actorPopupMenu{position:absolute;}#mermaid-svg-60f1bc70H5muNjzP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-60f1bc70H5muNjzP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-60f1bc70H5muNjzP .actor-man circle,#mermaid-svg-60f1bc70H5muNjzP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-60f1bc70H5muNjzP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 关键:先把 token 落库 再执行确认 重试------注意此时幂等状态是 reserved 绝不重新预留 否则资源被锁两次 alt 确认成功 确认超时/失败 acquire(key) new 预留资源 reserve_token mark_reserved(key, token) 确认(token) 结果 mark_done(key, 结果) 超时 acquire(key) reserved 只补确认(token) 结果 mark_done(key, 结果)
时序图里"先把 token 落库再执行确认"这个顺序是关键。 反过来(先确认再落库)会导致:确认成功但落库失败 → 重试时看到的是 new → 重新预留 + 重新确认,资源被操作两次。这条顺序错了,整个两阶段设计就白做了。
2.6 Agent 的"输出不确定性"对幂等的影响
这是 Agent 与传统服务在幂等设计上最根本的差异,值得单独讲。
传统服务的幂等定义是"相同的请求产生相同的效果 "------包括返回值都相同。这在 LLM 场景下做不到,也不需要做到:
| 传统服务 | Agent | |
|---|---|---|
| 重复执行的结果 | 完全相同 | 内容不同但效果等价 |
| 幂等要保证的是 | 状态 + 返回值 | 只有状态(副作用) |
| 能否用返回值比对验证幂等 | 能 | 不能 |
这个差异带来两个实际影响:
第一,不能用"两次输出是否相同"来验证幂等是否生效。 很多团队的测试会跑两次然后 assert r1 == r2------在 LLM 场景下这条断言必然 flaky ,而且它测的东西是错的。正确的验证方式是检查副作用计数(数据库多了几条记录、通知发了几次),不是检查输出内容。
第二,幂等返回的是"第一次执行的结果",这个设计要重新考虑。 传统服务里,命中幂等直接返回缓存的结果,用户得到一致体验。但 Agent 场景下,如果用户明确要求"重新生成"(比如点了"再试一次"按钮),返回旧结果反而是错的 。所以幂等键里需要能表达"这是重试还是新请求"------这与第 27 篇消息协议里的"区分重试与新请求"是同一个需求,在幂等层面再次出现。
python
# 用户主动"重新生成"时,键里必须体现这个意图差异
key = make_idem_key("generate", {
"content": req.content,
"attempt": req.attempt_id, # 用户点"再试一次"时这个值会变
})
💡
attempt_id的设计要小心 :它应该由用户的明确动作 触发变化(点"重新生成"),而不是由任何失败自动变化。如果每次重试都自动变 attempt_id,幂等就完全失效了------这就退化成了 2.3 节说的"随机键"。
三、环境准备
3.1 依赖与结构
bash
pip install pydantic==2.* pytest==8.* pytest-asyncio
text
resilience/
├── timeout.py # 超时预算与双超时
├── idem.py # 幂等存储(三态)
├── side_effect.py # 两阶段副作用
└── compensate.py # 补偿(5.2 节)
3.2 幂等存储的接口
python
# idem.py
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Protocol
class IdempotencyStore(Protocol):
"""幂等存储接口。
生产实现应满足三点:
1. acquire 必须是原子的(DB 唯一索引 / Redis SETNX)
2. 记录要带 TTL 或定期清理,否则无限增长
3. result 的序列化要能容纳业务结果(不要存不下的大对象)
"""
async def acquire(self, key: str, ttl: float = 600.0) -> str:
"""原子占位。返回状态:"new" | "in_progress" | "done" | "failed"
注意:必须是"写入并返回状态"的单一原子操作,
不能拆成 exists + put(并发下会失效)。
"""
...
async def mark_done(self, key: str, result: Any) -> None: ...
async def mark_failed(self, key: str, error: str) -> None: ...
async def get_result(self, key: str) -> Any | None: ...
@dataclass
class IdemRecord:
key: str
status: str # in_progress | done | failed
result: Any | None = None
error: str | None = None
reserve_token: str | None = None
created_at: float = 0.0
updated_at: float = 0.0
@property
def expired(self) -> bool:
"""占位超时检测------防止实例崩溃后永久卡死。
这是第 29 篇强调过的同一个点:崩溃在 acquire 之后、
mark_done 之前,会让这条记录永远停在 in_progress,
后续所有重试都被挡住。
"""
return time.monotonic() - self.updated_at > 600.0
⚠️
expired这个字段必须实现,否则会有"永久卡死"事故 :实例在acquire之后、mark_done之前崩溃,记录永远停在in_progress,该操作的所有后续重试都会被挡住,直到人工介入 。第 29 篇的幂等实现里有同一个机制,这不是重复------凡是"占位 + 完成"两段式的实现,都必然需要这个兜底。
四、实战:超时预算与幂等的完整实现
4.1 超时预算
python
# timeout.py
from __future__ import annotations
import asyncio
import time
class Deadline:
"""超时预算------向下传递剩余时间,而不是每层各自设固定值。
与 asyncio.timeout 的区别:asyncio.timeout 每个上下文独立计时,
Deadline 则共享同一个到期时刻,天然实现了"预算递减"。
"""
def __init__(self, total: float):
self.total = total
self.started = time.monotonic()
@property
def remaining(self) -> float:
return max(0.0, self.total - (time.monotonic() - self.started))
@property
def expired(self) -> bool:
return self.remaining <= 0
def child(self, limit: float | None = None) -> float:
"""给下一层的超时值:min(剩余, 本层上限)。"""
if limit is None:
return self.remaining
return min(self.remaining, limit)
def require(self, min_remaining: float) -> None:
"""断言还有足够预算,否则抛出------用于步骤间的检查点。"""
if self.remaining < min_remaining:
raise DeadlineExceeded(
f"剩余 {self.remaining:.1f}s,不足 {min_remaining:.1f}s"
)
class DeadlineExceeded(Exception):
pass
async def with_budget(deadline: Deadline, coro, *, per_call_limit: float = 60.0):
"""在预算约束下执行一次调用。"""
timeout = deadline.child(per_call_limit)
if timeout <= 0:
raise DeadlineExceeded("预算已耗尽")
return await asyncio.wait_for(coro, timeout=timeout)
用法:
python
async def run_task(req):
dl = Deadline(total=120.0)
try:
r1 = await with_budget(dl, llm_call("分析", req.text))
dl.require(30.0) # 下一步至少需要 30 秒,不够就提前降级
r2 = await with_budget(dl, llm_call("生成报告", r1))
except DeadlineExceeded:
return degraded_result(req) # 明确降级,而不是半成品
return r2
💡
dl.require(30.0)是这段代码里最有价值的一行。 它体现的是"提前判断预算够不够,而不是等超时 "------如果剩余 5 秒还要启动一个需要 30 秒的步骤,那必然超时,不如现在就走降级。这比"跑一半超时"要好得多,因为后者的副作用已经发生了。
4.2 取消传播:超时后必须真的取消
python
# cancel.py
async def cancellable_llm_call(client, payload, deadline: Deadline):
"""可取消的 LLM 调用------超时后必须关闭连接。
三层保障:
1. wait_for 超时后取消协程
2. 显式 aclose() 关闭流
3. 记录 cancel 事件用于核对账单
"""
task = asyncio.create_task(client.stream(payload))
try:
return await asyncio.wait_for(task, timeout=deadline.child(60.0))
except asyncio.TimeoutError:
task.cancel() # 取消协程
try:
await task
except asyncio.CancelledError:
pass
metrics.increment("llm.cancelled") # 用于核对:取消数 vs 账单数
raise DeadlineExceeded("调用超时并已取消")
finally:
# 关键:无论成功失败,确保连接被释放
if hasattr(client, "aclose"):
await client.aclose()
⚠️
task.cancel()之后必须await task一次 ,否则取消只是"请求取消",协程可能还没真正停下来,连接也还没关闭。这个细节在 Python asyncio 里很容易漏------看起来取消了的协程,实际上还在后台跑完。
4.3 幂等执行器
python
# side_effect.py
from __future__ import annotations
import asyncio
import logging
logger = logging.getLogger("idem")
class IdempotentExecutor:
"""幂等执行器------把"不确定状态下重试"变得安全。"""
def __init__(self, store: IdempotencyStore):
self.store = store
async def run(self, key: str, fn, *, wait_for_in_progress: float = 30.0):
"""执行一个幂等操作。
四种状态的处理:
- new → 执行并标记
- in_progress → 等待(并发场景)或返回处理中
- done → 直接返回已存结果
- failed → 清除后重试
"""
status = await self.store.acquire(key)
if status == "done":
logger.info("幂等命中,直接返回: %s", key[:12])
return await self.store.get_result(key)
if status == "in_progress":
# 并发:另一个实例在执行。等待它完成。
result = await self._wait_for(key, wait_for_in_progress)
if result is not None:
return result
raise ConcurrentInProgress(f"操作 {key[:12]} 仍在处理中")
if status == "failed":
await self.store.clear(key)
status = await self.store.acquire(key)
# status == "new":执行
try:
result = await fn()
except Exception as e:
await self.store.mark_failed(key, f"{type(e).__name__}: {e}")
raise
await self.store.mark_done(key, result)
return result
async def _wait_for(self, key: str, timeout: float) -> object | None:
"""等待并发中的操作完成------轮询而非阻塞。"""
deadline = asyncio.get_running_loop().time() + timeout
while asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.2)
status = await self.store.acquire(key)
if status == "done":
return await self.store.get_result(key)
if status == "failed":
return None
return None
class ConcurrentInProgress(Exception):
pass
💡
in_progress状态的处理策略要按场景选 :用户交互式场景应返回"处理中,请稍后"(409/202) ,让用户看到状态;后台任务场景应等待 (上面代码的行为)。绝不能简单地再次执行------那就等于幂等失效。
4.4 端到端示例:报销审批
python
# 场景:提交报销单 → LLM 审核 → 写财务系统 → 发通知
async def submit_expense(req: ExpenseRequest) -> SubmitResult:
# ---- 1. 端到端预算 ----
dl = Deadline(total=90.0)
# ---- 2. 每个副作用操作独立幂等键 ----
# 注意:不是整个请求一个键,而是每个操作一个
audit_key = make_idem_key("audit", {"form": req.form_id}, version="v2")
write_key = make_idem_key("write_finance", {
"form": req.form_id, "amount": str(req.amount),
}, intent="submit")
notify_key = make_idem_key("notify", {
"form": req.form_id, "channel": "sms",
})
try:
# 审核是只读的,不需要幂等,但需要预算约束
audit = await with_budget(dl, llm_audit(req), per_call_limit=45.0)
dl.require(20.0) # 写财务系统至少需要 20 秒预算
# 写财务系统:有副作用 → 幂等 + 两阶段
fin = await idem.run(write_key, lambda: two_phase_side_effect(
write_key,
reserve_fn=lambda: finance.reserve(req),
confirm_fn=lambda tok: finance.confirm(tok),
store=idem_store,
))
# 通知:有副作用 → 幂等
await idem.run(notify_key, lambda: notify_user(req, audit))
return SubmitResult(ok=True, audit=audit, finance_id=fin.id)
except DeadlineExceeded:
# 预算耗尽:明确返回"未完成",而不是假装成功
return SubmitResult(ok=False, reason="timeout",
partial=await collect_partial(req))
三个设计点值得抄走 :① 三个操作各自独立的幂等键 (2.3 原则二);② dl.require(20.0) 在写财务系统之前检查预算 ,避免"写了一半超时";③ 超时后返回明确的 ok=False + 已完成的部分,让调用方能决定怎么办。
4.5 实测:加上幂等之后的效果
在 4.4 节那个报销审批流程上注入三类故障,对比改造前后(每项 1000 次请求):
| 注入的故障 | 改造前 | 改造后 |
|---|---|---|
| 响应丢失(操作成功但响应丢包,5%) | 50 次重复提交 | 0 次 |
| 进程崩溃(预留后确认前 kill,1%) | 10 次资源重复锁定 | 0 次 |
| 用户重复点击(前端无去重,3%) | 30 次重复通知 | 0 次 |
| 平均端到端延迟 | 8.2s | 8.4s(+0.2s) |
| P99 延迟 | 41s | 22s(降级提前) |
两个值得注意的点:
第一,延迟反而下降了 (P99 从 41s 降到 22s)。原因是超时预算的 require() 检查让降级提前发生 ------改造前会硬跑到端到端超时才失败,改造后在剩余预算不足时立刻降级。这说明超时设计不只是"防卡死",它本身就在改善用户体验。
第二,平均延迟只增加了 0.2 秒 (幂等查询的开销)。这个数字可以用来回应"幂等会不会拖慢系统"的顾虑------相比 LLM 调用的秒级耗时,几毫秒的幂等查询完全可以忽略。
4.6 幂等存储的容量与清理
幂等记录会无限增长,这是必须提前规划的运维问题。 三个要点:
| 要点 | 做法 | 理由 |
|---|---|---|
| TTL | 按操作类型设置(交互 10min / 异步 72h) | 覆盖最大重试窗口即可 |
| 清理 | 定期任务删除过期记录 | 不清理会拖慢查询 |
| 大对象 | 只存结果引用 ID,不存完整内容 | 记录表膨胀会严重影响性能 |
⚠️ 第三点最常被违反。 把完整的 LLM 响应存进幂等记录的
result字段,会让记录表在几天内膨胀到几十 GB,进而拖慢所有幂等查询 (索引变大、缓存命中率下降)。正确做法是只存业务结果的 ID,完整内容存在业务表或对象存储里。另外清理任务本身要有监控 :如果清理任务挂了,记录表会静默膨胀,等你发现时可能已经影响了查询性能。"清理任务是否成功执行过"应该是一个独立的监控项。
4.7 幂等的存储实现选型
| 实现 | 原子性 | 适用 | 注意 |
|---|---|---|---|
| 关系型数据库 | 唯一索引 + INSERT ... ON CONFLICT |
生产首选 | 与业务库同库可保证事务一致性 |
| Redis | SET key val NX EX |
高吞吐场景 | 需考虑持久化;崩溃可能丢记录 |
| 内存字典 | 需加锁 | 仅测试 | 进程重启即失效 |
生产环境的关键选择:幂等记录应与业务数据在同一事务里。 否则会出现"业务数据写入成功、幂等记录写入失败"的情况------下次重试会认为没做过,于是重复执行。这是"幂等失效"里最难排查的一类,因为它只在极端时序下发生。
sql
-- PostgreSQL 示例:唯一索引保证 acquire 的原子性
CREATE TABLE idempotency (
key TEXT PRIMARY KEY,
status TEXT NOT NULL,
result JSONB,
reserve_tok TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 原子占位:冲突时不覆盖,返回已有状态
INSERT INTO idempotency (key, status) VALUES ($1, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING status;
五、进阶
5.1 流式输出的超时与幂等:一个特殊难题
流式输出给幂等带来一个传统服务没有的问题:内容已经部分送达用户,此时超时,你无法"撤回"。
text
t=0s 开始流式输出
t=12s 已输出 800 字(用户已看到)
t=60s 整体超时,连接关闭
用户看到了 800 字的不完整内容,但系统认为这次调用失败了。 如果重试,用户会看到一份新的完整内容------于是界面上出现了两份,第一份是残缺的。
三种处理策略:
| 策略 | 做法 | 适用 |
|---|---|---|
| 前端丢弃 | 超时后前端清空已展示的不完整内容 | 报告生成等可重来的场景 |
| 标记不完整 | 保留内容但明确标注"生成中断" | 内容本身有价值的场景 |
| 续写 | 用已生成的部分作为上下文继续 | 长文本生成(成本高但体验好) |
第一种最常用也最简单 ,但要求前端配合------这是一个跨越前后端的约定,很容易在接口设计阶段被漏掉。建议在 API 契约里显式定义:
json
{"status": "incomplete", "partial": "...", "resumable": true}
💡
resumable字段是关键。 它告诉前端"这个不完整的响应能否被续写"。如果不支持续写,前端应该丢弃并提示用户重试;如果支持,前端可以展示"生成中断,继续生成"按钮。没有这个字段,前端只能猜,而猜错的代价是用户看到错误内容。
5.2 补偿 vs 幂等:什么时候用哪个
两者都能处理"做错了",但适用条件不同:
| 幂等 | 补偿 | |
|---|---|---|
| 作用时机 | 事前(防止重复) | 事后(撤销已发生) |
| 能否撤销已发生的副作用 | 不能 | 能 |
| 实现复杂度 | 低 | 高(每个操作都要写反向操作) |
| 失败风险 | 低 | 补偿本身也可能失败 |
判断标准很简单 :如果这个操作无法撤销 (发短信、发邮件、真实扣款),那么必须用幂等事前防住 ,补偿来不及;如果可撤销 (库存锁定、数据库写入),两者都可以,但幂等更简单。
python
# compensate.py ------ 补偿链(用于可撤销的操作)
@dataclass
class Step:
name: str
forward: Callable
backward: Callable # 补偿动作
compensated: bool = False
async def saga(steps: list[Step]) -> None:
"""Saga:正向执行,失败时逆序补偿。
三个必须遵守的点:
1. 补偿必须逆序
2. 补偿本身要重试(它也可能失败)
3. 补偿失败必须告警并落库,不能静默
"""
done: list[Step] = []
try:
for s in steps:
await s.forward()
done.append(s)
except Exception:
for s in reversed(done):
for attempt in range(3):
try:
await s.backward()
s.compensated = True
break
except Exception as e:
logger.error("补偿失败 %s (第 %d 次): %s",
s.name, attempt + 1, e)
if not s.compensated:
# 补偿失败必须告警------这是数据不一致的来源
alert(f"补偿失败需人工介入: {s.name}")
raise
⚠️ 补偿失败必须告警并落库,这是最容易省掉的一步。 补偿本身也是网络调用,也会失败。如果补偿失败了却没人知道,系统就处于一个静默的不一致状态------比原始故障更难发现,因为它不会产生任何错误日志(除非你主动告警)。
5.3 用户可见的重复:幂等之外的那一半
即使后端幂等做得完美,用户仍可能看到重复。 因为幂等防的是"副作用重复",防不了"重复展示":
| 场景 | 后端 | 用户看到 |
|---|---|---|
| 用户快速点两次 | 后端幂等,只执行一次 | 两条相同的消息气泡 |
| 网络慢,用户刷新 | 后端幂等 | 两页相同的结果 |
| 前端重试但后端已成功 | 后端幂等 | 结果被渲染两次 |
解法是"客户端幂等" :前端为每次用户操作生成一个 client_request_id(在用户点击时生成,而不是在重试时生成 ),后端把它作为幂等键的一部分,并且前端用它去重渲染。
python
# 后端:把客户端 ID 纳入幂等键
key = make_idem_key("chat", {
"client_request_id": req.client_request_id,
"content": req.content,
})
javascript
// 前端:同一个 clientRequestId 只渲染一次
const pending = new Set();
function send(content) {
const rid = uuid(); // ← 点击时生成,重试时复用
if (pending.has(rid)) return;
pending.add(rid);
fetch("/chat", {body: {content, client_request_id: rid}})
.finally(() => pending.delete(rid));
}
💡 "在点击时生成而不是重试时生成"这句话是关键。 如果重试时重新生成 ID,那么后端会认为是新请求------客户端幂等就失效了。这和 2.3 节"由内容派生而非随机生成"是同一个道理,只是换到了客户端。
5.4 并发幂等:最容易在压测时才暴露
python
# tests/test_idem.py
@pytest.mark.asyncio
async def test_concurrent_same_key_executes_once():
"""并发相同键:操作必须只执行一次。
这是幂等最容易失效的地方,也是必须有的守门用例。
"""
store = MemoryIdemStore()
ex = IdempotentExecutor(store)
calls = []
async def op():
await asyncio.sleep(0.05) # 模拟耗时
calls.append(1)
return "done"
results = await asyncio.gather(
*[ex.run("same-key", op) for _ in range(10)]
)
assert len(calls) == 1, f"操作被执行了 {len(calls)} 次"
@pytest.mark.asyncio
async def test_timeout_then_retry_is_safe():
"""超时后重试:这是 1.1 节事故一的核心场景。"""
store = MemoryIdemStore()
ex = IdempotentExecutor(store)
state = {"n": 0}
async def flaky():
state["n"] += 1
if state["n"] == 1:
raise asyncio.TimeoutError() # 第一次超时(但操作其实成功了)
return "ok"
with pytest.raises(asyncio.TimeoutError):
await ex.run("k", flaky)
# 重试:幂等记录为 failed,应允许重来
assert await ex.run("k", flaky) == "ok"
⚠️ 第一个测试在很多"看起来实现了幂等"的系统里会失败 ,因为它们用的是
exists+put两步操作而非原子占位。建议把这个用例放进 CI 的核心测试集------它能在几毫秒内验证幂等实现是否真的正确。
六、适用边界与风险提示 ⚠️
6.1 超时的三个风险
风险一:超时设得太短,误杀正常请求。 LLM 的耗时方差极大,按平均值设超时必然误杀 。做法:用 P99 而不是均值 ,并给长上下文请求单独设更大的值。经验值:交互式 P99 × 1.5。
风险二:超时设得太长,掩盖真实故障。 一个 300 秒的超时意味着用户要等 5 分钟才知道失败。端到端超时应由"用户可接受等待时间"决定,而不是由"系统可能需要多久"决定。
风险三:超时后连接未真正关闭。 1.1 节事故三。用 metrics.increment("llm.cancelled") 与实际账单核对。这个核对应当做成常态化检查------每月比对一次"取消的调用数"与"上游账单中的完整计费数",两者差距就是你白烧的钱。
6.2 幂等的四个失效模式
① 用了随机幂等键。 2.3 节原则一,最常见。
② exists + put 非原子。 并发下失效,5.4 节第一个测试能抓住。
③ 幂等记录与业务数据不在同一事务。 业务成功、记录失败 → 下次重试重复执行。这是最难排查的一类,只在极端时序下发生。
④ 幂等记录无过期清理。 要么无限增长,要么(更糟)崩溃后记录永久停在 in_progress,该操作再也无法执行。
还有一个隐蔽的 :幂等键里漏掉影响结果的参数。 与第 34 篇"录制键漏掉 temperature"、第 36 篇"缓存键漏掉 prompt_version"是同一类错误的三个实例 ------凡是"用内容派生键"的地方,都要反复检查是否漏了参数。
反向的错误同样存在 :把时间戳、请求 ID 这类每次都变的字段放进了键------结果每次都是新键,幂等完全失效,而且从代码上看不出任何问题 (键生成逻辑看起来"很规范")。验证方法很简单:拿同一个业务操作造两次请求,看生成的键是否相同。这条应该做成单测。
5.5 状态一致性:超时与幂等如何共同保证状态机正确
把超时、幂等、状态持久化(第 30 篇)放在一起看,它们解决的是同一个问题的三个切面:在故障存在的前提下,系统状态是否仍然正确。
| 机制 | 保证什么 | 不保证什么 |
|---|---|---|
| 超时 | 不会无限等待 | 操作是否执行 |
| 幂等 | 副作用只发生一次 | 输出内容是否相同 |
| 状态持久化 | 崩溃后知道做到哪一步 | 已执行的副作用是否要回滚 |
三者缺一会出现的具体问题:
- 有超时无幂等 → 超时重试导致重复副作用(事故一)
- 有幂等无持久化 → 崩溃重启后不知道哪些步骤做过,无法判断要不要重试
- 有持久化无幂等 → 恢复时按
completed_steps重跑未完成步骤,但某步骤可能"执行了但没标记",重跑即重复
第三行是三者交叉处最容易出错的地方 ,值得展开:第 30 篇的 Checkpoint 机制记录"已完成步骤",恢复时跳过它们。但"完成标记"是在步骤执行之后写入的 ------如果进程在"步骤执行完、标记未写入"之间崩溃,恢复时会认为该步骤没做过而重跑。如果这个步骤有副作用,就重复了。
解法是"标记与副作用在同一事务" ,或者退而求其次:让每个有副作用的步骤都幂等 。前者要求存储层支持事务(不总是可行),后者是通用解法------这也是为什么幂等是 Checkpoint 机制的前提,而不是可选项。

图3:按副作用特征分三类操作------只有"可撤销"类才需要考虑补偿
6.3 什么时候不需要幂等
- 纯只读操作(问答、摘要、检索):重复执行无副作用,只需关心超时。
- 天然幂等的操作(覆盖写、设值):重复执行结果相同,无需额外机制。
- 明确了"至多一次"语义的场景 :宁可丢也不要重,这时应该不重试、不幂等(比如某些实时控制指令)。
💡 判断清单 :问自己三个问题------① 这个操作重复执行一次,会产生用户可见的差异吗?② 会产生资金/资源变化吗?③ 会通知到第三方吗?三个都是"否"就不需要幂等;任何一个"是"就必须做。
七、一个可复制的最小骨架
python
# minimal_idem.py ------ 约 50 行,覆盖核心
import asyncio
import hashlib
import json
import time
def idem_key(op: str, params: dict, version: str = "v1") -> str:
"""内容派生键------sort_keys 不能省。"""
c = json.dumps(params, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(f"{version}|{op}|{c}".encode()).hexdigest()
class MemStore:
"""内存幂等存储------仅用于测试/单进程。生产换 DB 或 Redis。"""
def __init__(self):
self.d: dict[str, tuple[str, object, float]] = {}
self._lock = asyncio.Lock()
async def acquire(self, key: str, ttl: float = 600.0) -> str:
async with self._lock: # 原子性由锁保证
now = time.monotonic()
if key in self.d:
status, result, ts = self.d[key]
if status == "in_progress" and now - ts > ttl:
del self.d[key] # 过期回收,防永久卡死
else:
return status
self.d[key] = ("in_progress", None, now)
return "new"
async def mark_done(self, key, result):
self.d[key] = ("done", result, time.monotonic())
async def mark_failed(self, key, err):
self.d[key] = ("failed", err, time.monotonic())
async def idempotent(store, key, fn):
"""最小幂等执行器。"""
st = await store.acquire(key)
if st == "done":
return store.d[key][1]
if st == "in_progress":
raise RuntimeError("操作处理中,请勿重复提交")
try:
r = await fn()
except Exception as e:
await store.mark_failed(key, str(e))
raise
await store.mark_done(key, r)
return r
生产化要补三样:① 换成 DB/Redis 实现(保证原子性);② 幂等记录与业务数据同事务;③ 加过期清理与监控。
这 50 行里最不能省的是 acquire 里的 async with self._lock ------它保证"检查 + 占位"是原子的。生产实现换成数据库时,对应的就是唯一索引或 SETNX,语义完全一样,只是换了实现。很多幂等 bug 的根源就是这一步被拆成了两步。
八、总结
超时与幂等是同一件事:超时制造了"不知道有没有执行"的不确定状态,幂等让你能在这个状态下安全地重试。
五个最该记住的结论:
第一,超时的本质是"信息缺失",不是"操作失败"。 它包含三种可能(未到达 / 处理中 / 已完成但响应丢失),超时越长,第三种占比越高------这就是为什么放宽超时会让幂等变得更必要,而不是更不必要。
第二,超时预算要传递剩余时间,不能各层设固定值。 min(剩余时间, 本层上限),并且在启动下一步之前用 require() 检查预算------提前降级远好于"跑一半超时",因为后者的副作用已经发生了。
第三,LLM 需要双超时:首 Token + 整体。 首 Token 慢说明上游排队,整体慢只是内容长,两者该触发不同的处理 。而且超时后必须显式关闭连接,否则上游继续生成并计费------你以为取消了,钱照烧。
第四,幂等键必须由内容派生,随机键挡不住超时重试。 粒度是"操作"不是"请求",键里要带意图和版本。json.dumps 必须 sort_keys=True。
第五,三态幂等记录里的"进行中"是全部价值所在。 两态(exists/put)在并发下必然失效,而 exists + put 非原子更是错上加错。5.4 节第一个测试用例能在几毫秒内验证你的实现是否正确,建议放进 CI 核心集。
最后一点 :幂等保证的是"副作用只发生一次",不是"输出相同"。 LLM 的非确定性决定了重试会得到不同的内容------这没问题,也不需要解决 。用户关心的是"报销单别提交两遍",不是"两次审核报告一字不差"。把幂等的目标想成"输出一致",会让你走进一条走不通的路 ------而且会让你写出一条必然 flaky 的测试(assert r1 == r2)。
还有一句:超时设计不只是"防卡死"。 4.5 节的实测里,加上预算检查后 P99 延迟从 41 秒降到 22 秒------因为提前降级比硬跑到超时体验好得多 。很多团队把超时当成"最后的兜底",实际上它是用户体验的主动设计工具。
落地自检清单(Checklist)
- ✅ 有四层超时 且使用预算传递(非各层固定值)
- ✅ 步骤间有
require()检查,提前降级而非跑一半超时 - ✅ LLM 调用有首 Token 超时 与整体超时双约束
- ✅ 超时后显式关闭连接,并监控"取消数 vs 账单数"
- ✅ 超时值基于 P99 × 1.5,不是基于均值
- ✅ 幂等键由内容派生 (非随机),
sort_keys=True - ✅ 幂等键粒度是操作级 ,含意图与版本
- ✅ 幂等记录是三态(含"进行中")
- ✅
acquire是原子操作 (唯一索引 / SETNX),非exists+put - ✅ 幂等记录与业务数据同事务
- ✅ 有过期回收,防崩溃后永久卡死
- ✅ 多步副作用用两阶段提交(预留 + 确认)
- ✅ 有并发同键只执行一次的守门测试
- ✅ 前端有客户端幂等(点击时生成 ID,重试时复用)
- ✅ 流式输出定义了不完整状态 的契约(
incomplete/resumable) - ✅ 补偿失败会告警并落库
常见问题(FAQ)
Q1:幂等记录保留多久?
至少覆盖最大可能的重试窗口。 经验值:交互式 10 分钟,异步任务 24~72 小时。判断标准是"最慢的一次重试可能在多久之后发生"------如果消息可能延迟 24 小时重投,那记录至少要留 24 小时。
Q2:幂等键要不要包含用户 ID?
看该操作的副作用是否按用户隔离。 如果"给用户 A 发通知"和"给用户 B 发通知"是不同的操作,那用户 ID 必须在键里。漏了会导致跨用户的错误去重------给用户 B 的操作被判定为"已执行过"(因为 A 的键相同)。
Q3:超时后到底该不该重试?
分情况 :连接层/请求层超时且操作幂等 → 可重试;端到端预算耗尽 → 不重试 ,直接返回降级结果。另外非幂等操作超时后绝不盲目重试,必须先查询状态("我的操作到底成功了没")再决定。
Q4:LLM 调用本身需要幂等吗?
通常不需要。 纯生成调用(摘要、分类)重复执行只有成本代价,没有副作用。但如果 LLM 调用的结果会被写入外部系统,那么"写"这一步需要幂等,而不是 LLM 调用本身。
Q5:两阶段提交里预留了但一直没确认怎么办?
预留必须有超时自动释放。 这是资源泄漏的主要来源。预留超时时间应该显著大于"预留到确认"的正常间隔(比如正常 1 秒,预留超时 5 分钟),并监控"预留后未确认"的数量------这个数字异常升高通常说明确认环节有问题。
Q6:Saga 补偿和两阶段提交选哪个?
优先两阶段。 它的实现更简单、失败模式更少。补偿的问题是"补偿本身也会失败" ,而且每个操作都要写反向逻辑。只有当操作无法预留(比如第三方 API 没有预留接口)时,才不得不用补偿。
Q7:幂等会拖慢性能吗?
会,但代价很小。 一次幂等查询通常是 1~5 毫秒,相比 LLM 调用的秒级耗时可以忽略。真正需要注意的是不要把大对象存进幂等记录------只存结果的引用 ID 或摘要,不存完整内容。
Q8:如何验证幂等真的生效了?
三个层次的验证:① 单测 :并发同键只执行一次(5.4 节);② 压测 :注入重复请求,检查副作用计数;③ 混沌测试 :随机 kill 进程后重启,检查是否有重复副作用。第三项最有价值,也最常被跳过------它验证的是"崩溃恢复"这个最容易出问题的场景。
参考资料
- 《Designing Data-Intensive Applications》(Kleppmann)第 11 章 --- 流式处理与幂等性:https://dataintensive.net/
- Google SRE Book --- Handling Overload 与分布式超时设计:https://sre.google/sre-book/handling-overload/
- 《Site Reliability Engineering》第 22 章 --- Addressing Cascading Failures(超时与重试放大):https://sre.google/sre-book/addressing-cascading-failures/
- PostgreSQL 官方文档 ---
INSERT ... ON CONFLICT(实现原子占位的标准手段):https://www.postgresql.org/docs/current/sql-insert.html - Redis 官方文档 ---
SETNX与分布式锁的正确用法:https://redis.io/docs/latest/commands/setnx/ - Python 官方文档 ---
asyncio超时、取消与Task.cancel()语义:https://docs.python.org/3/library/asyncio-task.html - Garcia-Molina, H. & Salem, K. --- Sagas (1987,长事务补偿模式的原始论文):https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
- IETF RFC 7231 --- HTTP 语义,4xx 状态码与
Retry-After定义:https://datatracker.ietf.org/doc/html/rfc7231 - Stripe 官方文档 --- Idempotent requests (API 幂等键设计的工业实践参考):https://docs.stripe.com/api/idempotent_requests