直接回答
LLM Gateway 是位于 AI 应用与模型后端之间、负责执行策略的网络中间层。生产架构应分离低延迟数据面和版本化控制面:数据面处理身份、协议转换、配额、流式传输、故障控制与用量事件;控制面发布路由、后端能力、密钥引用、预算和灰度策略。网关可以减少重复接入,但不会让供应商能力自动等价,也不能保证模型质量或消除供应商耦合。
本文是 AI 架构师课程 第 18 篇。Agent 步骤级模型选择、输出验收与升级属于 AI Agent 模型路由,本文只讨论共享网络与策略层。
目录
- 什么时候值得引入 LLM Gateway
- 控制面与数据面如何分工
- 把协议兼容写成显式契约
- 身份、配额与准入控制
- 流式响应、重试与熔断
- 计量、预算预留与账单对账
- 租户隔离、缓存安全与密钥
- 不泄露 Prompt 的可观测性
- 配置版本、灰度与回滚
- 在发布前校验网关策略
- 生产验证清单
- 常见问题
什么时候值得引入 LLM Gateway
只有多个应用确实需要同一组可强制执行的边界时,LLM Gateway 才值得存在;“调用了大模型”本身并不是引入理由。它适合内联原本会散落在各 SDK 包装层中的工作负载身份、后端密钥、模型别名、能力检查、配额、出网控制、用量记录和 Trace 传播。
这是一项运维决策,而不是技术潮流:
| 场景 | 优先本地适配层 | 优先共享网关 |
|---|---|---|
| 单应用、单后端 | 是 | 通常没有必要 |
| 多团队共享凭证或配额 | 控制容易漂移 | 强适配 |
| 业务依赖供应商特有能力 | 保留原生客户端 | 仅在有显式透传契约时使用 |
| 需要统一出网审计与数据合规 | 重复实现风险高 | 强适配 |
| 团队无力运营新的关键服务 | 保持调用链简单 | 暂不引入 |
网关只是改变耦合发生的位置。应用可以依赖 support-chat 这类稳定别名,但网关仍然依赖供应商协议、模型行为和账单导出;它还会成为新的故障域。因此,高可用设计必须同时覆盖网关副本、配置恢复、密钥服务和依赖网络,并为每类工作负载预先定义“旁路、拒绝还是受限运行”,不能在事故中临时决定。
控制面与数据面如何分工
核心架构原则是把配置决策与请求执行分开。控制面校验并发布不可变配置快照;数据面消费已知正确的快照,不能在每个请求上同步查询持续变化的管理数据库。
控制面职责
控制面管理变化较慢、需要审阅的状态:
- 工作负载身份、租户边界和授权规则;
- 虚拟模型别名及其候选后端;
- 按端点定义的能力矩阵;
- 密钥引用,而不是路由文件中的明文凭证;
- 请求、Token、并发和预算策略;
- 超时、重试、熔断与排队限制;
- 缓存资格、数据驻留和遥测脱敏规则;
- 配置 Schema、版本、签名、激活与回滚。
发布必须具备事务性。如果某条路由引用了不存在的后端或不支持的能力,应拒绝整个快照,不能让不同数据面副本观察到不同的半成品配置。
数据面请求生命周期
数据面按确定顺序执行策略,并记录每次决策:
认证回答“谁在调用”,授权回答“这个身份能使用哪些别名、工具、区域和预算范围”。两者必须与供应商 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,同时说明影响范围以及稍后重试是否有意义。
转发前预留,完成后结算
对于长度不确定的生成请求,应根据已校验请求与租户策略预留保守上界。完成后按权威实际用量结算并释放差额;取消或供应商结果不确定时,则把预留转入待确认状态,直到取得用量或触发明确的过期规则。
预算耗尽时不能静默切换到更便宜、更弱的模型。诚实拒绝预算超限,比破坏质量、安全、区域或工具调用契约更可靠。成本感知选型只能在已经通过工作负载验收门禁的候选集合内进行。
流式响应、重试与熔断
流式输出改变了故障边界:一旦网关已经向客户端发送响应字节,换后端重放请求可能产生重复文本、重复工具调用或重复副作用。通用安全规则是:仅在向下游提交响应前重试,提交后不再跨模型重试。只有应用协议明确支持稳定偏移、断点续传和去重时才能例外。
重试策略
RFC 9110 定义了 HTTP 方法语义,但 LLM POST 不会自动变成可安全重放的操作。只有同时满足以下条件才能重试:
- 尚未越过向下游发送响应字节的提交边界。
- 请求不存在未保护的外部副作用。
- 错误分类明确允许重试。
- 总截止时间仍足以完成下一次尝试。
- 路由尝试上限和全局重试预算都允许继续。
- 备用后端满足相同能力与数据策略契约。
退避应加入随机抖动,并在适用时尊重 Retry-After。重试预算用于限制“重试流量占健康原始流量的比例”,避免故障依赖把每个请求放大成多次请求。Envoy 的熔断文档还分别限制连接、等待请求、活跃请求与重试。
熔断与取消
熔断器用于保护网关和健康后端不被故障依赖拖垮。状态至少应按后端和端点隔离,必要时再区分租户等级;图像生成故障不应自动熔断 Chat。被动故障可以驱动熔断,但主动探测要谨慎,因为探测同样消耗配额,而且未必覆盖真实请求路径。
客户端取消必须向上游传播,尽快停止已经无用的读取、生成和计量工作,同时仍写入终态用量事件。缓冲区必须有界,并向慢客户端实施背压,防止单个连接造成内存无界增长。
计量、预算预留与账单对账
网关计量是实时运营估算与归因台账,不是最终发票。它可以快速提供租户级可见性,但财务对账仍应以供应商账单导出为权威来源。
每次终态尝试都应生成不可变用量记录:
{
"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 传播 traceparent 与 tracestate,同时在信任边界落实规范提出的隐私、信息泄漏和拒绝服务约束。可参考持续演进的 OpenTelemetry GenAI 语义约定统一字段,但遥测契约必须固定所采用的约定版本,避免上游变更直接破坏查询。
默认优先记录哈希、长度、策略标签、模型别名和经过采样脱敏的片段。任何获批的原文采集都应使用独立授权、保留期、加密和访问审计。
配置版本、灰度与回滚
网关配置就是生产代码。错误别名、过宽权限或不兼容的备用后端会立即影响全部应用,因此配置需要与二进制相同的审阅和发布纪律。
安全发布路径包括:
- 校验 Schema、引用、能力闭包和安全不变量。
- 签名不可变快照,并记录父版本。
- 在测试副本加载快照,回放代表性请求。
- 运行影子评测,不改变用户可见响应。
- 按租户、工作负载或副本灰度,并比较决策指标。
- 仅在错误、拒绝、延迟、重试与计量门禁通过后放量。
- 门禁失败时自动恢复上一份已知正确快照。
控制面可用性与数据面连续性要分开设计。控制面不可用时,数据面可在有限时间内继续使用本地校验通过的快照;快照过期后究竟拒绝请求还是进入受限应急策略,必须预先定义。
在发布前校验网关策略
静态校验可以在流量进入网关前发现不安全引用。下面的纯 Python 标准库程序校验一份 JSON 策略:后端引用、必需能力、租户缓存命名空间、重试边界、Prompt 日志以及预算耗尽行为。
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 成功当成模型结果可用,才是最危险的架构误判。
参考资料与延伸阅读
- W3C Trace Context:跨服务 Trace 传播及安全边界
- RFC 9110:HTTP Semantics:方法、中间层与重试语义
- RFC 6585:Additional HTTP Status Codes:
429 Too Many Requests - Envoy 熔断机制:连接、请求、等待与重试限制
- Envoy AI Gateway v1.0 发布文档:显式列出供应商与端点覆盖的当前实现示例
- OpenTelemetry GenAI 语义约定:持续演进的 GenAI 遥测约定
- OWASP GenAI LLM Top 10:生成式 AI 安全威胁分类
- 生产级语义缓存:缓存正确性、租户隔离与生命周期
- AI 推理成本工程:端到端成本与 Goodput 核算