幂等设计:超时重试之后,那次重复请求到底跑没跑
凌晨两点,监控报了一批 ReadTimeout。你的重试逻辑很老实地按退避策略重发了一遍,第二次成功返回,接口给用户吐出了结果,看起来一切正常。
三天后财务对账,那一晚的 token 消耗比预期多出一截。再往下查,客服工单里有几个用户说”同一份报告收到了两遍”,还有两条扣费流水是重复的。
问题就出在那次超时上。客户端等不下去主动断开的时候,服务端那边的请求很可能已经完整跑完了——它生成了全部 token、计了费、把响应写回了一个已经没人在听的连接。你的重试等于让整件事又发生了一次。
这是我想让每个接大模型 API 的人先想明白的一件事:超时是唯一一种”你不知道它成功还是失败”的失败。
其他失败都有确定的语义。连接被拒绝,说明请求根本没到;返回 400,说明服务端收到了、拒绝了、什么也没做;返回 429,说明被限流挡在门外了;返回 500 也基本可以认为这次调用没产出有效结果。唯独超时不是——它只说明”在你规定的时间内我没等到答复”,至于对面干没干、干到哪一步,客户端一无所知。网络分区、连接被中间设备掐断、负载均衡器提前回收连接,都属于同一类不确定性。
大模型调用把这类不确定性放大了,原因有两个。一是单次调用耗时长,长输出动辄几十秒,超时窗口天然容易被撞到;二是单次调用很贵,重复执行不只是浪费一点 CPU,是真金白银按 token 计费。所以在别的系统里可以”重试就重试了”的糊涂账,在这里必须算清楚。
一、先分清三层幂等,别只防第一层
我见过不少团队讨论幂等时,翻来覆去只在说”模型调用要不要重复”。这其实是最不要紧的一层。完整地看应该分三层:
| 层级 | 内容 | 重复执行的后果 | 常见防护 |
|---|---|---|---|
| 第一层:模型调用本身 | 向上游发一次生成请求 | 多花一份 token 钱,结果可能不一致 | 大多数人只做到这里 |
| 第二层:你的业务操作 | 拿到结果后写库、扣费、发消息、触发工作流 | 数据重复、余额多扣、外部通知重复发出 | 事故高发区,却常常没防护 |
| 第三层:用户可感知的结果 | 界面上出现两条一样的记录、收到两封邮件 | 信任受损,人工善后成本高 | 靠前两层兜住,也可做展示层去重 |
第一层的重复只是钱的问题,第二层的重复才是事故。 纯生成调用本身没有副作用,你把同一段 prompt 发两遍,上游不会因此少一个用户或多一笔订单,最坏的结果是账单变胖、两次输出内容不一致。但第二层不一样:模型返回之后,你的代码通常还要做一串带副作用的事——把结果落库、扣掉用户的额度、往消息队列里投一条、调下游系统发个通知。这些动作里任何一个被执行两次,都是真实的业务损坏。
更麻烦的是,超时发生的位置决定了你已经走到了哪一层。列几种典型情形:
- 超时发生在上游生成阶段:上游可能已经生成完成并计了费,但你没拿到结果,第二层完全没执行。重试的代价是多付一次钱。
- 超时发生在你自己的服务对外接口上(调用方等不及你):这时上游可能已返回,你的第二层写库逻辑正跑到一半。调用方重试,就可能触发第二次写库。
- 写库成功但响应丢失:这是最阴的一种,数据已经落了,调用方以为失败,重试一次就是重复数据。
所以防护的重心应该往下沉。只在调用大模型那一层加个开关,挡住的是钱;把幂等做在**“一次完整业务操作”**的边界上,挡住的才是事故。判断边界的方法很简单:从调用方视角看,哪一段操作它会整体重试?就以那一段为幂等单元。
二、幂等键怎么设计
幂等的核心机制不复杂:给每次”业务操作”一个唯一的键,服务端记住这个键做过什么,重复请求来了直接返回上次的结果,而不是再执行一遍。难点全在细节上。
顺便说清楚一个边界:部分服务商的接口本身支持传入幂等键,让重复请求在服务端被识别。具体的字段名、生效范围、保留时长各家不同,以你所用服务的官方文档为准——我不会在这里点名说某家叫什么,因为写错一个字段名比不写更糟。下面讲的是你在自己这一侧必须建立的那套机制,它不依赖上游是否提供这个能力。
键怎么取:自然键优于随机 UUID
这是最容易做错的一步。很多实现是这样的:
# 反例:重试时键会变,幂等形同虚设
def call_with_retry(payload):
for attempt in range(3):
key = str(uuid.uuid4()) # ← 每次重试都是新键
resp = do_request(payload, idem_key=key)
...
每次重试生成一个新的随机键,服务端自然认不出这是同一次操作,幂等就成了摆设。幂等键必须在重试之间保持稳定,这是它能工作的前提。
正确的做法是让键来自业务本身的自然维度,比如”订单 ID + 处理步骤”、“会话 ID + 消息序号”、“任务 ID + 重试轮次之外的固定部分”。这种键有个关键好处:它是可重现的。哪怕客户端进程重启、请求从另一台机器重发、甚至用户在前端手抖点了两次提交,只要业务标识没变,算出来的键就还是同一个。随机 UUID 只有在”同一个进程内的同一次重试循环”里才稳定,跨进程就废了。
如果实在没有天然的业务标识(比如用户直接发起的一次性提问),退而求其次的做法是由请求发起方在第一次尝试前生成一次 UUID,然后在整条重试链路上带着它走,而不是每次重试重新生成。再或者对请求内容做哈希——但要小心,内容相同的两次请求未必就是同一次业务操作,用户连问两遍同样的问题是完全合法的行为,纯内容哈希会把它们错误地合并。稳妥的折中是”用户 ID + 会话 ID + 内容哈希 + 时间窗”。
键存哪:要有 TTL,且覆盖最长重试窗口
幂等记录不能永久留着,那会把存储撑爆;也不能太快过期,否则一次迟到的重试会穿透防护。定 TTL 的原则很直白:幂等键的生命周期必须覆盖你系统里最长的那条重试路径。
把这条路径算清楚:客户端最多重试几次、每次退避多久、上游超时设了多少、有没有异步任务会在几分钟后补偿、有没有人工触发的重放。把这些加起来再留一倍余量,就是 TTL 的下限。我见过设成几十秒的 TTL,结果客户端指数退避到第三次时早就过期了,等于白做。关于退避与重试次数怎么定,可以看 重试与指数退避怎么写 里的三道闸门。
存储选型上,需要的能力其实只有三个:原子的”不存在则写入”、可设过期时间、读写足够快。带 TTL 的内存型存储是最常见的选择,数据库表加唯一索引和过期字段也完全可行——用数据库还有个额外好处,幂等记录能和业务写入放进同一个事务里,避免”幂等记录写了但业务没写成”的错位。取舍在于:内存型快但可能丢,数据库慢但更可靠。如果你的业务操作涉及扣费,别为了那点延迟选可能丢数据的方案。
三种状态:“进行中”才是关键
大多数人第一版实现只写两种状态——没记录就执行,有记录就返回。这个版本在慢请求面前会漏。
设想两个重复请求几乎同时到达:第一个查了一下,没有记录,开始执行大模型调用(要跑 40 秒);这期间第二个请求也来查,此时第一个还没写完成记录,第二个照样查到”没有”,于是两个请求并发执行了同一个操作。这正是超时重试场景下最容易发生的时序——因为重试往往就发生在第一次请求还没跑完的时候。
所以状态机必须有三态:
PENDING(进行中):已经有人在处理这个键,还没出结果DONE(已完成):处理完了,结果存在记录里- 无记录:还没有人碰过
PENDING 是整套机制的胜负手。 它把”占位”和”完成”拆成了两步:请求进来先原子地抢占这个键写入 PENDING,抢到的才去执行,没抢到的说明有人在跑,直接按”进行中”处理。
import json, time, hashlib
PENDING, DONE = "PENDING", "DONE"
class DuplicateInFlight(Exception):
"""同一幂等键正在处理中"""
def make_key(user_id: str, session_id: str, payload: dict) -> str:
body = json.dumps(payload, sort_keys=True, ensure_ascii=False)
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()[:16]
return f"idem:{user_id}:{session_id}:{digest}"
def execute_once(store, key: str, ttl: int, do_work):
"""
store 需要提供三个原子能力:
set_if_absent(key, value, ttl) -> bool
get(key) -> dict | None
set(key, value, ttl) -> None
"""
acquired = store.set_if_absent(
key, {"state": PENDING, "ts": time.time()}, ttl
)
if not acquired:
record = store.get(key)
if record is None:
# 恰好在读之前过期了,当作首次执行
return execute_once(store, key, ttl, do_work)
if record["state"] == DONE:
return record["result"], True # 命中缓存,不再调上游
raise DuplicateInFlight(key) # 有人在跑,交给调用方决定等还是退
try:
result = do_work()
except Exception:
store.delete(key) # 失败必须释放占位,否则后续重试全被挡死
raise
store.set(key, {"state": DONE, "result": result, "ts": time.time()}, ttl)
return result, False
这段代码有几个细节值得单独说:
失败时必须删掉占位记录。 如果执行抛异常了却把 PENDING 留在那儿,接下来所有合法重试都会撞上 DuplicateInFlight,你等于亲手把系统锁死了。这个坑我见过不止一次,表现是”出了一次错之后这个用户再也提交不了”。
PENDING 需要有自己的超时。 进程被 kill、机器掉电,占位记录就成了孤儿。给 PENDING 单独设一个较短的过期时间(略大于单次操作的最长耗时),让它能自动腐烂掉;DONE 才用长 TTL。
碰到 PENDING 时怎么办要想清楚。 三种选择:直接返回 409 让调用方稍后再来(最简单,语义最清楚);短轮询等一小会儿再看结果(体验好,但会占住连接);返回一个任务 ID 转异步(最适合长耗时生成)。别默默地把请求也放进去执行,那就白设计了。
返回值里带上”是否命中”这个标志。 上面代码第二个返回值就是干这个的,后面讲可观测性时它是核心指标。
顺带提一句 HTTP 方法本身的幂等语义:GET、PUT、DELETE 在规范里是幂等的,POST 不是。而绝大多数大模型接口都是 POST——因为要在请求体里带 prompt。所以你不能指望方法语义帮你兜底,幂等必须由应用层自己实现。
三、超时怎么设才不会自己制造重复
幂等机制解决的是”重复发生之后不出事”,但更划算的是让重复少发生。而重复的第一大来源,就是超时设得太短。
客户端超时必须大于服务端可能的处理时间。 这句话听着像废话,实际做错的比例很高。如果上游生成一段长文本需要 60 秒,你的客户端超时设 30 秒,那么每一次正常的长输出都会被你判成失败——请求实际成功了、钱也花了,你却在那边报错重试。这不是容错,这是主动制造重复请求,而且是稳定复现的那种。设短超时唯一合理的场景,是你确实希望这类慢请求被放弃(比如面向交互式界面,超过 10 秒用户已经走了),但那时候你要做的是放弃并告知,而不是放弃并重试。
流式响应会改变这个判断,而且改得很彻底。非流式调用里,“没收到响应”和”没有结果”大致可以画等号;流式调用里,一旦首个 token 已经到达,连接后续断开就意味着部分结果已经产生并且很可能已经计费。这时候无脑重试不但会重复付费,还可能让用户看到”前半段说了一遍,后半段又从头说了一遍”的诡异输出。流式场景下的正确姿势是:记录已收到的 token 数,断流后先判断是不是已经拿到了可用的部分结果,能用就用,需要重试也要在业务层明确”这是一次重新生成”而不是”续上”。
比”一个超时值”更好用的是分层超时,把三段分开设:
| 分层 | 含义 | 设置思路 |
|---|---|---|
| 连接超时 | TCP 握手 / TLS 建立 | 短,几秒级。连不上就是连不上,等再久也没用,且这类失败没有副作用,可以放心重试 |
| 首字节 / 首 token 超时 | 从发出请求到收到第一个字节 | 中等。这一段主要反映排队和调度,超时说明上游拥塞,重试相对安全 |
| 总超时 | 整个请求的时间上限 | 长,要覆盖最长的合理生成时间。这一段超时是危险的,重试前必须过幂等 |
分层的好处是让你能区别对待:连接超时和首字节超时基本可以认为”服务端没有产生副作用”,重试是安全的;只有总超时才是那个真正不确定的情况,必须走幂等。把它们混成一个值,你就永远只能按最坏情况假设。超时参数在不同语言 SDK 里的写法差别很大,具体配置可以参考 大模型 API 超时与连接管理。
还有一点容易被忽略:排队等待时间也算在超时里。如果你在客户端做了并发限制或令牌桶,请求可能在自己进程里排了很久才发出去,而计时器从入队就开始跑,那么真正留给上游的时间就被压缩了。计时应该从”实际发出请求”算起,或者干脆把排队时间和请求时间分开计。
四、幂等能帮你把重复花的钱省回来
把幂等和重试放在一起看,会发现一个额外收益:幂等键命中时直接返回记录里的结果,根本不用调上游。挡住重复副作用的同时,那次重复调用的 token 钱也省了。这是幂等机制里性价比最高的部分,成本随重试率线性下降。
这里要澄清一个常见混淆:这个结果缓存和 prompt 缓存不是一回事。
| 维度 | 幂等结果缓存 | prompt 缓存 |
|---|---|---|
| 在哪一层 | 你自己的应用侧 | 上游服务侧 |
| 缓存什么 | 一次业务操作的最终结果 | 请求前缀的计算中间态 |
| 命中的效果 | 完全不发起上游调用 | 仍然发起调用,只是前缀部分的费用和延迟降低 |
| 匹配依据 | 幂等键 | 请求前缀是否一致(规则由服务方定义) |
| 谁控制生命周期 | 你(自己设 TTL) | 服务方(规则以官方文档为准) |
| 语义目的 | 防重复副作用,顺带省钱 | 纯粹省钱和降延迟 |
两者可以叠加,但不能互相替代。prompt 缓存命中率再高,也挡不住重复扣费;幂等做得再好,也不会降低首次调用的成本。见过有人以为”开了 prompt 缓存就不怕重试了”,这是把省钱手段当成了正确性手段,方向就错了。
另外提醒一句:幂等结果缓存里存的是完整业务结果,可能包含用户输入和模型输出。这块存储要按敏感数据对待——设置合理 TTL、按需脱敏、别把它当成日志随便打出去。
五、把幂等命中率打成指标,它是最早的故障信号
幂等做完了别只当它是个防护网,它同时是一个质量极高的观测点。至少建议打这几个指标:
idempotency_hit_total:命中已完成记录、直接返回缓存结果的次数idempotency_inflight_conflict_total:撞上PENDING的次数idempotency_hit_ratio:命中数 / 总请求数- 按幂等键维度的重复次数分布(P50 / P99 各重复几次)
重点看命中率的变化趋势。命中率突然升高,几乎必然意味着重试量在上升,而重试量上升的原因通常是上游变慢或在大面积超时。 关键在于,这个信号往往比错误率更早出现——因为你的重试逻辑正在默默兜住这些失败,对外的成功率还没塌,错误率曲线还很平静,但幂等命中率已经翘起来了。等错误率开始报警,重试预算通常已经耗尽,事情就大了。
这也是我建议把命中率作为独立告警而不是只放看板的原因。规则可以简单点:命中率相对基线翻倍且持续几分钟就告警。配合 PENDING 冲突数一起看更准——冲突数暴涨说明请求处理时间被拉长到超过了客户端超时,这是过载最直接的证据。
日志侧的建议是:把幂等键作为一个字段贯穿整条链路,和 trace ID 并列。排查”用户说收到两遍”这类问题时,能按幂等键把所有相关请求捞出来,几分钟就能定位;没有这个字段,就只能靠时间戳和用户 ID 大海捞针。这套指标怎么接进现有的追踪体系,可以看 LLM 应用可观测与日志。
六、什么时候可以不做
幂等不是免费的。它带来一份额外存储、一次额外的读写往返、一套需要维护的状态机,还有”忘了释放 PENDING 把自己锁死”这类新的失败模式。给所有东西套幂等是工程洁癖,不是工程判断。
以下场景我认为可以放心不做:
- 纯查询、纯读取:本身就没有副作用,重复执行只是多花点资源。
- 纯展示类生成:结果只渲染给当前用户看,不落库不扣费不通知外部,重复执行的最坏结果是用户多等一会儿。
- 重复执行代价极低:短 prompt、小模型、单次成本可以忽略的调用。为它上一套幂等,维护成本远大于收益。
- 本身就是覆盖写:比如用固定 ID 做全量覆盖更新,天然幂等,再加一层反而多余。
- 有更上层的兜底:比如整条链路已经在一个可靠的工作流引擎里,引擎自己保证了 exactly-once 语义。
反过来,只要一次操作会写库、会扣费、会给用户发东西、会触发下游系统,就老老实实做。判断标准可以简化成一句话:如果这个操作被执行两遍,你需要人工去善后吗? 需要,就做。
至于限流场景下的重试是否需要幂等——429 通常意味着请求被拒在门外、没有产生任何副作用,重试相对安全,具体判断可以参考 429 限流的处理。但注意”通常”两个字:如果限流发生在你自己的服务内部、而且是在部分处理之后才触发的,那就不安全了。
上线前自查清单
- 幂等单元的边界画在业务操作上,而不是画在”调用大模型”这一步上——确认第二层(写库、扣费、通知)在保护范围内。
- 幂等键在重试之间稳定不变,用业务自然键或在首次尝试前生成一次并全程携带;检查代码里没有在重试循环内部生成键。
- 状态机有三态,
PENDING能挡住并发重复;PENDING有独立的短过期时间,防止孤儿占位锁死后续请求。 - 执行失败路径会释放占位,用
try/except或finally保证,并写一条单测覆盖”失败后能重新提交”。 - TTL 覆盖最长重试窗口:把客户端重试次数 × 退避间隔 + 异步补偿延迟加总,再留一倍余量。
- 超时分三层设(连接 / 首字节 / 总时长),总超时大于上游可能的最长生成时间;流式场景单独判断首 token 之后断流的处理方式。
- 幂等命中率和
PENDING冲突数已上报并配了告警,命中率相对基线翻倍即告警。 - 幂等键写进日志和追踪字段,能按键把一次操作的所有重试捞成一条时间线。