重试 幂等 可靠性 LLM API

安全地实现 LLM API 重试与幂等

重试 LLM API 调用而不产生重复扣费或重复触发副作用:哪些错误可以重试、带抖动的退避、幂等键,以及网关层去重。

AveMujica API 1 分钟阅读

只有当你能保证一次调用即便在网络看起来失败了两次的情况下仍然最多执行一次时,重试失败的 LLM API 调用才是安全的。这里的隐患很微妙:一个请求可能已经在服务商侧成功了,然后响应在返回途中丢失,你的客户端看到超时,于是重试,结果你被扣了两次费,或者副作用被触发了两次。解决办法是一对总是形影不离的准则——只重试值得重试的错误,配合退避与抖动;同时让每个可重试的请求都具备幂等性,这样重复请求会被识别并合并,而不是被再次执行。本文将展示如何同时做好这两件事。

这一点对 LLM 流量尤为重要:生成调用按 token 消耗真金白银,而智能体(agent)工作流会把多个调用串联起来,其中某一步被重复就会污染下游状态。对于一个 GET,"失败就重试"没问题;但对于一个会生成 token 的 POST,它就是一起等着发生的计费事故。

在重试之前先给错误分类

并非每个失败都应该重试。重试一个永久性错误既浪费时间,而且在限流的情况下还会主动伤害你,因为失败的请求同样会计入你的配额。把失败分成三类:

类别示例处理方式
可重试的瞬时错误超时、连接重置、500、502、503、429带退避重试
不可重试的客户端错误400 请求错误、401/403 认证、404、422快速失败,向上暴露错误
模糊情形请求已发出之后才超时仅当幂等时才重试

第三行是最危险的。超时并不能告诉你服务商是否处理了该请求——它只告诉你响应没有到达。在没有幂等性的情况下重试一个模糊失败,正是重复扣费产生的根源。这也正是为什么这两个话题密不可分。

重试循环:退避、抖动与上限

一个正确的重试循环会做指数退避,加入抖动以免并发客户端同步,限制尝试次数,并且只重试上面列出的可重试类别。

import random, time
import httpx

RETRYABLE = {429, 500, 502, 503, 504}

def call_with_retries(payload, key, max_attempts=5):
    for attempt in range(max_attempts):
        try:
            r = httpx.post(
                "https://your-gateway.example.com/v1/chat/completions",
                json=payload,
                headers={"Authorization": "Bearer AVE_GATEWAY_API_KEY",
                         "Idempotency-Key": key},
                timeout=30,
            )
        except (httpx.TimeoutException, httpx.TransportError):
            _sleep(attempt); continue          # ambiguous: safe because key is stable
        if r.status_code in RETRYABLE:
            _sleep(attempt); continue
        return r                                # success or non-retryable client error
    raise RuntimeError("exhausted retries")

def _sleep(attempt, base=0.5, cap=15.0):
    time.sleep(random.uniform(0, min(cap, base * (2 ** attempt))))

注意 Idempotency-Key 是传入的,并且在同一逻辑请求的每一次尝试中都保持不变。正是这一个细节让模糊超时的重试变得安全。如果你为每次尝试都生成一个新的 key,就会彻底破坏整个机制。

幂等键究竟做了什么

幂等键是一个由客户端生成的唯一标识符——常见的选择是 v4 UUID——你通过 Idempotency-Key 请求头将它附加到一个会改变状态的请求上。服务器把第一个请求的结果存储在该键之下,当任何携带相同键的重复请求到来时,返回已存储的结果,而不是再次执行该操作。每个逻辑操作生成一次键,并在该操作的每次重试中复用它:

import uuid
key = str(uuid.uuid4())          # once, before the first attempt
result = call_with_retries(payload, key)

健壮的实现会额外加上指纹:将请求体的哈希与键一起存储。如果之后有一个请求带着相同的键但不同的请求体到来,服务器会拒绝它——通常返回 HTTP 409 Conflict(即 idempotency_conflict)——而不是悄悄地返回一个错误的缓存结果。键带有 TTL(24 小时是一个典型窗口);过期后键被遗忘,再次复用会开启一个全新的操作。

关于提供商支持的说明:OpenAI 在其 Agentic Commerce 结账流程中明确记录了 Idempotency-Key,在那里用相同的键重复调用创建或完成操作必须返回相同结果,参数不匹配则返回 409。通用的 chat 和 responses 端点并未把它作为一项突出功能来宣传,而且包括本项目在内的大多数网关,目前也不会自动为 /v1/chat/completions 或 /v1/responses 调用去重。这个缺口恰恰说明为什么网关是加入幂等性的合适位置:它是唯一的流量汇聚点,你可以只构建一次去重存储,就覆盖所有服务,而不必依赖每个上游各自的行为。

在网关层实现幂等

当许多服务通过同一个网关调用模型时,只要你实现了键控缓存,网关就能为整个系统捕获重复请求。这是一个需要构建的模式,而不是一个可以直接打开的开关:

  1. 客户端发送带 Idempotency-Key: <uuid> 的请求。
  2. 网关在一个短 TTL 存储(Redis 很合适)中查找该键。
  3. 未命中 → 转发到上游,然后在返回之前存储 {key → response, fingerprint}。
  4. 命中,指纹相同 → 返回已存储的响应,不调用上游。
  5. 命中,指纹不同 → 返回 409 Conflict。
  6. 命中,仍在处理中 → 返回 409(或等待),这样快速的重复提交不会被重复执行。

把它实现一次,就能让“重试安全”从每个服务各自重复实现的机制,变成一项统一强制的属性。它还能防护并非由你主动造成的重复——例如用户双击、队列重复投递消息,或代理在底层自行重试。记录幂等键及其处理结果,这样被抑制的重复请求就能在你自己的存储中接受审计,而不是悄无声息地消失。跨众多渠道的重试风暴也是集中化更有帮助的原因:阅读 LLM API 限流与背压,了解同一问题在并发方面的处理方式。

还要如实限定这项保证:只有产生副作用的那一层也配合时,真正的“最多一次”才成立。如果上游自身不具备幂等性,网关去重可以合并客户端可见的重复提交,却无法撤回一条上游已经处理、但响应在返回途中丢失的请求。应把键控缓存视为防止重新提交的强力保护;只有当上游支持幂等键,或副作用能以原子方式协调时,才应声称实现了“恰好一次”。

幂等与流式响应

流式响应让去重更复杂,因为响应是增量交付的,而且可能在传输途中被中断。如果一个流在发出一半 token 后断开,对一个朴素的缓存用相同的键做盲目重试,可能会重放一个不完整的结果。更安全的做法:只把已完成的结果存储在键之下;把被中断的流当作无结果(这样重试会干净地重新生成);或者使用流本身的续传机制重新连接,而不是重新发起请求。SSE 续传的细节见用 SSE 实现流式 LLM 响应。

重试与幂等检查清单

  • 已对错误分类:瞬时错误/429/5xx 重试,4xx 快速失败。
  • 模糊超时仅在请求幂等时才重试。
  • 退避采用指数增长,配合全抖动和硬性尝试上限。
  • 每个逻辑操作一个 Idempotency-Key,在所有重试中复用。
  • 服务器或网关存储键 + 指纹,并带 TTL。
  • 请求体不匹配的重复请求返回 409,而不是过期的成功结果。
  • 处理进行中的重复请求,而不是重复执行。
  • 流式重试采用重连或重新生成,而不是重放不完整的片段。

选择一个安全失败的重试预算

重试是以延迟换取成功概率,而无限重试则把整个系统都赔进去。用三个数字设定一个明确的预算:最大尝试次数、最大总耗时、以及每次尝试的超时时间。当预算耗尽时,向上暴露一个干净的错误,而不是无休止地重试——一个已经带退避失败了五次的请求,几乎不可能在第六次成功,而这些尝试正在消耗其他请求所需的容量。

在规模上会出现一种微妙的失败模式:如果十个服务中的每一个都独立重试,它们合并起来的重试流量本身就可能超过你的限流阈值,从而产生更多 429,进而触发更多重试。这就是重试风暴,而且是自作自受。首要防线是把重试预算设为总流量的一个比例——例如将重试限制在请求量的 10% 以内,这样一次大范围故障就不会让负载翻倍。当失败的是整个渠道而非个别调用时,正确做法是停止撞墙式重试并转移流量;这套熔断与故障转移机制由 LLM API 故障转移策略 负责,因此应让重试预算与它配合,而不是在这里重复实现熔断逻辑。

  • 同时限制尝试次数和总的墙钟时间,而不仅仅是尝试次数。
  • 给聚合重试设上限,让恢复流量无法放大一次故障。
  • 把持续的按渠道失败交给故障转移处理,而不是继续盲目重试。

可观测性闭合了这个环路:对你自己的重试率——即调用中属于重试的比例——进行埋点,这样数值上升时就能在事故发生前发出预警。这个指标需要根据你的调用日志自行计算;重试比例持续上升,是最早提示你需要收紧预算的信号。

常见问题

如果一个请求超时了,它到底被处理了没有?

仅凭超时你无法判断——它只意味着响应没有到达,而不代表工作没有发生。把它当作模糊情形。只有当请求携带一个稳定的幂等键、从而重复执行会在服务器侧被合并时,才重试它;否则把这种模糊性暴露给调用方。

我应该为每次重试生成一个新的幂等键吗?

不应该——这是经典的错误。每个逻辑操作生成一个键,并在它的每一次重试中发送完全相同的键。为每次尝试生成新键会让每次重试看起来都像一个全新的请求,于是重复请求会正常执行,你也就失去了全部保护。

如果两个完全相同的请求几乎在同一瞬间到达怎么办?

要显式处理进行中的情形。在第一个请求完成之前,第二个带相同键的请求应该收到一个 409 Conflict(或阻塞直到第一个完成),而不是被转发——否则一次快速的重复提交会抢在缓存之前,被执行两次。

用一次真实的重复请求证明去重有效

把模型调用包裹进一个接受 Idempotency-Key 参数的重试辅助函数,并为每个操作只生成一次该键。然后搭建上文所述的键控存储,有意通过它发送一个重复请求:在你自己的去重日志中确认第二次调用已被抑制,而且只计费一次。在这套存储真正存在之前,重复的键只是一条上游可能忽略的请求头;只有用真实重复请求证明它确实被抑制,测试才算数。

参考资料