你以为给 LLM 调用加个重试就够了?直到某天凌晨,某国产大模型 API 挂了 3 小时,你的 retry storm 把下游打垮了,你才意识到熔断和降级是两回事。
背景:为什么重试不够
2025 年以来,主流 LLM 提供商的 API 可用性已经相当高------国内外主流大模型的 P99 可用性都在 99.5% 以上。听起来不错,但放到具体场景里:
- 每天 10 万次调用,0.5% 失败 = 500 次失败
- P999 延迟 经常超过 30 秒(尤其是大上下文窗口)
- 限流(429) 在业务高峰期是常态,不是偶发
- 提供商级故障(整个服务 degraded)每季度至少 1-2 次
面对这些,大多数团队的第一反应是加重试:
python
# 最常见的"修复"方式
for attempt in range(3):
try:
response = client.chat.completions.create(...)
break
except Exception as e:
if attempt == 2:
raise
time.sleep(2 ** attempt)
这段代码在瞬时故障 (网络抖动、短暂限流)下完全够用。但它有一个致命盲点:它不知道失败是否持续。
当某大模型 API 服务 degraded,上面的代码会:
- 第 1 次:等 1 秒重试
- 第 2 次:等 2 秒重试
- 第 3 次:抛异常
问题是,每个请求都要经历完整的超时等待,整个系统的吞吐量直接崩塌。更糟糕的是,大量并发重试会形成 retry storm,进一步加剧提供商压力,让恢复时间更长。
这就是 Circuit Breaker(熔断器) 要解决的核心问题。
Circuit Breaker 的状态机
Circuit Breaker 最早由 Michael Nygard 在《Release It!》中系统化,本质是一个三状态机:
sql
失败次数超阈值 超时后半开
CLOSED ─────────────────→ OPEN ──────────────→ HALF-OPEN
↑ │
│ 探测请求成功 │
└────────────────────────────────────────────────┘
探测请求失败 → 回到 OPEN
| 状态 | 行为 | 触发条件 |
|---|---|---|
| CLOSED | 正常放行所有请求 | 初始状态 |
| OPEN | 立即拒绝所有请求,不发出网络调用 | 失败率超过阈值 |
| HALF-OPEN | 放行少量探测请求 | OPEN 状态持续 N 秒后 |
关键点:OPEN 状态下不发出任何网络调用 。这与重试的根本区别在于:熔断器主动隔离故障,而不是一遍遍消耗等待时间。
LLM 场景的特殊性
传统微服务的 Circuit Breaker(比如 Hystrix 或 Resilience4j)通常基于:
- 失败率(error rate)
- 延迟阈值
- 并发限制
但 LLM 调用有几个特殊情况,直接套用传统方案会踩坑:
坑 1:429 限流 ≠ 服务故障
python
# 错误做法:把 429 也计入熔断失败
def is_failure(error):
return isinstance(error, Exception) # 429 也触发熔断
# 正确做法:区分限流和故障
def is_failure(error):
if hasattr(error, 'status_code'):
if error.status_code == 429:
return False # 限流用单独的 rate limiter 处理
if error.status_code in (500, 502, 503):
return True # 服务端错误才触发熔断
return True
429 是"你请求太多了",服务本身是健康的。如果把 429 计入熔断失败,高并发时熔断器会被自己打开。
坑 2:超时阈值设置
LLM 的正常响应时间跨度极大:
- 小模型(Qwen-Turbo):1-5 秒
- 大模型首 token(DeepSeek-R1):3-15 秒
- 长上下文推理:可达 60-120 秒
一个统一的超时阈值(比如 30 秒)在小模型场景会虚报"慢",在长推理场景会误判"超时"。
解法:按模型分类设置超时,而不是全局统一阈值。
python
TIMEOUT_MAP = {
"qwen-turbo": 15,
"qwen-max": 30,
"deepseek-chat": 30,
"deepseek-r1": 90, # 推理模型时间更长
"glm-4": 20,
}
def get_timeout(model: str) -> int:
return TIMEOUT_MAP.get(model, 45)
坑 3:慢响应不等于超时
服务 degraded 时,一个很常见的表现是:请求没有超时,但响应时间从 P50=2s 变成了 P50=25s。如果你的熔断器只看失败率,它不会被触发------但用户体验已经崩了。
解法:增加 P95 延迟作为熔断触发维度。
生产级实现
下面是一个完整的、适用于 LLM 生产场景的 Circuit Breaker 实现:
python
import time
import threading
from collections import deque
from enum import Enum
from dataclasses import dataclass, field
from typing import Callable, Optional, Any
import logging
logger = logging.getLogger(__name__)
class State(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class CircuitBreakerConfig:
# 触发熔断的条件
failure_rate_threshold: float = 0.5 # 50% 失败率触发
slow_call_rate_threshold: float = 0.8 # 80% 慢响应触发
slow_call_duration_threshold: float = 10.0 # 超过 10s 算慢响应
minimum_calls: int = 10 # 至少 10 次调用才计算比率
# 滑动窗口
sliding_window_size: int = 20 # 统计最近 20 次调用
# OPEN -> HALF_OPEN
wait_duration_in_open_state: float = 30.0 # 熔断 30 秒后进入半开
# HALF_OPEN 探测
permitted_calls_in_half_open: int = 3 # 半开状态放行 3 个请求
# 不计入熔断失败的错误类型
ignore_exceptions: tuple = (RateLimitError,)
@dataclass
class CallRecord:
success: bool
duration: float
timestamp: float = field(default_factory=time.monotonic)
class LLMCircuitBreaker:
"""
LLM 场景专用熔断器
- 区分限流(429)和服务故障
- 支持慢响应熔断
- 线程安全
"""
def __init__(self, name: str, config: CircuitBreakerConfig):
self.name = name
self.config = config
self._lock = threading.RLock()
self._state = State.CLOSED
self._records: deque = deque(maxlen=config.sliding_window_size)
self._open_time: Optional[float] = None
self._half_open_calls = 0
self._half_open_successes = 0
@property
def state(self) -> State:
with self._lock:
if self._state == State.OPEN:
elapsed = time.monotonic() - self._open_time
if elapsed >= self.config.wait_duration_in_open_state:
logger.info(f"[CB:{self.name}] OPEN→HALF_OPEN after {elapsed:.1f}s")
self._state = State.HALF_OPEN
self._half_open_calls = 0
self._half_open_successes = 0
return self._state
def is_request_allowed(self) -> bool:
s = self.state
if s == State.CLOSED:
return True
if s == State.OPEN:
return False
# HALF_OPEN
with self._lock:
if self._half_open_calls < self.config.permitted_calls_in_half_open:
self._half_open_calls += 1
return True
return False
def record_success(self, duration: float):
with self._lock:
self._records.append(CallRecord(success=True, duration=duration))
if self._state == State.HALF_OPEN:
self._half_open_successes += 1
if self._half_open_successes >= self.config.permitted_calls_in_half_open:
logger.info(f"[CB:{self.name}] HALF_OPEN→CLOSED (probe succeeded)")
self._state = State.CLOSED
def record_failure(self, duration: float, exception: Exception):
# 忽略不应触发熔断的错误(如 429)
if isinstance(exception, self.config.ignore_exceptions):
logger.debug(f"[CB:{self.name}] Ignoring {type(exception).__name__} (not a circuit failure)")
return
with self._lock:
self._records.append(CallRecord(success=False, duration=duration))
if self._state == State.HALF_OPEN:
logger.warning(f"[CB:{self.name}] HALF_OPEN→OPEN (probe failed)")
self._open_time = time.monotonic()
self._state = State.OPEN
return
self._maybe_trip()
def _maybe_trip(self):
"""检查是否应该打开熔断器(在锁内调用)"""
records = list(self._records)
if len(records) < self.config.minimum_calls:
return
failure_count = sum(1 for r in records if not r.success)
slow_count = sum(
1 for r in records
if r.success and r.duration > self.config.slow_call_duration_threshold
)
failure_rate = failure_count / len(records)
slow_rate = slow_count / len(records)
should_open = (
failure_rate >= self.config.failure_rate_threshold or
slow_rate >= self.config.slow_call_rate_threshold
)
if should_open and self._state == State.CLOSED:
logger.warning(
f"[CB:{self.name}] CLOSED→OPEN "
f"(failure_rate={failure_rate:.1%}, slow_rate={slow_rate:.1%})"
)
self._state = State.OPEN
self._open_time = time.monotonic()
def call(self, fn: Callable, *args, **kwargs) -> Any:
if not self.is_request_allowed():
raise CircuitBreakerOpenError(
f"Circuit breaker '{self.name}' is OPEN. "
f"Will retry after {self._remaining_open_time():.0f}s"
)
start = time.monotonic()
try:
result = fn(*args, **kwargs)
self.record_success(time.monotonic() - start)
return result
except Exception as e:
self.record_failure(time.monotonic() - start, e)
raise
def _remaining_open_time(self) -> float:
if self._open_time is None:
return 0
elapsed = time.monotonic() - self._open_time
return max(0, self.config.wait_duration_in_open_state - elapsed)
class CircuitBreakerOpenError(Exception):
pass
class RateLimitError(Exception):
"""429 错误,不计入熔断失败"""
pass
与降级策略的结合
熔断器本身只做两件事:放行 或拒绝。拒绝了之后怎么处理,是降级策略的事。
常见的 LLM 降级链:
css
主模型(DeepSeek-R1)
↓ [熔断/超时]
备用模型(Qwen-Max)
↓ [熔断/超时]
轻量模型(Qwen-Turbo)
↓ [熔断/超时]
缓存响应(语义缓存命中)
↓ [未命中]
规则响应(硬编码 fallback)
↓ [不适用]
优雅错误提示(告知用户稍后重试)
实现这条降级链:
python
import asyncio
from typing import AsyncIterator
class LLMFallbackChain:
def __init__(self, semantic_cache=None):
self.providers = [
{
"name": "deepseek-r1",
"cb": LLMCircuitBreaker("deepseek-r1", CircuitBreakerConfig(
slow_call_duration_threshold=60.0,
)),
"client": DeepSeekClient(model="deepseek-reasoner"),
},
{
"name": "qwen-max",
"cb": LLMCircuitBreaker("qwen-max", CircuitBreakerConfig(
slow_call_duration_threshold=30.0,
)),
"client": QwenClient(model="qwen-max"),
},
{
"name": "qwen-turbo",
"cb": LLMCircuitBreaker("qwen-turbo", CircuitBreakerConfig(
slow_call_duration_threshold=15.0,
)),
"client": QwenClient(model="qwen-turbo"),
},
]
self.semantic_cache = semantic_cache
async def complete(self, messages: list, **kwargs) -> str:
# 先查语义缓存
if self.semantic_cache:
cached = await self.semantic_cache.lookup(messages)
if cached:
logger.info("[Fallback] Cache hit")
return cached
last_error = None
for provider in self.providers:
cb = provider["cb"]
if not cb.is_request_allowed():
logger.info(f"[Fallback] Skipping {provider['name']}: CB OPEN")
continue
try:
result = await self._call_with_timeout(
provider["client"],
messages,
timeout=kwargs.get("timeout", 45),
**kwargs
)
return result
except CircuitBreakerOpenError as e:
logger.info(f"[Fallback] {provider['name']} CB blocked: {e}")
continue
except Exception as e:
logger.warning(f"[Fallback] {provider['name']} failed: {e}")
last_error = e
continue
# 所有提供商都失败,返回保底响应
raise AllProvidersFailedError(
f"All LLM providers unavailable. Last error: {last_error}"
)
async def _call_with_timeout(self, client, messages, timeout, **kwargs):
return await asyncio.wait_for(
client.complete(messages, **kwargs),
timeout=timeout
)
生产中的 5 个踩坑
坑 1:熔断器粒度太粗
错误做法:整个 LLM 提供商 API 共用一个熔断器。
问题:Chat API 挂了,但 Embeddings API 正常,结果 Embeddings 也被熔断了。
正确做法 :按 (提供商 + 接口类型 + 模型) 分别维护熔断器:
python
circuit_breakers = {
"qwen:chat:qwen-max": LLMCircuitBreaker("qwen:chat:qwen-max", ...),
"qwen:chat:qwen-turbo": LLMCircuitBreaker("qwen:chat:qwen-turbo", ...),
"qwen:embeddings:text-embedding-v3": LLMCircuitBreaker(...),
"deepseek:chat:deepseek-r1": LLMCircuitBreaker(...),
}
坑 2:没有 Probe 请求限制
错误做法:HALF_OPEN 状态下不限制并发探测请求数量。
问题:积压的请求在熔断器进入 HALF_OPEN 时全部涌入,实际上形成了另一次 storm。
正确做法 :HALF_OPEN 状态严格控制探测请求数(上面代码中的 permitted_calls_in_half_open),其余请求继续快速失败。
坑 3:Jitter 缺失导致 Thundering Herd
多个服务实例同时维护独立熔断器,它们会在同一时刻转入 HALF_OPEN,同时发出探测请求。
解法:在 wait_duration_in_open_state 上加随机抖动:
python
import random
def time_until_half_open(self) -> float:
base = self.config.wait_duration_in_open_state
jitter = random.uniform(0, base * 0.2) # ±20% 抖动
return base + jitter
坑 4:熔断状态不共享
单机熔断器无法感知整个集群的故障率。节点 A 触发熔断,节点 B 还在盲目重试。
解法:用 Redis 共享熔断状态:
python
import redis
import json
class RedisCircuitBreaker(LLMCircuitBreaker):
"""集群级熔断器,状态存 Redis"""
def __init__(self, name: str, config: CircuitBreakerConfig, redis_client: redis.Redis):
super().__init__(name, config)
self.redis = redis_client
self.redis_key = f"cb:state:{name}"
def _get_state_from_redis(self) -> dict:
data = self.redis.get(self.redis_key)
if data:
return json.loads(data)
return {"state": "closed", "open_time": None}
def _set_state_to_redis(self, state: str, open_time: Optional[float]):
self.redis.set(
self.redis_key,
json.dumps({"state": state, "open_time": open_time}),
ex=300 # 5 分钟过期,防止 Redis 故障导致熔断器永久 OPEN
)
注意:Redis 本身也可能挂。记得设置 TTL 和本地状态兜底,避免 Redis 故障导致熔断器永久 OPEN。
坑 5:熔断和限流器职责混淆
这是最常见的架构错误。把两者功能合并到一个组件里。
| 组件 | 职责 | 触发条件 |
|---|---|---|
| Rate Limiter | 控制发出请求的速率 | 主动,在请求发出前 |
| Circuit Breaker | 隔离故障的下游 | 被动,在检测到故障后 |
| Retry | 恢复瞬时失败 | 被动,在单次调用失败后 |
三者应该组合使用,而不是互相替代:
请求 → Rate Limiter → Circuit Breaker → Retry → LLM API
可观测性:你的熔断器在干什么?
熔断器不打日志等于黑盒。必须暴露的指标:
python
from prometheus_client import Counter, Gauge, Histogram
class InstrumentedCircuitBreaker(LLMCircuitBreaker):
def __init__(self, name: str, config: CircuitBreakerConfig):
super().__init__(name, config)
self.state_gauge = Gauge(
"llm_circuit_breaker_state",
"Circuit breaker state (0=closed, 1=open, 2=half_open)",
["circuit"]
)
self.requests_total = Counter(
"llm_circuit_breaker_requests_total",
"Total requests through circuit breaker",
["circuit", "result"] # result: allowed, rejected, success, failure
)
self.latency_histogram = Histogram(
"llm_circuit_breaker_call_duration_seconds",
"Call duration in seconds",
["circuit", "outcome"],
buckets=[0.1, 0.5, 1, 5, 10, 30, 60, 120]
)
def is_request_allowed(self) -> bool:
allowed = super().is_request_allowed()
state_map = {State.CLOSED: 0, State.OPEN: 1, State.HALF_OPEN: 2}
self.state_gauge.labels(circuit=self.name).set(state_map[self._state])
result = "allowed" if allowed else "rejected"
self.requests_total.labels(circuit=self.name, result=result).inc()
return allowed
def record_success(self, duration: float):
super().record_success(duration)
self.requests_total.labels(circuit=self.name, result="success").inc()
self.latency_histogram.labels(circuit=self.name, outcome="success").observe(duration)
def record_failure(self, duration: float, exception: Exception):
super().record_failure(duration, exception)
self.requests_total.labels(circuit=self.name, result="failure").inc()
self.latency_histogram.labels(circuit=self.name, outcome="failure").observe(duration)
告警规则(Prometheus AlertManager):
yaml
groups:
- name: llm_circuit_breaker
rules:
- alert: LLMCircuitBreakerOpen
expr: llm_circuit_breaker_state == 1
for: 1m
labels:
severity: warning
annotations:
summary: "Circuit breaker {{ $labels.circuit }} is OPEN"
description: "LLM circuit '{{ $labels.circuit }}' has been open for > 1 minute"
- alert: LLMCircuitBreakerHighRejectionRate
expr: |
rate(llm_circuit_breaker_requests_total{result="rejected"}[5m]) /
rate(llm_circuit_breaker_requests_total[5m]) > 0.3
for: 5m
labels:
severity: critical
annotations:
summary: "High rejection rate on {{ $labels.circuit }}"
真实场景:大模型 API 间歇性 503 的处理
以一个具体故障场景为例。某次国内主流 LLM 平台 ChatCompletions API 出现间歇性 503,持续约 40 分钟。
没有熔断器的系统表现:
- P50 延迟:从 2s → 45s(等待超时)
- 错误率:30% → 80%(503 + 重试耗尽)
- 系统吞吐量:下降 70%
有熔断器的系统表现:
- 前 10 次失败后(约 90 秒)触发熔断
- 熔断期间:所有请求立即 fallback 到 Qwen-Turbo
- 延迟:维持在 P50 = 3-4s(fallback 模型)
- 30 秒后进入 HALF_OPEN,探测到主模型恢复
- 自动回切主模型
关键数据对比:
| 指标 | 无熔断器 | 有熔断器 |
|---|---|---|
| 故障期间 P50 延迟 | 45s | 3.5s |
| 用户可见错误率 | 80% | 8%(fallback 质量差异) |
| 系统吞吐量下降 | 70% | 15% |
| 自动恢复时间 | 需人工介入 | ~35 秒 |
使用现有库
不想从头造轮子?以下是生产中常用的选择:
Python:
pybreaker:轻量,接口干净,适合单机场景circuitbreaker:装饰器风格,对函数级熔断友好tenacity:主要是重试,但有RetryError可结合熔断
Node.js:
cockatiel:功能全面,支持 circuit breaker + retry + bulkheadopossum:GitHub 12k+ star,API 设计合理
网关层(推荐):
- LiteLLM:内置 circuit breaker 和 fallback,配置驱动,支持国内主流大模型
- 国内各云厂商 AI 网关:阿里云、腾讯云的 AI 网关均提供熔断配置
如果你用 LiteLLM,开启熔断只需几行配置:
python
import litellm
litellm.set_verbose = True
# 配置熔断
litellm.circuit_breaker = {
"deepseek": {
"failure_threshold": 5, # 5 次失败触发
"recovery_timeout": 30, # 30 秒后尝试恢复
"success_threshold": 2, # 2 次成功恢复
}
}
# 配置 fallback 链
response = litellm.completion(
model="deepseek/deepseek-chat",
messages=[{"role": "user", "content": "Hello"}],
fallbacks=["qwen/qwen-max", "qwen/qwen-turbo"],
)
决策树:我该在哪里加熔断?
yaml
你的 LLM 调用有哪些特征?
│
├── 调用量 < 1000 次/天
│ └── → 简单重试 + 错误报警就够,不需要熔断器
│
├── 调用量 1000-10万 次/天
│ ├── 单机部署 → pybreaker/cockatiel 单机熔断
│ └── 多机部署 → Redis 共享状态
│
└── 调用量 > 10万 次/天
├── 有 API 网关 → LiteLLM / 云厂商 AI 网关层熔断(推荐)
└── 无 API 网关 → 先建网关,再加熔断
经验法则:
- 超时阈值 = P99 正常延迟 × 2(不是 P50 × 5)
- 失败率阈值 = 40-60%(太低会误触发,太高等于没用)
- 窗口大小 = 15-30 次调用(太小抖动大,太大反应慢)
- 熔断持续时间 = 30-60 秒(给提供商时间恢复)
小结
| 场景 | 推荐方案 |
|---|---|
| 偶发瞬时错误 | 指数退避重试 |
| 提供商限流(429) | 独立 Rate Limiter + Retry-After |
| 提供商服务 degraded | Circuit Breaker + Fallback 链 |
| 整体 SLA 保障 | CB + Fallback + 语义缓存 + 规则兜底 |
Circuit Breaker 的核心价值不是减少失败次数,而是控制失败的传播速度。一个没有熔断的 LLM 调用链,就像一个没有保险丝的电路------某个节点过载,整条链路都烧了。
熔断器是电路保险丝,不是电池。它不给你能量,但它保护你已经有的东西。
参考资料:
- Michael Nygard, Release It! (2nd Edition)
- Portkey.ai: Retries, Fallbacks, and Circuit Breakers in LLM Apps (2025)
- Maxim.ai: A Production Guide to LLM Reliability Patterns (2026)
- ResearchGate: Resilient Integration of LLMs in Microservices Using Circuit Breakers (Dec 2025)
- AWS Architecture: Circuit Breaker Pattern