2630 字
约 8 分钟
14
S23-A19 · 多副本推理:请求怎么落到"有缓存的那个实例"

一个推理服务从 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. 自测

  1. 三个层次的状态里,哪一项是"命中率下降"的根源?为什么它"每实例私有"?
  2. 四种解法里,哪一种最常用?它的两个主要代价是什么?
  3. "共享 KV"为什么在多数场景不成立?它还有一个"安全"上的代价是什么?
  4. 为什么"复制通用前缀"是最划算的一类优化?它的前提是什么?
  5. "扩容后新实例最慢"为什么会被误读?正确解释是什么?
  6. 判断"是否需要粘性"的那个问题是什么?
S23-A19 · 多副本推理:请求怎么落到"有缓存的那个实例"
https://www.clxhxhhr.top/posts/6565/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。