官方 SDK 已内置指数退避重试,但业务层错误处理(幂等性、熔断、死信队列)需自行实现,且不要在 SDK 之上重复叠加退避。
一次 LLM API 调用总是在最糟糕的时刻失败。流量高峰时的 429、提供商过载时的 529、流式传输中途的连接断开。一个简陋的应用会把这些问题直接暴露给用户,或者更糟——一个从未重试过的半途而废的操作。重试逻辑就是差距所在,而大多数人手写的重试逻辑要么与 SDK 重复,要么暗藏错误。
以下是真正需要处理的内容。
官方客户端不是裸 HTTP。默认情况下,它们会替你重试瞬时故障——429、5xx 系列错误、连接错误,通常重试几次,带有指数退避,并且在服务器发送 Retry-After 头时遵循它。所以第一步不是写一个重试循环,而是阅读你所用客户端的默认配置,如果你的工作负载需要,就提高重试次数或超时时间,而不是在已经正常工作的退避逻辑之上糟糕地重新实现退避。
SDK 无法替你决定的是那些取决于你应用的部分,而这才是值得写的部分。
把错误分成两类。可重试的:429 限流、529 过载、500 系列的服务器错误、超时、连接断开。这些是瞬时的,后续请求有可能成功。不可重试的:400 错误请求、401 和 403 认证问题、404。重试这些只会浪费时间金钱,而结果始终相同,因为问题出在你的请求上,而不是服务器的脾气。捕获一条特定错误链,而不是一个宽泛的异常,这样 404 快速失败,而 429 则退避等待。
一个隐蔽的陷阱:超时本身也会被重试,所以"3 次重试"配合 60 秒超时可能意味着用户在一个调用上等待了三分钟。要限制总时间,而不只是重试次数。在退避中加入随机抖动,这样一批同时失败的 worker 不会步调一致地重试,在同一时刻猛击提供商。
import random, time
RETRYABLE = {429, 529, 500, 502, 503, 504}
def call_with_retry(make_request, attempts=4, cap_seconds=45):
start = time.monotonic()
for i in range(attempts):
try:
return make_request()
except HttpError as e:
if e.status not in RETRYABLE or i == attempts - 1:
raise
if time.monotonic() - start > cap_seconds:
raise
wait = min(2 ** i, 8) + random.uniform(0, 0.5) # backoff + jitter
time.sleep(e.retry_after or wait) # honor Retry-After
流式传输不会干净地重试。如果你已经向用户展示了 200 个 token,而流中断了,简单的重试会从零重新开始,用户会看到卡顿或重复的答案。提前决定:缓冲直到流完成,或者设计 UI 以容忍重启。
重试可能导致副作用重复触发。如果调用触发了某个操作——发送邮件、扣款、预订时段——超时后的重试可能会执行两次,因为第一次请求可能在连接断开前已经成功。在添加任何重试之前,使操作具有幂等性,或者在你控制的一个 key 上去重。
重试逻辑很容易在写了一个 try/except 加一个 sleep 之后就感觉完成了。能经受生产环境考验的版本知道要重试哪些错误,对无法修复的错误停止浪费时间,限制总等待时间,并且永远不会重复触发副作用。SDK 给你退避逻辑,而判断部分才是你拥有的东西。
如果你有一个重试 bug 只在真实负载下才暴露,我想听听,因为 demo 和产品之间的差距总是体现在那里。