我的大模型学习记录写到第六篇了。前几篇从tokenizer一路折腾到Transformer结构,又从预训练聊到指令微调,自以为对“大模型是怎么工作的”已经有点感觉了。直到上周试着把某个7B开源模型在本地跑起来,输入一段2K字的长文本让它做摘要,才发现真正卡脖子的地方不在训练,而在推理阶段——显存涨得飞快,每秒只吐出几个token,整张显卡都在发抖。这篇记录,我准备把推理时人人都提、但很少有人把账算清楚的KV Cache彻底拆开看一遍:它从哪来、要吃多少显存、长上下文时该怎么管,以及我实际把上下文从2K推到32K时踩进去的那些坑。
1. 为什么生成长文本反而变慢:KV Cache是怎么“被迫”出现的
1.1 没有缓存时,每一步都像从零开始
先还原一下生成过程。大模型是自回归的:给定一段输入,它一次只预测下一个token,预测完把这个token拼到句子末尾,再预测下下个。每生成一个token,整个模型都要重新跑一次前向计算,序列里每个位置都要做一次注意力计算。
问题就出在这个“重新跑一次”上。注意力的公式是: Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V。
其中Q来自当前新token,而K和V来自句子里的所有历史token。如果不做任何缓存,那么每生成一个新token,都要把所有历史token的K和V重新算一遍。生成第一个token时要算1个位置的K/V,生成第二个时要算2个,第三个时要算3个……累计计算量是1+2+...+L,也就是O(L^2)。这意味着序列越长,生成速度会以平方的速度劣化,而不是线性退化。
这个场景很像写一篇论文时,每写一句话就要把之前所有引用文献重新读一遍摘要。明明你上一步已经读过这些文献了,下一步却全部推倒重来。模型当然不会觉得累,但显存带宽和算力会。
1.2 KV Cache:一场典型的“用空间换时间”
工程师们的解决办法很朴素:既然K/V每步都会被重复使用,那就把算好的结果先存起来。KV Cache就是在推理过程中,把每一层、每一个历史token的K矩阵和V矩阵缓存在显存里。新token来了以后,只需要计算它自己的Q,然后直接从显存里读取历史K/V,完成注意力计算即可。
这样一来,解码阶段的计算量就从O(L^2)降到了O(L):每一步只需要处理一个新token的Q,历史K/V都是现成的。这个优化在工程上是决定性的,没有KV Cache的Transformer解码器根本没法用于实际生产——7B模型生成2000个token会慢到让人怀疑显卡坏了。
代价也很直接:显存。历史token的数量越多,KV Cache占用的显存就越大,而且它是线性增长的。上下文翻一倍,KV Cache就翻一倍。搞懂模型为什么“长文本跑不起来”,其实就是在搞懂这笔显存账。
2. 把KV Cache的显存账算清楚:7B模型在4K上下文下的开销拆解
2.1 一个公式,自己就能估算显存
KV Cache的大小可以写成这样:
KV Cache字节数 = 2(K和V各一份) × 层数 × KV头数 × 序列长度 × 每个头的维度 × 每个数值的字节数
以目前主流的7B参数量为参照,我按常见的配置来算:32层、隐藏维度4096、32个Q头、8个KV头(用分组查询注意力GQA)、每个头的维度128、半精度FP16(每个数值2字节)。
先算每个token要占多少:K和V两份 × 32层 × 8个KV头 × 128维 × 2字节 = 131,072字节,正好128KB。
这个128KB意味着什么呢?看下表:
| 上下文长度 | KV Cache(GQA,8个KV头) | 对应MHA(32个KV头) |
|---|---|---|
| 2K | 256MB | 1GB |
| 4K | 512MB | 2GB |
| 8K | 1GB | 4GB |
| 32K | 4GB | 16GB |
| 128K | 16GB | 64GB |
一个24GB显存的显卡,如果用多头注意力MHA结构,4K上下文就要吃掉2GB,32K直接吃掉16GB,再算上模型权重和中间激活,基本就到了极限。这也是为什么现在新的开源模型普遍采用GQA,甚至更进一步做MLA(多头潜在注意力)——因为KV Cache已经在实打实地决定你显卡的上限。
2.2 从MHA到GQA再到MLA:为什么新模型都在压缩KV
- MHA:每个注意力头都有自己独立的K/V,KV头数等于Q头数。以7B为例通常就是32个KV头,每个token缓存512KB,开销最大,但效果最“传统”。
- GQA:让多个Q头共享一组K/V,比如32个Q头只配8个KV头。KV Cache直接降到MHA的1/4,效果损失在主流任务上很小,属于性价比极高的折中。
- MLA:更进一步,把K/V先压缩到一个低维隐向量再缓存,有的实现每个token只需要保存几十KB量级的数据,同时还能保证效果。
我自己在选模型时有个习惯:先看一眼它的注意力结构。如果是MHA且只有24G显卡,那长上下文基本别指望;如果是GQA,32K还能勉强跑一跑;如果是MLA,128K都有操作空间。这个判断比看宣传页上的“支持多少K上下文”靠谱得多。
3. 长上下文场景下的缓存管理:滑动窗口、注意力锚点与显存分页
3.1 预分配缓冲区的陷阱:显存是按最大值花的
很多推理框架为了省事,会预先给KV Cache分配一个最大值。假设最大长度设成32K,那不管你实际只输入100个token,KV Cache也按32K的容量占着。在单卡自己玩的时候这问题不明显,但做部署、做多并发服务时就是灾难:每个人都在按最大值占空间,能同时服务的用户数少得可怜。
业界的解法思路和操作系统管理内存很像。显存被切成固定大小的“页”或“块”,KV Cache按需分配,不够了再申请新块,块与块之间物理上不必连续。这种做法也叫“按块注意力”或“分页注意力”。它最大的价值不是省显存,而是让显存利用率大幅提高,支持更大的并发和更长的上下文。理解这一层后再看推理框架,就不会觉得那些参数设置是在故弄玄虚。
3.2 滑动窗口与Attention Sink:为什么不能只留“最近聊天记录”
另一个常见的省显存思路是滑动窗口:既然上下文太长,那就只保留最近N个token的K/V,更早的丢掉,毕竟人聊天也是只记最近几句。这个思路本身没问题,但直接丢掉开头会出大事。
实际实验里会发现,序列第一个token的注意力权重总是异常地高,所有位置都在“注视”它,哪怕它只是一个句号或者一个无关紧要的助词。这种现象被称为Attention Sink(注意力汇聚点)。让模型失去这个汇聚点,生成质量会迅速崩溃。
所以后来很多长上下文方案采取的是“锚点+窗口”的组合:保留最前面的若干token作为全局锚点,同时维护一个滑动窗口管理最近的token,中间的过期KV按需淘汰。这个设计启示很实用:你以为自己在管理“最近聊天记录”,其实还要保住开头的“定盘星”。在我自己的部署实践中,遇到长文本任务时会专门把任务描述、角色设定放在序列开头,而不是放在离生成位置最近的地方。
3.3 长上下文不是越长越好的“免费午餐”
把上下文从4K扩到32K,你能装进prompt的材料多了,但KV Cache线性增长、计算量平方增长、注意力被稀释的风险也跟着涨。我在某次长文档问答测试中发现,上下文拉到16K之后,模型确实能引用远端的材料,但对中段信息的召回率反而下降了不少。这说明“窗口够长”和“内容能记住”是两回事,缓存管理做得再好,也只是保证工程上跑得动,不保证语义上记得牢。后续做长文本任务时,我把检索增强作为标配:先筛信息,再把最相关的原文放到prompt靠后的位置,效果好了很多。
4. KV Cache量化与稀疏注意力:省显存时到底在牺牲什么
4.1 INT8量化KV Cache:显存省一半,但是有“离群值”要注意
既然KV Cache是显存大头,自然有人想到把它从FP16变成INT8,一档直接省一半。以128KB/token为例,量化后降到64KB/token,32K上下文从4GB降到2GB,这对24GB显卡来说是实打实的宽容度。
但量化没这么简单。K/V矩阵里经常存在少数“离群值”——个别通道的数值特别大或者特别小,如果按全局min-max范围做整数映射,大部分正常数值会被压得很粗糙,精度损失会被放大。实践中通常用按头、按通道或按token分组的量化方式,让每个局部范围都有自己的缩放系数,才能把误差压住。
我实测下来,INT8量化KV Cache在普通生成任务里几乎没有感知差异,但做低困惑度的评估任务时,困惑度会有少量上升;如果原本就是长上下文+多轮对话,误差会不会累积,最好用你自己的数据实测一遍,不要只看宣传里的“无损”。
4.2 稀疏注意力:用“只关注重点”换显存和速度
再往前走一步,就是稀疏注意力:不去保留全部历史token的KV,而是只保留局部窗口、加上少数全局锚点,或者根据注意力分数只保留Top-K个关键位置。
好处非常明显:显存和计算量可以再降一个量级。代价也很明显——如果答案恰好藏在被丢弃的那段中间文本里,模型就彻底“失忆”了,这不是量化那种精度损失,而是信息根本不在缓存里。我的经验是:在RAG类场景里,目标段落往往已经被检索到并重新组织过,稀疏注意力可以放心用;但在开放域长文档问答中,模型需要自己去长文中定位线索,保守才是对的,顶多只在窗口选择上做文章,别把缓存丢得太大胆。
5. 实测:同一个7B模型从2K上下文推到32K,我踩过的坑
5.1 实验配置与账面数据
我用的是一张24GB的单卡,加载某7B开源模型,权重做4bit量化,模型注意力结构是GQA(8个KV头),分别对比2048、8192、32768三档上下文长度,测了KV Cache、峰值显存和生成速度。
| 上下文长度 | KV Cache(FP16) | KV Cache(INT8) | 24G卡峰值显存 | 生成速度(约) |
|---|---|---|---|---|
| 2K | 256MB | 128MB | 7.5GB | 25 token/s |
| 8K | 1GB | 512MB | 10.8GB | 18 token/s |
| 32K | 4GB | 2GB | 22.6GB | 7 token/s |
账面数据基本符合前面的公式,32K档用FP16时整张卡已经非常紧张,生成速度掉到个位数。INT8量化后显存松了一些,但速度并没有大幅回升——因为长上下文下访存量依然很大。
5.2 三个真实症状与对应解法
第一个症状是复读。把上下文推到超过模型训练长度后,生成内容开始卡在某几句话反复绕圈,明显是注意力分布出了问题。位置编码外推时,softmax的分数会被某些位置主导,模型像是“忘了下一步该说什么”。我试了把RoPE的base从10000调大,又加了NTK-Aware的缩放,复读情况才明显缓解,困惑度也回落到正常区间。
第二个症状是“感觉模型忘了我开头说了什么”。明明注意力理论上覆盖所有位置,但模型对序列开头的关注是“礼貌性”的,真正有效用的往往集中在最后一个位置附近。最开始的提示、背景材料放在序列中部时,召回很不稳定。后来我调整了prompt的组织方式:关键指令放开头,关键材料放结尾,中间只放辅助信息。这个改动比换任何解码参数都来得有效。
第三个症状出现在INT8量化+32K的超长档。模型在多文档问答里偶尔会引用错误文件里的内容,单独跑FP16时没有这个问题。看起来量化误差在超长上下文叠加后,确实影响了模型对远端信息的判断。我最后对文档类任务保留FP16,对一般闲聊和摘要任务用INT8,既保住了关键场景的质量,也换来了显存余量。
5.3 别迷信“32K”这个数字
最后一个体会是:宣传上的“32K上下文”和你实际能用的32K是两个概念。能跑通,只说明位置编码和显存管理撑得住;能不能答好,还得看注意力分布、KV量化策略、prompt组织方式,甚至你的检索方案。我现在选模型,会先看注意力结构和KV Cache算法,再看训练时的长度外推方法,最后才看宣传页上的最大长度。
这篇记录写到这里,我对KV Cache的心态已经从“知道名字”变成了“能算账、能排障”。如果你也在折腾大模型推理,建议先把上面那个公式代入自己的模型试算一遍,再决定要不要上长上下文、要不要开量化。毕竟显存的每一G,都是要花真金白银买的。