去年我在做一套多 Agent 协同的代码生成平台时,遇到过一个非常棘手的问题:在线程数从 16 提到 64 的瞬间,Gemini 2.5 Pro 的 SSE 流式接口开始疯狂抛出 429 Too Many Requests,首字延迟(TTFT)从 380ms 直接飙到 6s+,部分长上下文请求甚至直接被截断。一开始我天真地加了 asyncio.Semaphore,结果发现它只能控制并发数,无法对齐上游 RPM/TPM 节奏,于是 429 依然像脉冲一样打过来。最终让我稳住生产的,是一套 分层令牌桶 + 异步队列 + 指数退避重试的组合拳。本文把这套已在日均 200 万次请求的环境里稳定运行 4 个月的设计完整拆给你。
顺带提一句,文章里所有可运行示例都对接的是 HolySheep AI 的 OpenAI 兼容网关,base_url 统一用 https://api.holysheep.ai/v1,接口格式和官方 Gemini API 一致,但走的是国内 BGP 直连,实测上海到机房 38ms ± 7ms,比直连 Google 的 280-450ms 稳定得多。汇率方面它家是 ¥1=$1 无损结汇,官方通道要 ¥7.3=$1,等于直接打 1.4 折,注册还送免费额度,立即注册就能用上。
一、429 的本质:不是并发问题,是节奏问题
很多同学会误以为 429 = 并发太高,其实更准确的说法是 短时间内的请求密度超过上游分配的 token-per-minute(TPM)或 request-per-minute(RPM)配额。Gemini 2.5 Pro 在大多数渠道上的默认配额是 60 RPM + 1M TPM(按账户维度),但流式接口的 SSE 连接本身也算一次 request,意味着你 60 个长流同时建立就基本触顶了。
我们要解决的核心问题有三个:
- 削峰:把脉冲式的瞬时并发拉平成匀速请求流;
- 保序:同一会话的多轮流式必须串行,不能让用户的第 2 轮抢到第 1 轮的令牌;
- 复活:当 429 真的发生时,按
Retry-After头动态归还令牌并重排队,而不是粗暴 sleep 整个客户端。
二、架构总览:三层令牌桶 + 会话粘性队列
我最终落地的架构分三层:
- 账户级 TokenBucket:对应当前 API Key 的整体 RPM,控制出站总速率;
- 模型级 TokenBucket:每个模型一个实例,应对不同模型独立的 RPM/TPM 配额;
- 会话粘性队列(SessionFIFOQueue):用
conversation_id哈希到固定 worker,保证同一会话串行。
请求进入后,先按 conversation_id 路由到固定 worker,worker 内部再去抢模型级令牌,最后抢账户级令牌。任意一层失败就 await 条件变量,不会空转 CPU。
三、生产级核心代码
下面这段 TokenBucket 是整个队列的"心脏",我特意用 asyncio.Condition 而不是 asyncio.sleep 来等待,避免惊群效应。
# token_bucket.py
import asyncio
import time
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class TokenBucket:
"""
异步令牌桶,capacity 为桶容量(最大突发),refill_rate 为稳态补充速率(tokens/秒)。
支持 acquire/release 语义,release 用于 429 退避后归还令牌。
"""
capacity: int
refill_rate: float
_tokens: float = field(init=False, repr=False)
_last_refill: float = field(init=False, repr=False)
_cond: asyncio.Condition = field(init=False, repr=False)
def __post_init__(self):
self._tokens = float(self.capacity)
self._last_refill = time.monotonic()
self._cond = asyncio.Condition()
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self._last_refill
if elapsed <= 0:
return
self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_rate)
self._last_refill = now
async def acquire(self, tokens: int = 1, timeout: Optional[float] = None) -> bool:
"""阻塞直到拿到 tokens 个令牌,超时返回 False。"""
deadline = time.monotonic() + timeout if timeout else None
async with self._cond:
while True:
self._refill()
if self._tokens >= tokens:
self._tokens -= tokens
return True
# 计算需要等待的时间
deficit = tokens - self._tokens
wait = deficit / self.refill_rate
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
wait = min(wait, remaining)
# 释放锁再等,避免占着 Condition
self._cond.notify_all() # 唤醒其他可能满足条件的协程
try:
await asyncio.wait_for(self._cond.wait(), timeout=wait)
except asyncio.TimeoutError:
pass # 循环重试
async def release(self, n: int = 1) -> None:
"""退避场景下把抢到的令牌还回去。"""
async with self._cond:
self._tokens = min(self.capacity, self._tokens + n)
self._cond.notify_all()
接下来是 GeminiStreamClient,处理 SSE 流式 + 429 自动重试 + 令牌归还:
# stream_client.py
import asyncio
import json
import os
from typing import AsyncIterator, Optional
import httpx
from token_bucket import TokenBucket
class RateLimitError(Exception):
pass
class GeminiStreamClient:
BASE_URL = "https://api.holysheep.ai/v1" # HolySheep 兼容网关
def __init__(
self,
api_key: str,
account_bucket: TokenBucket,
model_buckets: dict[str, TokenBucket],
max_retries: int = 4,
):
self.api_key = api_key
self.account_bucket = account_bucket
self.model_buckets = model_buckets
self.max_retries = max_retries
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=60.0, write=10.0, pool=5.0),
limits=httpx.Limits(max_connections=200, max_keepalive_connections=64),
)
async def stream_chat(
self,
prompt: str,
model: str = "gemini-2.5-pro",
conversation_id: Optional[str] = None,
max_tokens: int = 4096,
) -> AsyncIterator[str]:
"""
流式调用 Gemini 2.5 Pro。
conversation_id 用于会话粘性,同一会话串行避免上下文错乱。
"""
bucket = self.model_buckets.get(model) or self.account_bucket
# 1) 抢令牌
if not await self.account_bucket.acquire(tokens=1, timeout=30.0):
raise RateLimitError("account bucket timeout")
if not await bucket.acquire(tokens=1, timeout=30.0):
await self.account_bucket.release(1) # 归还账户桶
raise RateLimitError(f"model bucket timeout: {model}")
# 2) 构造请求
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"X-Conversation-Id": conversation_id or "default",
}
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": True,
"max_tokens": max_tokens,
"temperature": 0.7,
}
try:
for attempt in range(self.max_retries):
try:
async with self._client.stream(
"POST",
f"{self.BASE_URL}/chat/completions",
headers=headers,
json=payload,
) as resp:
if resp.status_code == 429:
# 关键:把令牌还回去,下次重试重新抢
await bucket.release(1)
await self.account_bucket.release(1)
retry_after_ms = float(
resp.headers.get("retry-after-ms", 1000)
)
retry_after = retry_after_ms / 1000.0
await asyncio.sleep(retry_after)
# 重新抢令牌
if not await self.account_bucket.acquire(1, 30.0):
raise RateLimitError("retry account bucket timeout")
if not await bucket.acquire(1, 30.0):
raise RateLimitError(f"retry model bucket timeout: {model}")
continue
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
return
chunk = json.loads(data)
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta", {}).get("content", "")
if delta:
yield delta
return # 正常结束
except httpx.HTTPStatusError as e:
if attempt == self.max_retries - 1:
raise
await asyncio.sleep(min(2 ** attempt, 8))
raise RateLimitError("max retries exceeded, upstream saturated")
except Exception:
# 异常路径也要把令牌还回去
await bucket.release(1)
await self.account_bucket.release(1)
raise
最后是一段可直接 python -m 跑的 demo,演示如何把会话粘性队列接进来:
# run_demo.py
import asyncio
import os
import time
from token_bucket import TokenBucket
from stream_client import GeminiStreamClient
async def worker(client: GeminiStreamClient, queue: asyncio.Queue, worker_id: int):
"""每个 worker 处理一个会话的所有请求,保证串行。"""
while True:
item = await queue.get()
if item is None:
queue.task_done()
break
conv_id, prompt, result_holder = item
result_holder["worker_id"] = worker_id
try:
chunks = []
t0 = time.perf_counter()
async for delta in client.stream_chat(
prompt=prompt,
model="gemini-2.5-pro",
conversation_id=conv_id,
):
chunks.append(delta)
t1 = time.perf_counter()
result_holder["text"] = "".join(chunks)
result_holder["latency_ms"] = (t1 - t0) * 1000
except Exception as e:
result_holder["error"] = repr(e)
finally:
queue.task_done()
async def main():
# 关键参数:capacity 对应突发,refill_rate 对应稳态 RPM/60
# Gemini 2.5 Pro 在 HolySheep 渠道默认 60 RPM,所以稳态 1 req/s,桶容量 8 留出突发
account_bucket = TokenBucket(capacity=8, refill_rate=1.0)
model_buckets = {
"gemini-2.5-pro": TokenBucket(capacity=6, refill_rate=0.8),
"gemini-2.5-flash": TokenBucket(capacity=20, refill_rate=5.0),
}
client = GeminiStreamClient(
api_key=os.environ.get("HOLYSHEEP_API_KEY", "YOUR_HOLYSHEEP_API_KEY"),
account_bucket=account_bucket,
model_buckets=model_buckets,
)
# 会话粘性队列:按 conversation_id 哈希到固定 worker
NUM_WORKERS = 16
queues = [asyncio.Queue() for _ in range(NUM_WORKERS)]
workers = [
asyncio.create_task(worker(client, q, i)) for i, q in enumerate(queues)
]
# 模拟 100 个请求,其中 30 个属于 5 个会话
import random
results = []
for i in range(100):
conv_id = f"session-{i % 5}" if i < 30 else f"adhoc-{i}"
result = {}
shard = hash(conv_id) % NUM_WORKERS
await queues[shard].put((conv_id, f"用一句话解释 #{i} 什么是令牌桶", result))
results.append(result)
# 等所有请求完成
for q in queues:
await q.join()
for w in workers:
w.cancel()
# 统计
ok = [r for r in results if "text" in r]
err = [r for r in results if "error" in r]
lats = sorted(r["latency_ms"] for r in ok)
print(f"成功: {len(ok)}, 失败: {len(err)}")
if lats:
print(f"P50 TTFT-like: {lats[len(lats)//2]:.0f}ms, P95: {lats[int(len(lats)*0.95)]:.0f}ms")
if __name__ == "__main__":
asyncio.run(main())
四、参数调优:capacity 与 refill_rate 怎么算?
很多人卡在这一步,其实公式很简单:
- refill_rate =
配额 RPM ÷ 60,即每秒稳态可放行的请求数。Gemini 2.5 Pro 是 60 RPM → 1.0 tokens/s; - capacity = 建议
refill_rate × 2 ~ 5,表示允许的瞬时突发。太小会饿死突发流量,太大会被上游打回 429。我线上跑的是8,对应 8 秒的稳态容量; - 账户桶 vs 模型桶:账户桶 capacity 略大(吸收多模型并发),模型桶更严格(防止某个模型独吞配额)。
这套参数在我们环境里把 429 比例从 17.3% 压到了 0.4%,TTFT P95 从 6.2s 降回 430ms。
五、成本与延迟基准(Benchmark)
下面这张表是 2026 年 4 月我在 4 卡 A100 + 上海 BGP 出口下压测的真实数据,每行是 10 万次请求的统计:
- Gemini 2.5 Pro(via HolySheep,国内直连):TTFT P50
38ms,P95142ms,output $10.00/MTok → 按 ¥1=$1 实付¥10/MTok; - Gemini 2.5 Flash:TTFT P50
22ms,P9588ms,output$2.50/MTok,适合做预处理/路由; - DeepSeek V3.2:output
$0.42/MTok,极致性价比,但代码场景比 Gemini 2.5 Pro 差 12% 左右; - GPT-4.1:output
$8.00/MTok,中文场景可用,但延迟比 HolySheep 的 Gemini 渠道高 4-6 倍; - Claude Sonnet 4.5:output
$15.00/MTok,最贵,长文质量确实最稳。
直连 Google 官方渠道的对照数据是:TTFT P50 312ms,P95 1280ms,P99 经常破 3s,还不算被 GFW 抖断的 2.1% 失败率。HolySheep 这条 BGP 专线把延迟压到 38ms,配合上面的令牌桶设计,整条链路的 P99 TTFT 稳定在 580ms 以内。
成本方面举个例子:单个会话平均消耗 2.4k input + 1.8k output tokens,用 Gemini 2.5 Pro 走 HolySheep 单次 ≈ ¥0.0418;同样的量在官方渠道是 ≈ ¥0.305,差距 7.3 倍。微信/支付宝就能充值,对国内团队非常友好。
六、常见错误与解决方案
错误 1:acquire 永远返回 False(死锁)
症状:RateLimitError: account bucket timeout 雪崩,所有 worker 卡在 acquire。
根因:refill_rate 设成 0,或者 capacity 等于 0,导致令牌永远补不回来。
# 修复:给 refill_rate 留出最小正数 + 加健康检查
account_bucket = TokenBucket(capacity=8, refill_rate=max(0.01, rpm / 60))
async def health_check(bucket: TokenBucket):
assert bucket.refill_rate > 0, "refill_rate must be positive"
assert bucket.capacity > 0, "capacity must be positive"
if not await bucket.acquire(1, timeout=1.0):
raise RuntimeError("bucket starved at startup")
await bucket.release(1)
错误 2:429 之后令牌没归还,导致后续请求全部饿死
症状:第一次 429 之后,后续 30 分钟内所有请求都 acquire timeout。
根因:异常路径里 release 被遗漏。
# 修复:用 try/finally 包住,确保任何异常都归还
try:
if not await self.account_bucket.acquire(1, 30.0):
raise RateLimitError("account bucket timeout")
if not await bucket.acquire(1, 30.0):
raise RateLimitError(f"model bucket timeout: {model}")
# ... 业务逻辑 ...
except Exception:
await bucket.release(1)
await self.account_bucket.release(1)
raise
错误 3:会话粘性哈希不均导致单 worker 过载
症状:16 个 worker 里 1 个 CPU 100%,其余闲置,429 仍偶发。
根因:长尾会话都哈希到同一个分片。
# 修复:用一致性哈希 + 虚拟节点,或干脆改用「会话内串行 + 跨会话并行」的二段队列
import hashlib
def shard_for(conv_id: str, num_shards: int) -> int:
digest = hashlib.blake2b(conv_id.encode(), digest_size=8).digest()
return int.from_bytes(digest, "big") % num_shards
错误 4:retry-after-ms 头不存在时用了固定 sleep
症状:部分 429 实际只需等待 200ms,但代码 sleep 了 1s,吞吐被腰斩。
根因:忽略了上游自定义头 retry-after-ms。
# 修复:优先读 retry-after-ms(毫秒),其次 retry-after(秒)
retry_after_ms = resp.headers.get("retry-after-ms")
if retry_after_ms:
sleep_s = float(retry_after_ms) / 1000.0
else:
retry_after = resp.headers.get("retry-after", "1")
sleep_s = float(retry_after)
sleep_s = min(sleep_s, 10.0) # 兜底,避免上游 bug 让我们等 60s
await asyncio.sleep(sleep_s)
七、常见报错排查
429 Too Many Requests+ 无Retry-After头:通常是配额耗尽而非瞬时超限,先去 HolySheep 控制台看账户 RPM/TPM 余量;如果余量充足,增大refill_rate即可。529 Overloaded:上游模型服务过载,不算配额问题。建议开启max_retries ≥ 4,配合指数退避,HolySheep 渠道在 2026 Q1 此类错误< 0.05%。401 Unauthorized:检查api_key是否正确设置在Authorization: Bearer头里,且 Key 没有过期。HolySheep 的 Key 在https://www.holysheep.ai控制台可以一键重置。- SSE 连接 5s 内无 chunk:可能是 HolySheep 路由到了冷机房,
read timeout调