1. 从一次线上推理抖动说起:为什么KV Cache值得单独拎出来讲
去年帮一个团队排查线上推理服务的延迟毛刺问题,现象很典型:单条请求的P99延迟在业务低峰期反而比高峰期更差,GPU利用率却始终上不去。一开始所有人都在怀疑调度、怀疑网络、怀疑批处理策略,最后定位到的根因却落在了一个很多人"知道但没深究"的东西上——KV Cache的内存布局和生命周期管理。
这件事让我意识到,KV Cache在大模型推理里早就不是一个"优化技巧"了,它是整个推理架构的地基。你去看现在主流的推理框架,vLLM的PagedAttention、TensorRT-LLM的KV Cache量化、SGLang的RadixAttention,本质上都在回答同一个问题:注意力机制里那份不断增长的键值缓存,到底该怎么存、怎么取、怎么复用、怎么淘汰。而当我们把视角再拉高一层,会发现KV Cache的管理问题正在演变成一个更宏大的命题——AI内存系统。它不再只是显存里的一块缓冲区,而是横跨HBM、DRAM、SSD甚至远端存储的分层体系。
这篇内容我想聊的就是这条演进线索:从最朴素的KV Cache实现,到PagedAttention这样的分页管理,再到prefix caching、KV量化、分层卸载,最后落到"AI内存系统"这个更大的框架上。适合谁看?如果你正在做推理服务的性能优化、正在选型推理框架、或者单纯想搞明白为什么同样一张卡别人能跑出几倍吞吐,那这篇应该能给你一些能直接上手的东西。我不会只讲概念,会把每一步"为什么这么设计"讲透,也会把我在实际调优里踩过的坑摊开来说。
2. KV Cache的本质:Transformer自回归解码里那份躲不掉的重复计算
2.1 从注意力公式推导KV Cache为什么必须存在
先把最基础的东西捋清楚,不然后面所有优化都是空中楼阁。Transformer的自注意力计算是这样的:对于输入序列的每个token,我们通过三个投影矩阵得到Query、Key、Value三个向量,然后注意力输出等于 softmax(QK^T / sqrt(d)) · V。
在训练阶段,整个序列是一次性喂进去的,所有token的Q、K、V同时算出来,矩阵乘法一把梭,效率很高。但推理阶段不一样,尤其是自回归生成——你生成第n个token的时候,需要用到前面n-1个token的Key和Value。如果每生成一个token就把前面所有token重新算一遍K和V,那计算量会随着序列长度平方级增长,这显然不可接受。
KV Cache的核心洞察就在这里:前面token的Key和Value在生成后续token时是不变的,因为它们的输入表示已经确定了。所以我们可以把每一步算出来的K和V缓存起来,生成新token时只需要计算当前token的Q、K、V,然后把当前K、V追加到缓存里,用当前Q去和缓存里所有的K做注意力。这样每步的计算量从O(n²)降到O(n),代价是显存里要常驻一份随序列长度线性增长的缓存。
用生活化的类比:KV Cache就像你写长文时做的笔记。每写一个新段落,你不需要把前面所有段落重新读一遍再理解,只需要翻一翻之前记下的要点(K和V),结合当前要写的内容(Q)就能继续。笔记越记越多,占的地方越来越大,但省下了反复重读的时间。
2.2 一份KV Cache到底占多少显存:把账算清楚
很多人对KV Cache的显存占用只有一个模糊的"很大"的概念,但具体多大、怎么算,说不清楚。这里把公式给出来,你以后选卡、估容量都用得上。
单个token的KV Cache占用 = 2 × 层数 × 注意力头数 × 头维度 × 数据类型字节数。那个2是因为K和V各一份。
以一个13B级别的模型为例,假设40层、40个注意力头、头维度128、FP16存储(2字节):单token占用 = 2 × 40 × 40 × 128 × 2 = 819200字节,约0.78MB。看起来不大?但你要乘以序列长度和并发数。序列长度2048、并发32路的时候,光KV Cache就是 0.78MB × 2048 × 32 ≈ 51GB。而模型权重本身FP16也就26GB左右。也就是说,在高并发长序列场景下,KV Cache的显存占用会超过模型权重本身,成为真正的显存瓶颈。
这就是为什么很多推理框架的优化重点根本不在权重上,而在KV Cache上。你把权重压到INT4省下来的那点显存,可能还不够多开两路并发的KV Cache。
2.3 朴素实现的三个致命问题
最原始的KV Cache实现就是给每个请求预分配一块连续的显存,大小按最大序列长度算。这个做法有三个绕不开的问题。
第一是内部碎片。你按最大长度2048预分配,但实际请求可能只生成200个token就结束了,剩下1800个token的显存白白浪费。第二是外部碎片。不同请求的缓存块大小不一,频繁分配释放之后显存里全是零散空洞,明明总空闲量够,却凑不出一块连续空间给新请求。第三是无法共享。多个请求如果有相同的prompt前缀(比如同一个system prompt),它们的KV Cache是完全一样的,但朴素实现里每个请求各存一份,重复计算又重复占用。
这三个问题叠加起来,导致朴素实现的显存利用率经常只有30%到40%,剩下的全浪费了。PagedAttention的出现就是为了解决这个。
3. PagedAttention与分页管理:把操作系统的虚拟内存思路搬进显存
3.1 分页的核心思想:逻辑连续,物理离散
PagedAttention的思路直接借鉴了操作系统的虚拟内存分页机制。它把KV Cache切成固定大小的块(block),比如每个block存16个token的KV。一个请求的KV Cache在逻辑上是连续的,但物理上可以分散在显存里任意不连续的block中,通过一张block table(页表)来映射逻辑位置到物理block。
这样做的好处立竿见影。内部碎片从"最大长度减实际长度"变成了"最后一个block内的浪费",最多浪费15个token的空间,相比之前动辄上千token的浪费,几乎可以忽略。外部碎片也没了,因为所有block大小一致,显存分配器管理起来非常干净。更关键的是,block级别的粒度让共享成为可能——两个请求如果有相同的prompt前缀,它们的block table可以直接指向同一批物理block,实现零拷贝共享。
我实测过一个对比:同样的硬件、同样的模型,朴素实现在并发32、序列长度1024的场景下显存就爆了,换成PagedAttention之后并发能开到80以上,吞吐提升接近2.5倍。这个提升不是来自计算优化,纯粹是显存利用率从35%提到了90%以上。
3.2 Copy-on-Write:共享block的写时复制机制
共享block带来一个新问题:如果两个请求共享了前缀block,其中一个请求要继续生成新token,需要往block里写数据,那另一个请求的数据不就被污染了吗?
PagedAttention用写时复制(Copy-on-Write)解决这个问题。当某个请求要修改一个被共享的block时,系统先把这个block复制一份,让该请求的block table指向新副本,然后再写入。这样共享关系在需要写入时才断开,读的时候始终共享。
这个机制和Linux的fork()之后父子进程共享内存页、写入时触发COW是完全一样的思路。实际效果是:对于大量共享system prompt的场景(比如客服机器人、代码助手),前缀部分的KV Cache只存一份,显存节省非常可观。我见过一个场景,system prompt占了1500个token,并发200路,光这一项共享就省下了接近20GB显存。
3.3 Block大小怎么选:一个容易被忽略的调参点
block size是PagedAttention里少数几个需要手动调的参数,默认一般是16。这个值不是随便定的,它有个权衡。
block越小,内部碎片越少(最后一个block浪费更少),共享粒度越细,但block table越大,管理开销越高,而且每次注意力计算要遍历更多block,kernel启动次数增加。block越大则相反。实测下来,16在大多数场景是个不错的平衡点。但如果你的请求序列普遍很短(比如平均只有100个token),可以试试调到8;如果是长序列为主(平均2000以上),调到32反而能减少管理开销。
注意:block size一旦设定,已分配的block无法动态改变大小。切换block size需要重启服务,所以上线前最好用真实流量压测一遍再定。
4. Prefix Caching与RadixAttention:让相同前缀的请求真正复用起来
4.1 Prefix Caching解决的是什么问题
PagedAttention解决了显存碎片和共享的底层机制,但"哪些block可以被共享"这个决策需要上层策略来做。Prefix Caching就是干这个的:它把请求的prompt前缀做哈希,如果发现之前有请求算过相同的前缀,就直接复用那批KV Cache block,跳过前缀部分的prefill计算。
这个优化的价值在特定场景下极其巨大。比如RAG应用,每次请求都带着一大段检索到的文档作为上下文,如果多个请求检索到相同文档,前缀就重复了。再比如多轮对话,历史对话内容在后续轮次里是重复的前缀。还有代码补全场景,同一个文件的内容反复出现在不同请求里。
我做过一个测试:一个RAG问答服务,平均prompt长度3000 token,其中检索文档占2500 token。开启prefix caching后,prefill阶段的平均耗时从380ms降到了90ms,因为大部分请求都能命中之前算过的文档前缀。TTFT(首token延迟)直接降了四分之三。
4.2 RadixAttention:用基数树管理前缀复用
SGLang提出的RadixAttention把prefix caching做得更彻底。它用一棵基数树(radix tree)来组织所有已缓存的KV Cache,树的每条边代表一段token序列,节点代表共享的前缀。新请求进来时,沿着树做最长前缀匹配,能匹配多少就复用多少,匹配不到的部分再新算并挂到树上。
这个结构比简单的哈希匹配更高效,因为它能处理部分匹配的情况。比如请求A的前缀是"你是助手,请回答",请求B的前缀是"你是助手,请总结",哈希匹配的话两者完全不同,但基数树能识别出"你是助手,请"这6个token是共享的,直接复用这部分。
基数树还天然支持LRU淘汰。当显存不够时,从树的叶子节点开始淘汰最久未使用的分支。这个淘汰策略比按请求整体淘汰更精细,因为一个请求可能只有后半段是独有的,前半段是热点共享前缀,淘汰时应该保留共享部分。
4.3 前缀复用的边界条件与踩坑
Prefix caching不是万能的,有几个边界条件必须清楚。
第一,前缀必须完全一致才能复用。差一个token都不行,因为KV Cache是按token位置算的,位置变了缓存就失效。所以如果你的prompt里有时间戳、随机ID这类每次都变的内容,而且放在开头,那整个前缀缓存就废了。正确做法是把变化的内容放到prompt末尾,让前面稳定的部分能被复用。
第二,缓存命中率依赖流量特征。如果你的请求前缀高度分散,每个请求都不一样,那prefix caching基本没收益,反而增加了哈希计算和树维护的开销。上线前一定要统计真实流量的前缀重复率,低于30%的话收益就很有限了。
第三,缓存一致性。如果模型权重更新了,之前缓存的KV Cache全部失效,必须清空。这个在滚动更新的时候要特别注意,否则会出现新旧模型缓存混用导致输出错乱的问题。
5. KV Cache量化与压缩:在精度和显存之间找平衡
5.1 为什么KV Cache可以量化而权重不行
KV Cache量化的逻辑和权重量化不太一样。权重是模型训练出来的固定参数,量化误差会直接影响每一层的计算结果,累积起来很容易崩。而KV Cache是推理过程中动态产生的中间激活值,它的数值分布相对集中,而且注意力机制本身对K和V的精度有一定的容忍度——softmax之后注意力权重会做归一化,小幅度的数值扰动对最终输出的影响会被稀释。
实测下来,把KV Cache从FP16量化到INT8,精度损失几乎可以忽略(困惑度上升不到0.1),显存直接减半。量化到INT4的话,精度损失开始显现,困惑度可能上升0.3到0.5,但在很多对精度不那么敏感的场景(比如内容分类、意图识别)仍然可用。
5.2 量化粒度:per-tensor还是per-channel
KV Cache量化有个关键选择:量化粒度。per-tensor是整个缓存用一个scale,per-channel是每个通道一个scale,per-token是每个token位置一个scale。
per-tensor最简单但精度最差,因为不同通道的数值范围差异可能很大,一个scale没法覆盖。per-channel精度好很多,但需要存储每个通道的scale,有额外开销。per-token(也叫per-token-head)是现在比较主流的做法,对每个token的每个注意力头单独算scale,精度最好,开销也可控。
具体实现上,K和V的量化策略可以不同。K的数值分布通常比V更宽,因为K要参与QK^T的点积,对精度更敏感,所以K建议用per-channel或per-token,V可以用更粗的粒度。
5.3 量化带来的kernel适配问题
KV Cache量化不是简单地把数据存成INT8就完事了,注意力计算的kernel必须适配量化后的数据。具体来说,QK^T计算时,Q是FP16而K是INT8,需要做反量化或者用混合精度的矩阵乘法。这个在CUDA层面需要专门的kernel实现,不是所有推理框架都支持。
我踩过的一个坑:某个框架号称支持KV Cache INT8,但实际只支持prefill阶段的量化,decode阶段还是走FP16,导致显存节省只有预期的一半。选型的时候一定要确认量化是全流程生效还是只覆盖部分阶段。
6. 分层内存与卸载:当显存真的不够时怎么办
6.1 KV Cache卸载到CPU内存的基本机制
当显存实在装不下所有KV Cache时,一个自然的想法是把不常用的部分卸载到CPU内存,需要时再换回来。这个思路和操作系统的swap是一样的。
具体做法是:GPU显存里只保留当前活跃请求的KV Cache,已经完成prefill但暂时不需要decode的请求、或者被抢占的请求,它们的KV Cache被搬到CPU内存。当这些请求重新被调度时,再从CPU内存搬回GPU。
这个机制的代价是PCIe带宽。PCIe 4.0 x16的双向带宽大约32GB/s,而HBM的带宽是TB/s级别,差了将近两个数量级。所以卸载只适合那些对延迟不敏感、或者换入换出频率很低的场景。如果频繁换入换出,PCIe带宽会成为严重瓶颈,延迟反而比不卸载更差。
6.2 抢占与重计算的权衡
除了卸载,还有一种处理显存不足的方式是直接丢弃被抢占请求的KV Cache,等它重新调度时重新计算prefill。这叫重计算(recomputation)。
卸载和重计算各有适用场景。卸载适合序列很长、重计算代价高的请求,因为搬数据的代价小于重算的代价。重计算适合序列较短、prefill很快的请求,因为重算比搬数据还快。vLLM里有个参数控制这个策略的选择,实际调优时要根据你的平均序列长度来定。
我的经验是:序列长度超过1000时,卸载通常比重计算划算;低于500时,重计算更优;中间地带要看具体的PCIe带宽和GPU算力配比,最好实测。
6.3 走向真正的AI内存系统
把视角再拉高,KV Cache的分层管理其实是一个更大趋势的缩影:AI推理正在从"把一切塞进显存"走向"构建一个分层的AI内存系统"。
这个系统里,HBM是最快但最贵的一层,放最热的数据(当前活跃请求的KV Cache、模型权重)。DRAM是第二层,容量大但带宽低,放温数据(近期可能用到的KV Cache)。SSD是第三层,容量巨大但延迟高,放冷数据(历史对话的KV Cache、长期记忆)。再往外可能还有远端存储,放归档数据。
这个分层结构和CPU的缓存体系(L1/L2/L3/DRAM)本质上是同一个逻辑:用容量换速度,用速度换成本。区别在于AI内存系统的数据访问模式更复杂——KV Cache的访问和序列位置强相关,有很强的局部性,但又有跨请求的共享需求,这对淘汰策略和预取策略提出了新要求。
现在已经有框架在探索这个方向了,比如把KV Cache按重要性分级,重要的留在HBM,次要的下沉到DRAM,用类似LRU-K的策略做淘汰。但整体还处于早期,没有形成标准。我个人判断,未来一两年这个方向会有比较大的进展,因为单靠显存堆容量已经走到头了,成本扛不住。
7. 实操建议:从选型到调优的完整路径
7.1 推理框架选型的几个硬指标
如果你现在要选一个推理框架,别只看benchmark上的吞吐数字,那几个指标更值得关注。
第一,KV Cache管理机制。是否支持PagedAttention或类似的分页管理?block size是否可配?这直接决定了你的显存利用率和并发上限。第二,prefix caching的支持程度。是简单的哈希匹配还是基数树?是否支持部分前缀匹配?第三,量化支持。KV Cache量化是否全流程生效?支持哪些量化粒度?第四,卸载能力。是否支持CPU卸载?抢占策略是卸载还是重计算,还是可配?
把这四个问题问清楚,基本就能判断一个框架适不适合你的场景了。
7.2 显存容量估算的实操方法
上线前估算显存需求,我的做法是分三块算:模型权重、KV Cache、激活值和其他开销。
模型权重好算,参数量乘以数据类型字节数。KV Cache用前面给的公式,乘以你预期的最大并发和平均序列长度,再留20%余量。激活值和其他开销比较难精确估,一般按模型权重的10%到15%预留。
三块加起来如果超过单卡显存,就要考虑张量并行或者卸载。张量并行能线性扩展显存,但会引入通信开销,卡间带宽不够的话反而拖慢速度。卸载则要看你的延迟容忍度。
7.3 几个我踩过的坑和对应的解法
第一个坑:prefix caching的哈希碰撞。早期某个框架用简单的字符串哈希做前缀匹配,结果不同前缀哈希到同一个值,导致KV Cache串用,输出完全错乱。解法是确认框架用的是加密级哈希(如SHA256)还是简单哈希,简单哈希的话要评估碰撞概率。
第二个坑:block table的内存开销被低估。PagedAttention的block table本身也占显存,序列特别长、block特别小的时候,block table的开销可能占到KV Cache的5%以上。调block size的时候要把这个算进去。
第三个坑:量化后的精度回归测试。KV Cache量化上线前一定要做精度回归,用一批有标准答案的测试集对比量化前后的输出差异。我见过量化后某些特定类型的输入(比如数字密集的表格)输出质量明显下降的情况,这种问题不跑回归测试根本发现不了。
第四个坑:多轮对话的缓存失效。多轮对话场景下,如果每轮都把完整历史作为prompt,prefix caching能很好地复用。但如果框架对历史做了截断或摘要,截断点之后的前缀就变了,缓存全部失效。设计prompt模板的时候要尽量让前缀稳定。
7.4 一个可复现的调优流程
最后给一个我常用的调优流程,你可以直接套用。
第一步,用真实流量跑一遍baseline,记录TTFT、TPOT(每token延迟)、吞吐、显存占用四个指标。第二步,开启PagedAttention,调block size(试8、16、32),找到显存利用率和kernel开销的最佳平衡点。第三步,开启prefix caching,统计命中率,如果低于30%就检查prompt模板是否把变化内容放在了开头。第四步,尝试KV Cache INT8量化,跑精度回归,通过的话保留,不通过就回退。第五步,如果显存还是不够,评估卸载策略,根据平均序列长度决定用卸载还是重计算。
每一步只改一个变量,改完记录指标变化,这样才能定位到每个优化的实际收益。一次性全开的话,出了问题都不知道是哪个环节导致的。
这套流程我在好几个项目里用过,基本上两三天就能把一个新的推理服务调到比较理想的状态。核心不是记住某个参数值,而是理解每个优化背后的权衡逻辑,这样遇到新场景才能自己判断该怎么调。