我手上有个批量任务,每天要把几百条文案交给大模型 API 打分。跑了一周都挺顺,结果上周三下午开始突然报错,一看日志全是 429,还夹着几个超时。我当时第一反应就是往上加重试,结果越加越糟,报错更多了。这篇把这次踩坑的过程和最后定下来的写法写清楚。

先说下我那次批量任务的现场
我用的是 OpenAI 兼容接口,Python 里用 requests 直接调。平时每分钟能过三四十个请求,那天下午开始,连续十几条返回 429,意思是限流了,我这一分钟内打得太密,服务端不接了。接着又有几个请求直接超时,连状态码都没有,就是连不上。
我当时没多想,给请求包了个循环,报 429 就立刻重试,想着多试几次总能过。结果试了十分钟,429 越报越勤,后来干脆连别的接口也跟着超时。这时候我才反应过来,重试不是万能的,乱重试等于给服务端火上浇油。

哪些报错能重试,哪些重试只会更糟
这次我吃了个教训,先按状态码把错误分了个类,再决定要不要重试。
- 429 和 5 开头的错(500、502、503、504)可以重试,但必须等一会儿再试
- 超时这种没状态码的,可以重试,次数别多,两次封顶
- 400、401、403 这种别重试,重试一万次结果一样,是我的请求本身有问题
- 408 这种也要小心,有些网关它内部已经处理了一半,你重发可能造成重复
判断是不是该重试,我总结成一个土办法:先看是不是我这边的错,是就不重试;再看是不是服务端临时抽风,是就退避重试。
指数退避加抖动,代码就这么写
网上讲的指数退避就是每次等待时间翻倍,第一次等 1 秒、第二次等 2 秒、第三次等 4 秒这样。但光翻倍有个毛病,多个请求同时失败时会一起重试,又撞在一起。加个随机抖动就能错开,这个思路我是从网上一个开源项目里学的。
我最后定下来的写法是这样,直接用:
def call_with_retry(fn, times=5):
wait = 1
for i in range(times):
try:
return fn()
except RateLimitError as e:
if i == times - 1:
raise
wait = min(wait * 2, 60)
time.sleep(wait + random.uniform(0, wait))
重点就是最后一行,wait 每次翻倍,最多到 60 秒,再叠加一个随机数,这样同一时间失败的请求不会整整齐齐地一起重试。换上这个以后,我那个批量任务的 429 基本降到了零。

超时和重试次数,我是这么定的
超时时间我最初设的是 5 秒,发现经常超时,后来改成 30 秒,一个请求最久等半分钟。重试次数设成 5 次,重试间隔最长 60 秒,也就是说最坏情况一个请求要拖几分钟。这对我来说能接受,因为跑的是后台任务,不赶时间。
如果你是给用户直接调用的接口,重试次数要再少一点,两三次就够,不然用户等得太久。这个数值没有标准答案,得看你任务的容忍度,我建议先按我这个参数跑一天,再根据日志里实际的重试占比调整。
真扛不住就降级,别硬顶
重试只能解决临时抖动,如果服务端是真的忙不过来了,或者你当天的额度用完了,重试再多次也没用。这时候要降级。我的降级方案是按重要程度排的:最要紧的请求排队慢速处理,不着急的丢到明天的队列,实在处理不了的就记下来人工过一遍。
再往前一步,就是提前看限流。很多大模型 API 会在响应头里给你返回余量,比如 x-ratelimit-remaining 这种字段,你可以在代码里读出来,快用完了就先放慢速度,别等到 429 打脸才被动重试。这个我还在试,后面专门写一篇讲怎么读限流头、怎么做动态限速。
这篇是大模型 API 调用系列的一篇。前面写过批量过文档怎么省成本,后面打算写限流头怎么读、多账号轮询怎么分配,都是自己踩过的。想看后续的可以关注账号。