目录
- 前言
- [一、问题定义:不接网关,直连 LLM API 会出什么事](#一、问题定义:不接网关,直连 LLM API 会出什么事)
- [二、核心方案:LLM 网关能力清单 + 什么规模选什么](#二、核心方案:LLM 网关能力清单 + 什么规模选什么)
-
- [2.1 能力清单:每项为什么必须在网关层](#2.1 能力清单:每项为什么必须在网关层)
- [2.2 硬指标:支持按版本 ID 路由 Prompt / 从 registry 拉取](#2.2 硬指标:支持按版本 ID 路由 Prompt / 从 registry 拉取)
- [2.3 什么规模选什么](#2.3 什么规模选什么)
- [2.4 决策树:先收敛候选,再定规模](#2.4 决策树:先收敛候选,再定规模)
- 三、代码实战:一个可离线跑的自研网关决策骨架
- 四、踩坑记录:这几个坑每个都真付过费
-
- [4.1 fallback 粒度过粗:主厂商一坏,全网关涌向同一个备胎](#4.1 fallback 粒度过粗:主厂商一坏,全网关涌向同一个备胎)
- [4.2 限流阈值拍脑袋:全局一个桶照抄文档,大促一过误伤正常业务](#4.2 限流阈值拍脑袋:全局一个桶照抄文档,大促一过误伤正常业务)
- [4.3 网关吞错误:catch 所有异常返回"系统繁忙"](#4.3 网关吞错误:catch 所有异常返回“系统繁忙”)
- [五、选型对比:一张表 + 决策树 + 一份 checklist](#五、选型对比:一张表 + 决策树 + 一份 checklist)
- [六、总结 + 下一篇预告](#六、总结 + 下一篇预告)
前言
registry 上线第二周,转人工率又爬回来了。这次不是回滚错版本------是回滚了也没用:值班同学把版本指针切回旧版,等了十分钟,曲线没下来。查日志才看清:线上压根没人按版本 ID 路由------三个服务直连 LLM API,各自写各自的重试,key 散在三个环境变量里,只有一处接了 registry 的 get()。这篇是 LLM 网关选型,但我先不摆参数,只回答一个问题:什么规模,选什么网关,才能让"按版本 ID 路由 Prompt"落地。答案先放这儿:registry 管的是资产,网关管的是路由,这层决策必须收敛到一处------不然第四篇造的资产,线上就用不起来。

一、问题定义:不接网关,直连 LLM API 会出什么事
| 错 | 表现 | 为什么致命 |
|---|---|---|
| key 散落无审计 | 三个服务三个环境变量,谁调的、哪个 key、花了多少,全无记录 | 第六篇的安全拦截要挂的"输入前置",根本没有统一入口可挂 |
| 每个服务各写重试 | 各写各的 while 循环,主厂商一限流,所有服务同时重试 | 放大故障 + 成本爆炸,账单和错误一起涨 |
| 版本路由没落点 | registry 切了指针,但调用方不认版本 ID | 第四篇的资产被架空:指针切了,没人看 |
这几种错病在同一个地方:把 LLM 调用当成每个服务自己的事。网关不是"多一层代理",是序章支柱二"运行时韧性"的统一入口------路由、fallback、重试、限流、成本、安全、Trace,这几件事必须收敛到同一个入口做,才算一处决策。
二、核心方案:LLM 网关能力清单 + 什么规模选什么
选型不是"参数罗列",是"你的规模需要哪几项能力、哪些必须自研"。能力清单先摆,分档判断接着来,对比表和 checklist 收尾。
2.1 能力清单:每项为什么必须在网关层
| 能力 | 必须在网关层的理由 | 不在网关层的代价 |
|---|---|---|
| 统一接入 | 所有厂商归一成一个接口,业务只认一个 complete() | 业务代码跟着模型走,换模型改代码、发版 |
| 路由 + 负载均衡 | 主备分流、多模型按权重分流量是运营策略 | 写死在业务代码里,改策略要动多个服务 |
| fallback + 重试 | 坏了退给谁是全局决策,要一处说了算 | 每个服务各救各的,谁也不兜底,主厂商一坏全网乱撞 |
| 限流 | 恶意调用、超配额要在入口拦下 | 恶意调用直接打到模型层,抢占资源、账单爆炸 |
| 成本追踪 | 谁用了多少 token 要有一份总账 | 账单来了一眼黑,按服务摊不清 |
| 安全钩子 | 输入前置拦截 + 输出后置校验要有统一挂点 | 每个服务各挂各的护栏,漏一个就裸奔 |
| Trace | 一次请求跨模型/跨服务要能串起来 | 出事时只能看单点日志,全链路无从谈起 |
2.2 硬指标:支持按版本 ID 路由 Prompt / 从 registry 拉取
先说清一个容易混的点:2.1 的"路由"是模型路由(走哪个模型),这里的"路由"是按版本 ID 路由 Prompt(走哪一版 Prompt),两件事。上篇把"线上跑哪版"管成"一个版本指针",本篇的网关就是那个认指针的入口。具体到一次请求 :业务代码不再把 Prompt 文本写死传过来,而是只传一个 Prompt 名字(可带版本 ID),由网关在转发给模型前,把它翻译成 registry 里那一版的真实文本。这一下"名字 → 文本"的翻译,就是整件事的关键------翻译落在网关这一处,指针才有人看。为什么这条必须是硬指标?因为路由决策点只有落到网关层,第四篇的"回滚 = 切指针"才有运行时意义------指针切了,网关下一请求就取到新指针指向的 Prompt,这才叫回滚生效。自研网关直接调 registry.get(),一行的事;商业/开源网关逐项核对:支持从你自己的 registry 拉取 Prompt 吗?请求头能带版本 ID 吗?LiteLLM、Portkey 这类没有"版本化 Prompt 资产"概念的网关,这项不达标,就得在网关外面自包一层版本路由。
不过这条硬指标有个前提,别一刀切:硬指标按你的 Prompt 是不是业务逻辑来定。客服话术、抽取指令这种业务逻辑,必须按版本路由,这条是否决项;纯配置(换语气词)的团队,这条自动降级,别为它买单。这篇下面所有 ✓/✗,都是站在"你要版本路由"的立场打的。
2.3 什么规模选什么
| 档位 | 规模 | 选什么 | 一句话理由 |
|---|---|---|---|
| A | 小团队/快速验证(<10 并发) | 轻量代理起步(One API / LiteLLM),先收拢 key | 目标不是全能力,是把 key 收拢到一个统一入口 |
| B | 中规模生产(几十~几百 QPS) | 开源网关(LiteLLM / Portkey 开源版)+ 外层自包一层版本路由 | 网关本身开源可用,硬指标靠外层补齐 |
| C | 规模化/强合规(政企/高并发) | 自研决策骨架,或 Kong AI Gateway | 私有化优先,能力要能审计、要能自定义 |
2.4 决策树:先收敛候选,再定规模
先回答这几个问题,把候选圈到"必须满足的约束"(第五节给完整决策树):数据出不出得去?Prompt 要不要按版本路由?团队养不养得起自研运维?圈定之后,再回 2.3 的规模表定位起点。
三、代码实战:一个可离线跑的自研网关决策骨架
先交代自包含方案:磁盘上没有独立的 prompt_registry.py(第四篇的 registry 嵌在文章里),所以这份骨架内联了一个 15 行不到的极简 registry,够演示"按版本 ID 路由";生产里换成第四篇的完整版(内容哈希身份 / diff / rollback),resolve_prompt 一行不改。
骨架里几个关键设计,我按顺序说:① 决策与 I/O 分离 ------路由/fallback/重试/限流全是不碰外部 I/O 的决策逻辑,唯一碰外部世界的点是 Provider.complete(),藏在可 mock 接口后,和第三篇熔断状态机同款理由:决策逻辑必须能离线单测、回放、审计,不能被"跑起来要一个 key"拖下水;② Provider 接口 = "统一接入"的落点 ------所有厂商实现同一个 complete(),业务只认一个接口,换模型不换代码(序篇支柱二"多模型协议归一"的最小实现);③ 按版本 ID 路由 Prompt 是第一优先级 ------resolve_prompt 找不到版本就拒绝路由,不拿"最新的"糊弄过去;④ fallback 是配置驱动的纯函数 ------链从路由表读,按健康度和冷却期跳过,链写死 = 踩坑①;⑤ 重试是"决定式"的 ------只对可恢复错误(429/超时/5xx)重试,4xx 不重试,超 budget 停止走降级(序章支柱二 2.3:避免无限重试推高成本);⑥ 限流是令牌桶------按租户分桶(生产再细分场景/模型),耗尽返回 429,全局一个桶 = 踩坑②。
python
"""
LLM 网关决策骨架:统一路由 / fallback / 重试 / 限流 / 按版本 ID 路由 Prompt。
纯标准库实现(dataclasses / random / time),无第三方依赖、无任何 API 密钥------
决策逻辑和外部 I/O 分离,唯一碰外部的点是 Provider.complete(),藏在可 mock 接口后。
依赖安装:无(Python >= 3.10 即可)
python llm_gateway.py # 跑完整演示
python test_llm_gateway.py # 跑 5 个断言
六条设计为什么(全篇就记这六条):
1. 决策与 I/O 分离:路由/fallback/重试/限流全是不碰外部 I/O 的决策逻辑,离线可单测、回放、审计------
生产里把 MockProvider 换成真实 SDK,决策逻辑一行不改。
2. Provider 接口 = "统一接入"的落点:所有厂商实现同一个 complete(),
业务只认一个接口,换模型不换代码(序章支柱二"多模型协议归一"的最小实现)。
3. 按版本 ID 路由 Prompt 是第一优先级:resolve_prompt 找不到版本就拒绝路由------
本专栏硬指标:不认版本 ID 的网关,把第四篇的 Prompt 资产架空了。
4. fallback 是配置驱动的纯函数:链从路由表读,跳过不健康/冷却中的 provider。
链写死 = 全局一条链,主厂商一坏全网关涌向同一个备胎,备胎被打爆(踩坑①)。
5. 重试是"决定式"的,不是盲重:只对可恢复错误(429/超时/5xx)重试,
4xx 业务错误重试也白搭还烧钱,超 budget 停止走降级兜底(序章支柱二 2.3)。
6. 限流是令牌桶纯函数:按租户/场景分桶,耗尽返回 429------
全局一个桶,一个租户刷爆全网关(踩坑②)。
"""
import random
import time
from dataclasses import dataclass, field
# ============================================================
# 内联极简 Prompt Registry(生产换第四篇的完整版)
# 为什么内联而不是 from prompt_registry import ...:
# 磁盘上没有第四篇的 release/prompt_registry.py(它嵌在文章里),demo 必须独立可跑。
# 这里只留 register / set_latest / get 最小骨架,够演示"按版本 ID 路由";
# 生产里换成第四篇完整版(内容哈希身份 / diff / rollback),resolve_prompt 一行不改。
# ============================================================
@dataclass(frozen=True)
class PromptVersion:
name: str
version_id: str
content: str
class MiniRegistry:
def __init__(self) -> None:
self._store: dict[str, dict[str, str]] = {} # name -> version_id -> content
self._latest: dict[str, str] = {} # name -> 当前生效的 version_id(指针)
def register(self, name: str, version_id: str, content: str) -> None:
self._store.setdefault(name, {})[version_id] = content
self._latest[name] = version_id # 注册即成为当前生效(第四篇:latest = 最近注册)
def set_latest(self, name: str, version_id: str) -> None:
# 回滚 = 切指针(第四篇语义):指针切回旧版,网关下一请求就取到旧内容。
# 为什么校验存在性:切到一个不存在的版本,线上所有请求都会 400。
store = self._store.get(name)
if not store:
raise KeyError(f"Prompt '{name}' 不存在")
if version_id not in store:
raise KeyError(f"版本 '{version_id}' 不存在(name={name})")
self._latest[name] = version_id
def get(self, name: str, version_id: str | None = None) -> PromptVersion:
# version_id=None -> 走当前生效指针;显式给 ID -> 精确取那一版。
# 为什么显式 ID 无视指针:事故复盘时你手里往往只有日志里一段版本 ID,
# 要能绕过"当前生效"直接翻出线上实际跑的那一版(第四篇get() 同款语义)。
store = self._store.get(name)
if not store:
raise KeyError(f"Prompt '{name}' 不存在")
vid = version_id or self._latest[name]
content = store.get(vid)
if content is None:
raise KeyError(f"版本 '{version_id}' 不存在(name={name})")
return PromptVersion(name, vid, content)
# ============================================================
# Provider:唯一碰外部世界的点(统一接入的落点)
# ============================================================
class GatewayError(Exception):
"""带错误类型的异常。err_type 决定重试策略------4xx 业务错误不重试,
429/超时/5xx 是可恢复错误(呼应 should_retry 的"决定式")。"""
def __init__(self, err_type: str, message: str) -> None:
self.err_type = err_type # ratelimit / timeout / server_error / bad_request
self.message = message
super().__init__(message)
@dataclass
class Completion:
text: str
tokens: int
provider: str
class Provider:
"""所有厂商实现同一个接口------业务只认 complete(),换模型不换代码。
生产实现里这个接口换成 openai/anthropic 等真实 SDK 的调用即可。"""
name: str = ""
def complete(self, prompt: str) -> Completion:
raise NotImplementedError
def health(self) -> bool:
return True
class MockProvider(Provider):
"""CI 里模拟厂商故障:可注入 fail_rate / timeout / ratelimited / health_ok。
fail_rate:一定比例随机返回 5xx;timeout:必超时;ratelimited:必 429。"""
def __init__(self, name: str, fail_rate: float = 0.0,
timeout: bool = False, ratelimited: bool = False,
health_ok: bool = True) -> None:
self.name = name
self.fail_rate = fail_rate
self.timeout = timeout
self.ratelimited = ratelimited
self.health_ok = health_ok
def health(self) -> bool:
return self.health_ok
def complete(self, prompt: str) -> Completion:
if self.timeout:
raise GatewayError("timeout", f"{self.name} 超时")
if self.ratelimited:
raise GatewayError("ratelimit", f"{self.name} 429 限流")
if random.random() < self.fail_rate:
raise GatewayError("server_error", f"{self.name} 5xx")
# 回显 prompt 前缀,方便在 demo 输出里肉眼确认"哪版 Prompt 被路由了"
return Completion(text=f"[{self.name}] {prompt[:12]}...", tokens=120, provider=self.name)
# ============================================================
# 决策纯函数:路由 / fallback / 重试 / 限流
# ============================================================
@dataclass(frozen=True)
class RouteEntry:
provider: str
model: str
weight: int = 100
@dataclass(frozen=True)
class RouteDecision:
provider: str
model: str
def decide_route(routes: list[RouteEntry]) -> RouteDecision:
"""按权重从路由表选 provider+model(决策函数,无外部 I/O)。
为什么权重分配:主备分流量是成本/容灾运营策略,权重写配置,改配置不换代码;
99/1 分流 = 9 成流量试新模型,1 成兜底。"""
if not routes:
raise ValueError("路由表为空,无法决策")
total = sum(r.weight for r in routes)
if total <= 0:
raise ValueError("路由权重之和必须为正")
pick = random.randint(1, total)
for r in routes:
pick -= r.weight
if pick <= 0:
return RouteDecision(r.provider, r.model)
return RouteDecision(routes[-1].provider, routes[-1].model)
def pick_fallback(primary: str, chain: list[str],
providers: dict[str, Provider],
cooldowns: dict[str, float], now: float) -> str | None:
"""fallback 链决策:跳过主路、不健康、冷却期内的 provider。
chain 按业务/场景配置(每场景一条链),不是全局一条链(踩坑①);
cooldowns 承接第三篇的冷却期心智------刚挂过的备胎冷却期内不碰。"""
for name in chain:
if name == primary:
continue # 主路自己挂了,别把自己又选回来
if cooldowns.get(name, 0.0) > now:
continue # 这个备胎刚熔断,冷却期没到
p = providers.get(name)
if p is None:
continue # 链里配了个不存在的 provider,跳过(配置错误别炸成 500)
if not p.health():
continue # 备胎也不健康,继续往后找
return name
return None
RETRYABLE = {"ratelimit", "timeout", "server_error"}
def should_retry(err_type: str, retry_count: int, max_retries: int) -> bool:
"""决定式重试:只对可恢复错误重试。
为什么 4xx 不重试:业务错误重试也白搭,还烧 token(LLM 盲重 = 成本爆炸);
超 budget 停手,交给降级兜底------呼应序章支柱二 2.3"避免无限重试推高成本"。"""
if err_type not in RETRYABLE:
return False
return retry_count < max_retries
class RateLimiter:
"""令牌桶,按 key(租户/场景/模型)分桶。
为什么按 key 分桶:全局一个桶,一个租户刷爆全网关,正常业务全 429(踩坑②)。"""
def __init__(self, capacity: float, refill_per_sec: float) -> None:
self.capacity = capacity
self.refill = refill_per_sec
self._tokens: dict[str, float] = {}
self._last: dict[str, float] = {}
def allow(self, now: float, key: str) -> bool:
last = self._last.get(key, now)
tokens = min(self.capacity, self._tokens.get(key, self.capacity) + (now - last) * self.refill)
self._tokens[key], self._last[key] = tokens, now
if tokens < 1.0:
return False
self._tokens[key] = tokens - 1.0
return True
# ============================================================
# 编排:限流 -> resolve_prompt -> decide_route -> complete -> fallback/重试 -> 降级
# ============================================================
@dataclass
class Request:
tenant: str
scenario: str
prompt_name: str
version_id: str | None = None
@dataclass
class Response:
status: int
content: str = ""
provider: str | None = None
error: str | None = None
degraded: bool = False # 是否走了降级兜底
@dataclass
class RouteConfig:
routes: list[RouteEntry] = field(default_factory=list)
fallback_chain: list[str] = field(default_factory=list)
max_retries: int = 2
def resolve_prompt(registry, name: str, version_id: str | None = None) -> PromptVersion:
"""按版本 ID 从 registry 取 Prompt(本专栏硬指标的落点)。
registry 是 duck-typed:给第四篇的完整 PromptRegistry 或本文件的 MiniRegistry 都行。
version_id=None 走当前生效指针;显式给 ID 精确取那一版------找不到就抛异常,
上层 GatewayPipeline 捕获后拒绝路由,绝不拿"最新的"糊弄过去。"""
return registry.get(name, version_id)
class GatewayPipeline:
"""网关编排:限流 -> resolve_prompt -> decide_route -> complete -> fallback/重试 -> 降级。
错误必须透传:status + err_type + 原始 message 一路带到底(踩坑③),
下游才能分清是厂商限流还是网关自己挂,而不是一句"系统繁忙"。"""
def __init__(self, registry, providers: dict[str, Provider],
routes: dict[str, RouteConfig], limiter: RateLimiter) -> None:
self.registry = registry
self.providers = providers
self.routes = routes
self.limiter = limiter
self.cooldowns: dict[str, float] = {} # provider -> 冷却到何时
def handle(self, req: Request) -> Response:
# ① 限流:按租户分桶,一个租户刷爆不能拖垮别人
if not self.limiter.allow(time.time(), req.tenant):
return Response(429, error=f"rate_limited: 租户 {req.tenant} 超限")
# ② 按版本 ID 路由 Prompt(硬指标):找不到版本就拒绝路由
try:
prompt = resolve_prompt(self.registry, req.prompt_name, req.version_id)
except KeyError as exc:
return Response(400, error=f"prompt_not_found: {exc}")
# ③ 主路:决定式重试,失败后进 fallback
cfg = self.routes.get(req.scenario)
if cfg is None:
return Response(503, error=f"unknown_scenario: {req.scenario}", degraded=True)
decision = decide_route(cfg.routes)
last_error: GatewayError | None = None
for attempt in range(cfg.max_retries + 1):
provider = self.providers[decision.provider]
if not provider.health():
break # 主路不健康,别浪费重试配额
try:
comp = provider.complete(prompt.content)
return Response(200, content=comp.text, provider=comp.provider)
except GatewayError as exc:
last_error = exc
if exc.err_type in RETRYABLE: # 只有可恢复错误才冷却,4xx 是请求问题不是 provider 故障
self.cooldowns[decision.provider] = time.time() + 30 # 该 provider 30s 内不参与 fallback
if not should_retry(exc.err_type, attempt, cfg.max_retries):
break # 4xx 或超 budget:停手,走 fallback
# 生产里这里做指数退避 + jitter(429 尤其要带 Retry-After);demo 省略退避,保持离线跑秒回
# ④ fallback:跳过不健康/冷却中的备胎
fb = pick_fallback(decision.provider, cfg.fallback_chain,
self.providers, self.cooldowns, time.time())
if fb is not None:
try:
comp = self.providers[fb].complete(prompt.content)
return Response(200, content=comp.text, provider=comp.provider)
except GatewayError as exc:
last_error = exc
# ⑤ 全部失败:降级兜底,错误透传(不吞成"系统繁忙")
msg = f"degraded: {last_error.err_type}: {last_error.message}" if last_error else "degraded: 无可用 provider"
return Response(503, error=msg, degraded=True)
def build_demo() -> None:
"""完整演示:注册两版 Prompt -> 打表看:版本路由 / 主路失败 fallback / 回滚切指针 / 限流。"""
random.seed(7)
# ① 注册两版 Prompt(沿用第四篇的售后场景:v2 在回复前先核实支付状态)
reg = MiniRegistry()
reg.register("after_sales", "v1",
"你是售后客服。先核实支付状态,再决定是否支持退款。")
reg.register("after_sales", "v2",
"你是售后客服。回复前先核实支付状态,再决定是否支持退款。")
# ② Provider:claude 主路必挂(fail_rate=1.0),gpt4/llama 健康------演示 fallback
providers = {
"claude": MockProvider("claude", fail_rate=1.0),
"gpt4": MockProvider("gpt4"),
"llama": MockProvider("llama"),
}
# ③ 路由配置:每场景一条 fallback 链(踩坑①的修复);after_sales 主备按 9:1 分流
routes = {
"after_sales": RouteConfig(
routes=[RouteEntry("claude", "claude-sonnet", weight=9),
RouteEntry("gpt4", "gpt-4o", weight=1)],
fallback_chain=["claude", "gpt4", "llama"],
),
"critical": RouteConfig( # 关键场景:只有 claude,fallback 行为可预期
routes=[RouteEntry("claude", "claude-sonnet")],
fallback_chain=["claude", "gpt4", "llama"],
),
}
# ④ 限流:每个租户一个桶,容量 3,每分钟补充 2 个 token
limiter = RateLimiter(capacity=3, refill_per_sec=2 / 60)
gw = GatewayPipeline(reg, providers, routes, limiter)
print("== 1) 按版本 ID 路由(硬指标):同一场景,换版本 ID 换 Prompt ==")
for vid in (None, "v1", "v2"):
r = gw.handle(Request(tenant="t1", scenario="after_sales",
prompt_name="after_sales", version_id=vid))
print(f" version_id={vid!s:>3} -> status={r.status} provider={r.provider} 内容={r.content}")
print(" (version_id=None 走 registry 当前生效指针 = 最新注册的 v2)")
print("\n== 2) 主路必挂(claude fail_rate=1.0)-> 重试耗尽后 fallback 到 gpt4 ==")
r = gw.handle(Request(tenant="t2", scenario="critical",
prompt_name="after_sales", version_id="v2"))
print(f" status={r.status} provider={r.provider} 内容={r.content}")
print("\n== 3) 回滚 = 切指针:set_latest 把指针从 v2 切回 v1,下一请求默认走 v1 ==")
reg.set_latest("after_sales", "v1")
r = gw.handle(Request(tenant="t3", scenario="after_sales", prompt_name="after_sales"))
print(f" status={r.status} provider={r.provider} 内容={r.content}")
print("\n== 4) 限流:t4 桶容量 3,第 4 个请求 429 ==")
for i in range(4):
r = gw.handle(Request(tenant="t4", scenario="after_sales",
prompt_name="after_sales", version_id="v2"))
print(f" 第 {i + 1} 个请求 -> status={r.status} error={r.error}")
if __name__ == "__main__":
build_demo()
跑 python llm_gateway.py,四个小节各演一件事:① 同一场景换版本 ID,路由到的 Prompt 内容跟着变(硬指标肉眼可见);② 主路必挂时重试两次后落到 gpt4;③ 回滚 = 切指针,set_latest 切回 v1 后默认请求自动走 v1;④ 同一租户第 4 个请求 429,别的租户不受影响。
生产里替换三处就够:把 MockProvider 换成真实 SDK------但每个适配器的 complete() 里要多做一件事:把 SDK 异常翻译成 GatewayError(openai.RateLimitError → err_type="ratelimit"、连接/读超时 → "timeout"、5xx → "server_error")。这一层翻译做完,路由/fallback/重试/限流的决策逻辑一行不改------因为它们只认 GatewayError.err_type,不认识任何 SDK 异常。再把 MiniRegistry 换成第四篇完整版(其 get() 同样抛 KeyError,resolve_prompt 无需改动),build_demo 删掉换成线上入口。另外,第八篇的全链路 Trace 就在 Provider.complete() 这个接口上打点------本篇的网关骨架顺手把可观测的钩子留好了。
test_llm_gateway.py------5 个断言,一条锁一种决策:
python
"""
5 个断言锁死网关决策骨架:版本路由 / 路由决策 / fallback / 重试决定式 / 限流。
纯标准库 + assert,直接跑:
python test_llm_gateway.py
# 或 pytest test_llm_gateway.py
"""
import random
from llm_gateway import (
MiniRegistry, MockProvider, RateLimiter, RouteEntry,
decide_route, pick_fallback, resolve_prompt, should_retry,
)
def test_version_routing():
"""断言①(硬指标):按版本 ID 路由------显式版本 ID 无视指针精确取那一版;
version_id=None 走当前生效指针;回滚 = 切指针;不存在的版本拒绝路由。"""
reg = MiniRegistry()
reg.register("after_sales", "v1", "你是售后客服。先核实支付状态,再决定是否支持退款。")
reg.register("after_sales", "v2", "你是售后客服。回复前先核实支付状态,再决定是否支持退款。")
# 默认走当前生效指针(注册即生效,latest = v2)
assert resolve_prompt(reg, "after_sales").version_id == "v2"
# 显式版本 ID -> 无视指针,精确取 v1
assert resolve_prompt(reg, "after_sales", "v1").content.startswith("你是售后客服。先核实")
# 回滚 = 切指针:set_latest 把指针从 v2 切回 v1,默认解析跟着变
reg.set_latest("after_sales", "v1")
assert resolve_prompt(reg, "after_sales").version_id == "v1"
# 不存在的版本 -> 拒绝路由(上层 GatewayPipeline 捕获返回 400)
try:
resolve_prompt(reg, "after_sales", "v9")
assert False, "不存在的版本应拒绝路由"
except KeyError:
pass
def test_decide_route_weighted():
"""断言②:路由决策------路由表有且只有 modelA 时选中 modelA;
配置权重时两个 provider 按比例分配(高权重占多数,低权重也有机会)。"""
assert decide_route([RouteEntry("modelA", "claude-sonnet")]).provider == "modelA"
random.seed(42)
routes = [RouteEntry("modelA", "claude-sonnet", weight=9),
RouteEntry("modelB", "gpt-4o", weight=1)]
primary = backup = 0
for _ in range(2000):
d = decide_route(routes)
if d.provider == "modelA":
primary += 1
else:
backup += 1
assert backup > 0, "低权重 provider 也要有机会被选中"
assert primary > backup, f"高权重 provider 应占多数(modelA={primary}, modelB={backup})"
def test_pick_fallback_skips_unhealthy_and_cooldown():
"""断言③:fallback------主 provider health()=False 时返回链上下一个健康 provider;
备胎在冷却期内被跳过;全部不可用返回 None(走降级兜底)。"""
providers = {
"claude": MockProvider("claude", health_ok=False),
"gpt4": MockProvider("gpt4"),
"llama": MockProvider("llama"),
}
now = 100.0
# 主路不健康 -> 选链上下一个健康 provider
assert pick_fallback("claude", ["claude", "gpt4", "llama"], providers, {}, now) == "gpt4"
# gpt4 在冷却期 -> 跳过,落到 llama
cooldowns = {"gpt4": 200.0}
assert pick_fallback("claude", ["claude", "gpt4", "llama"], providers, cooldowns, now) == "llama"
# 全部不健康/冷却中 -> None,交给降级兜底
cooldowns2 = {"gpt4": 200.0, "llama": 200.0}
assert pick_fallback("claude", ["claude", "gpt4", "llama"], providers, cooldowns2, now) is None
def test_should_retry_decidable():
"""断言④:重试是决定式的------429/超时/5xx 重试,4xx 业务错误不重试,超 budget 停止。"""
assert should_retry("ratelimit", 0, 2) is True
assert should_retry("timeout", 0, 2) is True
assert should_retry("server_error", 0, 2) is True
assert should_retry("bad_request", 0, 2) is False # 4xx 业务错误:重试也白搭还烧钱
assert should_retry("ratelimit", 2, 2) is False # 超 budget:停手,走降级兜底
def test_rate_limiter_bucket():
"""断言⑤:限流------令牌桶耗尽返回 False(网关 429),令牌补充后恢复;
按租户分桶,一个租户刷爆不影响另一个。"""
limiter = RateLimiter(capacity=2, refill_per_sec=1.0)
now = 1000.0
assert limiter.allow(now, "t1") is True
assert limiter.allow(now, "t1") is True
assert limiter.allow(now, "t1") is False # 桶耗尽 -> 拒绝
assert limiter.allow(now, "t2") is True # 租户分桶:t2 有自己的配额
assert limiter.allow(now + 1.0, "t1") is True # 1 秒补充 1 token -> 恢复
if __name__ == "__main__":
test_version_routing()
test_decide_route_weighted()
test_pick_fallback_skips_unhealthy_and_cooldown()
test_should_retry_decidable()
test_rate_limiter_bucket()
print("全部 5 个断言通过:版本路由 / 路由决策 / fallback / 重试决定式 / 限流")
跑 python test_llm_gateway.py,全绿。把 resolve_prompt 改成"取不到版本就返回最新版",断言①当场挂------这就是硬指标在代码里的样子:不按版本 ID 路由的网关,测试第一个就不让过。其他四条同理:把 fallback 链写死成全局一条,断言③的 cooldown 分支就没了;把重试改成盲重,断言④的 4xx 分支过不去;把限流改成全局一个桶,断言⑤的租户隔离就失效。
四、踩坑记录:这几个坑每个都真付过费
4.1 fallback 粒度过粗:主厂商一坏,全网关涌向同一个备胎
- 症状:我们第一版网关 fallback 链只有一条全局配置(claude → gpt4 → llama)。某天下午 4 点 claude 崩了,全网关所有场景同时涌向 gpt4,gpt4 被干爆,P99 从 300ms 爬到 3s------比 claude 挂了还难受。
- 排查:日志里 gpt4 的错误率曲线和 claude 的熔断曲线完全同形,一条 fallback 链把所有场景捆在了一起。
- 根因 :fallback 链粒度 = 全局,不是按业务/场景分。
- 修复 :每场景一条链(售后一条、抽取一条),链从路由表读、配置驱动,换链不换代码------demo 里
RouteConfig.fallback_chain就是这行。
4.2 限流阈值拍脑袋:全局一个桶照抄文档,大促一过误伤正常业务
- 症状:限流阈值照抄文档 10 QPS,全局一个桶。上线第一周就误伤------业务高峰一到就 429,客服反馈"用户什么都发不出去"。
- 排查:429 曲线和业务高峰完全重合,一个桶把所有租户、所有场景的配额混在一起。
- 根因 :全局一个桶 + 阈值拍脑袋,没有分桶也没有校准。
- 修复 :按租户分桶(生产再细分场景/模型),阈值从流量曲线校准(demo 里
RateLimiter.allow(now, key)的 key 就是分桶位)------和第三篇阈值 3σ 校准同一个心智:阈值写死是起点,统计是活的。
4.3 网关吞错误:catch 所有异常返回"系统繁忙"
- 症状:网关 catch 所有异常返回"系统繁忙",某次线上事故排查,下游团队问"是模型挂了还是网关挂了",谁都答不上来------网关日志里只有"系统繁忙"四个字,原始错误全丢了。
- 根因 :吞错误 = 把排障信息也吞了。
- 修复 :错误透传,status + err_type + 原始 message 一路带到底(demo 里
Response.error就是透传位),下游能分清是厂商限流(429)还是网关自己挂(5xx),也给第八篇的可观测埋好了钩子。
五、选型对比:一张表 + 决策树 + 一份 checklist
比快慢之前先说清口径:Kong 官方出过一个基准,称 Portkey 慢约 65%、LiteLLM 慢约 86%(相对它的实现)------这数是我写这篇时翻它官网文档记下的。这是厂商基准,只当方向性参考,别当排名------多数场景下网关开销相对模型推理时间(300ms--30s)可以忽略,省几个点延迟不如选对能力。
下表维度对齐上面的能力清单,不另起炉灶:
| 方案 | 统一接入 | 路由/负载 | fallback+重试 | 限流 | 成本/安全/可观测 | 按版本ID路由Prompt | 私有化 | 推荐 |
|---|---|---|---|---|---|---|---|---|
| 自研(本篇) | ✓ 自己定接口 | ✓ 权重/灰度全掌控 | ✓ 决定式重试 | ✓ 按租户分桶 | △ 成本/护栏/打点全自写 | ✓ 直接调 registry.get() | ✓✓ | ⭐⭐⭐⭐ |
| LiteLLM | ✓✓ OpenAI 兼容,100+ 厂商 | ✓ 多模型路由 | ✓ 自动 fallback 开源可用 | △ 分布式限流靠 Redis | △ 治理在 Enterprise,日志/OTEL 可用 | ✗ 无 registry 概念,需外层包 | ✓ MIT 自托管 | ⭐⭐⭐⭐ |
| Portkey | ✓✓ 250+ 厂商 | ✓ | ✓ | ✓ | ✓ 成本/护栏/可观测强 | △ 有 prompt 版本/A-B,需核对语义 | △ 自托管有限,收购后待评估 | ⭐⭐⭐⭐ |
| Kong AI Gateway | ✓ AI Proxy 八大厂商 | ✓ 通用网关路由 | ✓ 插件 | △ token 级,高级在 Enterprise | △ 高级可观测在 Enterprise,护栏可用 | ✗ 需外层接 | ✓✓ 开源基础路由 + Konnect | ⭐⭐⭐⭐ |
| OpenRouter | △ 托管聚合 | ✓ 市场语义 | △ 部分 | ✗ 无护栏 | △ 计费有,无护栏/可观测 | ✗ | ✗ 数据出得去 | ⭐⭐ |
| One API / New API | △ ~28 渠道 / 多格式互转 | ✓ 加权随机+负载均衡 | ✓ 自动重试 | ✓ 令牌/额度 | △ 额度有明细粗,无护栏/可观测 | ✗ | ✓✓ 单二进制/Docker | ⭐⭐⭐⭐ |
每条路线一句话判断:
- 自研:唯一在"按版本 ID 路由 Prompt"上原生达标的路线,代价是运维 + 五模块全自己写。适合决策逻辑本身是竞争力(版本路由、fallback 粒度、限流口径都要自定义)的团队------本篇骨架就是第一版。
- LiteLLM:MIT 开源自托管的性价比之选,虚拟密钥/预算/成本追踪/自动 fallback 开源版就有。两个注意:分布式限流依赖 Redis,Redis 挂限流会降级;2026-03 的 PyPI 供应链事件(已修复)------这条当时在圈子里传得挺快,我当天就把自己用的版本锁了------提醒我们自托管要锁已修复版本,别追 latest。硬指标不达标,要版本路由就得外层包一层。
- Portkey:语义缓存/guardrails/prompt 版本管理/A-B 是亮点,但主打托管、自托管能力有限。2026-05-29 被 Palo Alto Networks 收购(现为 Prisma AIRS 组件)------公告出来那天,好几个用 Portkey 的朋友都在问路线图怎么办------评估时路线图与定价要重新算;生产环境日志只保留 30 天,长期审计别指望它。硬指标是"半达标":有 prompt 版本管理,但语义是平台自己的,不是从你的 registry 拉取,需要逐项核对。
- Kong AI Gateway:通用 API 网关 + AI 插件,适合"已有 Kong 或要统一管 API+AI"的团队;3.14(2026-04)新增 Agent Gateway,MCP/agent 流量也开始管------这条是我翻它 changelog 看到的。但高级能力(AI Proxy Advanced、语义缓存、高级限流)在 Enterprise/Konnect,开源版只有基础路由。
- OpenRouter:聚合模型市场、按 token 计费,适合个人和轻量验证------无护栏、无私有化、数据出得去,把它当"工具"别当"基础设施"。
- One API / New API:国产自建中转的实惠路线。One API(Go + MIT)单二进制/Docker 部署,约 28 家渠道,令牌/额度/兑换码/负载均衡/自动重试,SQLite/MySQL/PG;New API 是它的 AGPL-3.0 二开(写这篇时 GitHub 约 4 万 star),支持 OpenAI/Claude/Gemini 多格式互转,还加了 Rerank/Midjourney/Suno/Embeddings/Realtime,渠道加权随机 + 故障渠道自动禁用。注意 2026-04 曾爆支付逻辑漏洞(已修复),自建中转要对版本敏感;硬指标不达标、护栏基本为零。
另有一条 SaaS 支线:想全球化分发、边缘缓存、零运维的团队,可以看 Cloudflare AI Gateway(序章的表里带过)------托管、快,但绑 Cloudflare 生态,数据在边缘层过一遍,合规严格的直接排除。
决策树长这样:
数据出不出得去?
├─ 出不去 → 私有化强制:自研 / LiteLLM / Kong 开源 / One API(托管出局)
└─ 出得去 → 往下问
Prompt 要不要按版本路由?
├─ 要 → 硬指标成否决项:自研直调 registry.get();开源网关外层自包版本路由
└─ 不要 → 往下问
团队养不养得起自研运维?
├─ 养得起 → 自研决策骨架(本篇 demo 就是第一版)
└─ 养不起 → 开源网关(LiteLLM / Portkey 开源 / One API)起步,按规模升档
最后一张可复用 checklist,八个"是/否",答完落到一个方案:
- 数据能不能出内网/境外?不能 → 直接进私有化候选,托管出局。
- Prompt 是业务逻辑、需要按版本 ID 路由并回滚到字节?是 → 硬指标必考,自研或外层自包。
- 每年能拨出 0.5 人月养自研网关(决策逻辑 + 基础运维)?有 → 自研骨架可行。
- 已有通用 API 网关(Kong/APISIX/Nginx)?有 → 优先挂 AI 插件,别新起一套网关。
- 需要 SSO/RBAC(登录权限与审计)这类治理能力?需要 → 商业版或自研,开源版多为基础能力。
- 预估峰值 QPS?<10 轻量代理起步;几十~几百 开源网关;更高 商业或自研。
- 要不要多模型 fallback + 成本按服务摊账?要 → 网关层必须带成本追踪。
- 要不要全链路 Trace?要 → 网关层必须有 OTEL(可观测标准协议)出口。
组合示例:数据出不去 + 要版本路由 + 养得起 → 自研骨架;数据出不去 + 不要版本路由 + 养不起 → LiteLLM/One API 自托管;数据可出 + 要省心 + 治理要强 → Portkey 托管或 Kong Konnect;已有 Kong → 挂 AI 插件;个人/轻量验证 → OpenRouter/Cloudflare。
六、总结 + 下一篇预告
到这里,能力清单回答"你需要哪几项",规模判断 + 决策树回答"什么规模选什么",硬指标回答"按版本 ID 路由 Prompt 落在哪个方案"------自研原生达标,开源网关外层自包,商业网关逐项核对。回到开头值班同学的十分钟:网关到位之后,指针切回旧版,下一个请求就取到旧 Prompt,转人工率跟着曲线下来。回滚从"切了没人看"变成"切了就生效"。
网关是运行时韧性的"统一入口",第六篇OWASP 安全护栏(输入前置拦截 + 输出后置校验)和 第七篇JSON Schema 强约束(输出结构校验 + 重试/兜底)都要挂在这上面。本篇选好网关,后面两篇才有"挂在哪个钩子上"的落点------能力清单里的"安全钩子"和"Trace"两行,就是提前留好的挂载点。
评论区聊聊:你们现在直连 LLM API 的代码有几处?有没有和我一样,第一版网关死于"三处裸调、只有一处接 registry"?

🎯 更多专栏系列文章可以查看博客主页📑 👍 若文章对你有所触动,恳请点赞 ⭐ 关注 ⭐ 收藏