用 Python 从零实现 LLM 多模型熔断降级——asyncio + 自动切换实战

21 阅读1分钟

线上跑着 3 个 LLM 供应商,凌晨 2 点 OpenAI 429 了,Claude 超时,DeepSeek 返回空响应。Slack 告警炸了一屏,值班同事一脸懵。

这场景我在过去半年里经历了不下 5 次。每次事后复盘都说"要加熔断",然后就没有然后了——直到第 6 次,我花了一个周末把这套东西写出来跑上了生产。

这篇文章把完整实现拆开讲,代码可以直接跑。

问题到底出在哪

LLM API 跟传统 REST API 不一样,它有几个特别恶心的特点:

  1. 延迟不稳定。 同一个模型,相同 prompt,响应时间可能从 800ms 到 30s 不等。你没法用固定超时。
  2. 错误类型多。 429(限流)、500(服务挂了)、超时、返回空内容、返回格式错误——每种要不同处理策略。
  3. 成本差异大。 GPT-4o 每百万 token $5,DeepSeek V4 ¥1。切换模型不只是备份,还影响账单。
  4. 降级不等于失败。 用户可能感知不到你从 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-4o72%1.8s85%
Claude Sonnet18%2.1s12%
DeepSeek V410%1.2s3%

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 区也触发熔断又浪费了一波请求。需要共享熔断状态,但这又引入了分布式系统的复杂度。

总结几条经验

  1. 熔断阈值别设太低。 一开始我设连续 2 次失败就熔断,结果网络抖一下就触发了。改成 3-5 次比较合理。
  2. 冷却时间要可配置。 不同模型供应商的恢复速度不一样,OpenAI 一般几分钟就好,某些小供应商可能半小时。
  3. 日志要记模型名。 出问题排查的时候,如果日志里不知道这个请求走的哪个模型,基本就是盲猜。
  4. 先加监控再加熔断。 没有监控的熔断等于盲人摸象。先搞清楚每个模型的错误率和延迟分布,再决定阈值。
  5. 降级策略跟产品讨论。 "用便宜模型替代"这个决定不是纯技术决定。用户能不能接受质量下降?哪些场景可以降级?这些要跟产品经理对齐。

完整代码我放在了 GitHub Gist 上,大概 300 行,可以直接拿去用。生产环境跑了两个月,处理了大约 50 万次请求,可用性从之前的 97.2% 提到了 99.8%。