去年我在做一套多 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 个长流同时建立就基本触顶了。

我们要解决的核心问题有三个:

二、架构总览:三层令牌桶 + 会话粘性队列

我最终落地的架构分三层:

  1. 账户级 TokenBucket:对应当前 API Key 的整体 RPM,控制出站总速率;
  2. 模型级 TokenBucket:每个模型一个实例,应对不同模型独立的 RPM/TPM 配额;
  3. 会话粘性队列(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 怎么算?

很多人卡在这一步,其实公式很简单:

这套参数在我们环境里把 429 比例从 17.3% 压到了 0.4%,TTFT P95 从 6.2s 降回 430ms

五、成本与延迟基准(Benchmark)

下面这张表是 2026 年 4 月我在 4 卡 A100 + 上海 BGP 出口下压测的真实数据,每行是 10 万次请求的统计:

直连 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)

七、常见报错排查