LLM 应用的熔断降级工程实践:Circuit Breaker 不只是重试的升级版

你以为给 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 次:等 1 秒重试
  2. 第 2 次:等 2 秒重试
  3. 第 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 + bulkhead
  • opossum: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
相关推荐
程序员爱钓鱼1 小时前
Go 开发环境安装(Windows、macOS、Linux)
后端·面试·go
程序员爱钓鱼1 小时前
Rust String 与 &str 详解:字符串所有权、借用与转换
前端·后端·rust
倔强的石头_9 小时前
不想每次都从头解释:我用 Doubao-Seed-Evolving 做了一个「稿件接力站」
ai编程
阳光是sunny10 小时前
LangGraph中的Reducer是什么
前端·人工智能·后端
灯澜忆梦10 小时前
GO_并发编程---定时器
开发语言·后端·golang
阳光是sunny10 小时前
从链到图:LangGraph 入门基础全解析
前端·人工智能·后端
皮皮林55110 小时前
开源实力派,给本地 AI 装上深度调研能力,一张 3090 跑到 95.7 分!
后端
wangruofeng11 小时前
opencodex 解锁 Codex 任意模型,一个本地代理打通 Claude/Kimi/GLM/DeepSeek
llm·github·openai
wangruofeng11 小时前
姚顺雨长谈:在 Anthropic 和 Gemini 训练模型,英雄主义已经过时
llm·aigc