直接回答

LLM Gateway 是位于 AI 应用与模型后端之间、负责执行策略的网络中间层。生产架构应分离低延迟数据面和版本化控制面:数据面处理身份、协议转换、配额、流式传输、故障控制与用量事件;控制面发布路由、后端能力、密钥引用、预算和灰度策略。网关可以减少重复接入,但不会让供应商能力自动等价,也不能保证模型质量或消除供应商耦合。

本文是 AI 架构师课程 第 18 篇。Agent 步骤级模型选择、输出验收与升级属于 AI Agent 模型路由,本文只讨论共享网络与策略层。

目录

  1. 什么时候值得引入 LLM Gateway
  2. 控制面与数据面如何分工
  3. 把协议兼容写成显式契约
  4. 身份、配额与准入控制
  5. 流式响应、重试与熔断
  6. 计量、预算预留与账单对账
  7. 租户隔离、缓存安全与密钥
  8. 不泄露 Prompt 的可观测性
  9. 配置版本、灰度与回滚
  10. 在发布前校验网关策略
  11. 生产验证清单
  12. 常见问题

什么时候值得引入 LLM Gateway

只有多个应用确实需要同一组可强制执行的边界时,LLM Gateway 才值得存在;“调用了大模型”本身并不是引入理由。它适合内联原本会散落在各 SDK 包装层中的工作负载身份、后端密钥、模型别名、能力检查、配额、出网控制、用量记录和 Trace 传播。

这是一项运维决策,而不是技术潮流:

场景 优先本地适配层 优先共享网关
单应用、单后端 通常没有必要
多团队共享凭证或配额 控制容易漂移 强适配
业务依赖供应商特有能力 保留原生客户端 仅在有显式透传契约时使用
需要统一出网审计与数据合规 重复实现风险高 强适配
团队无力运营新的关键服务 保持调用链简单 暂不引入

网关只是改变耦合发生的位置。应用可以依赖 support-chat 这类稳定别名,但网关仍然依赖供应商协议、模型行为和账单导出;它还会成为新的故障域。因此,高可用设计必须同时覆盖网关副本、配置恢复、密钥服务和依赖网络,并为每类工作负载预先定义“旁路、拒绝还是受限运行”,不能在事故中临时决定。

控制面与数据面如何分工

核心架构原则是把配置决策与请求执行分开。控制面校验并发布不可变配置快照;数据面消费已知正确的快照,不能在每个请求上同步查询持续变化的管理数据库。

flowchart LR subgraph CP["控制面"] ID["身份与租户策略"] CAT["后端能力目录"] CFG["版本化路由和配额"] ROLL["灰度与回滚控制器"] ID --> CFG CAT --> CFG CFG --> ROLL end subgraph DP["数据面"] EDGE["认证与准入"] RESOLVE["解析别名与能力"] PROXY["协议转换与流式转发"] METER["用量与审计事件"] EDGE --> RESOLVE --> PROXY --> METER end ROLL -->|"签名快照"| EDGE PROXY --> B1["模型后端 A"] PROXY --> B2["模型后端 B"]

控制面职责

控制面管理变化较慢、需要审阅的状态:

  • 工作负载身份、租户边界和授权规则;
  • 虚拟模型别名及其候选后端;
  • 按端点定义的能力矩阵;
  • 密钥引用,而不是路由文件中的明文凭证;
  • 请求、Token、并发和预算策略;
  • 超时、重试、熔断与排队限制;
  • 缓存资格、数据驻留和遥测脱敏规则;
  • 配置 Schema、版本、签名、激活与回滚。

发布必须具备事务性。如果某条路由引用了不存在的后端或不支持的能力,应拒绝整个快照,不能让不同数据面副本观察到不同的半成品配置。

数据面请求生命周期

数据面按确定顺序执行策略,并记录每次决策:

sequenceDiagram participant C as 调用方 participant G as 网关 participant Q as 配额台账 participant B as 模型后端 participant U as 用量台账 C->>G: 请求与工作负载身份 G->>G: 租户授权和 Schema 校验 G->>G: 解析别名与必需能力 G->>Q: 预留请求、Token、并发与预算 Q-->>G: 预留成功或拒绝 G->>B: 规范化请求与截止时间 B-->>G: 响应头与流式结果 G-->>C: 按背压转发数据流 G->>U: 实际用量、路由、结果、配置版本 G->>Q: 结算或释放预留

认证回答“谁在调用”,授权回答“这个身份能使用哪些别名、工具、区域和预算范围”。两者必须与供应商 API Key 解耦,避免单个应用凭证泄漏后可以绕过网关直接调用全部后端。

把协议兼容写成显式契约

统一接口只是协议转换契约,不是语义等价证明。“OpenAI 兼容”可能只表示 URL 和部分 JSON 字段相似,工具调用增量、结构化输出、推理控制、多媒体、Token 统计、结束原因、流式事件和错误体仍可能不同。

能力必须按端点和行为声明并测试:

契约维度 网关必须回答的问题
端点 支持 Chat、Responses、Embeddings、Rerank、图像还是音频?
输入 支持哪些角色、媒体、工具、Schema 与大小限制?
输出 是否保留工具调用、引用、推理块和用量字段?
流式 有哪些事件类型、顺序、终止信号与取消路径?
错误 哪些错误可重试、会计费、受限流或应由调用方修正?
计量 缓存、推理、输入与输出单位能否一致归一?
数据策略 使用哪个区域、保留策略和训练开关?

只有完成必需能力校验后,才能解析虚拟别名。例如,请求要求 JSON Schema 输出时,“接收但忽略 response_format”的后端并不兼容。显式拒绝不支持的契约,比静默降级更安全。

协议转换本身也要版本化。Envoy AI Gateway 的发布文档会逐项列出特定端点与供应商的转换覆盖,而不是宣称所有后端完全一致。任何网关都应采用相同思路:维护经过测试、损失已知的能力矩阵。

身份、配额与准入控制

生产准入必须是多维的,因为请求数无法约束真实资源消耗。租户即使没有超过每分钟请求数,也可能提交超长上下文、打开大量流式连接或耗尽共享预算。

需要在对应作用域分别执行限流与资源约束:

  • 请求速率:限制突发流量和错误循环;
  • 预估输入与最大输出单位:转发前预留模型容量;
  • 活跃并发:限制未结束的数据流和上游连接;
  • 队列深度与等待时间:在饱和时保护延迟;
  • 预算:限制金额风险,但不能替代质量策略;
  • 供应商额度:防止单个租户耗尽共享上游配额。

HTTP 429 Too Many Requests 可以携带 Retry-After,但 RFC 6585 有意不规定服务端如何识别调用方或统计请求,这些语义必须由网关定义。错误体应提供稳定的机器可读原因,例如 tenant_token_reservation_exhausted,同时说明影响范围以及稍后重试是否有意义。

转发前预留,完成后结算

对于长度不确定的生成请求,应根据已校验请求与租户策略预留保守上界。完成后按权威实际用量结算并释放差额;取消或供应商结果不确定时,则把预留转入待确认状态,直到取得用量或触发明确的过期规则。

预算耗尽时不能静默切换到更便宜、更弱的模型。诚实拒绝预算超限,比破坏质量、安全、区域或工具调用契约更可靠。成本感知选型只能在已经通过工作负载验收门禁的候选集合内进行。

流式响应、重试与熔断

流式输出改变了故障边界:一旦网关已经向客户端发送响应字节,换后端重放请求可能产生重复文本、重复工具调用或重复副作用。通用安全规则是:仅在向下游提交响应前重试,提交后不再跨模型重试。只有应用协议明确支持稳定偏移、断点续传和去重时才能例外。

stateDiagram-v2 开始 --> 已准入 已准入 --> 请求中 请求中 --> 可重试: 首字节前瞬时故障 可重试 --> 请求中: 预算与截止时间充足 请求中 --> 流式输出: 已发送首字节 流式输出 --> 已完成 流式输出 --> 流错误: 后端或客户端故障 请求中 --> 已失败: 终态或不可安全重复 可重试 --> 已失败: 预算或截止时间耗尽 已完成 --> 结束 流错误 --> 结束 已失败 --> 结束

重试策略

RFC 9110 定义了 HTTP 方法语义,但 LLM POST 不会自动变成可安全重放的操作。只有同时满足以下条件才能重试:

  1. 尚未越过向下游发送响应字节的提交边界。
  2. 请求不存在未保护的外部副作用。
  3. 错误分类明确允许重试。
  4. 总截止时间仍足以完成下一次尝试。
  5. 路由尝试上限和全局重试预算都允许继续。
  6. 备用后端满足相同能力与数据策略契约。

退避应加入随机抖动,并在适用时尊重 Retry-After。重试预算用于限制“重试流量占健康原始流量的比例”,避免故障依赖把每个请求放大成多次请求。Envoy 的熔断文档还分别限制连接、等待请求、活跃请求与重试。

熔断与取消

熔断器用于保护网关和健康后端不被故障依赖拖垮。状态至少应按后端和端点隔离,必要时再区分租户等级;图像生成故障不应自动熔断 Chat。被动故障可以驱动熔断,但主动探测要谨慎,因为探测同样消耗配额,而且未必覆盖真实请求路径。

客户端取消必须向上游传播,尽快停止已经无用的读取、生成和计量工作,同时仍写入终态用量事件。缓冲区必须有界,并向慢客户端实施背压,防止单个连接造成内存无界增长。

计量、预算预留与账单对账

网关计量是实时运营估算与归因台账,不是最终发票。它可以快速提供租户级可见性,但财务对账仍应以供应商账单导出为权威来源。

每次终态尝试都应生成不可变用量记录:

json
{
  "request_id": "req_01",
  "tenant_id": "tenant_red",
  "config_version": "2026-08-09.3",
  "virtual_model": "support-chat",
  "backend_id": "provider_a_chat",
  "attempt": 1,
  "status": "completed",
  "input_units": 1840,
  "output_units": 276,
  "usage_source": "provider_response",
  "price_catalog_version": "catalog_42",
  "estimated_cost": "0.000000",
  "currency": "USD"
}

以上数值只展示记录形态,不代表当前供应商价格。金额应使用十进制定点数或最小货币单位整数,不能用二进制浮点数;估算还要保留当时使用的价格目录版本。未知模型、缺失用量或无法映射的计费单位都不能记为零,必须进入异常队列。

对账需要按账号、区域、模型、时间窗口和可用请求标识,把网关记录与供应商账单数据关联起来。差异可能来自延迟用量、供应商缓存、重试、最小计费单位、舍入、抵扣或绕过网关的调用。FinOps Open Cost and Usage Specification 有助于统一分摊和发票对账字段,但不会让实时网关估算变成财务权威。

API、自部署与端侧推理的完整成本口径参见 AI 推理成本工程

租户隔离、缓存安全与密钥

租户隔离必须覆盖所有有状态表面,而不只是 API 认证。限流计数器、队列、响应缓存、向量、日志、Trace、用量台账和管理查询都要按有效租户与策略作用域命名空间化。

语义缓存也是授权决策

语义相似不能证明缓存答案对另一个请求仍然安全、正确。缓存键至少要包含:

  • 租户与授权作用域;
  • 规范化模型及能力契约;
  • 系统策略与工具定义版本;
  • 检索语料和数据权限版本;
  • 语言与输出 Schema;
  • 安全策略与缓存生成版本。

除非内容明确公开且生成契约完全等价,否则不能跨租户共享缓存。相似度和正确性门禁必须根据每类工作负载的离线评测集确定,不存在通用安全阈值或必然命中率。语义缓存碰撞与投毒研究还表明,攻击者可能操纵语义接近度;当缓存输出可以触发 Agent 工具时,风险更高。完整生命周期参见生产级语义缓存

密钥与出网边界

供应商凭证应保存在密钥管理系统中,并在供应商支持时使用短期凭证。数据面只能获得其负责后端所需的最小权限凭证,同时限制出网目标、校验 TLS、轮换密钥,并避免把供应商响应头原样反射给客户端。

OWASP GenAI LLM Top 10 可用于审查 Prompt Injection、敏感信息泄漏和无界资源消耗等威胁。网关可以实施出网与资源策略,但关键词过滤器无法“解决” Prompt Injection。

不泄露 Prompt 的可观测性

有效的网关遥测应记录决策与资源行为,而不是默认采集完整 Prompt。原始输入和模型输出经常包含凭证、个人信息、检索文档或私有源代码。

需要关联三类信号:

  • 指标:准入、拒绝、排队、活跃、重试、取消和完成请求,以及延迟与用量分布;
  • Trace:准入、路由解析、上游尝试、首字节、流式传输和结算阶段;
  • 审计与用量事件:身份、策略决策、配置版本、后端、终态和计量来源。

按照 W3C Trace Context 传播 traceparenttracestate,同时在信任边界落实规范提出的隐私、信息泄漏和拒绝服务约束。可参考持续演进的 OpenTelemetry GenAI 语义约定统一字段,但遥测契约必须固定所采用的约定版本,避免上游变更直接破坏查询。

默认优先记录哈希、长度、策略标签、模型别名和经过采样脱敏的片段。任何获批的原文采集都应使用独立授权、保留期、加密和访问审计。

配置版本、灰度与回滚

网关配置就是生产代码。错误别名、过宽权限或不兼容的备用后端会立即影响全部应用,因此配置需要与二进制相同的审阅和发布纪律。

安全发布路径包括:

  1. 校验 Schema、引用、能力闭包和安全不变量。
  2. 签名不可变快照,并记录父版本。
  3. 在测试副本加载快照,回放代表性请求。
  4. 运行影子评测,不改变用户可见响应。
  5. 按租户、工作负载或副本灰度,并比较决策指标。
  6. 仅在错误、拒绝、延迟、重试与计量门禁通过后放量。
  7. 门禁失败时自动恢复上一份已知正确快照。

控制面可用性与数据面连续性要分开设计。控制面不可用时,数据面可在有限时间内继续使用本地校验通过的快照;快照过期后究竟拒绝请求还是进入受限应急策略,必须预先定义。

在发布前校验网关策略

静态校验可以在流量进入网关前发现不安全引用。下面的纯 Python 标准库程序校验一份 JSON 策略:后端引用、必需能力、租户缓存命名空间、重试边界、Prompt 日志以及预算耗尽行为。

python
from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any


def require(condition: bool, message: str, errors: list[str]) -> None:
    if not condition:
        errors.append(message)


def string_set(value: Any, field: str, errors: list[str]) -> set[str]:
    if not isinstance(value, list) or any(
        not isinstance(item, str) or not item for item in value
    ):
        errors.append(f"{field} must be a list of non-empty strings")
        return set()
    return set(value)


def validate(policy: dict[str, Any]) -> list[str]:
    errors: list[str] = []
    require(
        isinstance(policy.get("config_version"), str)
        and bool(policy["config_version"]),
        "config_version must be a non-empty string",
        errors,
    )

    backend_rows = policy.get("backends")
    route_rows = policy.get("routes")
    tenant_rows = policy.get("tenants")
    require(isinstance(backend_rows, list), "backends must be a list", errors)
    require(isinstance(route_rows, list), "routes must be a list", errors)
    require(isinstance(tenant_rows, list), "tenants must be a list", errors)
    if not all(isinstance(rows, list) for rows in (
        backend_rows, route_rows, tenant_rows
    )):
        return errors

    backends: dict[str, set[str]] = {}
    for index, row in enumerate(backend_rows):
        if not isinstance(row, dict):
            errors.append(f"backends[{index}] must be an object")
            continue
        backend_id = row.get("id")
        if not isinstance(backend_id, str) or not backend_id:
            errors.append(f"backends[{index}].id must be non-empty")
            continue
        require(
            backend_id not in backends,
            f"duplicate backend id: {backend_id}",
            errors,
        )
        backends[backend_id] = string_set(
            row.get("capabilities"),
            f"backend {backend_id} capabilities",
            errors,
        )
        require(
            isinstance(row.get("secret_ref"), str)
            and bool(row["secret_ref"]),
            f"backend {backend_id} must use secret_ref",
            errors,
        )

    aliases: set[str] = set()
    for index, route in enumerate(route_rows):
        if not isinstance(route, dict):
            errors.append(f"routes[{index}] must be an object")
            continue
        alias = route.get("alias")
        if not isinstance(alias, str) or not alias:
            errors.append(f"routes[{index}].alias must be non-empty")
            continue
        require(alias not in aliases, f"duplicate route alias: {alias}", errors)
        aliases.add(alias)
        required = string_set(
            route.get("required_capabilities"),
            f"route {alias} required_capabilities",
            errors,
        )
        candidates = string_set(
            route.get("candidates"), f"route {alias} candidates", errors
        )
        require(bool(candidates), f"route {alias} has no candidates", errors)
        for backend_id in candidates:
            require(
                backend_id in backends,
                f"route {alias} references unknown backend {backend_id}",
                errors,
            )
            if backend_id in backends:
                missing = required - backends[backend_id]
                require(
                    not missing,
                    f"route {alias} backend {backend_id} lacks "
                    f"{sorted(missing)}",
                    errors,
                )

        retry = route.get("retry")
        if not isinstance(retry, dict):
            errors.append(f"route {alias} retry must be an object")
        else:
            attempts = retry.get("max_attempts")
            require(
                isinstance(attempts, int) and not isinstance(attempts, bool)
                and 1 <= attempts <= 3,
                f"route {alias} max_attempts must be between 1 and 3",
                errors,
            )
            require(
                retry.get("after_downstream_started") is False,
                f"route {alias} must not retry after streaming starts",
                errors,
            )
            ratio = retry.get("budget_ratio")
            require(
                isinstance(ratio, (int, float))
                and not isinstance(ratio, bool)
                and 0 <= ratio <= 1,
                f"route {alias} retry budget_ratio must be in [0, 1]",
                errors,
            )

    for index, tenant in enumerate(tenant_rows):
        if not isinstance(tenant, dict):
            errors.append(f"tenants[{index}] must be an object")
            continue
        tenant_id = tenant.get("id")
        require(
            isinstance(tenant_id, str) and bool(tenant_id),
            f"tenants[{index}].id must be non-empty",
            errors,
        )
        require(
            isinstance(tenant.get("cache_namespace"), str)
            and bool(tenant["cache_namespace"]),
            f"tenant {tenant_id!r} needs an isolated cache_namespace",
            errors,
        )
        require(
            tenant.get("on_budget_exhausted") in {"reject", "manual_approval"},
            f"tenant {tenant_id!r} must reject or require approval "
            "when budget is exhausted",
            errors,
        )

    telemetry = policy.get("telemetry")
    require(isinstance(telemetry, dict), "telemetry must be an object", errors)
    if isinstance(telemetry, dict):
        require(
            telemetry.get("log_prompt_body") is False,
            "telemetry.log_prompt_body must default to false",
            errors,
        )
    return errors


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("policy", type=Path)
    args = parser.parse_args()
    try:
        value = json.loads(args.policy.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as error:
        parser.error(str(error))
    if not isinstance(value, dict):
        parser.error("policy root must be an object")
    errors = validate(value)
    if errors:
        for error in errors:
            print(f"ERROR: {error}")
        raise SystemExit(1)
    print(f"valid policy: {args.policy}")


if __name__ == "__main__":
    main()

执行 python gateway_policy_validator.py gateway-policy.json。合法配置以状态码 0 退出;任何不变量冲突都会逐条打印,并以状态码 1 退出。示例中的尝试次数上限是本地安全约束,不是通用行业答案;生产值应由工作负载截止时间、副作用风险和实测故障行为确定。

生产验证清单

LLM Gateway 必须按关键分布式系统验证,不能只检查 HTTP 请求是否成功转发。

契约验证

  • 为每个端点、能力、后端和流式事件回放 Golden Request。
  • 验证不支持的字段会明确失败,而不是静默丢失。
  • 覆盖取消、畸形 Chunk、缺失用量、半截工具调用和非 JSON 错误。
  • 确认模型别名不会选中区域或能力不合规的后端。

韧性验证

  • 注入连接失败、限流、响应头延迟、流中断和控制面不可用。
  • 确认首字节发送后不会发生跨后端重试。
  • 测量重试放大,并证明重试预算可以关闭。
  • 压满并发与队列,验证内存有界且拒绝原因可操作。
  • 在真实流量中恢复上一份配置快照。

计量与隔离验证

  • 覆盖成功、拒绝、取消、超时和结果不确定时的预留结算。
  • 验证未知价格与缺失用量进入异常台账。
  • 把网关分摊总额与供应商账单导出对齐。
  • 尝试跨租户读取缓存、Trace、用量和凭证。
  • 审计默认日志与 Trace 中不存在原始 Prompt。

结果指标

网关层与业务验收必须分开统计。HTTP 200 只表示响应经过了网关,不代表模型输出满足应用质量契约。网关 SLO 应覆盖可用性、排队、首字节、流完成、策略正确性、计量完整性和租户隔离;输出验收仍由应用团队单独评测。

常见问题

LLM Gateway 与传统 API Gateway 有什么区别?

传统 API Gateway 已经提供路由、认证、TLS 和流量策略。LLM Gateway 在此基础上增加模型别名、端点能力转换、Token 感知准入、长连接流式处理、供应商用量归一和模型特有故障处理。应尽量复用成熟网关原语,而不是在 LLM SDK 包装层里重新实现。

OpenAI 兼容接口足以实现供应商可移植吗?

不足。它可以降低一组已测试字段的客户端接入成本,但真正可移植还需要验证工具、结构化输出、多媒体、流式事件、错误、用量和数据策略。能力矩阵必须可测试;必要时保留受控原生逃生通道,并明确拒绝不支持的组合。

降级链路可以保证高可用吗?

不能。只有备用后端健康、足够独立、契约兼容、配额充足且能在截止时间前返回时,降级才有效;云平台、网络、凭证或网关本身的相关故障都可能让整条链路失效。可用性结论必须来自故障注入和生产遥测,而不是备用后端数量。

网关应该执行模型质量路由吗?

网关可以执行已审批的别名和候选集合,但按 Prompt 分类无法证明更便宜的后端一定产出可验收结果。步骤级质量路由、验证、升级、拒答和轨迹评测应放在应用或 Agent 策略中,详见 AI Agent 模型路由

团队应如何渐进引入 LLM Gateway?

先完成调用清单和被动遥测,再统一身份与用量记录;之后只强制一项低风险配额或路由,影子运行版本化策略,选择有限租户灰度,并保留经过演练的回滚。只有能力测试证明转换契约后,才迁移供应商特有能力。

总结

生产级 LLM Gateway 应是边界清晰、可审计的基础设施层。数据面执行身份、能力、配额、流式、韧性和计量策略,控制面负责版本化并安全分发这些策略。可靠设计会拒绝不支持的契约,在流式首字节处停止跨模型重试,通过对账而非猜测确认成本,隔离每个租户的有状态表面,并随时恢复已知正确配置。把统一 URL 当成通用兼容、把 HTTP 成功当成模型结果可用,才是最危险的架构误判。

参考资料与延伸阅读