限流的响应体大致长这样:
{"error":{"message":"Number of request tokens has exceeded your per-minute rate limit","type":"rate_limit_error","code":"rate_limit_exceeded"}}
Anthropic 原生协议下常见的是 {"type":"error","error":{"type":"rate_limit_error","message":"..."}}。看到 rate_limit_error 就是 429 的领域,看到 overloaded_error 通常是 529 或者 503,那是上游自己扛不住,跟你的调用频率没关系。这两种错误的重试节奏不一样:429 要等你的窗口滑过去,过载要等上游缓过来。
429 表示服务端认出了你,只是这一刻不想再接更多请求。余额不足会返回 402 或者额度提示,Key 无效是 401,模型没开通是 403。看到 429 去充值,等于在错误的环节上花钱。
| 报错类型 | 状态码 | 真实的含义 | 正确的动作 |
|---|---|---|---|
rate_limit_error | 429 | 你这一侧的请求太快 | 退避、降并发 |
overloaded_error | 529 | 上游算力吃紧 | 等得更久再试 |
| 额度耗尽提示 | 402 | 余额或额度不够 | 充值或换 Key |
invalid_api_key | 401 | 身份没通过 | 查鉴权头和 Key |
| 502 / 504 | 5xx | 网关或上游断了 | 少量重试,别猛冲 |
一是每分钟请求数,二是同一时刻的并发连接数,三是上游对该模型的总负载。前者靠退避解决,中者靠排队解决,后者只能等。分不清维度,就会出现「我明明只发了几次也被限」这种困惑,因为你可能同时在跑十个并发。
固定窗口是每分钟清零一次,你在第 59 秒发满、下一秒继续发,看起来是正常的。滑动窗口算的是最近 60 秒内的累计量,任何时候发满都会被立刻拦下。多数实现用的是滑动窗口,所以「掐着整分钟发」这类技巧没有用,真正有效的手段只有一个:把并发压下来。
多数网关在 429 的响应里带上 retry-after,单位是秒。它比你自己猜的等待时间更准。先读它,读不到再退回指数退避。
| 头名字 | 含义 | 出现场景 |
|---|---|---|
retry-after | 建议等待的秒数 | 429、503 |
anthropic-ratelimit-requests-limit | 每分钟请求上限 | Anthropic 原生协议 |
anthropic-ratelimit-requests-remaining | 当前窗口剩余请求数 | Anthropic 原生协议 |
anthropic-ratelimit-tokens-remaining | 当前窗口剩余 token 数 | Anthropic 原生协议 |
x-ratelimit-remaining-requests | 剩余请求数 | OpenAI 兼容协议 |
x-ratelimit-reset-requests | 窗口重置时间 | OpenAI 兼容协议 |
用 curl 直接看一眼响应头,比在代码里猜快得多:
curl -i -s -o /dev/null -D - https://xyuapi.top/v1/chat/completions \
-H "Authorization: Bearer sk-你的Key" \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-4-5-thinking","max_tokens":64,"messages":[{"role":"user","content":"ping"}]}' \
| findstr /i "ratelimit retry-after"
Windows 下用 findstr,macOS 和 Linux 换成 grep -i,作用是同一个:把限流相关的头单独拎出来看。
如果 x-ratelimit-remaining-requests 已经是个位数,说明窗口快满了。这时候主动降到一半并发,比撞上 429 再退避要划算得多,因为撞一次浪费的是一个完整请求的等待时间。
import random
import time
import requests
def call_with_retry(payload, key, max_retry=5):
url = "https://xyuapi.top/v1/chat/completions"
headers = {"Authorization": "Bearer " + key,
"Content-Type": "application/json"}
for attempt in range(max_retry + 1):
resp = requests.post(url, headers=headers, json=payload, timeout=(10, 300))
if resp.status_code == 200:
return resp.json()
if resp.status_code in (429, 529, 503):
wait = float(resp.headers.get("retry-after") or 0)
if wait <= 0:
wait = min(2 ** attempt, 30) * (0.6 + random.random() * 0.8)
print(f"[429] 第 {attempt+1} 次退避 {wait:.1f}s")
time.sleep(wait)
continue
resp.raise_for_status()
raise RuntimeError("重试次数用尽")
2 ** attempt 让等待时间翻倍,random.random() 那一段是抖动,作用是让并发的多个任务不要在同一毫秒一起重试。没有抖动的话,十个并发任务会在同一时刻再次撞上去,形成规律的次次失败。抖动幅度取 0.6 到 1.4 倍之间,是够用的范围。
三次到五次是合理区间。设成二十次,一旦上游真的挂了,你的程序会卡在里面出不来。另外,按次计费模式下每次重试都是一次真实请求,重试风暴会实实在在消耗额度,所以重试次数不只是性能问题,也是成本问题。
401、403、400 这类错误重试一万次也是同样的结果。重试白名单只有三种:429、5xx,以及网络层的连接超时。其余错误直接抛出,让上层看到真实原因。
每次退避打印三样:第几次重试、等待多少秒、服务端返回的错误类型。日志一摆出来,「偶发一次就恢复」和「连续五次都失败」立刻分明。前者调并发就能解决,后者要去看上游状态,改自己的代码没有用。
import asyncio, httpx
async def worker(sem, client, payload, key):
async with sem: # 同时最多 N 个请求在飞
r = await client.post(
"https://xyuapi.top/v1/chat/completions",
headers={"Authorization": "Bearer " + key},
json=payload, timeout=httpx.Timeout(300, connect=10))
return r.json()
async def main(payloads, key, concurrency=4):
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient() as client:
tasks = [worker(sem, client, p, key) for p in payloads]
return await asyncio.gather(*tasks, return_exceptions=True)
Semaphore(4) 的意思是同一时刻最多四个请求在场,第五个必须等前面一个结束。把并发从 20 降到 4,多数场景下总耗时不升反降,因为不再有大批请求排队失败再重试。
| 场景 | 建议起始并发 | 观察指标 |
|---|---|---|
| 单用户交互式对话 | 1 | 不该出现 429 |
| 小批量文本处理 | 2 到 4 | 看剩余请求数 |
| 批量跑长上下文 | 1 到 2 | 看上游过载频率 |
| 带工具调用的多轮流程 | 1 | 看单次耗时 |
先按这个起点跑,出现 429 就减半,连续几轮都没有 429 再考虑加一。一次加到二十个并发,只会让你花时间在重试日志上。
一百条文本不要一次全发。分成每批十条,批与批之间留一秒,整体跑完的时间几乎一样,429 出现的概率低很多。批量任务还可以失败重跑,把失败条目单独收集起来二轮重试,比整批重来省额度。
流式请求如果并发过高,429 会在 HTTP 状态码这一层返回,根本不会开始吐内容。这种最容易处理,走同样的退避逻辑即可。
少数情况下连接建立成功、内容吐了一部分之后被切断,客户端会看到流提前结束。这时的处理原则是:已经收到的那部分内容保留下来,不要丢掉重新生成,否则用户会看到文字闪回重来。把已生成的内容作为上下文再请求一次,让它接着写,比从头来更省也更快。
如果已经收到了八成内容,剩下两成重试一次要付一次请求的钱,还不如把已收到的部分直接交给用户。判断标准很简单:内容够用就不重试。
这些客户端的请求是你手动点的,一个人用几乎撞不到 429。真出现了,先看是不是同时开了多个会话在自动生成,或者装了插件在批量发请求。把自动请求关掉,问题通常立刻消失。
Cline、Cursor 这类插件会在你打字时后台反复发请求,还带工具调用,一次任务可能发几十条。插件里通常有最大迭代次数的设置,把它从默认的高值调到合理范围,能明显降低触发限流的概率。
手机、电脑、服务器同时用一个 Key,各自的并发会叠加,从任何单台设备看都很正常,合起来就超了。排查时把这个因素算进去,或者在需要高频调用的场景单独建一个令牌。
浏览器开着网页版、桌面客户端也开着、编辑器插件还在后台自动补全,三份流量汇到同一个 Key 上。任何一份单独看都正常,合起来就超了。排查时先把不用的关掉,只留一份,是最快的验证方式。
不做重试的客户端会直接弹一句请求失败;做了重试却不给提示的客户端,界面就是长时间没动静。两种观感都不好。在重试期间给一句「请求较多,正在重试」的提示,用户的耐心会明显不一样,这是几行代码的事。
不要把 rate_limit_error 原文弹给用户。翻译成「当前请求较多,已自动重试」并带上进度,用户知道程序还活着,就不会反复点按钮。反复点击是 429 变得更频繁的直接原因之一。
用户点一次按钮发一次请求就够了。把按钮在等待期间禁用,能避免用户手动制造出来的并发。这个小改动对降低限流的作用,往往比调参数更直接。
重试要放在最外层,不要在业务逻辑里再包一层。嵌套两层的时候,两层重试相乘就是九次请求,日志里看起来只重试了两次,实际发出去的请求数会远超预期,也是 429 一直不退的一个隐蔽原因。
小鱼API 是 AI API 接入平台,走 OpenAI 兼容协议,主入口 https://xyuapi.top/v1,备用 https://xyuai.cc/v1。Chatbox、Cherry Studio、NextChat、Cline 这些客户端把地址和令牌填进去就能用。按次计费,一次请求一个固定价,输入多长都不改价,所以长上下文任务不需要为长度额外算账。
| 模型 | 单次价格 |
|---|---|
| gemini-2.5-pro | 0.031 元/次 |
| deepseek-v3.2-thinking | 0.049 元/次 |
| claude-sonnet-4-5-thinking | 0.09 元/次 |
| claude-opus-4-5-thinking | 0.12 元/次 |
按次计费还带来一个排查上的好处:重试的成本是可预期的,不会因为一次重试把预算打穿。但这也不意味着可以放任重试,仍然要设上限。
| 顺序 | 动作 | 完成标准 |
|---|---|---|
| 1 | 看响应体类型 | 确认是 rate_limit_error 而不是过载 |
| 2 | 读 retry-after | 按服务端给的秒数等待 |
| 3 | 加抖动退避 | 并发任务不再同时重试 |
| 4 | 压并发数 | 起始并发降到 2 到 4 |
| 5 | 批量任务分批 | 批间留出间隔 |
| 6 | 检查多设备共用 | 高频场景单独建令牌 |
这套动作走完,429 会从「随机出现」变成「偶尔出现且能自动恢复」,程序不需要你盯着。