生产环境 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,无预算 - 第二天账单报警
改造步骤:
- 脚本改打网关,使用
sk-batch-nightly虚拟 Key - 该 Key 绑定模型白名单:只允许
gpt-4o-mini/local-qwen max_budget设为 $3 / 日- 网关日志按
key_alias=batch-nightly聚合
效果:超限返回明确错误,任务失败但主站聊天不受影响——爆炸半径被关进钥匙维度。
6. 可执行清单
- fallback 写在网关配置,而不是业务 if/else
- 429 / 超时 / 5xx 分开处理;单上游重试 ≤ 1
- 日志固定字段:
model, upstream, latency_ms, tokens, cost_usd, cache_hit - 调试 Key 与生产 Key 分预算
- 高重复路由才开语义缓存,并观察命中率与差评
AI 工程化不是堆更多模型,而是让失败、成本和可观测性可控。网关是这条链路的控制面。