大模型工程化实战(五):LLM 网关到底怎么选 - 2026 自研 / LiteLLM/Portkey/Kong/One API 全面对比

目录

  • 前言
  • [一、问题定义:不接网关,直连 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 异常翻译成 GatewayErroropenai.RateLimitErrorerr_type="ratelimit"、连接/读超时 → "timeout"、5xx → "server_error")。这一层翻译做完,路由/fallback/重试/限流的决策逻辑一行不改------因为它们只认 GatewayError.err_type,不认识任何 SDK 异常。再把 MiniRegistry 换成第四篇完整版(其 get() 同样抛 KeyErrorresolve_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,八个"是/否",答完落到一个方案:

  1. 数据能不能出内网/境外?不能 → 直接进私有化候选,托管出局。
  2. Prompt 是业务逻辑、需要按版本 ID 路由并回滚到字节?是 → 硬指标必考,自研或外层自包。
  3. 每年能拨出 0.5 人月养自研网关(决策逻辑 + 基础运维)?有 → 自研骨架可行。
  4. 已有通用 API 网关(Kong/APISIX/Nginx)?有 → 优先挂 AI 插件,别新起一套网关。
  5. 需要 SSO/RBAC(登录权限与审计)这类治理能力?需要 → 商业版或自研,开源版多为基础能力。
  6. 预估峰值 QPS?<10 轻量代理起步;几十~几百 开源网关;更高 商业或自研。
  7. 要不要多模型 fallback + 成本按服务摊账?要 → 网关层必须带成本追踪。
  8. 要不要全链路 Trace?要 → 网关层必须有 OTEL(可观测标准协议)出口。

组合示例:数据出不去 + 要版本路由 + 养得起 → 自研骨架;数据出不去 + 不要版本路由 + 养不起 → LiteLLM/One API 自托管;数据可出 + 要省心 + 治理要强 → Portkey 托管或 Kong Konnect;已有 Kong → 挂 AI 插件;个人/轻量验证 → OpenRouter/Cloudflare。

六、总结 + 下一篇预告

到这里,能力清单回答"你需要哪几项",规模判断 + 决策树回答"什么规模选什么",硬指标回答"按版本 ID 路由 Prompt 落在哪个方案"------自研原生达标,开源网关外层自包,商业网关逐项核对。回到开头值班同学的十分钟:网关到位之后,指针切回旧版,下一个请求就取到旧 Prompt,转人工率跟着曲线下来。回滚从"切了没人看"变成"切了就生效"。

网关是运行时韧性的"统一入口",第六篇OWASP 安全护栏(输入前置拦截 + 输出后置校验)和 第七篇JSON Schema 强约束(输出结构校验 + 重试/兜底)都要挂在这上面。本篇选好网关,后面两篇才有"挂在哪个钩子上"的落点------能力清单里的"安全钩子"和"Trace"两行,就是提前留好的挂载点。

评论区聊聊:你们现在直连 LLM API 的代码有几处?有没有和我一样,第一版网关死于"三处裸调、只有一处接 registry"?


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

相关推荐
thesky1234562 小时前
27届大模型面试准备(四十七):大模型水印与生成内容溯源——从隐写签名到可控溯源
大模型·aigc检测·大模型水印·生成内容溯源·watermarking·kgw·绿名单
前沿在线3 小时前
Vbot ATOM发布,人形机器人进入产品化时刻
人工智能·ai·大模型
qq7422349844 小时前
Gradio 极简入门:三分钟为AI模型打造交互界面,并对比Streamlit与Dash如何选型
人工智能·算法·大模型·交互·dash
月亮和九磅十五便士4 小时前
朝闻 AI|2026-08-26
人工智能·大模型·ai agent
Raas1004 小时前
AI网关是做什么的?MAI Gateway (魔芋企业级AI网关)给出企业级答案
网络·人工智能·gateway·企业级·ai网关·mai gateway·魔芋
circuitsosk7 小时前
NL2SQL在工业级场景下的精度优化:Schema Linking + 动态Few-shot实战
人工智能·python·sql·大模型·nl2sql
thesky12345612 小时前
27届大模型面试准备(五十五)多模态 RAG 工程实战——从跨模态检索到 4MRAG 线上化
大模型·跨模态检索·重排·reranker·多模态rag·4mrag·置信度校准
thesky12345614 小时前
27届大模型面试准备(四十五):代码大模型与仓库级软件工程理解——从行级补全到仓库级智能
大模型·缺陷检测·代码大模型·代码补全·code llm·仓库级理解·单元测试生成
JJJennie77720 小时前
【AI 网关】七类网关方案横向测评|MAI Gateway 会带来什么不同
人工智能·大模型·gateway·ai网关·魔芋ai