☰
KV cache 用量虚高 91%?预留块口径排查实录
2026/11/3 23:44:53 网站建设 项目流程

KV cache用量报91.3%,点开面板前我以为下一秒就要OOM了,结果服务端延迟稳如老狗,P99只有72ms。这种“告警说你要死了,实际活蹦乱跳”的诡异场面,在推理服务运维里并不少见。折腾了一下午,最后定位到的根因是:KV cache块池里塞满了预留块,真实写入数据的块只有一半不到——监控看着快满了,实际物理显存还松得很。这篇文章就把我这套排查链路、统计口径的坑、以及最后定位到的代码级原因全部摊开讲一遍。

1. 先说结论:KV cache“报90%”不等于真的快满了

接到告警的第一时间,我先干了两件事:看请求量曲线,看KV cache绝对数值。请求量很平稳,QPS和触发前5分钟几乎没有变化;KV cache的绝对数值也很稳定,但比例从75%跳到了91%。这个组合让我基本排除了流量突增和真实负载上涨,剩下的可能性就是“比例的分母变了”或者“分配逻辑里有幽灵占用”。

为了把问题聊透,先给不熟悉的朋友补个底。KV cache是Transformer推理时为了省去重复计算而缓存的Key和Value张量,每来一个新Token,都会往缓存里追加一段定长的K和V,最终占用的大小可以粗算:

KV_cache_bytes ≈ 2 (K和V) × num_layers × num_kv_heads × head_dim × seq_len × bytes_per_element

以常见的7B模型为例,假设32层、GQA配置下num_kv_heads为8、head_dim为128、用FP16存储,那每个Token的KV开销大概是:

2 × 32 × 8 × 128 × 2 = 131,072 bytes ≈ 128 KB/Tok

一个2048长度的上下文,纯KV cache就需要约256 MB。这个基准数字很重要,后面判断“90%到底合不合理”全靠它。

我在这个集群上用的是自建推理服务,基于vLLM 0.6.x做调度,8卡A100(80GB),模型是一个7B对话模型,启用了GQA和chunked prefill,最大序列长度设成32K,gpu_memory_utilization设为0.85。告警规则是KV cache用量超过85%并持续5分钟触发。

把基准盘完,再看91.3%这个比例是怎么算出来的。大多数推理框架(vLLM、SGLang)统计KV cache用量时,用的是“已分配KV块数 / 总KV块数”。问题就出在“总KV块数”按什么算。如果按最大序列长度32K来分配KV块池,那一个2048长度的请求实际只占了池子的6.25%,根本不该报出90%。所以,看到90%的第一反应不应该是“加显存”,而是“查口径”。

这里有个容易混淆的点:KV cache管理器在分配时,会把“预留块”和“已使用块”混在一起统计。预留块看起来像被占了,实际上没存任何数据。这正是“只装了一半”这个说法的由来——监控显示快满了,但真正参与Attention计算的块只是其中一小部分。

2. 完整排查链路:从告警到指标口径,再到代码级验证

先说我的排查顺序,固定四步走,能避免被各种中间指标带偏:

  1. 看请求量曲线,确认是不是流量突增。结果:平稳。
  2. 看KV cache绝对数值,确认是“量涨了”还是“比例阈值变了”。结果:绝对值稳定,比例从75%跳到91%。
  3. 看单请求的KV cache分配情况。结果:单请求分配量正常,没有异常超长序列。
  4. 重点来了——看block manager的空闲块数和预留块数。结果:空闲块充足,但预留块的数值异常偏高。

四步走完,基本锁定问题在“预留”这一块。为了验证,我在代码里加了临时日志,把每次KV cache分配时的预留块数打出来。跑了一段时间后,观察到一种典型场景:一个batch里同时来了12个请求,其中8个是长尾请求(输入约6K token),4个是短请求(输入约200 token)。长尾请求进入chunked prefill后,立即为每个请求预留了完整的KV空间,这里的“完整”指的是按max_model_len预留,而不是按实际输入长度预留。

12个请求的预留效果如下:

请求类型数量实际输入长度预留长度预留块数占比
长尾请求8~6K32K79%
短请求4~20032K4%

8个长尾请求按32K预留,即使它们实际只需要6K的KV空间,块也被“锁住”了。从监控上看,预留块加上已使用块,总量一下子就接近90%。

看到这个数据,我的第一反应是“这预留策略也太激进了”。但冷静下来想,框架这么做其实是故意的——预留块是为了保证长请求在后续prefill阶段不会因为KV块不足而中断。如果在prefill中间发现块不够,要么触发重新计算(recursive preemption),要么直接拒绝请求,两种结果都比“多预留”更糟。所以,预留逻辑本身没问题,问题是“监控把预留当作使用”。

定位到这一步,我已经能确认:告警是假阳性,服务本身没有风险。但为了把问题彻底讲清楚,还需要确认另一件事——为什么“统计链路算出来的用量和真实用量差一半”?

3. 统计口径的坑:平均Token、峰值Token与KV cache告警的矛盾

“只装了一半”这个说法的另一层含义,是上层监控系统在“用平均值冒充峰值”导致的偏差。

我们内部监控系统接的是Prometheus,访问日志会记录每个请求的input_tokens和output_tokens。监控聚合时,如果拿“平均Token数”去算KV cache用量,很容易得出一个偏低的数字;而告警系统如果拿“峰值Token数”去算,又很容易得出一个偏高的数字。两套口径一撞,就会产生“实际用量50%,告警报90%”的错觉。

举个例子,假设5分钟内只有10个请求:

请求input_tokensoutput_tokens
110050
28000200
312060
49000150
58040
67500180
720080
86000120
915060
108500160

平均input_tokens约3980,平均total token约4180;但第2、4、6、8、10这几个请求的峰值total token都超过8200。如果用平均token去算KV cache,算出来只有峰值的一半左右。

具体计算差异是这样的:

  • 平均口径:3980 input + 200 output ≈ 4180 token,按7B模型每token 128KB算,KV cache峰值约 523 MB。
  • 峰值口径:9000 input + 150 output ≈ 9150 token,KV cache峰值约 1.14 GB。

同样的物理负载,两种口径算出来的“峰值需求”差了一倍。如果你的监控面板显示“当前KV cache用量50%”,而告警却报“90%”,不用怀疑,大概率就是口径不一致。

那么,怎么让指标回到“真实水位”?我的做法是彻底放弃“用token数反推KV cache”的间接计算,直接暴露框架内部的块级指标。vLLM的metrics里已经有cache_total_blocks和cache_free_blocks,直接用这两个算:

kv_cache_usage_ratio = (cache_total_blocks - cache_free_blocks) / cache_total_blocks

这个数值反映的是“块分配占比”,虽然它仍然包含预留块,但至少不会出现“平均值与峰值”打架的问题。在这个基础上,再叠加我前面提到的reserved_blocks指标,算一个真实使用率:

kv_cache_real_usage_ratio = (cache_total_blocks - cache_free_blocks - reserved_blocks) / cache_total_blocks

我用第二个公式重算后,前面那个91.3%的告警,真实水位其实只有46.8%。监控面板终于显示出一个“物理上可信”的数字。

不过,这里有一个特别容易被忽略的点:cache_total_blocks也不是一个恒定值。vLLM会在初始化时根据gpu_memory_utilization、max_num_seqs、max_model_len算出KV块池的总量,但如果后续有显存碎片,或者手动设置了max_num_batched_tokens,实际可用的块数会比cache_total_blocks少。所以我建议把cache_free_blocks作为分母的基准,而不是cache_total_blocks。

4. 代码级复现:chunked prefill的预留逻辑与“看似满”的机制

为了彻底说清楚“为什么90%只是表象”,我建议亲手复现一遍这个场景。下面这段是vLLM里简化版的预留逻辑,用伪代码的方式还原,方便理解,也方便在别的框架里对照参考。

# 简化自vLLM block_manager的sequence分配逻辑 class BlockManager: def __init__(self, total_blocks, max_model_len, block_size=16): self.total_blocks = total_blocks self.free_blocks = total_blocks # 普通使用块 self.used_blocks = 0 # 预留块(chunked prefill场景下为序列预留的块) self.reserved_blocks = 0 def allocate_for_sequence(self, seq, input_len): # 当前序列已经分配的块 current_blocks = len(seq.blocks) # 如果启用chunked prefill,预留至max_model_len if seq.prefill_chunk_size is not None: need_blocks = self._blocks_for_len(self.max_model_len) else: need_blocks = self._blocks_for_len(input_len) # 预留部分 = 目标块数 - 当前块数 reserved = need_blocks - current_blocks self.reserved_blocks += reserved self.free_blocks -= reserved def append_token(self, seq): # 真正写入token时,把预留块转化为使用块 if seq.blocks and self.reserved_blocks > 0: self.reserved_blocks -= 1 self.used_blocks += 1 def _blocks_for_len(self, seq_len): # 每个块固定block_size个token return math.ceil(seq_len / self.block_size)

这个伪代码的关键在于allocate_for_sequence:预分配是按照max_model_len来的,而不是按实际input_len。你可以在自己的推理框架旁边跑一个模拟脚本,构造一批“长输入但短预留需求”的请求,观察reserved_blocks的膨胀速度。

我用更直观的数字模拟一下:假设一个7B模型,block_size=16,总KV块数=20000,max_model_len=32768,那么单序列预留块数 = 32768/16 = 2048块。假设一个batch有8个长序列,需要预留 8×2048=16384块,占总池子的81.9%。此时如果真实写入块只有2000块(约10%),监控里显示的就是81.9%+10%=91.9%——“用量90%”就诞生了。

那为什么说“只装了一半”呢?因为真实写入数据、参与Attention计算的KV块,实际只有总池子的一半左右(或者更少),剩下的全是“占着茅坑不拉屎”的预留块。从用户的视角看,服务是好的;从运维的视角看,显存水位报警了;从资源的视角看,有一半的显存被白白锁住没法用。三个视角互相矛盾,但背后是同一个机制。

复现这个场景时,我还发现一个有意思的现象:如果把max_num_seqs调大,预留块会进一步膨胀。因为max_num_seqs决定了同时处理的序列数上限,而每个序列都按max_model_len预留的话,总预留块数 =max_num_seqs×max_model_len / block_size。在batch里序列数多但每个序列实际长度短的情况下,这个乘积会远远超过实际需求。

我用一组参数试算了一下:max_num_seqs=64,max_model_len=32768,block_size=16,那么理论预留块 = 64×2048=131072块。如果KV块池总共只有20000块,理论上连64个序列的完整预留都做不到,但框架不会等预留完才处理请求,它会按“实际已分配+预留”的容量去调度,于是就会出现“监控显示90%,但实际请求都能正常处理”的奇观。

5. 实战调整建议:先校准指标,再考虑配置优化

如果你也遇到了类似的“KV cache用量90%但不影响服务”的告警,我的建议是按下面的优先级来处理,不要一上来就调max_num_seqs或max_model_len。

第一步,校准指标。把cache_free_blocks、cache_total_blocks、reserved_blocks三个指标全部暴露出来,在监控面板上同时展示“总块”“空闲块”“预留块”三条线。然后按我前面给的公式,算一个real_usage_ratio,用它替换原来的告警阈值。

这里有一个实践经验:reserved_blocks不是所有框架都暴露的。vLLM内部虽然有这个概念,但Prometheus metrics默认不导出它。我用的办法是给BlockManager加一个自定义metric,在每次allocate和append_token时更新,几行代码的事,但排查问题的效率能提升很多。

第二步,如果确认是预留导致的假阳性,再考虑收紧预留策略。不要直接调max_model_len,因为这是模型能力上限,调低会影响可用序列长度。更合理的做法是:

  • 把max_num_seqs调低,让并发序列数减少,从而减少预留块总量。
  • 调整gpu_memory_utilization,给KV cache的块池留更多空间,相当于给“预留”留余地。
  • 如果业务场景里长输入很少,可以把max_model_len设为一个相对合理的值,比如8K或16K,而不是32K,这样预留块会大幅下降。

第三步,对告警规则做“分级处置”。不要只用KV cache用量单指标触发告警。我现在的告警规则是多条件组合:

# 分级告警规则 if kv_cache_usage_ratio > 0.85 for 5m: if kv_cache_real_usage_ratio < 0.6: P3 warning # 可能是预留膨胀,警惕但不紧急 else: P1 critical # 真实水位过高,立即处理

这一步看着简单,但非常有效。它能过滤掉大量“假告警”,让你的值班同事不至于大半夜被叫起来扩容。

结合我这次的排查,最终我做了三件事:

  1. 暴露并监控reserved_blocks,在面板上用灰度区分“预留”和“真实使用”。
  2. 把max_model_len从32K降到16K,因为这个业务实际最长请求只有12K左右,预留块瞬间减少一半。
  3. 把告警阈值从85%降到80%,但改成按real_usage_ratio触发。

调整之后,监控面板上KV cache用量从90%降到了稳定55%~60%,p95延迟还略有下降,因为显存不再被“锁死”,调度器在分配块时有了更多选择。

最后再分享一个我踩过的小坑:KV cache的“90%”未必是KV cache本身的问题。如果你的服务同时启用了PagedAttention、continuous batching,以及多LoRA adapter,那么显存里还住着LoRA权重和临时激活值。这些不归KV cache管,但会占显存总量。在计算“KV cache还能用多少”的时候,一定要把gpu_memory_utilization预留之外的那部分显存也考虑进去,否则你会看到一个“KV cache只用了60%,但显存已满OOM”的画面,那是另一个口味的坑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询