直接回答
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 的熔断文档还分别限制连接、等待请求、活跃请求与重试。
AWS 当前的 LLM 韧性模式同样把可用性、响应时间、成本和吞吐拆开,并区分跨区域容量与模型或供应商故障转移。这只是一个特定实现示例:每条路由仍需通过面向自身负载的故障注入,证明备用路径保持相同能力与数据策略契约。
熔断与取消
熔断器用于保护网关和健康后端不被故障依赖拖垮。状态至少应按后端和端点隔离,必要时再区分租户等级;图像生成故障不应自动熔断 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 语义约定统一字段,但遥测契约必须固定所采用的约定版本,因为 GenAI 约定目前已迁移到独立仓库持续演进。
默认优先记录哈希、长度、策略标签、模型别名和经过采样脱敏的片段。任何获批的原文采集都应使用独立授权、保留期、加密和访问审计。
配置版本、灰度与回滚
网关配置就是生产代码。错误别名、过宽权限或不兼容的备用后端会立即影响全部应用,因此配置需要与二进制相同的审阅和发布纪律。
安全发布路径包括:
- 校验 Schema、引用、能力闭包和安全不变量。
- 签名不可变快照,并记录父版本。
- 在测试副本加载快照,回放代表性请求。
- 运行影子评测,不改变用户可见响应。
- 按租户、工作负载或副本灰度,并比较决策指标。
- 仅在错误、拒绝、延迟、重试与计量门禁通过后放量。
- 门禁失败时自动恢复上一份已知正确快照。
控制面可用性与数据面连续性要分开设计。控制面不可用时,数据面可在有限时间内继续使用本地校验通过的快照;快照过期后究竟拒绝请求还是进入受限应急策略,必须预先定义。
在发布前校验网关策略
静态校验可以在流量进入网关前发现不安全引用。下面的纯 Go 标准库程序校验一份 JSON 策略:后端引用、必需能力、租户缓存命名空间、重试边界、Prompt 日志以及预算耗尽行为。
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
)
type Policy struct {
ConfigVersion string `json:"config_version"`
Backends []Backend `json:"backends"`
Routes []Route `json:"routes"`
Tenants []Tenant `json:"tenants"`
Telemetry *Telemetry `json:"telemetry"`
}
type Backend struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
SecretRef string `json:"secret_ref"`
}
type Route struct {
Alias string `json:"alias"`
RequiredCapabilities []string `json:"required_capabilities"`
Candidates []string `json:"candidates"`
Retry *RetryPolicy `json:"retry"`
}
type RetryPolicy struct {
MaxAttempts *int `json:"max_attempts"`
AfterDownstreamStarted *bool `json:"after_downstream_started"`
BudgetRatio *float64 `json:"budget_ratio"`
}
type Tenant struct {
ID string `json:"id"`
CacheNamespace string `json:"cache_namespace"`
OnBudgetExhausted string `json:"on_budget_exhausted"`
}
type Telemetry struct {
LogPromptBody *bool `json:"log_prompt_body"`
}
const examplePolicy = `{
"config_version": "2026-10-05.1",
"backends": [{
"id": "provider-a",
"capabilities": ["text", "json_schema"],
"secret_ref": "secret://provider-a"
}],
"routes": [{
"alias": "support-chat",
"required_capabilities": ["text", "json_schema"],
"candidates": ["provider-a"],
"retry": {
"max_attempts": 2,
"after_downstream_started": false,
"budget_ratio": 0.1
}
}],
"tenants": [{
"id": "tenant-a",
"cache_namespace": "tenant-a",
"on_budget_exhausted": "reject"
}],
"telemetry": {"log_prompt_body": false}
}`
func require(condition bool, message string, errors *[]string) {
if !condition {
*errors = append(*errors, message)
}
}
func stringSet(values []string, field string, errors *[]string) map[string]bool {
result := make(map[string]bool, len(values))
if values == nil {
*errors = append(*errors, field+" must be a list")
return result
}
for _, value := range values {
if strings.TrimSpace(value) == "" {
*errors = append(*errors, field+" contains an empty string")
continue
}
result[value] = true
}
return result
}
func validate(policy Policy) []string {
var errors []string
require(strings.TrimSpace(policy.ConfigVersion) != "", "config_version is required", &errors)
require(policy.Backends != nil, "backends must be a list", &errors)
require(policy.Routes != nil, "routes must be a list", &errors)
require(policy.Tenants != nil, "tenants must be a list", &errors)
backends := make(map[string]map[string]bool, len(policy.Backends))
for index, backend := range policy.Backends {
if strings.TrimSpace(backend.ID) == "" {
errors = append(errors, fmt.Sprintf("backends[%d].id is required", index))
continue
}
if _, exists := backends[backend.ID]; exists {
errors = append(errors, "duplicate backend id: "+backend.ID)
continue
}
backends[backend.ID] = stringSet(
backend.Capabilities,
"backend "+backend.ID+" capabilities",
&errors,
)
require(
strings.TrimSpace(backend.SecretRef) != "",
"backend "+backend.ID+" must use secret_ref",
&errors,
)
}
aliases := make(map[string]bool, len(policy.Routes))
for index, route := range policy.Routes {
if strings.TrimSpace(route.Alias) == "" {
errors = append(errors, fmt.Sprintf("routes[%d].alias is required", index))
continue
}
require(!aliases[route.Alias], "duplicate route alias: "+route.Alias, &errors)
aliases[route.Alias] = true
required := stringSet(
route.RequiredCapabilities,
"route "+route.Alias+" required_capabilities",
&errors,
)
candidates := stringSet(
route.Candidates,
"route "+route.Alias+" candidates",
&errors,
)
require(len(candidates) > 0, "route "+route.Alias+" has no candidates", &errors)
for backendID := range candidates {
capabilities, exists := backends[backendID]
require(exists, "route "+route.Alias+" references unknown backend "+backendID, &errors)
if !exists {
continue
}
var missing []string
for capability := range required {
if !capabilities[capability] {
missing = append(missing, capability)
}
}
sort.Strings(missing)
require(
len(missing) == 0,
fmt.Sprintf("route %s backend %s lacks %v", route.Alias, backendID, missing),
&errors,
)
}
if route.Retry == nil {
errors = append(errors, "route "+route.Alias+" retry is required")
continue
}
retry := route.Retry
require(
retry.MaxAttempts != nil && *retry.MaxAttempts >= 1 && *retry.MaxAttempts <= 3,
"route "+route.Alias+" max_attempts must be between 1 and 3",
&errors,
)
require(
retry.AfterDownstreamStarted != nil && !*retry.AfterDownstreamStarted,
"route "+route.Alias+" must not retry after streaming starts",
&errors,
)
require(
retry.BudgetRatio != nil && *retry.BudgetRatio >= 0 && *retry.BudgetRatio <= 1,
"route "+route.Alias+" budget_ratio must be in [0, 1]",
&errors,
)
}
for index, tenant := range policy.Tenants {
require(
strings.TrimSpace(tenant.ID) != "",
fmt.Sprintf("tenants[%d].id is required", index),
&errors,
)
require(
strings.TrimSpace(tenant.CacheNamespace) != "",
fmt.Sprintf("tenant %q needs an isolated cache_namespace", tenant.ID),
&errors,
)
require(
tenant.OnBudgetExhausted == "reject" ||
tenant.OnBudgetExhausted == "manual_approval",
fmt.Sprintf("tenant %q must reject or require approval when budget is exhausted", tenant.ID),
&errors,
)
}
require(policy.Telemetry != nil, "telemetry is required", &errors)
if policy.Telemetry != nil {
require(
policy.Telemetry.LogPromptBody != nil && !*policy.Telemetry.LogPromptBody,
"telemetry.log_prompt_body must explicitly be false",
&errors,
)
}
return errors
}
func decodePolicy(data []byte) (Policy, error) {
var policy Policy
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&policy); err != nil {
return Policy{}, err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return Policy{}, fmt.Errorf("policy must contain one JSON object")
}
return policy, nil
}
func main() {
data := []byte(examplePolicy)
source := "embedded example"
if len(os.Args) > 2 {
fmt.Fprintln(os.Stderr, "usage: go run gateway_policy_validator.go [gateway-policy.json]")
os.Exit(2)
}
if len(os.Args) == 2 {
var err error
source = os.Args[1]
data, err = os.ReadFile(source)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
}
policy, err := decodePolicy(data)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if errors := validate(policy); len(errors) > 0 {
for _, message := range errors {
fmt.Fprintln(os.Stderr, "ERROR:", message)
}
os.Exit(1)
}
fmt.Println("valid policy:", source)
}
执行 go run gateway_policy_validator.go 可验证内置 Fixture;也可通过 go run gateway_policy_validator.go gateway-policy.json 读取文件。合法配置以状态码 0 退出,Schema 或命令用法错误以状态码 2 退出,不变量冲突会逐条打印并以状态码 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 发布文档:显式列出供应商与端点覆盖的当前实现示例
- AWS LLM 韧性模式:特定供应商下的容量、故障转移、配额隔离与故障测试模式
- OpenTelemetry GenAI 语义约定:迁移到独立仓库后持续演进的 GenAI 遥测约定
- OWASP GenAI LLM Top 10:生成式 AI 安全威胁分类
- 生产级语义缓存:缓存正确性、租户隔离与生命周期
- AI 推理成本工程:端到端成本与 Goodput 核算