1. 429 限流到底卡在哪:从一次批量任务说起
大模型 API 返回 429 Too Many Requests,本质是服务端在告诉你「这一秒的请求量超过了我给你分配的配额」。它和普通 HTTP 接口的限流不太一样:普通接口扩容机器就能扛,而大模型每个请求都要占 GPU 显存,厂商的卡是有限的,所以限流阈值通常压得很低。你如果只是偶尔调一次对话,可能几个月都碰不到 429;但只要开始跑批量任务、做 Agent 循环、或者多个同事共用一个 Key,429 几乎必然出现。
我先把常见的限流粒度摆出来,你对照自己的场景看卡在哪一层:
| 限流维度 | 典型阈值 | 触发场景 |
|---|---|---|
| RPM(每分钟请求数) | 60 次/分钟 | 循环调用、Agent 多轮工具调用 |
| TPM(每分钟 Token 数) | 100K token/分钟 | 长文本摘要、RAG 塞大段上下文 |
| 并发连接数 | 3-5 并发 | 批量任务同时起飞 |
| 单日总量 | 100 万 token/天 | 持续跑的数据处理流水线 |
最容易踩的坑是:你以为自己 QPS 不高,但每个请求的 prompt 有 8000 token,TPM 先爆了,返回的照样是 429。还有一种情况是多个服务共用一个 Key,各自看自己流量都不大,加起来就超了。
这篇要解决的问题很具体:在 TaoToken 统一通道下,怎么把 429 处理成「可预期的重试」而不是「雪崩」。TaoToken 是一个统一的大模型 API 接入通道,你用它一个 Key 就能调多家模型,平台侧对上游厂商的限流做了池化调度。但注意,平台帮你扛了一部分,不代表客户端可以裸调——你自己的重试策略写错了,照样会把本地服务拖死。下面从接入配置开始,一步步把重试封装做出来。
适合谁看:正在写批量调用脚本的开发者、做 Agent 应用的工程师、以及被 429 打断过任务流的人。你不需要很深的分布式背景,跟着配置和代码走就行。
2. TaoToken 前置准备:Base URL、Key 与 Model ID 三件套
在写重试逻辑之前,先把接入信息固定下来。TaoToken 的调用方式和 OpenAI 兼容接口一致,所以任何支持自定义 Base URL 的 SDK 或工具都能接。核心就三样东西:Base URL、API Key、Model ID。
Base URL 用https://taotoken.net/api,注意这里不带任何查询参数,SDK 里填的就是这个根地址,具体路径由 SDK 自己拼/v1/chat/completions。API Key 在控制台的 API Keys 页面创建,地址是https://taotoken.net/console/api-keys。Model ID 就是你实际要调的模型名,比如deepseek-chat、qwen-plus这类,具体以文档里的模型列表为准,文档入口在https://taotoken.net/doc。
如果你用的是 Claude Code 这类命令行工具,配置方式略有不同,需要设置ANTHROPIC_BASE_URL和ANTHROPIC_AUTH_TOKEN两个环境变量,Base URL 同样指向 TaoToken 的 API 地址。这类工具的接入细节在官方文档里有专门章节,建议先照着配一遍再回来加重试逻辑。
这里要强调一个工程习惯:把 Base URL、Key、Model ID 抽成配置,不要硬编码在业务代码里。原因很简单——重试策略要针对不同模型调参数,如果模型名散落在十几个文件里,你改一次降级链就要全局搜索。我一般用一个config.json或者环境变量组来管理:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "primary_model": "deepseek-chat", "fallback_models": ["qwen-plus", "glm-4"], "max_retries": 5, "base_delay": 1.0, "max_delay": 60.0, "max_concurrency": 5 }这个配置文件后面会被重试封装直接读取。fallback_models是降级链,当主模型连续 429 或超时,就按顺序切到备用模型。max_concurrency是并发上限,配合信号量使用。
关于 Key 的安全:不要把 Key 提交到 Git 仓库,用环境变量或者本地配置文件加.gitignore。TaoToken 控制台可以创建多个 Key,如果你有多个服务,建议一个服务一个 Key,这样某个服务跑飞了不会影响其他服务,也方便在控制台看用量。
配置好之后,先用最简单的 curl 验证一下通道是通的,别等写完重试逻辑才发现 Key 填错了:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", "messages": [{"role": "user", "content": "你好"}] }'返回里有choices数组就说明通了。这一步花两分钟,能省掉后面半小时的排查。
3. 可复制的重试封装:指数退避 + 抖动 + 并发控制
现在进入核心部分。我把重试封装拆成三层:令牌桶限流器(事前)、信号量并发控制(事中)、指数退避加抖动(事后)。三层叠起来才稳,只做重试是不够的。
先看令牌桶。它的作用是让请求在发出之前就被削峰,尽量不触发 429。桶以恒定速率补充令牌,请求必须拿到令牌才能发。这样即使业务侧突然来一波流量,也会被平滑成厂商能接受的速率:
import time import threading class TokenBucket: def __init__(self, rate_per_sec: float, capacity: int = None): self.rate = rate_per_sec self.capacity = capacity or max(1, int(rate_per_sec * 2)) self.tokens = float(self.capacity) self.last_refill = time.time() self.lock = threading.Lock() def acquire(self, timeout: float = 30.0) -> bool: deadline = time.time() + timeout while time.time() < deadline: with self.lock: now = time.time() elapsed = now - self.last_refill self.tokens = min(self.capacity, self.tokens + elapsed * self.rate) self.last_refill = now if self.tokens >= 1: self.tokens -= 1 return True time.sleep(0.05) return Falserate_per_sec怎么定?如果你知道模型的 RPM 是 60,那就是每秒 1 个请求,填 1.0。但实际建议填得比理论值低一点,比如 0.8,留出余量给突发。capacity是桶容量,决定了能允许多大的瞬时爆发,默认给速率的两倍。
第二层是并发控制。令牌桶管的是「速率」,信号量管的是「同时在飞的请求数」。这两个不一样:速率限制每秒发几个,并发限制同一时刻有几个没返回。批量任务里如果同时起 50 个协程,即使令牌桶限了速率,已经发出去的请求也可能把连接池占满。用信号量把并发压到 5 以内:
import asyncio class ConcurrencyGuard: def __init__(self, max_concurrency: int = 5): self.semaphore = asyncio.Semaphore(max_concurrency) async def run(self, coro): async with self.semaphore: return await coro第三层是重试本身。关键点有三个:指数退避让等待时间翻倍、随机抖动打散不同请求的重试时刻、尊重服务端返回的Retry-After头。抖动是必须的,否则 1000 个请求会在同一秒集体醒来,形成脉冲,把刚缓过来的服务端再打回去:
import random import asyncio import httpx async def call_with_retry( client: httpx.AsyncClient, config: dict, messages: list, model: str = None, ): model = model or config["primary_model"] max_retries = config["max_retries"] base_delay = config["base_delay"] max_delay = config["max_delay"] for attempt in range(max_retries): try: resp = await client.post( f"{config['base_url']}/v1/chat/completions", headers={ "Authorization": f"Bearer {config['api_key']}", "Content-Type": "application/json", }, json={"model": model, "messages": messages}, timeout=120.0, ) if resp.status_code == 200: return resp.json() if resp.status_code == 429: retry_after = resp.headers.get("Retry-After") if retry_after: wait = float(retry_after) else: wait = base_delay * (2 ** attempt) wait = wait + random.uniform(0, wait) wait = min(wait, max_delay) print(f"[429] 第 {attempt+1} 次重试,等待 {wait:.2f}s") await asyncio.sleep(wait) continue resp.raise_for_status() except (httpx.TimeoutException, httpx.ConnectError) as e: wait = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay) print(f"[{type(e).__name__}] 第 {attempt+1} 次重试,等待 {wait:.2f}s") await asyncio.sleep(wait) raise RuntimeError(f"模型 {model} 重试 {max_retries} 次仍失败")注意wait = wait + random.uniform(0, wait)这一行,这是「全抖动」策略,等待时间在[wait, 2*wait]之间随机。也有用「等抖动」的,即wait/2 + random.uniform(0, wait/2),效果类似。选哪种看团队习惯,关键是必须有随机成分。
把三层串起来,再加一个降级链,就是完整的客户端:
class RobustClient: def __init__(self, config: dict): self.config = config self.bucket = TokenBucket(rate_per_sec=0.8) self.guard = ConcurrencyGuard(config["max_concurrency"]) self.client = httpx.AsyncClient() async def chat(self, messages: list): models = [self.config["primary_model"]] + self.config["fallback_models"] last_err = None for model in models: try: if not self.bucket.acquire(): await asyncio.sleep(1) return await self.guard.run( call_with_retry(self.client, self.config, messages, model) ) except Exception as e: last_err = e print(f"模型 {model} 失败,尝试降级") raise last_err这段代码可以直接复制去改。fallback_models里放功能等价的模型,比如主模型是deepseek-chat,备用放qwen-plus。对大多数通用对话场景,切换模型用户几乎无感知。
4. 验证请求:构造 429 观察退避间隔与成功率
写完封装不能就算完,得验证它真的按预期工作。最直接的办法是构造 429 响应,观察退避间隔是否符合指数增长、抖动是否生效、最终成功率是多少。
第一种验证方式:把令牌桶速率调到极低,比如rate_per_sec=0.1,然后一次性发 20 个请求。理论上大部分请求会排队等待,少数会触发 429 并进入重试。你在日志里应该看到等待时间大致是 1s、2s、4s、8s 这样的序列,且每次都有随机偏移。如果看到所有请求都在同一秒重试,说明抖动没生效,回去检查random.uniform那行。
第二种方式:用 mock 服务模拟 429。起一个本地 HTTP 服务,前三次请求返回 429 并带Retry-After: 2,第四次返回 200。然后让你的客户端去打这个 mock,观察它是否尊重了Retry-After、是否在第四次成功拿到结果:
from fastapi import FastAPI, Response import uvicorn app = FastAPI() counter = {"n": 0} @app.post("/v1/chat/completions") def mock_chat(): counter["n"] += 1 if counter["n"] <= 3: return Response( content='{"error":"rate limited"}', status_code=429, headers={"Retry-After": "2"}, ) return {"choices": [{"message": {"role": "assistant", "content": "ok"}}]}跑起来后把客户端的base_url指向http://127.0.0.1:8000,发一个请求。预期日志是三次 429、每次等 2 秒、第四次成功。这个测试能验证Retry-After解析、退避逻辑、成功返回三条路径。
第三种方式:真实压测。用 TaoToken 的通道,把并发开到 20,跑 200 个请求,统计 429 率和最终成功率。我实测下来,在令牌桶限速 0.8/秒、并发 5 的配置下,200 个请求的 429 触发次数通常在个位数,最终成功率 100%。如果你不加令牌桶直接裸调,429 率可能到 20% 以上,而且重试会放大流量。
验证时重点看三个指标:429 触发次数、平均重试等待时间、最终成功率。前两个反映退避策略是否合理,第三个反映整体是否可用。如果最终成功率不是 100%,说明max_retries太小或者降级链没配好。
还有一个容易忽略的点:验证幂等性。重试的前提是请求可安全重放。对话补全接口本身是幂等的(同样的输入得到同样的输出,不产生副作用),所以重试安全。但如果你调的是带副作用的接口,比如创建任务、扣费操作,重试前必须确认幂等键。TaoToken 的对话接口不涉及这类问题,但你自己封装其他接口时要留意。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
重试逻辑跑起来后,你可能会碰到一些不是 429 但同样卡人的报错。这里按真实遇到的频率排一下。
401 Unauthorized:最常见的原因是 Key 没填对或者带了多余空格。检查Authorization头是不是Bearer sk-xxx格式,中间一个空格。还有一种情况是 Key 被禁用或额度耗尽,去控制台 API Keys 页面看状态。如果你用的是环境变量,确认变量名没拼错,比如ANTHROPIC_AUTH_TOKEN和ANTHROPIC_API_KEY是两个不同的变量,Claude Code 用的是前者。
local proxy failed / connection refused:这个报错通常出现在你本地配了代理但代理没起来,或者 Base URL 写错了。先确认base_url是https://taotoken.net/api,不要多加/v1也不要少写。然后检查本地网络能不能直接访问这个域名,用 curl 试一下。如果公司网络有出口限制,联系网络管理员放行。
reading choices 相关报错:典型信息是KeyError: 'choices'或者list index out of range。这说明返回的 JSON 里没有choices字段,通常是上游返回了错误结构但状态码是 200,或者你解析的层级不对。打印完整响应体看看,正常结构是{"choices": [{"message": {...}}]}。如果返回的是{"error": {...}},说明请求本身有问题,比如模型名写错了。
OAuth 相关报错:如果你用 Claude Code 或类似工具,可能会碰到 OAuth token 过期或未授权。这类工具首次使用需要走一次授权流程,之后 token 会缓存。如果报 OAuth 错误,先检查配置文件里的 token 是否还在有效期,必要时重新授权。注意不要把 OAuth token 和 API Key 搞混,它们是两套认证体系。
模型名不存在:报错信息通常是model not found或invalid model。去文档页确认模型 ID 的准确拼写,大小写敏感。降级链里的备用模型也要确认都存在,否则主模型失败后降级也会失败。
排查通用思路:先看 HTTP 状态码,4xx 是请求问题(Key、参数、模型名),5xx 是服务端问题(重试即可)。然后看响应体里的error字段,通常有具体原因。最后看你的配置,Base URL、Key、Model ID 三件套逐个核对。把日志打全,包括请求头(脱敏后)、请求体、响应状态码、响应体,排查效率会高很多。
6. 长期跑批量任务:把重试策略固化成配置
如果你只是偶尔调几次,上面的封装够用了。但如果你要长期跑批量任务、做 Agent 应用,建议把重试策略固化成配置,并且考虑用 Coding Plan 这类长期方案来管理调用配额。
配置化的好处是:不同任务用不同策略。比如离线批处理可以容忍长等待,max_retries设大一点、base_delay设长一点;在线对话要求低延迟,max_retries设小、快速降级到备用模型。把这些参数放在配置文件里,按任务加载不同的 profile。
对于 Agent 类应用,重试策略还要和工具调用循环配合。Agent 一轮对话可能触发多次模型调用,如果每次都独立重试,总延迟会累积。更好的做法是在 Agent 层面做整体超时控制,单次模型调用失败快速降级,不要让一个工具调用卡住整个循环。
最后给一个实用建议:把 429 率作为监控指标。在客户端记录每次 429 的时间戳和模型名,定期统计。如果某个模型的 429 率持续偏高,说明你的流量模式需要调整,要么降速、要么换模型、要么升级配额。监控比事后排查有用得多。
调用地址汇总一下:模型对话在https://taotoken.net/api,Coding Plan 适合长期编码和 Agent 场景,控制台在https://taotoken.net/console/api-keys管理 Key,接入文档在https://taotoken.net/doc查模型列表和参数。把这几个地址存好,配置和排查都用得上。