一个推理服务从 1 个实例扩到 4 个,前缀缓存命中率从 92% 掉到 31%,首 token 延迟翻倍——因为缓存是进程内的,而负载均衡是按连接轮询的。同一个会话的第二轮请求有 75% 的概率落到"没有那份缓存"的实例上。
多副本部署几乎是"扩容"的默认动作,而对"有状态加速"的服务,它带来一个反直觉的效果:副本越多,单个实例的缓存命中率越低——这个"反直觉"在"有状态加速"的服务里是普遍规律:扩容增加容量,同时降低单实例的命中率。所以"扩容后性能提升"不是必然,要算两笔账。
于是"扩容"不一定提升体验。
1. 三个层次的状态
| 层次 | 是否随实例变化 |
|---|---|
| 模型权重 | 否(每实例都加载) |
| 前缀缓存(KV) | 是(进程内存) |
| 会话历史 | 视设计(会话存储 or 重新发送) |
第二项是"命中率下降"的根源:它是"每实例私有"的加速结构第二项的"体积"解释了它为什么难共享:KV 缓存按 token × 层数 × 维度存储,几 MB 到几百 MB,于是"随请求传输"的成本高于"重算一遍"。
,于是"请求分到别的实例"等于"缓存 miss"。
所以要提升命中率,必须让"同一会话/同一前缀的请求落到同一实例",或者"把缓存做成共享"。
2. 两种解法与代价
| 解法 | 代价 |
|---|---|
| 会话粘性(按 key 路由) | 负载不均、故障域放大 |
| 共享缓存(外部存储 KV) | 传输开销大、实现复杂 |
| 复制前缀(每个实例都预热) | 显存成倍 |
| 不优化(接受低命中) | 成本与延迟 |
第一项的实现要点是"用一致性哈希而不是取模":取模在"实例增减"时会让几乎所有会话重新分布,而一致性哈希只影响相邻的一段。
第一项最常用:按 session_id(或 user_id)做一致性哈希,于是同一会话总落到同一实例。
第二项的难点在"KV 体积":一个 3000 token 的前缀缓存可能几百 MB,而每轮都要"取/存",于是网络传输成为新的瓶颈。
第三项在"前缀数量少"时可行:把 Top N 个通用前缀在每个实例都预热第三项的判据是"前缀是否对所有用户相同":系统提示、工具描述、few-shot 示例都属于这一类,而"用户的历史对话"不属于。
,于是"通用部分"总能命中,只有"用户特有"部分 miss。
// 会话粘性:按 header 做一致性哈希(nginx upstream)
upstream inference {
hash $http_x_session_id consistent;
server 10.0.0.1:8000;
server 10.0.0.2:8000;
server 10.0.0.3:8000;
server 10.0.0.4:8000;
}
// 效果:同一 session 固定落到同一实例;代价:实例增减时哈希环重分布
3. 粘性的三个副作用
| 副作用 | 表现 |
|---|---|
| 负载不均 | 热门会话压住一个实例 |
| 故障域放大 | 实例挂 → 其会话全 miss(但可恢复) |
| 扩容不均衡 | 新增实例分到的会话少 |
第一项的缓解方式是"粘性 + 单会话限流":让热门会话"慢一点",而不是让它"占住一个实例"。这与"公平性 vs 吞吐"的取舍是同一类问题。
第一项在"少数会话请求量特别大"时明显:一个长对话用户可能占住一个实例 30% 的容量。
第三项是"扩容时的尴尬":新增实例初期"没有缓存",于是"最慢的实例"恰是新的第三项的缓解是"扩容时预热":新实例起来后先跑几条"通用前缀"的请求,把系统提示那一段的缓存建起来——成本几秒,收益是"第一波请求不慢"。
——直到它积累了缓存。
"粘性键"的选择直接决定效果,而它是常被忽略的一步:
// 三种粘性键的差别
按 session_id:多轮对话命中高,但"同一用户多会话"不共享
按 user_id:同一用户的所有会话共享前缀,但负载集中度更高
按 prefix_hash:按"前缀内容"粘性,通用前缀天然聚合(推荐用于"多租户但同一模板")
// 判据:你要复用的是什么——会话上下文、用户偏好,还是模板本身
4. 共享缓存的实际代价
| 环节 | 成本 |
|---|---|
| 序列化/传输 | KV 体积大(MB 级) |
| 命中判断 | 需 hash 比对 |
| 一致性 | 版本与失效 |
| 安全 | 前缀可能含敏感内容 |
第一项的另一种形态是"命中判断也要传输":如果为了"判断命中"要先传 hash,那"判断"本身的往返就可能吃掉收益——于是"共享缓存"常常只在"局域网 + 小 KV"里可行。
第一项最致命:在一个"每 token 几十微秒"的系统里,传输几百 MB 是不可能的。所以"共享 KV"在多数场景里不成立,只有"小前缀(< 1000 token)+ 高复用"时才考虑。
第四项常被忽略:把 KV 存到共享存储,等于把"用户前缀的内容"复制到别处,于是"数据留在实例内"的隔离假设被打破。
还有一个"成本核算"的粗公式,能避免拍脑袋:
// 粘性收益的粗算(每轮对话)
单实例命中:省掉 prefill(假设 3000 token × 0.05ms/token ≈ 150ms)
粘性后命中率 92%,无粘性 1/N(N=4 → 25%)
收益 ≈ (0.92 - 0.25) × 150ms ≈ 100ms/轮
// 代价:路由复杂度、负载不均、部署需考虑粘性
// 判据:100ms/轮 × 轮数,是否值得那些复杂度
5. 该选哪种:三个判据
| 判据 | 指向 |
|---|---|
| 会话长度(多轮对话) | 粘性 |
| 前缀是否通用(系统提示) | 复制/预热 |
| 显存是否紧张 | 不优化或复制少量 |
第二项是"最划算"的一类:系统提示对所有用户相同,于是"每个实例只缓存这一份"就能覆盖大部分复用第二项的前提是"把可变内容后置":如果系统提示里塞了"当前用户 ID",那前缀就"每人不同",复制也就没意义。
(前提是"把可变内容后置")。
第三项在"显存紧"时必须谨慎:复制前缀会占显存,而不优化只是"多算一点"——两者要按"显存 vs 算力"哪个更贵来选。
"命中率"要用"分实例"的口径看,否则平均值会掩盖"某实例极低":
// 三个必须分开看的数(按实例)
prefix_cache_hit_ratio{instance="0"} 0.91
prefix_cache_hit_ratio{instance="1"} 0.89
prefix_cache_hit_ratio{instance="2"} 0.12 ← 异常:请求没落到它,或刚扩出来没预热
// 结论:整体 0.64 看起来"掉了",实际是两个健康 + 一个新实例
6. 排错:五种"扩容后变慢"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 命中率随副本数下降 | 无粘性 | 加一致性哈希 |
| 首 token 延迟上升 | prefill 重算 | 同上 + 前缀稳定化 |
| 个别实例被打满 | 热门会话 | 粘性 + 限流/拆会话 |
| 扩容后新实例最慢 | 无缓存积累 | 预热通用前缀 |
| 共享缓存方案更慢 | 传输开销 | 放弃,改粘性 |
第四项的判据是"看命中率随时间的变化":新实例的命中率会从 0 爬到平台值,如果一直不爬,说明"请求根本没落到它上面"(粘性配置有问题)。
第四项容易被误读成"新实例性能差",而它只是"还没积累缓存"。
最后一个"演进"路径:先做"通用前缀预热",再做"会话粘性"。因为前者"只影响部署",后者"改变路由语义"——而前者的收益(系统提示那一段)往往已经占了大头。
// 三步走(按投入产出)
1 前缀稳定化(把可变内容后置):改提示,成本低,收益最大
2 通用前缀在每个实例预热:改部署脚本,成本低
3 会话粘性:改路由,成本高(要让部署/缩容都考虑它)
// 多数团队做完第 1、2 步就够,不必到第 3 步
7. 边界:什么情况不必做粘性
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单轮请求(无会话) | 无复用可言 | 不粘性 |
| 前缀很短(< 200 token) | 省不下多少 | 不优化 |
| 请求量小(< 10/s) | 缓存本来也攒不起来 | 不优化 |
| 已用引擎侧 continuous batching | 调度已优化 | 先测再决定 |
判断口径一句话:"前缀占总输入的比例"是否远大于"副本数带来的命中损失"。最后一条与"架构"有关的边界:粘性把"无状态服务"变成"有状态路由",于是"部署、缩容、故障转移"都变复杂。所以它应当被当作一次"架构决策",而不是"加一行配置"。
前缀占 90%,粘性收益极大;前缀占 20%,粘性只是让"少量复用"不丢,收益有限。
8. 自测
- 三个层次的状态里,哪一项是"命中率下降"的根源?为什么它"每实例私有"?
- 四种解法里,哪一种最常用?它的两个主要代价是什么?
- "共享 KV"为什么在多数场景不成立?它还有一个"安全"上的代价是什么?
- 为什么"复制通用前缀"是最划算的一类优化?它的前提是什么?
- "扩容后新实例最慢"为什么会被误读?正确解释是什么?
- 判断"是否需要粘性"的那个问题是什么?