大模型 API 429 报错:原因、重试与并发调优
429 不一定是「你请求太多了」。这篇拆开 RPM、TPM、并发数和免费档限流四种成因,给出带抖动的退避重试实现、并发池写法与降级链路,附监控指标清单。
跑批量任务时突然一片 429,日志里只有一行 rate limit exceeded。多数人的第一反应是「加个 sleep 重试」,结果流量高峰时 sleep 完继续撞墙,任务队列越堆越长。
429 的本质是「服务端认为你这一秒的请求超过了约定配额」。但配额有四种,撞的是哪一种,解法完全不同。
四种配额,四种解法
| 配额类型 | 含义 | 典型报错信息 | 解法 |
|---|---|---|---|
| RPM | 每分钟请求数 | requests per minute | 控制请求节奏,排队发送 |
| TPM | 每分钟 token 数 | tokens per minute | 裁剪上下文、拆长文本 |
| 并发限制 | 同时在飞的请求数 | concurrent requests | 信号量限流 |
| 免费档限额 | 每日/每分钟次数上限 | daily limit | 换付费模型或降级 |
先看响应体和响应头,别猜。多数服务会在 x-ratelimit-remaining-* 里告诉你还剩多少,在 retry-after 里告诉你等几秒。
curl -i https://api.zaimod.com/v1/chat/completions \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
返回头里如果有 retry-after: 3,那就等 3 秒;没有的话从 1 秒起步做指数退避。
先修代码:带抖动的退避重试
最容易被忽略的是「抖动」:十个 worker 同时 429、同时等 1 秒、同时重发,等于把一次拥塞变成十次。加上随机抖动才能把请求打散。
import random, time
from openai import OpenAI, RateLimitError, APIConnectionError
client = OpenAI(api_key="sk-xxx", base_url="https://api.zaimod.com/v1")
def chat(messages, model="gpt-4o-mini", max_retries=5):
for attempt in range(max_retries):
try:
return client.chat.completions.create(
model=model, messages=messages, max_tokens=800,
)
except RateLimitError:
if attempt == max_retries - 1:
raise
# 指数退避 + 抖动:1s, 2s, 4s, 8s 附近随机
wait = min(2 ** attempt, 20) + random.uniform(0, 1)
time.sleep(wait)
except APIConnectionError:
time.sleep(2 + random.uniform(0, 1))
raise RuntimeError("retries exhausted")
三个细节决定这段代码好不好用:
- 只重试可重试的错误。429、5xx、连接超时重试;400(参数错)、401(Key 错)重试一万次也没用
- 指数退避要封顶。退避到 30 秒就够了,再长不如直接失败让上层知道
- 记录重试次数。重试率持续高于 5%,说明配额已经不够用,加 sleep 只是把问题往后推
再修架构:并发池
单条请求重试解决不了「一次要跑一万条」。真正有效的是把并发压到一个稳定值,让服务端看到的是匀速流量。
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key="sk-xxx", base_url="https://api.zaimod.com/v1")
SEM = asyncio.Semaphore(8) # 同时在飞 8 个
async def one(text):
async with SEM:
for attempt in range(5):
try:
r = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": text}],
max_tokens=300,
)
return r.choices[0].message.content
except Exception:
await asyncio.sleep(min(2 ** attempt, 20) + random.uniform(0, 1))
return None
async def run(texts):
return await asyncio.gather(*(one(t) for t in texts))
并发数怎么定?从 4 开始,每次加 4,观察 429 比例:低于 1% 可以继续加,超过 5% 就退回来。不同模型、不同分组的限额不一样,同一个并发数在 gpt-4o-mini 上没事,换成 claude-opus-4-7 可能立刻撞墙。
降级链路:让失败有出口
批量任务里最贵的不是 429,是「因为 429 整批失败」。准备一条降级链,比无限重试划算:
- 主力模型 429 → 重试两次
- 仍然 429 → 换同价位备份模型(如
gpt-4o-mini→gemini-3.1-flash-lite-preview) - 仍然失败 → 走免费档或小模型出「粗略结果」,标记
degraded: true - 全部失败 → 落盘重试队列,下一轮补跑
关键是第 3 步要在数据里留标记,否则你不知道哪批结果是降级产出的。这条经验来自踩过的坑:某次批处理跑了 6 小时,第二天才发现三成摘要是小模型生成的,格式全不对。
从源头减流量:三个立刻见效的改动
- 裁上下文:只发最近 6 轮对话 + 一段摘要,别把整个历史塞进每次请求。长会话的成本是线性增长的,TPM 撞墙多半是这个原因
- 合并请求:十条短文本拼成一个 JSON 数组让模型一次处理,比十次请求省 60% 以上的 token(系统提示只发一次)
- 限制输出:
max_tokens不设上限等于放弃预算控制,一个「继续」就能生成两千字
监控这几个指标
上线前把四个数记进日志,出问题三十秒能定位:
- 429 比例:分模型、分 Key 统计,超过 1% 就该调整
- P95 延迟:429 之前通常先出现延迟抬升
- 重试次数分布:重试 3 次以上的请求占比
- token 用量曲线:TPM 撞墙前会有明显尖峰
流式请求要单独处理:SSE 连接建立后再断,客户端感知不到错误码,得靠首字节超时判断。细节见 流式输出 SSE 原理与调试。
常见问题
Q:加了重试还是 429,是不是被限流了?
先确认请求是不是「瞬间爆发」。把并发降到 1 单独跑一条,如果还 429,说明配额或账号级别被限制,需要换分组或联系服务方;如果单条能过,就是并发问题。
Q:429 会扣费吗?
不计费的失败请求通常不产生 token 账单,但会消耗 RPM 配额。所以「重试」本身也在消耗你的限流额度,这就是为什么要退避而不是立刻重发。
Q:为什么白天没事,凌晨批量任务全是 429?
批量任务通常是并发打满,而白天是零星请求。同一份额度,突发流量更容易撞墙。
Q:不同模型的限流是独立的吗?
取决于平台实现。多数平台按「模型 + 分组」分别计算,但账号级别往往还有一层总限额。跨模型分散请求能缓解 TPM,但缓解不了账号总并发。