2660 字
约 8 分钟
20
S23-X08 · 模型服务网关:路由、配额与缓存

模型网关不是"再包一层 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. 自测

  1. 为什么别名层与具体模型版本必须解耦?
  2. 四层配额中哪一层最容易配错成"配额没用完就 429"?
  3. 双桶限流时,token 桶拒绝为什么必须退还请求桶?
  4. 缓存键里少写 model_version 会造成什么后果?
  5. 读超时为什么不该在网关层重试?
  6. 流式响应被代理缓冲时,表现成"首字慢"还是"总时长长"?
S23-X08 · 模型服务网关:路由、配额与缓存
https://www.clxhxhhr.top/posts/5932/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。