线上跑着 3 个 LLM 供应商,凌晨 2 点 OpenAI 429 了,Claude 超时,DeepSeek 返回空响应。Slack 告警炸了一屏,值班同事一脸懵。
这场景我在过去半年里经历了不下 5 次。每次事后复盘都说"要加熔断",然后就没有然后了——直到第 6 次,我花了一个周末把这套东西写出来跑上了生产。
这篇文章把完整实现拆开讲,代码可以直接跑。
问题到底出在哪
LLM API 跟传统 REST API 不一样,它有几个特别恶心的特点:
- 延迟不稳定。 同一个模型,相同 prompt,响应时间可能从 800ms 到 30s 不等。你没法用固定超时。
- 错误类型多。 429(限流)、500(服务挂了)、超时、返回空内容、返回格式错误——每种要不同处理策略。
- 成本差异大。 GPT-4o 每百万 token $5,DeepSeek V4 ¥1。切换模型不只是备份,还影响账单。
- 降级不等于失败。 用户可能感知不到你从 GPT-4o 切到了 DeepSeek,但感知得到 10 秒没响应。
传统做法是在业务代码里写一堆 try-except,散落在各处。模型一多、调用点一多,就成了屎山。
整体设计
我的方案分三层:
业务代码
↓
LLMGateway(统一入口,选模型、分发请求)
↓
ModelEndpoint(每个模型一个,带独立熔断器 + 重试逻辑)
↓
CircuitBreaker(状态机:closed → open → half_open → closed)
核心思路:业务代码只管调 gateway.chat(messages),不关心底下用的哪个模型。模型挂了自动切,切回来也自动——业务层零感知。
熔断器实现
先写最底层的熔断器。状态机三个状态:
- closed(正常):请求正常通过,记录失败次数
- open(熔断):拒绝所有请求,等冷却时间过了进入 half_open
- half_open(试探):放一个请求进去,成功就回 closed,失败就回 open
import asyncio
import time
from enum import Enum
from dataclasses import dataclass, field
class BreakerState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class CircuitBreaker:
failure_threshold: int = 3
recovery_timeout: float = 60.0
half_open_max: int = 1
state: BreakerState = field(default=BreakerState.CLOSED, init=False)
failure_count: int = field(default=0, init=False)
last_failure_time: float = field(default=0.0, init=False)
half_open_calls: int = field(default=0, init=False)
_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)
async def can_proceed(self) -> bool:
async with self._lock:
if self.state == BreakerState.CLOSED:
return True
if self.state == BreakerState.OPEN:
elapsed = time.monotonic() - self.last_failure_time
if elapsed >= self.recovery_timeout:
self.state = BreakerState.HALF_OPEN
self.half_open_calls = 0
return True
return False
# half_open: 只放有限请求
if self.half_open_calls < self.half_open_max:
self.half_open_calls += 1
return True
return False
async def record_success(self):
async with self._lock:
self.failure_count = 0
if self.state == BreakerState.HALF_OPEN:
self.state = BreakerState.CLOSED
async def record_failure(self):
async with self._lock:
self.failure_count += 1
self.last_failure_time = time.monotonic()
if self.state == BreakerState.HALF_OPEN:
self.state = BreakerState.OPEN
elif self.failure_count >= self.failure_threshold:
self.state = BreakerState.OPEN
几个设计上的考量:
用 time.monotonic() 不用 time.time()。 系统时间可能被 NTP 调整,monotonic 保证单调递增,算冷却时间更靠谱。
asyncio.Lock 保证状态一致。 并发场景下多个协程同时调 record_failure,不加锁可能 failure_count 算错。
half_open 限制试探次数。 默认只放 1 个请求试探。如果试探也失败了,直接回 open,不浪费更多请求。
模型端点:重试 + 超时 + 分类错误处理
每个 LLM 模型封装成一个 ModelEndpoint,内含熔断器和重试逻辑:
import httpx
import json
import logging
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class ModelEndpoint:
name: str
base_url: str
api_key: str
model: str
timeout: float = 30.0
max_retries: int = 2
priority: int = 0 # 数字越小优先级越高
breaker: CircuitBreaker = field(default_factory=CircuitBreaker, init=False)
async def chat(self, messages: list[dict], **kwargs) -> dict:
if not await self.breaker.can_proceed():
raise BreakerOpenError(f"{self.name} 熔断中")
last_error = None
for attempt in range(self.max_retries + 1):
try:
result = await self._do_request(messages, **kwargs)
await self.breaker.record_success()
return result
except RetryableError as e:
last_error = e
if attempt < self.max_retries:
wait = min(2 ** attempt * 0.5, 8.0)
logger.warning(
f"{self.name} 第{attempt+1}次失败,{wait}s后重试: {e}"
)
await asyncio.sleep(wait)
except NonRetryableError as e:
await self.breaker.record_failure()
raise
await self.breaker.record_failure()
raise last_error
async def _do_request(self, messages, **kwargs) -> dict:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {"model": self.model, "messages": messages, **kwargs}
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
)
if resp.status_code == 429:
retry_after = resp.headers.get("retry-after")
raise RetryableError(
f"429 限流, retry-after={retry_after}"
)
if resp.status_code >= 500:
raise RetryableError(f"服务端错误 {resp.status_code}")
if resp.status_code != 200:
raise NonRetryableError(
f"客户端错误 {resp.status_code}: {resp.text[:200]}"
)
data = resp.json()
content = data.get("choices", [{}])[0].get("message", {}).get("content")
if not content:
raise RetryableError("返回内容为空")
return data
class BreakerOpenError(Exception):
pass
class RetryableError(Exception):
pass
class NonRetryableError(Exception):
pass
这里有几个容易踩的坑:
坑 1:超时设置。 LLM 响应时间差异巨大,一开始我设 10 秒,结果 GPT-4o 生成长文本时经常超时触发熔断。后来改成 30 秒,复杂任务甚至 60 秒。建议根据模型和任务类型动态设置。
坑 2:429 要区分对待。 429 是限流不是故障,重试一般能恢复。但 401(认证失败)重试 100 次也没用,所以分成 RetryableError 和 NonRetryableError 两类。
坑 3:空响应。 DeepSeek 偶尔返回 200 但 content 为空。不检查的话下游直接炸。我把空响应归为 RetryableError,重试一次通常能拿到正常结果。
坑 4:退避策略。 指数退避用 2^attempt * 0.5,上限 8 秒。第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒。不加上限的话第 10 次重试要等 8 分钟,用户早跑了。
网关层:多模型自动切换
网关是业务代码的唯一入口,负责按优先级尝试各个模型:
@dataclass
class LLMGateway:
endpoints: list[ModelEndpoint] = field(default_factory=list)
def __post_init__(self):
self.endpoints.sort(key=lambda e: e.priority)
async def chat(self, messages: list[dict], **kwargs) -> dict:
errors = []
for endpoint in self.endpoints:
try:
result = await endpoint.chat(messages, **kwargs)
result["_model_used"] = endpoint.name
return result
except BreakerOpenError:
logger.info(f"跳过 {endpoint.name}(熔断中)")
errors.append((endpoint.name, "breaker_open"))
continue
except (RetryableError, NonRetryableError) as e:
logger.warning(f"{endpoint.name} 失败: {e}")
errors.append((endpoint.name, str(e)))
continue
error_summary = "; ".join(f"{n}: {e}" for n, e in errors)
raise AllModelsFailedError(
f"全部 {len(self.endpoints)} 个模型都失败了: {error_summary}"
)
class AllModelsFailedError(Exception):
pass
返回结果里塞了 _model_used 字段——这个后面会用到。下游可以根据实际用了哪个模型做日志记录、成本核算。
实际使用
组装起来:
import os
gateway = LLMGateway(endpoints=[
ModelEndpoint(
name="gpt-4o",
base_url="https://api.openai.com/v1",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-4o",
priority=0,
timeout=30.0,
),
ModelEndpoint(
name="claude-sonnet",
base_url="https://api.anthropic.com/v1",
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-sonnet-4-20250514",
priority=1,
timeout=30.0,
),
ModelEndpoint(
name="deepseek-v4",
base_url="https://api.deepseek.com/v1",
api_key=os.environ["DEEPSEEK_API_KEY"],
model="deepseek-chat",
priority=2,
timeout=45.0,
),
])
async def main():
result = await gateway.chat([
{"role": "user", "content": "用一句话解释量子计算"}
])
content = result["choices"][0]["message"]["content"]
used = result.get("_model_used", "unknown")
print(f"[{used}] {content}")
asyncio.run(main())
实际跑起来的日志长这样:
WARNING - gpt-4o 第1次失败,0.5s后重试: 429 限流, retry-after=2
WARNING - gpt-4o 第2次失败,1.0s后重试: 429 限流, retry-after=2
WARNING - gpt-4o 失败: 429 限流, retry-after=2
INFO - 跳过 gpt-4o(熔断中)
[claude-sonnet] 量子计算利用量子比特的叠加和纠缠特性,在特定问题上实现指数级加速。
GPT-4o 连续 3 次 429 → 触发熔断 → 自动切到 Claude → 用户拿到正常结果。整个过程业务代码完全无感。
生产环境的几个补充
跑了两个月,补了几块东西:
1. 熔断状态监控
熔断器状态必须暴露出来,不然出问题了你都不知道哪个模型挂了。我用 Prometheus 指标:
from prometheus_client import Gauge, Counter
breaker_state = Gauge(
"llm_breaker_state",
"熔断器状态 0=closed 1=open 2=half_open",
["model"]
)
request_total = Counter(
"llm_request_total",
"请求总数",
["model", "status"]
)
# 在 ModelEndpoint.chat 里加:
async def chat(self, messages, **kwargs):
request_total.labels(model=self.name, status="attempt").inc()
try:
result = await self._original_chat(messages, **kwargs)
request_total.labels(model=self.name, status="success").inc()
return result
except Exception as e:
request_total.labels(model=self.name, status="failure").inc()
raise
Grafana 上看到某个模型 breaker_state 变成 1(open),就知道该去查日志了。
2. 动态超时
不同任务需要不同超时。生成一段摘要 10 秒够了,写一篇 3000 字文章可能要 60 秒。我加了个参数覆盖:
result = await gateway.chat(
messages,
_timeout_override=60.0, # 覆盖默认超时
)
在 _do_request 里检查 kwargs 有没有 _timeout_override,有就用它替代默认值。
3. 成本追踪
每次请求记录 token 用量和实际花费。这个数据积累起来之后能做成本优化——比如发现 80% 的简单查询都在用 GPT-4o,其实 DeepSeek 就够了,可以调优先级把简单任务路由到便宜模型。
@dataclass
class UsageRecord:
model: str
prompt_tokens: int
completion_tokens: int
cost_usd: float
latency_ms: float
timestamp: float
# 每次请求后记一条
usage = UsageRecord(
model=endpoint.name,
prompt_tokens=data["usage"]["prompt_tokens"],
completion_tokens=data["usage"]["completion_tokens"],
cost_usd=calculate_cost(endpoint.name, data["usage"]),
latency_ms=(time.monotonic() - start) * 1000,
timestamp=time.time(),
)
跑了一个月后的数据:
| 模型 | 请求占比 | 平均延迟 | 月成本占比 |
|---|---|---|---|
| GPT-4o | 72% | 1.8s | 85% |
| Claude Sonnet | 18% | 2.1s | 12% |
| DeepSeek V4 | 10% | 1.2s | 3% |
GPT-4o 吃了 85% 的钱,但其中大部分请求其实不需要那么强的模型。后来我加了个简单路由:消息长度 < 500 字且没有 system prompt 的直接走 DeepSeek,月成本降了 40%。
4. 健康检查与自动恢复
熔断器的冷却时间是固定的(默认 60 秒),但有时候 OpenAI 可能挂半小时。加一个后台任务定期 ping 各模型的健康检查端点:
async def health_check_loop(gateway: LLMGateway, interval: float = 30.0):
while True:
for ep in gateway.endpoints:
try:
await ep.chat(
[{"role": "user", "content": "hi"}],
max_tokens=1,
)
if ep.breaker.state != BreakerState.CLOSED:
logger.info(f"{ep.name} 恢复正常")
except Exception:
pass
await asyncio.sleep(interval)
这个循环 30 秒跑一次,用最小 token 的请求探测模型是否恢复。检测到恢复后,熔断器会自动回到 closed 状态。
没解决的问题
坦白说几个还没搞定的:
流式响应的熔断判断。 流式场景下,连接建立了、前几个 token 也收到了,但中途断了——这算成功还是失败?目前我的做法是流式中断算失败,但这会导致长文本生成时误触发熔断。还在想更好的方案。
模型能力不等价。 GPT-4o 和 DeepSeek V4 在代码生成上差距不大,但在某些推理任务上有差距。降级到弱模型后,返回质量下降了但不会报错——这种"静默降级"比直接报错更难发现。
多区域部署。 如果你的服务部署在多个区域,每个区域的熔断器状态是独立的。A 区发现 OpenAI 挂了,B 区还在往上打,等 B 区也触发熔断又浪费了一波请求。需要共享熔断状态,但这又引入了分布式系统的复杂度。
总结几条经验
- 熔断阈值别设太低。 一开始我设连续 2 次失败就熔断,结果网络抖一下就触发了。改成 3-5 次比较合理。
- 冷却时间要可配置。 不同模型供应商的恢复速度不一样,OpenAI 一般几分钟就好,某些小供应商可能半小时。
- 日志要记模型名。 出问题排查的时候,如果日志里不知道这个请求走的哪个模型,基本就是盲猜。
- 先加监控再加熔断。 没有监控的熔断等于盲人摸象。先搞清楚每个模型的错误率和延迟分布,再决定阈值。
- 降级策略跟产品讨论。 "用便宜模型替代"这个决定不是纯技术决定。用户能不能接受质量下降?哪些场景可以降级?这些要跟产品经理对齐。
完整代码我放在了 GitHub Gist 上,大概 300 行,可以直接拿去用。生产环境跑了两个月,处理了大约 50 万次请求,可用性从之前的 97.2% 提到了 99.8%。