凌晨两点,订单服务的压测还没结束,日志里HTTP 429已经从零星的几条变成每秒上百条。我们这次把 DeepSeek-V4.1-Flash 接进高并发链路,入口统一走 TaoToken(https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=retry429 ),Key 从同一个地方领,请求地址统一设成 https://taotoken.net/api。
第一轮排查其实跑偏了。一开始以为是 Key 本身有问题,换了两三次 Key,429 曲线纹丝不动;又把客户端并发从 64 压到 32,也只是把峰值往后推了十几分钟。真正有用的线索在响应里——retry-after、上游请求 ID,以及我们自己日志里那条被截断的响应体。把这三样对齐之后才发现,问题不是"额度不够",而是重试风暴叠加熔断器缺失:上游一次正常的限流信号,被客户端放大成了四到六倍的无效请求,越重试越堵,越堵越重试。
这篇文章把复盘过程按"可跟做"的顺序写下来,产出三样东西:一套能直接落地的重试退避参数、一个三态熔断器实现、一份能用来定位限流根因的 429 日志字段清单。它不解决模型推理速度问题,只解决"请求发出去的这一层"是否稳。
1. 先固定故障面:一条能定位问题的 429 日志
高并发下最怕的不是报错,而是报错没有上下文。429本身只说明"这一秒不该发这个请求",但到底是并发上限、单位时间请求数,还是请求体太大导致排队,全靠日志里那几个字段区分。我们最终统一的日志结构是这样:
{ "ts": "2025-06-11T02:14:37.412+08:00", "trace_id": "b7c1e2f0a94d", "upstream_request_id": "req_9f2a7c31", "endpoint": "chat.completions", "model": "deepseek-v4.1-flash", "attempt": 3, "status": 429, "retry_after_ms": 1000, "breaker_state": "CLOSED", "inflight": 78, "stream": true, "elapsed_ms": 812, "error_code": "rate_limit_exceeded" }几个字段的作用要分清:
attempt记录这是第几次尝试。如果日志里attempt长期大于 2,说明不是上游在限你,是你在放大流量。retry_after_ms直接来自响应头。有它就用它,不要自己拍一个固定值。breaker_state记录熔断器当时的状态。没有这一列,你无法区分"上游拒绝"和"我们主动拒绝"。inflight是发出请求那一刻的在途数量。限流和并发数几乎总是正相关,这个值能把两者对上。upstream_request_id是跟平台侧对账的唯一凭据,出问题时要能报出来。
写完日志结构之后要做的第一件事,是把它接进采集。我们只用一个很小的字典计数器就够了,不需要引入重型依赖:
import logging from collections import Counter log = logging.getLogger("taotoken.retry") STATS = Counter() def note_retry(status: int, attempt: int, breaker_state: str) -> None: STATS["requests_total"] += 1 if status == 429: STATS["http_429_total"] += 1 STATS["retry_amplification"] += attempt - 1 if breaker_state == "OPEN": STATS["breaker_reject_total"] += 1 if STATS["requests_total"] % 1000 == 0: log.warning("retry stats snapshot: %s", dict(STATS))压测跑完,如果retry_amplification / http_429_total大于 2,基本可以判定重试策略过激。
2. 把入口收敛到 TaoToken:Base URL 与 Key 的最小改动
故障面固定之后,第二步是把请求地址和密钥来源统一。这一步越简单越好,因为改动越小,越容易把限流问题和配置问题区分开。Key 在 TaoToken 控制台领取,具体入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=baseurl ,申请完成后在 API Keys 页面创建即可。请求地址是固定的:
export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="YOUR_API_KEY"调用侧用 OpenAI 兼容客户端时,只需要把base_url指过去:
import httpx from openai import OpenAI client = OpenAI( base_url="https://taotoken.net/api", api_key="YOUR_API_KEY", timeout=httpx.Timeout(connect=5.0, write=30.0, pool=2.0, read=180.0), ) resp = client.chat.completions.create( model="deepseek-v4.1-flash", messages=[{"role": "user", "content": "连通性自检"}], max_tokens=32, ) print(resp.choices[0].message.content)超时这里值得单独说一句。长上下文请求的 prefill 阶段耗时明显高于短请求,如果你沿用短请求那套 30 秒总超时,会出现大量客户端主动断开、但上游仍在计算的"幽灵请求"。这些请求照样占并发名额,表现就是:客户端看到的是超时,上游看到的是并发没降下来。所以拆成connect / write / pool / read四段超时,是长上下文链路的标配,不是可选项。
配置改完之后先跑连通性自检,再跑并发压测,两者之间要有明确的日志分界点,方便回滚。
3. 重试策略:区分 429、5xx 和超时
最常见的错误写法是"只要失败就重试三次,间隔 1 秒"。在限流场景里,这等于自己给自己叠了三倍流量。正确的做法是先分类,再决定要不要重试:
429:可重试,但必须尊重retry-after,且要计入熔断器失败计数。500 / 502 / 503 / 504:可重试,用指数退避加抖动。- 连接超时、读超时:可重试,但要区分是否是长上下文导致的正常慢。
400 / 401 / 403 / 404:不可重试,重试只会浪费配额。422或参数校验类错误:不可重试,直接落到业务异常。
下面这段是可以直接用的重试包装,退避参数按我们压测后的取值给:
import asyncio import logging import random from dataclasses import dataclass import httpx log = logging.getLogger("taotoken.retry") RETRYABLE_STATUS = {429, 500, 502, 503, 504} RETRYABLE_EXC = ( httpx.ConnectTimeout, httpx.ReadTimeout, httpx.WriteTimeout, httpx.RemoteProtocolError, ) @dataclass class RetryPolicy: max_attempts: int = 4 base_delay: float = 0.35 max_delay: float = 8.0 jitter_ratio: float = 0.3 def next_delay(self, attempt: int, retry_after_s: float | None) -> float: if retry_after_s is not None: return min(retry_after_s, self.max_delay) raw = min(self.base_delay * (2 ** (attempt - 1)), self.max_delay) return raw * (1 + random.uniform(-self.jitter_ratio, self.jitter_ratio)) def parse_retry_after(resp: httpx.Response) -> float | None: value = resp.headers.get("retry-after") if not value: return None try: return float(value) except ValueError: return None async def post_json( client: httpx.AsyncClient, path: str, payload: dict, policy: RetryPolicy, on_retry=None, ) -> httpx.Response: last_exc = None for attempt in range(1, policy.max_attempts + 1): try: resp = await client.post(path, json=payload) if resp.status_code not in RETRYABLE_STATUS: return resp if attempt == policy.max_attempts: return resp delay = policy.next_delay(attempt, parse_retry_after(resp)) log.warning( "retryable status=%s attempt=%s sleep=%.2fs body=%s", resp.status_code, attempt, delay, resp.text[:200], ) if on_retry: on_retry(resp.status_code, attempt) await asyncio.sleep(delay) except RETRYABLE_EXC as exc: last_exc = exc if attempt == policy.max_attempts: raise delay = policy.next_delay(attempt, None) log.warning("retryable exc=%r attempt=%s sleep=%.2fs", exc, attempt, delay) if on_retry: on_retry(-1, attempt) await asyncio.sleep(delay) raise last_exc # pragma: no cover两个细节容易被忽略。第一,抖动必须有,否则所有客户端会在同一个毫秒点同时回来,形成二次尖峰。第二,max_attempts=4是上限不是目标,如果日志里三次重试的占比超过 5%,说明入口并发就该往下调,而不是继续加尝试次数。
4. 熔断器:让"停下来"成为一种主动决策
重试解决的是偶发失败,熔断解决的是持续性失败。两者的边界很清楚:上游偶发 429,重试能救回来;上游连续 429,重试只会把连接池耗尽,把整个服务拖垮。我们用的是一个标准三态机:
import time from enum import Enum class State(str, Enum): CLOSED = "CLOSED" OPEN = "OPEN" HALF_OPEN = "HALF_OPEN" class CircuitBreaker: def __init__( self, failure_threshold: int = 20, window_s: float = 10.0, open_hold_s: float = 15.0, half_open_probes: int = 3, half_open_success_needed: int = 3, ): self.failure_threshold = failure_threshold self.window_s = window_s self.open_hold_s = open_hold_s self.half_open_probes = half_open_probes self.half_open_success_needed = half_open_success_needed self.state = State.CLOSED self._failures: list[float] = [] self._opened_at = 0.0 self._probes = 0 self._successes = 0 self._probe_inflight = 0 def _prune(self, now: float) -> None: cutoff = now - self.window_s self._failures = [t for t in self._failures if t >= cutoff] def allow(self) -> bool: now = time.monotonic() if self.state is State.OPEN: if now - self._opened_at >= self.open_hold_s: self.state = State.HALF_OPEN self._probes = 0 self._successes = 0 self._probe_inflight = 0 else: return False if self.state is State.HALF_OPEN: if self._probe_inflight >= self.half_open_probes: return False self._probe_inflight += 1 return True return True def on_success(self) -> None: if self.state is State.HALF_OPEN: self._probe_inflight = max(0, self._probe_inflight - 1) self._successes += 1 if self._successes >= self.half_open_success_needed: self.state = State.CLOSED self._failures.clear() else: self._prune(time.monotonic()) def on_failure(self) -> None: now = time.monotonic() if self.state is State.HALF_OPEN: self._probe_inflight = max(0, self._probe_inflight - 1) self._opened_at = now self.state = State.OPEN return self._failures.append(now) self._prune(now) if len(self._failures) >= self.failure_threshold: self._opened_at = now self.state = State.OPEN几个参数是压测出来的,不是拍脑袋:failure_threshold=20配合window_s=10,意味着 10 秒内 20 次失败就开闸;open_hold_s=15大约是长上下文请求平均完成时间的 3 倍,保证半开探测发出去之前,老请求已经落地;半开只放 3 个探测,且需要连续 3 次成功才闭合,避免上游刚恢复就被打回原形。
半开阶段是最容易写错的地方:如果探测请求不限流,几十个协程会同时挤进HALF_OPEN,等于开闸瞬间又来一次尖峰。所以allow()里的_probe_inflight计数不能省。
5. 并发闸门:客户端自己也要有上限
重试和熔断都是"事后"控制,真正的第一道闸门是并发数。我们的做法是把请求分成两档:
import asyncio class TwoTierGate: """短请求与长上下文请求分开限流,避免长请求把短请求的连接占满。""" def __init__(self, short_limit: int = 48, long_limit: int = 12): self.short_sem = asyncio.Semaphore(short_limit) self.long_sem = asyncio.Semaphore(long_limit) def pick(self, prompt_tokens: int) -> asyncio.Semaphore: return self.long_sem if prompt_tokens >= 32_000 else self.short_sem async def run(self, prompt_tokens: int, coro): sem = self.pick(prompt_tokens) async with sem: return await coro这样拆的原因是长上下文请求的 read 时间可能是短请求的几十倍,混在同一个信号量里,长请求会把并发名额占住,短请求排队到超时。分开之后,两档各自拥塞,互不牵连。
闸门数值的确定方法很简单:从单档 8 并发开始,每轮加 8,直到 429 占比超过 2% 就回退一档。这个值在不同业务里差别很大,别抄别人的数字。
6. 客户端侧配置:Claude Code、Codex 与 CC Switch
除了自研调用链路,很多团队的稳定性排查还涉及命令行工具。这三套配置的字段完全不同,混用是最常见的踩坑点。
Claude Code 走的是settings.json,环境变量前缀是ANTHROPIC_:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "deepseek-v4.1-flash" } }Codex 走的是config.toml,字段名和 Claude Code 没有一处相同,不要照搬ANTHROPIC_*:
model = "deepseek-v4.1-flash" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"env_key只写变量名,真正的值放在 shell 环境里:
export TAOTOKEN_API_KEY="YOUR_API_KEY"如果团队里有人用 CC Switch 做多供应商切换,那就当成"三件套"来配:供应商名称、Base URL、模型名。三件套里任何一项填错,表现都是 401 或者 404,而不是 429,所以遇到 429 时先别急着怀疑这里。
配完之后建议先做一次单请求验证,再进压测环境。验证阶段如果就报错,说明是配置问题,不该进入稳定性排查流程。
7. 长上下文请求的专属注意事项
这一版的模型在长上下文上做了不少优化,KV 缓存占用下降、跨层复用这类机制让长文本更省资源,但对客户端来说,有两点必须自己处理:
第一,首字节超时和总超时要分开。长 prompt 的 prefill 阶段可能几十秒都不返回第一个 token,如果你设的总超时是 60 秒,那么大量请求会在"还在算"的时候被客户端判定失败。失败的请求被重试,重试又占并发,这是典型的自伤。
# 流式场景:读超时放宽,但对首包单独计时 first_token_timeout_s = 45.0 stream = client.chat.completions.create( model="deepseek-v4.1-flash", messages=[{"role": "user", "content": long_prompt}], stream=True, )第二,长上下文请求不要和短请求共用一个熔断器失败窗口。长请求更容易因为超时被记为失败,从而误触发熔断,把本来健康的短请求链路一起切断。我们按prompt_tokens分了两条独立计数路径,逻辑和上面的双档闸门一致。
8. 把 429 变成可执行的结论
日志、指标、闸门都到位之后,最后一步是把结论固定成一张表。我们用的判定规则是这样的:
| 现象 | 大概率原因 | 动作 |
|---|---|---|
| 429 占比 < 1%,重试 1 次后成功 | 正常波动 | 不动 |
| 429 占比 1%~5%,attempt 集中在 2 | 并发略高 | 并发下调 1 档 |
| 429 占比 > 5%,retry_amplification > 2 | 重试放大流量 | 先降重试次数,再降并发 |
| 熔断器频繁 OPEN,恢复后很快再 OPEN | 半开探测过宽 | 收紧half_open_probes |
| 客户端超时多但上游无对应记录 | 读超时过短 | 按 prefill 耗时重设读超时 |
| 401/404 与 429 同时出现 | 配置问题混入 | 先修配置再谈限流 |
这张表的价值在于:它把"要不要加机器""要不要换 Key"这类拍脑袋的讨论,换成了一次数据比对。
9. 上线前的验收清单
压测通过不等于线上稳定,下面这几项是我们每次改配置都会重新跑一遍的:
- 单请求连通性验证通过,且日志里能看到上游请求 ID。
- 稳态压测 10 分钟,429 占比稳定在 2% 以内,且不随时间上升。
- 手动注入 429(把上游响应伪造为限流),确认熔断器在 10 秒内开闸,且开闸后不再发出无效请求。
- 熔断后等待
open_hold_s,确认半开探测只放行约定数量的请求。 - 长上下文请求与短请求同时打,确认两档互不影响。
- 关闭全部重试,观察 429 基线,再逐级打开,确认放大系数可控。
- 检查日志里
attempt、breaker_state、inflight三个字段无缺失。
其中第 3 项最容易被跳过,也最有用。很多团队的熔断器写了但从来没被触发过,真正出问题时才发现状态机有 bug。
整套流程里,唯一需要"改地址"的地方只有一处:把请求地址指向 https://taotoken.net/api ,Key 用自己的。剩下的重试、熔断、闸门、日志,全是客户端自己的工程问题,和模型本身无关。把这一层做扎实,换模型、换版本、换并发档位,都不会让链路重新抖一次。
如果要从零开始,按这个顺序走最省事:先去 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=flow 领 Key,创建好之后用模型对话页做一次单请求验证(https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=flow ),确认链路通了,再决定是走 Coding Plan(https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=flow )还是直接建 Key 接入(https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=flow )。命令行工具侧的完整字段说明在 Claude Code 文档里(https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=flow ),配置前先对一遍字段名,比事后翻 401 日志快得多。