429 限流:指数退避与重试策略
HTTP 429 Too Many Requests 是大模型 API 最常见的错误之一,表示你在单位时间内发送的请求或 token 超过了平台配额。正确的处理方式是指数退避 + 随机抖动重试,而不是立即重发或固定间隔重试。
半夜跑批量任务最容易踩这个坑:本地脚本 for 循环 100 个 prompt 依次发出去,前 20 个都正常返回,第 21 个开始突然全部炸掉,报错长这样:
openai.RateLimitError: Error code: 429 - {'error': {'message': 'Rate limit reached for gpt-4o-mini in organization org-xxxx on requests per min (RPM): Limit 20, Used 20, Requested 1. Please try again in 3s.', 'type': 'requests', 'param': None, 'code': 'rate_limit_exceeded'}}
看到这条报错先别急着重跑整个脚本。message 里已经把限流维度写得很清楚:requests per min (RPM)、Limit 20、Used 20,说明你这个账号等级的并发额度就是 20 RPM,第 21 个请求撞上了。如果 message 里写的是 tokens per min (TPM),那问题不在请求数而在单次请求塞的内容太长,解法完全不同——截短 prompt 比等待更有效。先看清报错里写的是哪个维度,再决定退避还是降本,这一步很多人图省事直接套一个重试装饰器就跑,结果 TPM 超限时越重试越死,因为每次重试请求体积没变,退避只是拖时间,没有解决根因。
限流维度
不同平台限流粒度不同,常见组合:
| 维度 | 含义 | 典型单位 |
|---|---|---|
| RPM(Requests Per Minute) | 每分钟请求数 | 60 RPM = 1 req/s |
| TPM(Tokens Per Minute) | 每分钟输入+输出 token 总量 | 100k TPM |
| RPD(Requests Per Day) | 每日请求总量 | 适用于低级别账号 |
| 并发(Concurrent) | 同时在途请求数 | 通常 5-20 |
触发哪种限制可通过响应头 x-ratelimit-limit-* / x-ratelimit-remaining-* 判断。用 curl 加 -i 就能直接看到这些头,不用写代码:
curl -i https://api.lidayun.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
正常响应里能看到类似 x-ratelimit-remaining-requests: 19、x-ratelimit-reset-requests: 3s 这样的字段——remaining 快归零时就该主动降速,而不是等 429 真的打过来才反应。这是判断”要不要退避”的第一手信号源,比在代码里猜配额准得多。养成习惯:接入新账号先跑一次这条 curl,把 x-ratelimit-limit-requests 记下来,心里有个数,写并发控制代码时才知道信号量该开多大(后面并发那节会用到这个数字)。
指数退避算法
退避公式:wait = min(base × 2^attempt + jitter, max_wait)
base:初始等待时间,建议 1sattempt:已重试次数(从 0 开始)jitter:随机抖动,random(0, base)或random(0, wait*0.1),防止多客户端同步重试造成”惊群”max_wait:最大等待上限,建议 60s
先说清楚为什么不能用”固定间隔重试”(比如每次都 sleep 3 秒)。固定间隔的问题不在单个客户端身上,而在多客户端同时发生时——假设你的服务有 10 个 worker 进程,同一秒都收到 429,如果都固定等 3 秒后重试,10 个请求会在第 3 秒那一刻再次同时打过去,大概率又集体撞限流,这就是”惊群”(thundering herd)。指数退避本身只解决”越等越久,给配额腾出恢复空间”这一半问题,真正防止惊群靠的是 jitter 这个随机量——把同时到达的请求在时间轴上错开,避免它们抱团重试。这也是为什么代码里 random.uniform(0, base_wait) 不能省,见过不少人图简单把这行删了,结果线上并发一高,退避完全不起作用,因为所有请求还是卡在同一个时间点上重新集合。
max_wait 设上限也不是随便拍的。指数增长到第 6 次重试(2^6 = 64)配合 base=1s 就已经是 64 秒,再往后指数级涨会导致个别请求等到几分钟甚至更久,用户体验上等同于卡死。设上限本质是把”退避策略”和”熔断策略”分开——退避负责给配额恢复留时间,到了上限还失败就该交给上层做熔断或者降级(换模型、返回缓存结果、直接告诉用户”请稍后再试”),而不是让底层重试逻辑无限期地扛着。
Python 实现
import os, time, random
from openai import OpenAI, RateLimitError, APIStatusError
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.lidayun.com/v1"),
)
def chat_with_retry(
messages: list[dict],
model: str = "gpt-4o-mini",
max_retries: int = 6,
base_wait: float = 1.0,
max_wait: float = 60.0,
):
for attempt in range(max_retries + 1):
try:
return client.chat.completions.create(
model=model,
messages=messages,
max_tokens=1024,
)
except RateLimitError as e:
if attempt == max_retries:
raise
# 优先读取 Retry-After 响应头
retry_after = getattr(e.response, "headers", {}).get("retry-after")
if retry_after:
wait = float(retry_after)
else:
wait = min(base_wait * (2 ** attempt) + random.uniform(0, base_wait), max_wait)
print(f"[429] 第 {attempt+1} 次重试,等待 {wait:.1f}s …")
time.sleep(wait)
except APIStatusError as e:
# 5xx 服务端错误也可重试,但 4xx(除 429)不应重试
if e.status_code >= 500 and attempt < max_retries:
time.sleep(min(base_wait * (2 ** attempt), max_wait))
else:
raise
resp = chat_with_retry([{"role": "user", "content": "你好"}])
print(resp.choices[0].message.content)
上面这段代码里有两个容易被忽略的细节。第一,retry_after 优先级高于自己算的指数退避——服务端返回的 Retry-After 是它自己统计出来的、最接近真实恢复时间的建议值,比你本地瞎猜的 2^attempt 准得多,能拿到就该直接用,只有拿不到时才退化到本地算法。第二,except APIStatusError 那一段专门把 5xx 和普通 4xx 分开处理——5xx 是服务端临时故障(比如上游模型服务重启),重试大概率能好;而 4xx 里除了 429 之外的错误(比如 400 参数错误、404 模型不存在)本质是你的请求本身有问题,重试 100 次结果都一样,属于”重试也没用”的类型,直接抛出让调用方去修代码,不要浪费重试次数和等待时间在这类错误上。这个区分很多人写重试逻辑时图省事用一个大大的 except Exception 全部兜住,看起来代码更短,但代价是把不该重试的错误也重试了,白白拖慢响应还掩盖了真实 bug。
Node.js 实现
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL ?? "https://api.lidayun.com/v1",
maxRetries: 0, // 禁用 SDK 内置重试,自行控制
});
async function chatWithRetry(messages, { maxRetries = 6, baseWait = 1000, maxWait = 60000 } = {}) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await client.chat.completions.create({ model: "gpt-4o-mini", messages });
} catch (err) {
if (err.status === 429 && attempt < maxRetries) {
const retryAfter = err.headers?.["retry-after"];
const wait = retryAfter
? parseFloat(retryAfter) * 1000
: Math.min(baseWait * 2 ** attempt + Math.random() * baseWait, maxWait);
console.log(`[429] 第 ${attempt + 1} 次重试,等待 ${(wait / 1000).toFixed(1)}s`);
await new Promise((r) => setTimeout(r, wait));
} else {
throw err;
}
}
}
}
Node 版本和 Python 版本的核心逻辑一致,但有个 Node 特有的坑要提一句:Node.js 默认单线程事件循环,如果你用 Promise.all 一次性并发发出几十个请求,一旦触发 429,几十个请求几乎是在同一毫秒收到错误、同一毫秒进入 setTimeout 排队重试,Math.random() * baseWait 这点抖动在几十个并发面前经常不够用——实测发现哪怕加了抖动,几十个请求还是可能扎堆在几百毫秒的窗口里重新触发限流。这种场景下光靠退避算法治标不治本,真正的解法是从源头限制并发数(下面单独讲),退避只是兜底,别指望它一个人扛住所有并发压力。
SDK 内置重试
openai SDK 默认已内置指数退避重试(maxRetries=2),如需自定义可在初始化时覆盖:
client = OpenAI(max_retries=5) # Python SDK
const client = new OpenAI({ maxRetries: 5 }); // Node.js SDK
SDK 只会对 429 和 5xx 重试,4xx(除 429)会立即抛出。
要不要用 SDK 内置的这几行配置,还是自己手写完整重试逻辑,看场景:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 单次调用、脚本类任务 | SDK 内置 max_retries | 够用,改一个参数的事,没必要重复造轮子 |
| 批量任务、需要记录重试日志 | 自己手写 | 内置重试是静默的,你看不到中间过程,排查问题两眼一抹黑 |
| 需要按 TPM/RPM 区分处理 | 自己手写 | SDK 只知道”429”,不知道是哪种维度打满,没法针对性降本 |
| 高并发生产服务 | 手写 + 源头限流双保险 | 光靠重试兜底,配额打满时重试队列会越堆越长,最终还是雪崩 |
源头限流:比重试更根本的做法
退避重试解决的是”打满配额之后怎么办”,但更聪明的做法是从源头控制并发,让请求速率一开始就别超过配额,429 根本不发生。做法是用信号量(semaphore)把同时在途的请求数量卡死在一个安全值以内,这个值就用前面 curl 探测到的 x-ratelimit-limit-requests 打个八折:
import asyncio
sem = asyncio.Semaphore(16) # 账号上限 20 RPM,留 20% 余量应对突发
async def call_with_limit(messages):
async with sem:
return await async_chat_with_retry(messages)
async def batch_run(all_messages):
return await asyncio.gather(*[call_with_limit(m) for m in all_messages])
这段代码的意思是:不管你一次性丢进去多少条 prompt,同一时刻真正发出去的请求永远不会超过 16 个,多出来的自动排队等信号量释放。这样跑批量任务时 429 出现频率会明显下降,因为你从源头就没有制造出超过配额的瞬时压力,退避重试变成极少数网络抖动情况下的兜底手段,而不是每次都要触发的常规流程。这也是并发控制与速率限制里讲的核心思路,跟退避是配合关系不是替代关系——两个都要写,缺一个都容易在流量涨上来之后出问题。
多进程/多实例部署时还有一层坑:如果你的服务开了 5 个进程,每个进程都各自维护一个 Semaphore(16),实际并发上限就变成了 5×16=80,早就超过账号的 20 RPM 配额,光看单进程代码完全看不出问题。这种场景信号量得改成跨进程共享(比如用 Redis 做一个分布式令牌桶),否则每次扩容进程数都会把限流问题重新炸出来一次,这是压测时最容易漏掉、上线后才暴露的坑。
自查:退避代码到底有没有生效
写完重试逻辑不要直接上线,先本地验证一下退避是不是真的按预期工作。最简单的办法是造一个假的 429 响应测试重试分支:
from unittest.mock import patch, MagicMock
def test_backoff_triggers():
call_times = []
def fake_create(*args, **kwargs):
call_times.append(time.time())
if len(call_times) < 3:
raise RateLimitError("mock 429", response=MagicMock(headers={}), body=None)
return MagicMock(choices=[MagicMock(message=MagicMock(content="ok"))])
with patch.object(client.chat.completions, "create", side_effect=fake_create):
chat_with_retry([{"role": "user", "content": "test"}], base_wait=0.2)
# 期望:第二次等待时间 ≈ 第一次的 2 倍左右(指数增长)
gap1 = call_times[1] - call_times[0]
gap2 = call_times[2] - call_times[1]
print(f"第一次等待 {gap1:.2f}s,第二次等待 {gap2:.2f}s(应大致翻倍)")
跑一次这个测试,终端打出来的两次间隔应该大致是翻倍关系(比如 0.2s 左右和 0.4s 左右,因为有抖动不会完全精确),如果两次间隔几乎一样,说明指数没生效,多半是 2 ** attempt 里的 attempt 变量没有正确递增,或者你不小心把重试逻辑写在了循环外面只执行了一次。这种验证花不了五分钟,但能在上线前把”退避写了但没生效”这种隐蔽 bug 提前揪出来,比等到生产环境批量报警再排查省事得多。
常见问题
Retry-After 响应头有值,为什么还要抖动? Retry-After 是服务端建议的最短等待时间,加抖动是为了防止同一时刻大量客户端集体在 Retry-After 时刻同时重发,再次触发限流。
TPM 触发和 RPM 触发处理方式一样吗? 触发 TPM 时可以在重试间隔内把单次请求的 prompt 截短,减少 token 消耗,辅助缓解。
重试多少次合适? 生产建议 3-6 次,配合 max_wait=60s,总等待不超过 2-3 分钟。超时后应向上层返回可重试错误,由调用方决定是否继续。
更多接入基础见大模型 API 接入完全指南与接入教程专题。鉴权类错误处理见 401 鉴权失败排查;高并发下从源头控制请求速率见并发控制与速率限制。