背景痛点:429 不是“服务器炸锅”,而是“官方限速”
第一次把 ChatGPT 塞进业务链路时,我 5 分钟就把配额打光——前端日志清一色429 Too Many Requests。
OpenAI 的限流策略分两层:
- RPM(Requests Per Minute):硬上限,窗口 60 s 滑动。
- TPM(Tokens Per Minute):软上限,按 prompt+completion 总 token 计数,超限后延迟返回 429,持续 60 s 才重置。
官方文档写得客气:“请优雅降级”,翻译过来就是:
“并发高?要么自己排队,要么等 10 s 后再试,别指望服务器帮你缓存。”
于是,客户端必须自己把“并发”削成“串行”,否则错误率一路飙到 100 %,吞吐却掉到 0。
技术对比:三种常见策略谁更适合你
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 轮询重试 | 脚本/一次性任务 | 实现简单 | 空转浪费、易雪崩 |
| 指数退避 + Jitter | 低频调用、可接受秒级延迟 | 官方 SDK 内置 | 高并发时仍可能齐步重试,打满配额 |
| 令牌桶 | 高并发、低延迟、需要 SLA | 严格限速、可分布式、可预测 | 实现复杂,需要额外存储 |
结论:业务链路只要 QPS>5,就选令牌桶;一次性跑批任务用指数退避即可。
核心实现:三段代码直接落地
以下代码全部基于 Python 3.11,带类型标注与异常处理,可直接粘进项目。
1. asyncio 请求队列:把“并发”变“串行”
import asyncio, time, logging from typing import List, Dict, Any import openai class OpenAIQueue: """单桶队列,保证任意时刻仅 N 个请求在飞。""" def __init__(self, rpm_limit: int = 60, tpm_limit: int = 90_000): self._rpm_limit = rpm_limit self._tpm_limit = tpm_limit self._sem = asyncio.Semaphore(1) # 串行化 self._token_ts: List[float] = [] # 记录最近 60 s 的 token 消耗时间戳 self._req_ts: List[float] = [] # 记录最近 60 s 的请求时间戳 self._logger = logging.getLogger("queue") async def _wait_until_allowed(self, tokens: int) -> None: while True: now = time.time() # 滑动窗口,剔除掉 60 s 之前的记录 self._req_ts = [t for t in self._req_ts if t > now - 60] self._token_ts = [t for t in self._token_ts if t > now - 60] if (len(self._req_ts) < self._rpm_limit and sum(1 for t in self._token_ts) + tokens < self._tpm_limit): self._req_ts.append(now) for _ in range(tokens): self._token_ts.append(now) return else: self._logger.debug("限速等待 …") await asyncio.sleep(0.5) async def ask(self, messages: List[Dict[str, str]], **kwargs: Any) -> str: async with self._sem: prompt_tokens = self._estimate_tokens(messages) await self._wait_until_allowed(prompt_tokens) try: resp = await openai.ChatCompletion.acreate( model="gpt-3.5-turbo", messages=messages, **kwargs ) completion_tokens = resp.usage.completion_tokens await self._wait_until_allowed(completion_tokens) # 把返回也算进去 return resp.choices[0].message.content except openai.error.RateLimitError as e: self._logger.warning("仍被限速,等 10 s 再重试: %s", e) await asyncio.sleep(10) return await self.ask(mes, **kwargs) # 简单重试一次 @staticmethod def _estimate_tokens(messages: List[Dict[str, str]]) -> int: # 1 token ≈ 4 英文字符,中文 ×1.3 粗略估算 text = "".join(m["content"] for m in messages) return int(len(text.encode()) / 3)调优建议:
rpm_limit比官方标称值低 10 %,留余量给突发重试。- 如果业务高峰固定,可动态读取响应头
x-ratelimit-remaining-requests做自适应。
2. 动态延迟算法:带 Jitter 的指数退避
import random, asyncio, logging from typing import Callable, Awaitable async def jittered_backoff( func: Callable[[], Awaitable[str]], max_retries: int = 8, base_delay: float = 1.0, max_delay: float = 60.0 ) -> str: """带全 jitter 的指数退避,适合脚本侧快速重试。""" for attempt in range(1, max_retries + 1): try: return await func() except openai.error.RateLimitError as e: delay = min(base_delay * (2 ** (attempt - 1)) * random.uniform(0.5, 1.5), max_delay) logging.warning("第 %s 次重试,等待 %.1f s", attempt, delay) await asyncio.sleep(delay) raise RuntimeError("仍失败,已达最大重试次数")关键参数:
base_delay=1与官方 SDK 默认一致,高并发时建议降到 0.5,减少齐步效应。max_delay不要超过 60 s,否则容易撞上滑动窗口重置,浪费一次重试机会。
3. Redis 令牌桶:分布式、多实例共享配额
import redis.asyncio as redis import time, math, random from typing import Optional class RedisTokenBucket: """基于单键 + Lua 脚本,保证原子性。""" def __init__(self, redis_url: str, key: str, capacity: int, refill_rate: float): self._r = redis.from_url(redis_url) self._key = key self._cap = capacity self._rate = refill_rate # token/s async def consume(self, tokens: int = 1) -> float: """返回需要 sleep 的秒数,0 表示立即可用。""" script = """ local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested= tonumber(ARGV[4]) local last = redis.call('HMGET', key, 'tokens', 'ts') local tokens = tonumber(last[1]) or capacity local last_ts = tonumber(last[2]) or now local delta = math.max(0, now - last_ts) tokens = tokens + delta * rate tokens = math.min(tokens, capacity) if tokens >= requested then redis.call('HMSET', key, 'tokens', tokens - requested, 'ts', now) return 0 else redis.call('HMSET', key, 'tokens', tokens, 'ts', now) return (requested - tokens) / rate end """ wait = await self._r.eval( script, 1, self._key, self._cap, self._rate, time.time(), tokens ) return float(wait)使用示例:
bucket = RedisTokenBucket("redis://localhost", "openai:tpm", capacity=90_000, refill_rate=1 500) wait = await bucket.consume(prompt_tokens) if wait > 0: await asyncio.sleep(wait)调优建议:
capacity与官方 TPM 一致,但 refill_rate 建议下调 20 %,给突发留缓冲。- 多业务方共享同一桶时,key 里加业务线后缀,避免互相挤爆。
避坑指南:上线前必须补的三块板
会话保持与幂等性
- 把
user_id + session_id作为 Redis 桶 key 的一部分,防止同一用户重复刷新导致双倍扣费。 - 对需要“重试不重复扣费”的场景,在 prompt 里埋
request_id,服务端返回后先查缓存,命中直接返回,避免 LLM 重复生成。
监控指标埋点
- 用 Prometheus client 暴露
openai_requests_total、openai_rate_limit_hits、openai_tokens_consumed三个 counter。 - Grafana 面板加“TPM 使用率”和“429 占比”两排折线,红线 80 % 即告警。
- 桶剩余 token 可定时写回 Redis 的
gauge键,供自动扩缩容决策。
冷启动流量突增
- 新部署实例先预热:启动后先 sleep 30 s,逐步放开并发,给令牌桶“蓄水”。
- 灰度发布采用“阶梯放量”:每 5 min 上调 20 % 流量,观察 429 曲线,一旦>1 % 立即回滚。
延伸思考:LLM API 的 SLA 与自建代理层
- 即使做到 99 % 成功率,剩余 1 % 仍可能击中关键链路——考虑双厂商互备(主 OpenAI + 备 Azure OpenAI),客户端在 429/5xx 时自动切换。
- 自建代理层(LLM Gateway)能统一收口:做鉴权、缓存、审计、审计、再审计,还能在网关侧做“预提示模板”减少 token 浪费。
- 未来 SLA 谈判:把“TPM 弹性突发”写进合同,或干脆买Provisioned Throughput Unit,用成本换确定性。
如果你也想亲手搭一套“能听会说”的实时语音 AI,顺便把上面的限流技巧揉进去,可以试试这个动手实验——
从0打造个人豆包实时通话AI
我跟着一步步跑通,把 ASR→LLM→TTS 整条链路搬到浏览器里,本地 30 分钟就能对话,代码里预留了限速钩子,直接插令牌桶即可。
小白也能顺利体验,建议边跑边改参数,比啃文档快多了。