1. 为什么 KV Cache 会成为大模型推理的“内存黑洞”
但凡真正上手部署过大模型的人,大概率都经历过这样的场景:模型权重加载完,显存看着还挺宽裕,结果一开始跑长上下文推理,显存占用就像坐了火箭一样往上窜,最后直接 OOM 崩掉。很多人第一反应是“模型太大了”,但实际情况往往不是权重的问题,而是KV Cache在背后疯狂吃显存。
先把概念说清楚。大模型在自回归生成的时候,每生成一个新 token,都需要对之前所有 token 做注意力计算。如果每次生成都重新算一遍历史 token 的 Key 和 Value,那计算量会随着序列长度呈平方级增长,推理速度会慢到没法用。所以工程上做了一个非常关键的优化:把历史 token 的 Key 和 Value 缓存下来,后续生成直接复用,不用重算。这个缓存就是KV Cache。
它带来的收益是巨大的——推理从“每步重算全部历史”变成“每步只算当前 token”,时间复杂度从平方级降到线性级。但代价也很直接:缓存要占显存,而且占得非常多。KV Cache 的大小和这几个因素直接相关:层数、注意力头数、头维度、序列长度、批次大小、数据类型。公式大致是这样的:
KV Cache 大小 = 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × 批次大小 × 每元素字节数注意最前面那个 2,是因为 Key 和 Value 各存一份。这个公式看着简单,但代入真实模型参数后数字非常吓人。以一个 70B 级别的模型为例,80 层、64 个注意力头、头维度 128,用 FP16 存储,单条 8K 上下文的序列,KV Cache 就要占掉好几个 GB。如果并发 16 路请求,直接几十 GB 就没了,比模型权重本身还夸张。
这就是为什么现在做大模型部署、企业大模型私有化部署的团队,越来越重视 KV Cache 的管理。它已经不是“优化项”,而是决定你能不能把服务跑起来、能跑多少并发的“生死线”。尤其是大模型上下文长度不断被拉长到 128K、256K 甚至更长的今天,KV Cache 的显存压力只会越来越大。
那怎么办?一个很自然的思路就是:不是所有缓存都同等重要,也不是所有缓存都需要放在最快的显存里。这就引出了本文要讲的核心——KV Cache 分级存储。它的本质是把 KV Cache 按照访问频率、重要程度、生命周期,分层放到不同速度、不同容量的存储介质上,用“冷热分层”的思路来缓解显存压力。这套思路和 CPU 的缓存层级、操作系统的内存分页,本质上是同一类工程哲学。
这篇文章我会从原理讲到实操,把 KV Cache 分级存储这件事拆透。不管你是刚接触ai大模型基础理论的新手,还是正在做vllm部署大模型、大模型微调之后要上线的工程师,都能从中拿到可以直接参考的方案。我会解释每一层为什么这么设计、参数怎么算、坑在哪里,以及我自己踩过的那些教训。
2. KV Cache 分级存储的整体设计思路拆解
2.1 分级存储到底分的是什么
先明确一个前提:分级存储不是把 KV Cache 随便挪来挪去,而是基于一个非常朴素的观察——在自回归生成过程中,不同位置的 KV 被访问的频率和时机是不一样的。
具体来说,最近生成的 token,它的 KV 在接下来每一步都会被用到,属于绝对的“热数据”;而序列开头那些很早的 token,虽然理论上每步也会被注意力机制访问,但在很多实际场景里,它们的贡献权重相对稳定,甚至可以通过一些策略降低精度或延迟加载。再加上请求本身有生命周期,一个请求结束后它的 KV 就该被回收,但回收的时机和方式也有讲究。
基于这些观察,分级存储通常会把 KV Cache 分成这么几层:
| 层级 | 存储介质 | 特点 | 典型用途 |
|---|---|---|---|
| L1 热层 | GPU 显存(HBM) | 带宽最高、容量最小、成本最高 | 当前活跃 token 的 KV |
| L2 温层 | GPU 显存中的压缩区 / 主机内存 | 带宽中等、容量较大 | 近期历史 token 的 KV |
| L3 冷层 | 主机内存 / 高速 SSD | 带宽低、容量大、成本低 | 长历史、低频访问的 KV |
| L4 归档层 | 普通 SSD / 分布式存储 | 带宽最低、容量最大 | 跨请求复用、前缀缓存 |
这个分层不是死的,不同框架实现会有差异。比如有些方案把 L2 直接做成“量化后的显存缓存”,有些则把 L2 放到主机内存通过 PCIe 传输。核心思想是一致的:用容量换成本,用分层换效率。
2.2 为什么不能只靠“压缩”或“丢弃”
你可能会问:既然显存不够,为什么不直接把 KV Cache 量化成 INT8 甚至 INT4,或者干脆丢弃一些不重要的 KV?这两个思路确实存在,而且很有效,但它们各有局限。
量化的问题在于,KV Cache 的数值分布对精度比较敏感,尤其是 Key 向量,过度量化会明显影响注意力打分的准确性,导致生成质量下降。丢弃(比如滑动窗口注意力)的问题在于,它会直接切断模型对远距离上下文的感知,对于需要长程依赖的任务,比如长文档问答、代码补全,效果会大打折扣。
分级存储的好处在于,它不牺牲信息的完整性,只是改变了信息的存放位置和访问时机。热数据在显存里全速跑,冷数据放在便宜介质上,需要时再调回来。这是一种“既要又要”的折中,代价是引入了调度和传输开销。所以分级存储的核心难点,从来不是“能不能分层”,而是“怎么分得聪明、调得及时”。
2.3 分层的触发条件和调度策略
分级存储能不能用好,关键看调度。我把它拆成三个决策问题:
- 什么时候把 KV 从显存挪到下一层?常见触发条件包括:显存占用超过阈值、序列长度超过某个窗口、某个 KV 块超过一定步数没被访问。
- 挪到哪一层?根据访问频率预测,频率高的放温层,频率低的放冷层。
- 什么时候调回来?当注意力计算需要访问某个不在显存里的 KV 块时,触发回迁。
这里有个很重要的工程权衡:传输开销 vs 显存节省。如果调度太激进,频繁在 PCIe 上搬数据,带宽会成为新瓶颈,推理速度反而下降。如果调度太保守,显存又省不下来。所以实际系统里通常会设置一个“访问频率阈值”和“批量迁移窗口”,避免零碎传输。
提示:分级存储的收益在长上下文、高并发场景下最明显。如果你的服务都是短请求、低并发,KV Cache 本身就不大,上分级存储反而增加复杂度,得不偿失。
3. 核心细节解析与实操要点
3.1 KV Cache 的块化管理:分页是分级的前提
要让 KV Cache 能分级,第一步不是分层,而是分块。因为如果你把整个序列的 KV 当成一整块连续内存,那根本没法部分迁移。所以现代推理框架普遍采用PagedAttention这类分页机制,把 KV Cache 切成固定大小的块(block),每个块存若干个 token 的 KV。
这个设计借鉴了操作系统的虚拟内存分页。每个块有独立的物理地址,逻辑上通过块表(block table)映射。这样一来,迁移的最小单位就是一个块,而不是整个序列。块大小通常取 16 或 32 个 token,这个值需要权衡:块太小,块表管理开销大;块太大,迁移粒度粗,灵活性差。
我实测下来,块大小 16 在大多数场景下比较均衡。如果你的序列特别长、并发特别高,可以试 32,减少块表压力。
3.2 各层的容量规划与参数计算
分级存储要落地,必须先算清楚每层需要多大容量。这里给一个可复用的计算流程。
假设模型参数:层数 L=80,注意力头数 H=64,头维度 D=128,数据类型 FP16(2 字节)。单个 token 的 KV 大小是:
单 token KV = 2 × L × H × D × 2 字节 = 2 × 80 × 64 × 128 × 2 = 2,621,440 字节 ≈ 2.5 MB也就是说,一个 token 的 KV 就要 2.5 MB。一条 8K 的序列就是 20 GB,这个数字足以说明问题。如果并发 8 路,就是 160 GB,单卡根本放不下。
所以容量规划的思路是:L1 只放当前活跃窗口,比如最近 2K token;L2 放 2K 到 8K 的历史;L3 放 8K 以上的部分。按这个划分,单请求 L1 需要 5 GB,L2 需要 15 GB,L3 需要更多但可以放主机内存。
实际规划时还要考虑并发数。假设你要支持 16 路并发,L1 总容量就是 80 GB,这已经超过单张卡的显存了。所以真实系统里,L1 的窗口往往要更小,或者配合量化。
3.3 迁移时机与批量策略
迁移时机的选择直接决定性能。我的经验是采用双阈值策略:
- 高水位触发:显存占用超过 85% 时,开始把最冷的块迁出。
- 低水位停止:显存占用降到 70% 以下时,停止迁出。
中间留出的 15% 缓冲,是为了避免“刚迁出又要迁回”的抖动。这个缓冲比例不是拍脑袋定的,而是根据 PCIe 传输延迟和注意力计算时间的比值估算的。如果传输一个块要 0.5 ms,而一步推理要 20 ms,那缓冲可以小一点;如果传输占比高,缓冲就要留大。
批量策略上,一定要攒够一批再传。零碎的单块传输会让 PCIe 的有效带宽利用率掉到很低。我一般设置批量阈值为 8 到 16 个块,攒够就一次性异步传输,用 CUDA stream 和计算重叠起来。
3.4 精度分层:不同层用不同数据类型
分级存储还有一个很实用的技巧:不同层用不同精度。L1 热层用 FP16 保证计算精度,L2 温层可以降到 FP8 甚至 INT8,L3 冷层进一步压缩。因为冷数据被访问的频率低,即使精度低一点,对最终输出的影响也有限。
这里要注意,Key 和 Value 对精度的敏感度不一样。Key 参与注意力打分,对精度更敏感;Value 参与加权求和,相对宽容。所以有些方案会对 Key 保留更高精度,对 Value 做更激进的压缩。这个细节在实现时要特别留意,别一刀切。
注意:精度分层一定要做消融实验。不同模型对 KV 量化的容忍度差异很大,有的模型 INT8 几乎无损,有的模型 INT8 就明显掉点。上线前务必用你的业务数据测一遍。
4. 实操过程与核心环节实现
4.1 环境准备与框架选型
要复现一套 KV Cache 分级存储,最省力的路径是基于已有的推理框架做二次开发。目前主流的选择有 vLLM、TensorRT-LLM、SGLang 等。它们都已经实现了 PagedAttention 和一定程度的 KV 管理,你需要在上面加分层逻辑。
环境上,我建议至少准备:
- 一张支持大显存的 GPU(A100 80G 或同级)
- 足够的主机内存(建议 256 GB 以上,冷层要用)
- 高速 SSD(NVMe,用于归档层)
- CUDA 12.x + PyTorch 2.x
选 vLLM 的原因是它的 PagedAttention 实现比较清晰,块管理接口开放,方便插入自定义的迁移逻辑。TensorRT-LLM 性能更好但改起来门槛高,适合追求极致性能的团队。
4.2 块迁移的核心代码结构
下面给一个简化的迁移调度伪代码,帮你理解核心逻辑。真实实现要复杂得多,但骨架是这样的:
class KVCacheTierManager: def __init__(self, gpu_capacity, cpu_capacity, high_watermark=0.85, low_watermark=0.70): self.gpu_blocks = {} # L1: 显存中的块 self.cpu_blocks = {} # L2/L3: 主机内存中的块 self.high_watermark = high_watermark self.low_watermark = low_watermark self.access_counter = {} # 记录每个块的访问频率 def on_step(self, active_blocks): # 更新访问计数 for blk in active_blocks: self.access_counter[blk] = self.access_counter.get(blk, 0) + 1 usage = self.gpu_usage() if usage > self.high_watermark: self.evict_cold_blocks() elif usage < self.low_watermark: self.prefetch_hot_blocks() def evict_cold_blocks(self): # 按访问频率排序,迁出最冷的块 candidates = sorted(self.gpu_blocks.keys(), key=lambda b: self.access_counter.get(b, 0)) batch = [] for blk in candidates: if self.gpu_usage() <= self.low_watermark: break batch.append(blk) if len(batch) >= 16: # 攒批传输 self.transfer_to_cpu(batch) batch = [] if batch: self.transfer_to_cpu(batch) def transfer_to_cpu(self, blocks): # 异步拷贝到主机内存,用 pinned memory 提升带宽 for blk in blocks: cpu_tensor = torch.empty_like(self.gpu_blocks[blk], device='cpu', pin_memory=True) cpu_tensor.copy_(self.gpu_blocks[blk], non_blocking=True) self.cpu_blocks[blk] = cpu_tensor del self.gpu_blocks[blk]这段代码里几个关键点值得展开说。第一,access_counter是调度决策的依据,但它的更新频率要控制,不能每步都全量更新,否则开销太大。第二,pin_memory=True非常重要,锁页内存能让 PCIe 传输带宽提升一大截,实测能差 30% 以上。第三,攒批传输的阈值 16 是我反复调出来的经验值,太小带宽利用率低,太大延迟高。
4.3 回迁路径与计算重叠
块被迁出后,当注意力计算需要它时,必须能及时调回来。这里的难点是回迁延迟不能阻塞计算。我的做法是提前预取:根据注意力模式预测下一步可能需要的块,提前异步调回。
具体实现上,用一个独立的 CUDA stream 做传输,主计算 stream 继续跑不需要冷块的部分。等传输完成,再用事件同步。这样传输和计算就能重叠起来,隐藏大部分延迟。
实测下来,如果预取命中率能到 80% 以上,整体推理速度的下降可以控制在 10% 以内。而显存占用能降低 40% 到 60%,这个交换是非常划算的。
4.4 前缀缓存与跨请求复用
分级存储还有一个高价值场景:前缀缓存。很多应用里,不同请求共享相同的前缀,比如系统提示词、few-shot 示例。这些前缀的 KV 完全可以算一次、存起来、跨请求复用。
把前缀 KV 放在 L3 或 L4 层,用哈希索引。新请求进来先查前缀缓存,命中就直接加载,省掉重复计算。这个优化在大模型应用开发里效果极其明显,尤其是那些系统提示很长的场景,首 token 延迟能降一半以上。
实现时要注意前缀的匹配粒度。按块做哈希匹配,块大小和 KV 分页保持一致,这样命中后可以直接复用块,不用重新组织内存。
5. 常见问题与排查技巧实录
5.1 迁移抖动:块刚迁出就被访问
这是最常见的问题。表现是显存占用忽高忽低,推理速度不稳定。根因是调度策略太激进,把还会被频繁访问的块迁走了。
解决办法有两个。一是把访问计数的统计窗口拉长,不要只看最近一步,而是看最近 N 步的累计频率。二是引入“冷却期”,块迁出后一段时间内不允许再迁出同类块,避免反复横跳。我一般把冷却期设为 50 到 100 步。
5.2 PCIe 带宽打满导致整体变慢
如果迁移量太大,PCIe 带宽会成为瓶颈。排查方法是监控 PCIe 利用率和传输队列长度。如果利用率长期超过 70%,说明迁移太频繁了。
优化方向:提高 L1 容量或降低迁出频率;增大批量传输的块数;对冷层数据做更激进的压缩,减少传输字节数。还有一个容易被忽略的点——传输的数据类型。如果冷层用 INT8 存储,传输量直接减半,带宽压力立刻缓解。
5.3 精度下降但找不到原因
如果上线后发现生成质量下降,但又不是明显的崩溃,很可能是精度分层导致的。排查步骤:
- 先把所有层都设成 FP16,确认基线正常。
- 逐层降低精度,每次只改一层,观察指标变化。
- 重点测 Key 的精度,它比 Value 敏感得多。
我踩过的坑是:一开始把 Key 和 Value 一起量化到 INT8,结果长文档问答的准确率掉了 5 个点。后来只对 Value 量化,Key 保持 FP16,准确率几乎无损,显存还省了不少。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 显存占用忽高忽低 | 迁移抖动 | 看访问计数窗口 | 拉长统计窗口,加冷却期 |
| 推理速度整体变慢 | PCIe 带宽瓶颈 | 监控 PCIe 利用率 | 增大批量,压缩冷层数据 |
| 生成质量下降 | 精度分层过度 | 逐层做消融 | Key 保高精度,Value 可压缩 |
| 首 token 延迟高 | 前缀缓存未命中 | 看缓存命中率 | 调整块哈希粒度 |
| 回迁阻塞计算 | 预取命中率低 | 统计预取命中率 | 优化预测策略,异步传输 |
5.5 几个容易被忽略的实操心得
第一个心得:分级存储的收益要用端到端指标衡量,不能只看显存。显存省了但吞吐掉了,那还不如不加。我一般看三个指标:显存峰值、吞吐(tokens/s)、首 token 延迟。
第二个心得:冷层的存储介质选择很关键。如果冷层放主机内存,容量受限于内存大小;如果放 SSD,延迟会高一个数量级,只适合归档和前缀缓存,不适合频繁回迁的数据。别把需要频繁访问的数据放到 SSD 层,否则性能会崩。
第三个心得:监控要提前埋点。分级存储引入的传输、调度、命中率这些指标,如果不上线前就埋好监控,出问题时根本无从下手。我建议至少监控:各层容量占用、迁移次数、迁移字节数、预取命中率、回迁延迟。
第四个心得:不要一上来就做全量分级。先从最简单的两层做起——显存 + 主机内存,把调度逻辑跑通、调稳,再考虑加 SSD 层和精度分层。一步到位往往调不明白。
这套 KV Cache 分级存储的方案,我在几个长上下文服务里落地过,最直观的感受是:它把“能不能跑起来”变成了“能跑多好”。以前显存不够只能砍上下文或者砍并发,现在通过分层,同样的硬件能支撑更长的上下文和更高的并发。当然代价是系统复杂度上去了,调度逻辑要反复调优。如果你正在做大模型部署或者企业大模型私有化部署,又恰好被长上下文的显存问题卡住,这套思路值得认真试一试。先从两层做起,把监控埋好,剩下的就是根据你自己的业务流量慢慢调参数了。