一个推理服务的 KV 池满了,新请求进来必须"抢"一块空间。默认策略是淘汰最久未使用的序列(LRU),听起来很合理——但上线后出现两种怪现象:P99 出现 3 秒尖刺,以及某些会话反复被截断,用户打字打到一半被告知"上下文已超限"。
原因在同一个地方:淘汰不是免费的。被淘汰的序列如果还想继续,就要从头重算 prefill;而"淘汰谁"这个决定,直接决定了重算成本由谁承担、以及哪些用户会被反复伤害。
1. 淘汰的代价是"重算 prefill"
| 淘汰对象 | 直接代价 | 隐藏代价 |
|---|---|---|
| 已结束但 KV 未释放 | 无 | 无(应当立即释放) |
| 空闲的长会话 | 下次输入时重算 | 重算时间 = 序列长度 × 单价 |
| 正在 decode 的短请求 | 丢弃部分输出 | 用户可感知的截断 |
| 刚被淘汰又回来的请求 | 重复淘汰 | 抖动 |
第三行是 P99 尖刺的来源:淘汰一个正在 decode 的请求,它要么被截断(用户看到不完整回答),要么重排、重算、再排队——一次淘汰可能变成一次 3 秒的等待。
// 一次 32k 会话被淘汰后再回来的成本
重算 prefill:32,000 token × 0.08ms/token ≈ 2.6 秒
而它原本只剩 200 token 就生成完了(约 3 秒)
// 结论:淘汰"快完成的长会话"是最亏的一笔
2. 三种淘汰策略,取舍不同
| 策略 | 保护谁 | 伤害谁 |
|---|---|---|
| LRU(最久未使用) | 活跃的新会话 | 思考间隔长的长会话 |
| 按剩余长度优先(先杀快完成的?不) | — | — |
| 按重算成本 / 剩余收益比 | 长会话、快完成者 | 新的短请求(需排队) |
| 抢占式(preemption + 重算) | 高优先级请求 | 低优先级(可恢复) |
第一行与第四行是"公平性"与"效率"的两种取向:LRU 公平但短视,抢占按优先级分配但会让低优请求反复重来。生产上通常先用 LRU 兜底,再叠加两条保护规则(快完成、已很长)。
第二行要说明白:"先杀快完成的"是反直觉的错误——快完成意味着"再给它 3 秒就能交付",杀掉它等于把已付出的成本全丢掉。正确方向是先杀"重算便宜 + 剩余价值低"的。
第三行的判据可以写成一个比值:
代价 = 重算成本(≈ 已完成 token 数) / 剩余收益(≈ 剩余 token 数)
比值越大越该杀——即"已经很长、但还需要很长"的会话,重算极贵;而"很短、剩余也很短"的,重算便宜。
// 三个候选,按"重算成本 / 剩余收益"排序
A: 已 30k,还需 8k → 3.75 ← 最不该杀(重算贵)
B: 已 2k, 还需 1k → 2.0 ← 可杀
C: 已 26k,还需 200 → 130 ← 千万别杀(马上就好)
// 注意 C:数值最大,但它是"快完成",杀了等于扔掉 26k 的成果
3. 抢占:让"重算"可控而不是随机
| 机制 | 做法 | 效果 |
|---|---|---|
| 全量重算 | 淘汰整个序列,重新 prefill | 简单,但代价最大 |
| 分块保留 | 只释放尾部块,保留前缀块 | 恢复时只需续算 |
| 挂起(swap 到 CPU/磁盘) | 移到慢速内存 | 恢复快、但占带宽 |
| 拒绝新请求 | 排队而非淘汰 | 保护在跑的,牺牲吞吐 |
第二行是块表(paged KV)带来的关键能力:因为 KV 是分块映射的,可以只释放尾部。前缀块保留后,恢复时只需对新增部分做 prefill——这让"抢占"从"全损"变成"损失一小段"。
第四行是最保守也最容易被忽视的选项:宁可让新请求排队,也不要杀掉在跑的。它的代价是首 token 延迟变高,收益是已投入的计算不浪费。在长会话为主的场景里,这往往是最优解。
// 一个可用的抢占配置(把"杀谁"写成显式规则)
kv_eviction:
preempt_mode: recompute # 或 swap
victim_order: # 从上往下尝试
- completed_not_released # 1 已结束未释放(零成本)
- short_low_remaining # 2 短且剩余少
- long_stalled # 3 长但长时间无新 token(用户走神)
protect:
- near_completion: remaining < 512 # 快完成的不杀
- high_priority_tenant # 高优租户不杀
fallback: queue_new_request # 都不该杀 → 让新请求排队
4. 抖动:为什么同一个会话被反复淘汰
| 现象 | 机制 |
|---|---|
| 同一会话被踢两次 | 淘汰后立刻回来,又选它(仍是最久未用) |
| 空闲长会话反复重算 | 每次回来都触发一次 2–3 秒 prefill |
| 高峰期整体变慢 | 淘汰率上升,重算占满算力 |
第一行的解法是给刚被淘汰的序列一个"冷却保护期"(例如 30 秒内不再被选为目标)。否则 LRU 会在"它最久未用"和"它刚回来"之间反复横跳。
第三行是系统性风险:淘汰会自我放大——淘汰越多,重算越多,算力越紧,淘汰越多。所以淘汰率本身要作为监控指标,而不是等到 P99 恶化才发现。
// 一次自我放大的形状
显存压力上升 → 开始淘汰 → 重算占用算力 → 有效吞吐下降 → 队列变长
→ 显存压力更大 → 淘汰更频繁
// 断点:淘汰率 > 每秒 5 次 或 重算 token 占比 > 20% 时,应拒绝新请求而不是继续淘汰
5. 什么该被监控(而不是靠 P99 猜)
| 指标 | 含义 | 阈值参考 |
|---|---|---|
kv_cache_usage |
池占用率 | > 90 % 预警 |
preemption_count/s |
淘汰频率 | > 5/s 说明过载 |
recompute_tokens/s |
重算浪费量 | 占总 token > 20 % 即异常 |
| 平均序列长度 | 决定淘汰的代价 | 越长越要保护 |
| 淘汰后重试率 | 抖动程度 | 上升说明策略有问题 |
第三行是最有信息量的:它直接量化了"被浪费的算力"。如果 20 % 的算力花在重算被淘汰的序列上,那"加卡"不如"改淘汰策略"。
// 三条命令确认是不是淘汰导致的问题
$ curl -s localhost:8000/metrics | grep -E 'kv_cache_usage|preemption|recompute'
$ grep -c "preempted" server.log | tail -1
$ nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv,noheader
// 判据:util 高 + kv 高 + preemption 频繁 → 淘汰策略问题,不是算力不足
6. 排错:五种"显存不够"的误判
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| OOM 但 KV 占用不高 | 权重/激活/碎片 | 查显存构成,不是淘汰 |
| P99 尖刺 + 淘汰频繁 | 淘汰了快完成的会话 | 加 near_completion 保护 |
| 同会话反复被截断 | 无冷却保护期 | 淘汰后 30s 内不再选它 |
| 吞吐随并发下降 | 重算占比过高 | 改分块保留或改排队 |
| 加卡后仍抖动 | 淘汰策略没变 | 先改策略再加卡 |
第五行是这类问题的通用规律:如果根因是策略,加资源只会把同一个问题放大到更贵的规模。而判断"是不是策略问题"只需要一个数:重算 token 占比。
7. 边界:什么情况不必精细淘汰
| 场景 | 为什么 | 说明 |
|---|---|---|
| 请求都很短(< 2k) | 重算便宜 | LRU 足够 |
| QPS 很低 | 池子几乎不满 | 不触发淘汰 |
| 无长会话(单轮问答) | 淘汰即释放 | 不需要保护 |
| 已用块表 + 大池 | 压力小 | 默认策略即可 |
判断口径一句话:淘汰策略的价值 = 重算成本 × 淘汰频率——短请求或无长会话时,这个乘积很小,精细策略没有意义。而一旦出现"长会话 + 高并发",保护"快完成"与"已很长"的两类序列就是最划算的两条规则。
8. 自测
- 淘汰的直接代价与隐藏代价分别是什么?为什么"快完成的会话"最不该杀?
- "先杀重算便宜的"这个直觉为什么对,而"先杀快完成"为什么错?
- 分块保留为什么能让抢占从"全损"变成"损失一小段"?
- "让新请求排队"什么时候优于"淘汰在跑的"?
- 抖动为什么会被自我放大?用什么指标能在恶化前发现它?
- 判断"是算力不足还是淘汰策略问题"看哪个数?
9. 一句话总结
淘汰不是免费的,它的代价是重算 prefill——所以"杀谁"本质是在分配重算成本。两条硬规则:保护"快完成"(剩余 < 512 token)与"已经很长"的序列,先杀"短且剩余少"的;淘汰率上升要加冷却保护期,否则同一会话会被反复横跳地踢出去。判据:重算 token 占比 > 20 % 就是策略问题,不是算力不足——此时加卡只会把同一个问题放大到更贵的规模。