跳至内容
生产环境 LLM 网关:失败切换、语义缓存和成本控制

生产环境 LLM 网关:失败切换、语义缓存和成本控制

July 17, 2026

模型进真实流量后,最先爆的通常不是「不够聪明」,而是:供应商限流、相似请求刷爆 token、没人说得清哪个功能在烧钱。把这些都写进业务 try/except,三个月后每个服务都会有一份半残的重试逻辑。

本文将失败切换、预算控制与缓存策略落到可执行的配置与代码,便于在个人站或小团队环境中直接落地。

熔断后走备选链路

1. 失败切换:别把「重试」当成「降级」

反例:盲目重试同一上游

# 危险:上游已经半死,重试只会放大 p99
import time
from openai import OpenAI, APIStatusError

client = OpenAI()  # 直连某家

def chat_bad(prompt: str) -> str:
    last_err = None
    for i in range(3):
        try:
            r = client.chat.completions.create(
                model="gpt-4o",
                messages=[{"role": "user", "content": prompt}],
                timeout=30,
            )
            return r.choices[0].message.content or ""
        except APIStatusError as e:
            last_err = e
            time.sleep(2 ** i)
    raise RuntimeError(f"still failing: {last_err}")

上游若 30% 请求要等满 30s 才失败,三次重试可能把尾延迟拖到分钟级。

正例:网关声明主备链

# litellm config 片段
model_list:
  - model_name: primary
    litellm_params:
      model: anthropic/claude-sonnet-4-0
      api_key: os.environ/ANTHROPIC_API_KEY
  - model_name: secondary
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY
  - model_name: local
    litellm_params:
      model: ollama/qwen2.5:14b
      api_base: http://192.168.1.20:11434

router_settings:
  fallbacks:
    - primary: ["secondary", "local"]
  num_retries: 1          # 单上游最多轻试一次
  timeout: 45
  allowed_fails: 3        # 短窗内失败次数触发冷却
  cooldown_time: 30

客户端仍然只认逻辑名:

curl -s http://127.0.0.1:4000/v1/chat/completions \
  -H "Authorization: Bearer sk-xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "primary",
    "messages": [{"role":"user","content":"用一句话解释熔断"}]
  }'

2. 自己写一层「最小熔断器」(理解原理用)

即便最终用现成网关,也建议搞清楚状态机:CLOSED → OPEN → HALF_OPEN

# tiny_breaker.py
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Callable, TypeVar

T = TypeVar("T")

@dataclass
class CircuitBreaker:
    fail_threshold: int = 5
    cooldown_sec: float = 30.0
    fails: int = 0
    opened_at: float | None = None

    def allow(self) -> bool:
        if self.opened_at is None:
            return True
        if time.time() - self.opened_at >= self.cooldown_sec:
            return True  # HALF_OPEN:放行一次试探
        return False

    def record_success(self) -> None:
        self.fails = 0
        self.opened_at = None

    def record_failure(self) -> None:
        self.fails += 1
        if self.fails >= self.fail_threshold:
            self.opened_at = time.time()

@dataclass
class Upstream:
    name: str
    call: Callable[[str], str]
    breaker: CircuitBreaker = field(default_factory=CircuitBreaker)

def chat_with_fallback(prompt: str, chain: list[Upstream]) -> tuple[str, str]:
    errors: list[str] = []
    for u in chain:
        if not u.breaker.allow():
            errors.append(f"{u.name}: circuit open")
            continue
        try:
            text = u.call(prompt)
            u.breaker.record_success()
            return text, u.name
        except Exception as e:  # noqa: BLE001 — 示例聚合错误
            u.breaker.record_failure()
            errors.append(f"{u.name}: {e}")
    raise RuntimeError("; ".join(errors))

把它接到 OpenAI 兼容客户端时,每个 upstream 包一次 base_url 不同的调用即可。生产环境更推荐把熔断放在网关,避免每个语言各写一份。

3. 成本:先算得出来,再谈省

3.1 请求级估算

# cost_estimate.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Price:
    input_per_mTok: float
    output_per_mTok: float

PRICE = {
    "gpt-4o-mini": Price(0.15, 0.60),
    "gpt-4o": Price(2.50, 10.00),
    "claude-sonnet": Price(3.00, 15.00),
}

def estimate_usd(model: str, prompt_tokens: int, completion_tokens: int) -> float:
    p = PRICE[model]
    return (
        prompt_tokens / 1_000_000 * p.input_per_mTok
        + completion_tokens / 1_000_000 * p.output_per_mTok
    )

# 用法:从响应 usage 取值
# cost = estimate_usd("gpt-4o-mini", u.prompt_tokens, u.completion_tokens)

3.2 简易日预算(进程内演示)

# daily_budget.py
import threading
from datetime import date

class DailyBudget:
    def __init__(self, limit_usd: float):
        self.limit = limit_usd
        self._day = date.today()
        self._spent = 0.0
        self._lock = threading.Lock()

    def charge(self, usd: float) -> None:
        with self._lock:
            if date.today() != self._day:
                self._day = date.today()
                self._spent = 0.0
            if self._spent + usd > self.limit:
                raise RuntimeError(
                    f"budget exceeded: spent={self._spent:.4f} + {usd:.4f} > {self.limit}"
                )
            self._spent += usd

budget = DailyBudget(limit_usd=5.0)  # 调试环境一天 5 美元封顶

虚拟 Key / 团队预算在 LiteLLM 一类网关里可用 API 创建;思路相同:超限在网关拒绝,而不是让业务把真实 Key 打到账单爆掉

4. 语义缓存:只给「重复度高」的路由开

精确字符串缓存对聊天帮助有限;embedding 近似命中更适合 FAQ、固定模板摘要。

# semantic_cache.py —— 演示逻辑,生产可换 Redis + 向量库
from __future__ import annotations
import math
import hashlib
from dataclasses import dataclass

def fake_embed(text: str, dim: int = 32) -> list[float]:
    """演示用伪向量:稳定、无外部依赖。生产请换真实 embedding。"""
    h = hashlib.sha256(text.encode()).digest()
    vals = [((h[i % len(h)] / 255.0) * 2 - 1) for i in range(dim)]
    norm = math.sqrt(sum(v * v for v in vals)) or 1.0
    return [v / norm for v in vals]

def cosine(a: list[float], b: list[float]) -> float:
    return sum(x * y for x, y in zip(a, b))

@dataclass
class Entry:
    text: str
    vec: list[float]
    answer: str

class SemanticCache:
    def __init__(self, threshold: float = 0.92):
        self.threshold = threshold
        self.items: list[Entry] = []

    def get(self, prompt: str) -> str | None:
        q = fake_embed(prompt)
        best, score = None, -1.0
        for it in self.items:
            s = cosine(q, it.vec)
            if s > score:
                best, score = it, s
        if best and score >= self.threshold:
            return best.answer
        return None

    def put(self, prompt: str, answer: str) -> None:
        self.items.append(Entry(prompt, fake_embed(prompt), answer))

阈值经验:

  • 分类 / 检索问答:可从 0.90~0.93 试起
  • 代码生成:宁可不缓存,或只缓存「完全相同 prompt」
  • 每次命中都打日志:cache_hit=true, score=...,否则你会不知道是在省钱还是在答错

5. 案例:夜间脚本把月预算打穿

场景:

  • 有人写了个「全站文档重摘要」脚本,循环 3000 次打 gpt-4o
  • 直连 Key 放在 .env,无预算
  • 第二天账单报警

改造步骤:

  1. 脚本改打网关,使用 sk-batch-nightly 虚拟 Key
  2. 该 Key 绑定模型白名单:只允许 gpt-4o-mini / local-qwen
  3. max_budget 设为 $3 / 日
  4. 网关日志按 key_alias=batch-nightly 聚合

效果:超限返回明确错误,任务失败但主站聊天不受影响——爆炸半径被关进钥匙维度

6. 可执行清单

  • fallback 写在网关配置,而不是业务 if/else
  • 429 / 超时 / 5xx 分开处理;单上游重试 ≤ 1
  • 日志固定字段:model, upstream, latency_ms, tokens, cost_usd, cache_hit
  • 调试 Key 与生产 Key 分预算
  • 高重复路由才开语义缓存,并观察命中率与差评

AI 工程化不是堆更多模型,而是让失败、成本和可观测性可控。网关是这条链路的控制面。