模型网关不是"再包一层 HTTP",而是把六件本该统一的事从各业务方收回来:鉴权与租户、路由与多模型、配额与限流、缓存、可观测、内容审核。做对了,换模型是一次配置变更;做错了,每个业务方各自持有一份 key、一套重试、一份 token 计费口径。
下面按"网关必须回答的问题"推进。
1. 网关收拢的六件事
| 能力 | 收拢前 | 收拢后 |
|---|---|---|
| 鉴权与租户 | 各业务持有厂商 key | 网关持 key,对内发租户 token |
| 路由与多模型 | 换模型要改代码 | 按别名路由,改配置 |
| 配额与限流 | 无法限制用量,账单不可控 | 按租户/用户/模型分层 |
| 缓存 | 重复请求重复付费 | 精确/前缀/语义三级缓存 |
| 可观测 | 只有业务日志 | TTFT、token 用量、命中率 |
| 内容审核 | 各业务各做一遍 | 统一入口拦截 |
判据很简单:这件事如果每个调用方都要做一遍,就该上移。
2. 路由:按什么选模型
| 依据 | 例子 | 实现位置 |
|---|---|---|
| 模型别名 | chat-fast → 具体版本 |
配置映射表,别写死 |
| 任务类型 | 分类用小模型、长文用大模型 | 请求头或 body 的 task 字段 |
| 租户等级 | 免费档限小模型,付费档走大模型 | 租户配置 |
| 故障转移 | 主模型 5xx/超时切备用 | 健康探测 + 熔断 |
| 成本上限 | 单请求预估 token 超阈值降档 | 预检 + 降级链 |
routes:
- alias: chat-fast
primary: { model: qwen2.5-7b-instruct, provider: local-vllm }
fallback: { model: gpt-4o-mini, provider: vendor-a }
timeout: { connect: 2s, read: 60s }
- alias: chat-deep
primary: { model: qwen2.5-72b-instruct, provider: local-vllm }
fallback: { model: claude-sonnet, provider: vendor-b }
别名与具体版本必须解耦:业务写 chat-deep,运营改 primary.model 就能换底。若业务直接写 qwen2.5-72b-instruct,换模型就是一次全量发布。
3. 配额的四个层级
| 层级 | 限制什么 | 典型用途 |
|---|---|---|
| 租户 | 总 token/月、总并发 | 合同、成本封顶 |
| 用户 | 每分钟请求数、每日 token | 防单账号刷爆 |
| 接口 | 单 endpoint 并发 | 保护昂贵接口 |
| 模型 | 每个模型的总并发与队列长度 | 保护 GPU |
四层是与关系,任一层拒绝即拒绝。降级顺序也有讲究,从轻到重:
降档模型(贵→便宜) → 限流(排队/退避) → 缩 max_tokens → 直接拒绝(429)
先降档再限流,是因为用户更愿意拿到"稍差的答案"而不是"等 30 秒"。最差的做法是一上来就 429——配额明明还有,只是贵模型满了。
4. 限流的两个维度:请求数与 token 数
只按请求数限流,一个请求塞 100k token 就能拖垮整个副本;只按 token 限流,则短请求的 QPS 无人管。所以要双桶:
| 桶 | 键 | 容量 | 补充速率 |
|---|---|---|---|
| 请求桶 | tenant:user:endpoint |
每分钟请求数 | 按分钟折算 |
| token 桶 | tenant:model |
每分钟 token 数 | 按实测吞吐折算 |
关键细节:两个桶都要扣,但 token 桶失败时要退还请求桶的那一颗,否则一次超长请求会把请求配额也吃掉,重试变成不可能。
-- Redis 原子双桶(示意,KEYS[1]=请求桶, KEYS[2]=token桶)
local ok1 = acquire(KEYS[1], 1)
local ok2 = acquire(KEYS[2], ARGV[1]) -- ARGV[1] = 预估 token
if ok1 and not ok2 then release(KEYS[1], 1) end -- 退还请求额度
if not (ok1 and ok2) then
return {err = 'RATE_LIMITED', retryAfter = retry(KEYS[2])}
end
return {ok = true}
token 数在流式场景只能预估:用 prompt token + max_tokens 上限做预扣,结束后按实际用量结算并退还差额。不预扣就会超卖。
5. 缓存的三级粒度
| 粒度 | 键 | 收益 | 风险 |
|---|---|---|---|
| 精确缓存 | 完整请求哈希 | 重复提问直接命中 | 命中率低 |
| 前缀缓存 | 公共前缀(system prompt、文档) | 长 system prompt 场景收益大 | 需后端支持 KV 复用 |
| 语义缓存 | 向量相似度检索 | 换说法也能命中 | 误命中,答案错得隐蔽 |
键的构成决定命中率和正确性,必须含:模型名 + 模型版本 + adapter/LoRA 名 + 采样参数(temperature/top_p/max_tokens)+ tools schema 指纹 + system prompt。少一项就会串答案。
key = sha256( model_version ‖ adapter ‖ temperature ‖ top_p ‖ max_tokens
‖ tools_schema_hash ‖ messages_json )
只有 temperature == 0(或等价贪心)的请求才进精确缓存。temperature > 0 的请求缓存起来等于人为降低随机性,用户会觉得"这个模型怎么老说一样的话"。
语义缓存还要额外设阈值与 TTL,并把命中率与误命中抽检一起看——没有抽检的语义缓存是事故预备役。
6. 流式响应穿过网关
SSE 请求经网关最常出两个问题:缓冲和超时。
| 问题 | 现象 | 处理 |
|---|---|---|
| 代理缓冲 | 首字迟迟不出,最后一次性吐出 | 响应头 X-Accel-Buffering: no,关 gzip |
| 总超时切断长回答 | 长文本中途断流 | 用 idle timeout 而非总超时 |
| 客户端断开 | 上游继续算,白白占卡 | 监听断开事件,主动 abort 上游 |
| 计费时机 | 中途断开如何算账 | 按已产出 token 计费,记录 finish_reason |
res.setContentType("text/event-stream;charset=UTF-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Accel-Buffering", "no"); // 关键:别让 nginx 缓冲
try (SseEmitter emitter = new SseEmitter(0L)) { // 0 = 不设总超时
upstream.stream(req, tok -> {
emitter.send(SseEmitter.event().data(tok)); // 逐块转发
meter.recordFirstTokenIfAbsent(); // TTFT 打点
usage.add(tok.usage());
});
emitter.complete();
} catch (IOException clientGone) {
upstream.abort(); // 别让上游空转
}
SseEmitter(0L) 关掉总超时后,要靠心跳(每 15~30 秒一个注释行)判断连接是否还活着,否则半开连接会一直挂着。
7. 重试放在哪一层
网关最容易制造重试放大:客户端重试 3 次 × 网关重试 3 次 = 9 个上游请求。
| 失败点 | 该不该重试 | 理由 |
|---|---|---|
| 连接失败 / 连接超时 | 可重试(换实例) | 请求没发出去 |
| 上游 502/503/504 | 可重试,带退避 | 瞬时故障 |
| 读超时 | 不重试 | 上游可能已在算,重试双倍占卡 |
| 流式中途断 | 不重试 | 已经产出内容,重放会重复计费 |
| 4xx(参数错/内容审核) | 不重试 | 同样输入必然同样失败 |
原则:只在一层重试。要么客户端负责(网关透传状态码),要么网关负责(客户端不重试)。两层都重试是纯放大。
8. 可观测:必须打的指标
| 指标 | 维度 | 用途 |
|---|---|---|
| 成功率 | 模型、租户、endpoint | 故障发现 |
| TTFT P50/P95/P99 | 模型、输入长度桶 | 首字体验 |
| 端到端 P95/P99 | 同上 | SLA 判定 |
| token 用量 | 租户、模型、输入/输出 | 计费与容量 |
| 缓存命中率 | 粒度(精确/前缀/语义) | 成本优化 |
| 队列等待时间 | 模型 | 判断该扩容还是该限流 |
基数控制是硬约束:租户 ID 与用户 ID 全打标签会让时序库爆掉。做法是租户 ID 打标签(数量有限)、用户 ID 只进日志不进指标、输入输出长度用桶(<256、256-1k、1k-4k、>4k)。
9. 常见坑
| 坑 | 现象 | 改法 |
|---|---|---|
| 业务直接持厂商 key | 无法限制用量,key 泄露面大 | 统一走网关,key 只在网关 |
| 只按请求数限流 | 单个超长请求打爆副本 | 双桶,token 也限 |
| token 桶失败没退请求桶 | 重试也不可能成功 | 双桶扣减要成对退还 |
| 缓存键缺版本 | 换模型后返回旧答案 | 键含模型版本与 adapter |
缓存 temperature > 0 |
用户反馈回答千篇一律 | 只缓存贪心请求 |
| 网关内重试读超时 | GPU 白算,成本翻倍 | 读超时不重试 |
| 流式被 nginx 缓冲 | 首字延迟假象 | X-Accel-Buffering: no |
| 指标带用户 ID | 时序库基数爆炸 | 用户维度只进日志 |
10. 排错
| 现象 | 排查方向 |
|---|---|
| 首字很慢但总时长正常 | 代理缓冲;查 X-Accel-Buffering 与 gzip |
| 长回答总是断在同一秒数 | 总超时被写死;改 idle timeout |
| 客户端断开后 GPU 利用率没降 | 未 abort 上游;查流关闭回调 |
| 配额没用完却大量 429 | 命中的是模型层配额,不是租户层 |
| 单租户跑满全站 | 只有全局配额,缺租户维度 |
| 缓存命中率虚高但账单没降 | 命中的是语义缓存但仍调了上游;查短路逻辑 |
11. 边界
| 场景 | 更适合的做法 |
|---|---|
| 单一业务、单一模型 | 不建网关,直连即可 |
| 极低延迟要求(边缘场景) | 网关只做鉴权,不做缓存与审核 |
| 需要厂商原生特性 | 网关透传原始 API,不做字段裁剪 |
| 离线批处理 | 走批接口与队列,不必走在线网关 |
| 模型数量少且不换 | 配置在业务侧,网关只做配额 |
12. 自测
- 为什么别名层与具体模型版本必须解耦?
- 四层配额中哪一层最容易配错成"配额没用完就 429"?
- 双桶限流时,token 桶拒绝为什么必须退还请求桶?
- 缓存键里少写
model_version会造成什么后果? - 读超时为什么不该在网关层重试?
- 流式响应被代理缓冲时,表现成"首字慢"还是"总时长长"?