演示时第一次调用就成功了。可生产环境里还有另外 999,999 次调用 —— 它们会在流量高峰时撞上限流、会在部署过程中遇到上游的 503、会卡在一个永远不应答的 socket 上。玩具级集成和可靠集成之间的差距,几乎全在于你怎么处理那条不顺的路径:你重试什么、等多久、什么时候放弃。一旦做错,供应商 一次短暂的小故障,就会演变成你自己造成的宕机 —— 因为你的每一个 worker 都在完美同步地重试,把限流器刚开了头的活儿给彻底干完。
这是一份务实的、达到生产级别的实操指南:对错误做分类,只重试可重试的;做指数退避,并加上抖动;遵循 Retry-After;设置合理的超时;用幂等性让重试变得安全;再用 熔断器停止反复捶打一个已经挂掉的依赖。示例代码是 OpenAI-SDK Python,针对 https://api.brievio.com/v1,但这些规则在任何 HTTP 客户端、任何语言上都一样成立。
只重试可重试的 —— 别的一概不重试
AI API 错误处理里最常见的一个 bug,就是去重试那些永远不会成功的东西。一个 401(密钥错误)、一个 400(请求格式错误)、一个 422(你的提示太长)—— 这些都是确定性的。第二次尝试会和第一次一模一样地失败,只不过现在变成了五次 尝试、晚了好几秒。更糟的是,你把一个真正的 bug 藏在了重试循环后面。唯一 值得重试的 4xx 是 429(被限流),因为它确实是临时性的。
- 重试:
429,以及500 / 502 / 503 / 504—— 这些都是服务端或容量侧的临时状况。 - 重试:连接错误和读超时(请求可能根本没到达模型,或者 模型把回复写进了一个已经关闭的 socket)。
- 绝不重试:任何其他
4xx——400、401、403、404、422。把它们暴露出来、告警出来,去修调用方。
一个有用的直觉:一次重试,是在赌同一个请求会得到不同的结果。只有 当失败是关于时机或容量、而不是关于请求本身时,这个赌注才成立。
带抖动的指数退避
一旦确定某次失败是可重试的,问题就变成了该等多久。立刻重试毫无意义 —— 造成那个 429 的状况,一毫秒后还在那儿。标准答案是指数退避:大致先等 0.5s,再 1s、2s、 4s,每次尝试翻倍,并设一个上限封顶,这样漫长的恢复才不会 把你无限期地卡住。
但纯粹的指数退避,在大规模下有一个恶性的失效模式。如果 500 个 worker 在同一瞬间全部被限流 —— 而这恰恰就是高峰期会发生的事 —— 它们就会退避同样长的时间,然后在同一瞬间一起重试,相当于按 2 秒一拍的节奏把那个高峰重新制造一遍。这就是「惊群」。解法是抖动:把每次的延迟随机化,让重试分散开。全抖动(在零到 退避上限之间随机睡眠一段时间)打散惊群的效果,要远好于在固定延迟上加一点 小小的随机扰动。
# 一个真正能上线的重试包装。两条规则就能解决大部分问题:
# 1. 只重试可重试的错误(429 + 5xx + 连接错误)。绝不重试 400/401/422。
# 2. 既要指数退避,又要加抖动,否则每个客户端都会步调一致地一起重试。
import random
import time
from openai import OpenAI, APIStatusError, APIConnectionError, APITimeoutError
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
timeout=30, # 每个请求的硬上限 —— 见下文「超时」一节
)
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 5
BASE_DELAY = 0.5 # 秒
MAX_DELAY = 20.0 # 给退避设上限,避免恢复缓慢时一直无限等下去
def chat_with_retry(**kwargs):
for attempt in range(MAX_ATTEMPTS):
try:
return client.chat.completions.create(**kwargs)
except APIStatusError as e:
# 非 429 的 4xx 是「你自己」的 bug(参数错、密钥错、内容太长)。
# 重试它只会白白增加延迟 —— 应当快速失败。
if e.status_code not in RETRYABLE_STATUS:
raise
last_error = e
except (APIConnectionError, APITimeoutError) as e:
# 网络抖动,或我们设的超时被触发。读请求重试是安全的。
last_error = e
if attempt == MAX_ATTEMPTS - 1:
break
# 带「全抖动」的指数退避:sleep ∈ [0, base * 2**attempt]。
# 真正能把「惊群」打散错开的,是全抖动(而不是「退避 + 一点小随机数」)。
# 参见 AWS 架构博客中关于抖动退避的文章。
ceiling = min(MAX_DELAY, BASE_DELAY * (2 ** attempt))
time.sleep(random.uniform(0, ceiling))
raise last_error请注意这里对尝试次数设了上限(MAX_ATTEMPTS = 5),也对延迟设了上限(MAX_DELAY)。无限重试 正是一次临时小故障演变成永远排不空的积压的根源:请求堆积的速度比清空还快、 延迟节节攀升,上游调用方也跟着超时,于是连它们的请求也一起重试。 把两者都设上界,让失败可见,而不是被缓冲掩盖。
遵循 Retry-After —— 别猜
你的退避曲线,是对「容量何时释放」的一个猜测。当服务器直接把答案告诉你时, 就用它。一个 429 往往会带上 Retry-After头(增量秒数或一个 HTTP 日期),它反映的是真实的重置窗口。睡上这个值, 严格优于任何公式,因为它是确凿的事实,而不是一个启发式估计。
# 当服务器明确告诉你该等多久时,就听它的。429(有时还有 503)
# 会带上 Retry-After 响应头。遵循它,胜过你自己拍脑袋设计的任何退避
# 曲线,因为它反映的是真实的重置窗口 —— 而不是猜测。
import email.utils as eut
import time
def retry_delay(resp_headers, attempt, base=0.5, cap=20.0):
# 1. 优先采用服务器给出的指示。
ra = resp_headers.get("retry-after")
if ra is not None:
try:
return float(ra) # 增量秒数形式:"2"
except ValueError:
when = eut.parsedate_to_datetime(ra) # HTTP 日期形式
return max(0.0, when.timestamp() - time.time())
# 2. 没有这个头?回退到带全抖动的指数退避。
import random
return random.uniform(0, min(cap, base * (2 ** attempt)))
# 说明:
# - 有些供应商还会发送 X-RateLimit-Reset;按同样的方式处理即可。
# - 加一个很小的下限(例如 50ms),避免「Retry-After: 0」造成空转死循环。
# - Brievio 会在 429 上明确返回 Retry-After,而不是悄悄把 socket 卡住,
# 所以这条路径是真实可达的 —— 你确实能依据信号去退避。这一招只有在网关真的返回这个头、而不是把限流默默吞掉、再把你的 socket 卡上九十秒时,才管用。Brievio 快速且响亮地失败 —— 限流会以一个干净的 429 带着 Retry-After返回,而不是一个挂死的连接 —— 所以「听服务器的」这条路径是真实可达的。 完整的错误分类,以及哪些状态码会带哪些响应头,都写在 错误参考里。
超时:没人测试的那个失效模式
如果请求根本不会返回回来供你重试,那再好的重试策略也没用。一个没有超时的 AI 调用,迟早会挂住 —— 半开的连接、卡死的上游、丢掉了这条流的负载均衡器。 没有截止时间,这一个请求就会占着一个 worker、一条连接,以及它后面每个队列里 的一个名额,直到几分钟后操作系统自己放弃为止。设一个明确的、针对单个请求的 超时(上面那个 timeout=30),让卡死的调用转化为一个你能重试的 APITimeoutError,而不是一坨拖后腿的死重量。
- 单请求超时限定单次尝试。对于流式响应,真正有意义的预算 是「首 token 时间」加上「分块间空闲超时」,而不是一个挂钟总时长。
- 整体截止时间限定整个重试序列。跟踪一个预算(例如端到端 60s),一旦花完就停止重试 —— 在等你的调用方,自己也有它的截止时间。
- 超时要 > 预期的 p99,而不是 p50。把它设在你真实的 长尾延迟略高一点的位置。设得太紧,你就会取消掉那些本来就要成功的好请求, 凭着一份不耐烦平白制造出负载。
幂等性:让重试变得安全
重试会引入一个微妙的隐患。当一个请求超时,你并不知道服务器到底处理 没处理它 —— 你对响应的读取失败了,但那份活儿可能已经完成。盲目重试,你就可能 对一位客户重复扣费、把一条通知发两遍,或者写进一行重复的数据。读请求天然可以 安全重试。带副作用的操作则不行。
防线是一个幂等键:你给一个逻辑操作附上一个唯一 ID,让服务器 (或你自己的处理器)把重复请求合并掉。对于纯推理调用,通常没有什么外部副作用 需要担心 —— 但只要一次补全会触发一次数据库写入、一笔支付,或一条外发消息, 就要为每个逻辑工作单元生成一个稳定的键,并据此去重。一条经验法则:如果一次重试可能发生两次,那就当它一定会发生两次来设计。
熔断器:别再去踹一个已经挂掉的依赖
退避处理的是单个挣扎中的请求。它对一场持续的宕机毫无办法 —— 如果上游硬挂了两分钟,每个请求都会把自己的整条重试阶梯走完、等满最大时长, 然后照样失败,与此同时你的延迟和队列深度都在爆炸。一个熔断器会把这条路短路掉:在连续 N 次失败之后,它会「断开」,对新调用立刻失败(或把它们路由到一个降级方案), 持续一个冷却窗口,然后放一个探测请求过去,先测试是否已恢复,再决定是否重新闭合。
- 闭合:正常运行,请求照常流通,失败被计数。
- 断开:触发阈值 —— 在一个冷却期内快速拒绝,而不是堆积一堆 注定失败的重试。
- 半开:冷却之后,放一个试探请求通过;成功就闭合熔断器, 失败就重新断开。
把熔断器和一条降级路径配在一起,宕机就会变成一次降级,而不是一次硬性失败。 这也正是网关体现价值的地方:Brievio 会做跨供应商故障转移, 所以在你的熔断器需要断开之前,单个供应商的失联就能被路由到一个健康的供应商。 关于这如何融入一份真实的可靠性预算,参见 我们如何把 99.95% 的 SLO 做出来。
关于重试成本的一点说明
激进地调优重试,会让人对账单心里发慌 —— 每一次重试不都是又一次计费调用吗? 在一个诚实计费的网关上并非如此。Brievio 对失败的 4xx/5xx 调用不收 任何费用,所以触发你退避的那个 429、以及你重试越过的那个 503,都是免费的。你只为那次真正返回补全的尝试付费,而不为系统 拒绝掉的那些付费。这意味着你可以为可靠性去调优尝试次数和超时,而不会 因此挨上一记计费惩罚 —— 而如果失控的重试仍然让你担心预算,也可以按 如何为你的 AI API 支出设上限里所讲的,直接给花销封顶。
要点回顾
AI API 的生产级错误处理就是六条规则,而且你一个下午就能把它们全部上线:
- 只重试
429和5xx。绝不重试其他4xx—— 它们是你自己的 bug,被暴露了出来。 - 做指数退避并带上全抖动,并对尝试次数和延迟双双封顶。
- 当服务器发来
Retry-After时遵循它 —— 它胜过任何公式。 - 设置明确的单请求超时和一个整体截止时间;绝不让一个调用挂死。
- 用一个幂等键,让带副作用的重试变得安全。
- 加一个熔断器,让一场持续的宕机走向降级,而不是彻底崩溃。
这些都算不上什么奇技淫巧 —— 它就是那套枯燥的基础设施,守着「保持在线」这个 同样枯燥的承诺。而它在一个失败时快速失败、把真实信号暴露出来、又不为失败向你 收费的底层之上,跑得最好,这样你的调优才既诚实又免费。如果你还在挑选该把流量 指向哪里,那么「在失败下的可靠性表现」正是最值得先测一测的东西之一 —— 这在 如何挑选一个 AI API 网关里有讲。