← 返回资讯

429 限流:指数退避与重试策略

2026-06-19

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 20Used 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: 19x-ratelimit-reset-requests: 3s 这样的字段——remaining 快归零时就该主动降速,而不是等 429 真的打过来才反应。这是判断”要不要退避”的第一手信号源,比在代码里猜配额准得多。养成习惯:接入新账号先跑一次这条 curl,把 x-ratelimit-limit-requests 记下来,心里有个数,写并发控制代码时才知道信号量该开多大(后面并发那节会用到这个数字)。

指数退避算法

退避公式:wait = min(base × 2^attempt + jitter, max_wait)

  • base:初始等待时间,建议 1s
  • attempt:已重试次数(从 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 鉴权失败排查;高并发下从源头控制请求速率见并发控制与速率限制