news 2026/10/26 5:54:39

KV Cache深度解析:大模型推理显存优化与长上下文工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
KV Cache深度解析:大模型推理显存优化与长上下文工程实践

做推理优化的人,迟早都会撞上KV Cache这个词。大模型(LLM)生成文本时是逐字"挤"出来的,每生成一个新token,都要重新看一眼前面所有已经生成的token,重新算一遍注意力。KV Cache就是在这个过程里把已经算好的Key和Value缓存下来,避免每生成一个字都把整段历史的注意力从头算一遍。没有它,一个7B量级的模型推理速度会慢到让人崩溃;有了它,每次生成只需要做一次"单token级别"的前向计算。这篇文章我想从头到尾拆一遍KV Cache的核心原理、显存账本、长上下文里常见的lost in middle现象,以及Prompt Cache到底缓存了什么,最后还会聊聊我在工程里实测过的优化手段和踩过的坑。适合正在做推理部署、服务优化,或者想搞懂注意力机制背后成本的同学。

1. 从自回归生成说起:KV Cache到底在解决什么问题

1.1 Decoder-only模型的"排队吐字"机制

现在主流的大模型基本都是Decoder-only架构,生成方式叫自回归(Autoregressive)。意思是模型每次都只预测下一个token,然后把这个token拼到输入末尾,再预测下一个。你可以把它想象成一个人在排队报数:报完一个数字,记住了前面所有数字,才能报下一个。

这个机制决定了推理天然是"串行"的,第一个token必须等prompt全部处理完,后面的每个token都依赖前一个的输出。很多刚接触大模型的人会奇怪:为什么GPU那么强,生成速度还是每秒只有几十个token?原因就在这里——不是GPU算不动,而是每一步都要被前一步的结果卡住。

如果完全没有KV Cache,第N步生成时,模型需要把前N-1个token的Key和Value全部重新计算一遍。这就相当于一个学生每天复习时都把整本课本重新抄一遍,而不是只看笔记,效率低到离谱。

1.2 朴素实现的计算量与重复劳动

我们看一个具体的例子。假设用户输入了1000个token的prompt,模型要生成500个token。用最朴素的实现,完全不缓存任何东西:

  • 第1次生成时,需要处理完整的1000个token,计算量来自这1000个token的注意力。
  • 第2次生成时,输入变成1001个token,又要重新计算这1001个token的注意力。
  • 第3次生成时,输入变成1002个token,继续从头算。
  • 以此类推,第500次生成时,模型要处理1499个token。

把整个过程加起来,实际算过的token量大约是:

[ 1000 + 1001 + 1002 + ... + 1499 ]

等差数列求和,总数大约375000个token的处理量。但仔细想一下,真正需要"从头算"的只有第一个新token吗?并不是。其实前1000个token的Key和Value,在第2次、第3次、第4次计算时,计算结果完全是一样的,因为这些token根本没有变化。唯一变化的是新追加的那个token。

也就是说,朴素实现里绝大部分计算都是同一个东西的重复计算,白烧了大量GPU算力。KV Cache的思路简单粗暴:把这些计算结果存下来,下次直接用。

1.3 KV Cache的本质:用空间换时间的记忆

KV Cache本质上是一种典型的空间换时间策略。它把已经计算过的历史token的Key矩阵和Value矩阵存在显存里,在后续的自回归过程中直接拼接使用,省掉了海量的重复矩阵乘法。

这里有一个很微妙的地方:每次生成新token时,我们其实只需要计算"当前这个新token"的Query、Key、Value。当前token的Query需要去跟所有历史token的Key做点积,得到注意力分数,然后再拿这些分数去加权所有历史token的Value。如果我们把历史Key和Value都缓存在显存里,这一步就只需要加载缓存、拼接新token的KV,然后做一次完整的Attention计算即可。

这个"记忆"的代价是显存,而且会随着序列长度增加和并发请求数增加而线性膨胀。后面我专门开一节讲显存账本,因为这是KV Cache在实际部署中最大的坎。

2. 为什么缓存的是KV,而不是QKV

2.1 Attention的四步流程

很多人第一次接触KV Cache时会有一个很自然的疑问:为什么不把Q也一起缓存了?毕竟Q也是算出来的,缓存起来不是更省事吗?要回答这个问题,得先看清Attention的计算流程。

以标准的缩放点积注意力为例,一个token的参与过程是:

  1. 通过三个权重矩阵 ( W_Q, W_K, W_V ) 分别算出当前token的Query、Key、Value。
  2. 用当前token的Query去跟序列里所有token的Key做点积,再缩放、softmax,得到注意力权重。
  3. 用注意力权重去加权所有token的Value,得到当前的输出向量。

这个流程里,每个token的Key和Value代表的是一种"被查询时的属性":Key是内容索引,Value是内容本身。一旦确定下来,在整段序列生成完毕之前都不会变。

而Query代表的是"查询者的意图",它的使命就是去寻找当前正在处理的位置应该关注哪些历史内容。每一步生成的token不同,它的Query就要重新计算,而且只服务于当前这一步。旧token的Query在生成它自己的时候已经把该做的事情做完了,之后再也没有任何用处。

2.2 Q的作用域:只活在"当下"

我们用一个生活化的类比来理解。想象你在一间资料室查资料,桌上摊着一堆档案,每份档案旁边贴着两个标签:一个是检索标签(Key,说明档案涉及什么主题),一个是正文内容(Value,档案的具体文字)。

你每次来查资料,都会带着一个"查询请求",这个请求就是Query。你要做的是:拿着查询请求,扫一遍所有档案的检索标签,挑出最匹配的几份,然后打开正文阅读摘录。

在这个场景里,档案馆的检索标签和正文内容是不会变的,变的是你每次带来的查询请求。如果你明天又来查一次,没有任何理由把今天的查询请求也存着,因为明天的请求大概率不同,而且今天这份请求在今天的查询里已经用完使命了。

注意力机制一模一样。Key就像档案的检索标签,Value就像档案正文,Query就是每一次新的"查询请求"。所以在推理时缓存KV完全说得通,而缓存Q反而没有意义,因为旧的Q在生成旧token时已经用过了,后续步骤根本不会再用到它。这就是"为什么需要KV Cache而不是QKV Cache"的根本答案。

2.3 QKV三者视角的直觉类比

再换个角度补充一下。在标准的自注意力里,一个token要扮演两个角色:

  • 作为"被关注者":它要被后续token的Query所查询,这时需要提供自己的Key和Value。
  • 作为"关注者":它要去查询前面的所有token,这时需要使用自己的Query。

假设序列里已经生成了1000个历史token,现在要生成第1001个token。第1001个token作为"关注者",必须用自己的Query去和前1000个token的Key做匹配。而前1000个token作为"被关注者",它们的Key和Value早就确定,且不会再变了。

缓存Q意味着把前1000个token的查询请求都留着。可它们的查询请求已经在它们各自被生成的时候执行完了,后续根本不存在"它们再次作为关注者去查询别人"的场景。所以工程上从没人做QKV Cache,最多只会做KV Cache。

3. 显存占用公式:KV Cache如何吃掉你的GPU

3.1 公式推导

KV Cache最大的坑不在原理,而在显存。它的占用是可以精确算出来的,我先把公式列出来。

对于每个token,每一层注意力都要缓存一组Key和Value,每组的维度是:

[ num_kv_heads \times head_dim ]

其中 ( num_kv_heads ) 是KV头的数量,( head_dim ) 是每个注意力头的维度。注意这里不是用Query头的数量,而是用KV头的数量。传统多头注意力(MHA)里KV头数等于Query头数,但像LLaMA 2-70B这类模型用了GQA,KV头数会少很多,这个我们后面细说。

每个token、每一层需要存储的字节数大约是:

[ 2 \times num_kv_heads \times head_dim \times 2 \text{ bytes} ]

乘以2是因为Key和Value各一份,再乘以2是因为一般用FP16存储,每个数占2字节。整个模型有 ( L ) 层,序列长度是 ( S ),并发请求数是 ( B ),那么KV Cache总显存大约是:

[ 4 \times L \times num_kv_heads \times head_dim \times S \times B ]

注意这里已经把Key和Value两份、FP16两个字节都折算进去了。如果要换成GB,再除以 ( 1024^3 )。

3.2 不同规模模型的量化对比

我用几个常见的模型配置来算一下,大家感受会更直观。

先看LLaMA-7B的经典配置:32层,32个Query头,每个头维度128,也就是32个KV头,Head Dim 128,FP16存储。当batch size为1、序列长度为2048时:

[ 4 \times 32 \times 32 \times 128 \times 2048 \times 1 = 4 \times 32 \times 32 \times 128 \times 2048 = 1,073,741,824 \text{ bytes} ]

也就是约1GB。你没看错,一个7B参数的模型,光KV Cache就占了1GB显存,这还没算模型权重本身和中间激活值。

再看LLaMA-13B的典型配置:40层,40个Query头,KV头数也是40,Head Dim 128。同样batch=1、序列2048:

[ 4 \times 40 \times 40 \times 128 \times 2048 \approx 1.67 \text{ GB} ]

注意13B参数只比7B多了一倍不到,KV Cache也差不多是1.67倍,基本跟层数和头数线性挂钩。

最夸张的是长上下文和并发场景。如果序列长度从2048拉到32768(32K),KV Cache直接涨16倍。如果同时有8个并发请求,再乘以8。一个7B模型光KV Cache就能冲到128GB以上,这已经超过很多单卡的全部显存了。

下面这个表可以帮你快速估算:

模型规模层数KV头数Head Dim2048长度单请求KV Cache8192长度单请求KV Cache
7B (MHA)3232128约1GB约4GB
13B (MHA)4040128约1.67GB约6.7GB
70B (GQA)808128约0.67GB约2.7GB

注意70B因为用了GQA,KV头数大幅缩减,KV Cache反而比13B的全量MHA更低。这个对比已经能说明很多事了。

3.3 与上下文长度的关系:长度是放大器

从公式看,KV Cache跟序列长度是明确的线性正比关系。所以现在各家都在卷上下文长度,8K、32K、128K、1M,每提升一档,KV Cache的显存压力也跟着提升一档。很多人只盯着"模型支不支持长上下文",没有意识到长上下文的成本不是模型参数,而是KV Cache。

举个实际案例:用7B模型、batch=1、FP16部署,显存预算假设12GB。模型权重大约占14GB(7B × 2字节),其实单卡都装不下,通常要量化到INT4后权重约3.5GB,才勉强腾出空间。序列长度如果跑到32K,KV Cache就要16GB,直接把显存爆掉。

这也是为什么纯靠"加显存"做长上下文不现实,必须靠工程手段去压KV Cache的体积。后面第5章我会展开讲几个真正有效的优化方向。

4. 长上下文的两大现象:Lost in Middle 与 Prompt Cache

4.1 Lost in the Middle到底是什么

长上下文场景里有一个很有名的现象,叫"Lost in the Middle",中文经常翻译成"中间迷失"。意思是模型在处理长文本时,对开头的部分和结尾的部分利用得比较好,但对夹在中间的内容经常"视而不见",导致检索或者问答任务里,相关信息如果落在长文本中段,模型反而不容易答对。

这个现象最早在2023年的论文《Lost in the Middle: How Language Models Use Long Contexts》里被系统性地描述出来。论文在做开放域问答和多项选择任务时发现,无论上下文里放了多少篇文档,模型答对问题的概率都跟"正确答案放在哪个位置"强相关:放在开头或者结尾,准确率高;放在中间,准确率显著下降。

你可能会想,这不就是人的注意力也有"首因效应"和"近因效应"吗?模型确实也有一点类似倾向。但更深层的原因包括:

  • 位置编码的远程衰减效应。目前很多模型用旋转位置编码(RoPE)一类的位置编码,某些设计天然让远处的token在注意力分数上容易被拉低,中间位置有时反而处于"尴尬区"。
  • 长上下文里注意力被稀释。序列越长,softmax越容易变得平滑,大量token分散注意力分数,中间token更难以脱颖而出。
  • 训练数据里信息密度不均衡。如果训练时很少有"正确答案藏在长文中间"的样本,模型天然不会特别擅长这种模式。

4.2 Prompt Cache / Prefix Caching 又是什么

这个话题下经常有另一个高频问题:"Prompt Cache是什么?跟KV Cache有什么关系?"我在网上看到很多人把这俩混为一谈,其实一个是底层机制,一个是工程复用策略。

KV Cache是个通用机制,只要你用自回归模型推理,每一步生成的KV都会缓存。Prefix Caching(也叫Prompt Cache)则是更上层的优化策略:它把多个请求之间重复的那部分前缀,在KV Cache层面做复用。

举个例子。你架设的服务里,system prompt是固定的一大段系统设定,然后每个用户会在这段设定后面追加自己的问题。没有Prefix Caching时,每次请求进来,即使system prompt完全一样,模型也会重新为这段system prompt算一遍KV。有了Prefix Caching后,第一请求处理完,系统会把system prompt对应的KV Cache按前缀做缓存;第二个请求进来,如果前缀完全一致,直接复用缓存,只需要计算用户新追加的那一小段内容的KV。

注意这里有一个容易误导的点:表面的"Prompt Cache"名称会让人觉得是在缓存提示词字符串,但其实底层缓存的载体仍然是KV Cache,只是按照前缀去索引和复用。现在vLLM、SGLang这类主流推理框架都有类似机制,有些框架直接叫Automatic Prefix Caching,原理是一样的。

4.3 两者之间的关联

Lost in the Middle和Prefix Caching看起来是两个不相关的话题,但是放在长上下文工程化里,它们其实是两条交织的主线。

一方面,上下文越长,KV Cache越大,服务商越需要Prefix Caching来降低重复计算的成本,否则多轮对话和共享system prompt的高并发场景根本跑不动。

另一方面,上下文越长,Lost in the Middle问题越明显,用户如果通过RAG往文档中间塞了一堆相关资料,模型可能压根"没看见",这时你再怎么优化KV Cache的显存,也不代表答案质量会变好。显存是成本问题,Lost in the Middle是效果问题,两个都要解决。

工程上常见的做法是文档重排:把最相关的片段放在上下文开头和结尾,或者至少避开正中间的位置。有人觉得这有点"玄学",但我实测下来确实有效,尤其是10段以上文档同时塞入的场景,重排带来的准确率提升经常有5到10个点。你可以在自己的RAG管线里直接试一下这个思路。

5. 工程优化实战:把KV Cache按进显存的几招

5.1 架构层面:GQA和MQA

先说说从模型架构层面解决KV Cache膨胀的思路。前面提到过KV头数直接影响KV Cache大小。传统多头注意力(MHA)里,每个Query头配一组独立的KV头,KV头数很多,缓存自然大。

MQA(Multi-Query Attention)的思路是所有Query头共享同一组Key和Value,KV头数直接降到1。这样KV Cache能缩小几倍到几十倍,但质量会有一定损失,因为所有Query头都用一套KV,表达能力受限。

GQA(Grouped Query Attention,分组查询注意力)则是MQA和MHA的折中:把Query头分成若干组,每组共享一套KV头。比如LLaMA 2-70B用8个KV头,但Query头有64个,相当于每8个Query头共享1个KV头。KV Cache相比全量MHA直接缩小8倍。

从效果上看,GQA的损失比MQA小得多,这也是为什么LLaMA 2中大规模模型都选择了GQA。你在微调或者选型时,如果想压推理显存,优先选用了GQA的底座模型,比什么优化技巧都省心。

5.2 存储层面:KV Cache量化

第二个方向是降低单个KV的存储字节数。常规FP16是2字节,如果量化到INT8,单个数值降到1字节,KV Cache直接减半;再激进一点压到INT4甚至FP8,还能再缩。

KV Cache量化的难点在于精度控制。Key和Value的数值分布跟权重不太一样,直接简单粗暴地量化,会导致注意力分数漂移,生成质量下降。现在主流做法通常是对每个head或者每个channel单独统计缩放因子,有些方案还会按token动态校准。实测下来,INT8量化的KV Cache在大多数任务上质量损失很小,但显存收益非常明显——8K上下文、16并发这种场景,能救回好几个GB。

我的建议是:优先启用INT8 KV Cache,如果任务对精度不太敏感(比如代码生成、格式化输出),可以再试FP8或INT4。但如果任务本身非常吃细节(比如长文档摘要、法律文本问答),量化级别先保守一点。

5.3 管理层面:PagedAttention与高效内存调度

第三个方向是显存管理。KV Cache是动态增长的,每个请求的长度你看不到,提前分配一整块固定显存,要么不够用,要么浪费严重。这块最出名的方案是PagedAttention,也就是vLLM的核心技术。

PagedAttention的思路非常像操作系统里的虚拟内存分页。传统做法给每个请求预留一大块连续显存,预留少了放不下长序列,预留多了其他请求又用不到,白白浪费。PagedAttention把KV Cache切成固定大小的块,按需分配,逻辑上连续,物理上可以散落在显存各处。这样就能实现接近"用多少占多少"的效果,显存利用率大幅提升。

我自己的经验是,换了vLLM这类带分页管理的框架后,同样一张卡能撑住的并发数明显增加。所以做在线推理服务,尽量别自己手写KV Cache管理,直接用成熟框架,踩坑成本低很多。

5.4 稀疏化与滑动窗口

第四个方向是"少存一点"。既然长上下文里很多历史信息其实用不到,那干脆只保留最近的一部分KV。

滑动窗口注意力是其中一种思路:只维护最近W个token的KV,超过窗口的历史信息直接丢掉。这样做Prompt很长时,KV Cache的上限被锁死在窗口大小,不会随着序列无限增长。代价是模型对窗口之外的早期信息"失忆",如果任务需要引用很早期的内容,效果会打折。

另一种思路是流式处理(StreamingLLM),它发现即使是滑动窗口,丢掉最开始的几个token的KV也会导致性能崩盘,原因在于注意力softmax需要一些"稳定的锚点"。方案是把序列最初几个token的KV单独保留,窗口只滑动中间和末尾的部分,这样既控制显存,又能保住关键位置。

如果你的业务场景是持续的对话流,而不是一次性超长文档,滑动窗口是性价比很高的选择。但如果用户喜欢把超长上下文整段丢进来做全局分析,那滑动窗口就不是最优解,得回到GQA和量化这套组合。

5.5 服务层:批次调度与Prefix Caching

最后再补一个服务层的优化,就是上一节说的Prefix Caching。在推理服务里,多轮对话和同类system prompt的重复率非常高。打开Prefix Caching之后,相同前缀的KV被复用,prefill阶段的计算量能缩掉一大截,首token延迟也会明显下降。

这里有一个细节:Prefix Caching通常要求前缀完全一致,哪怕差一个token,前面所有缓存都不能完整复用(有些实现支持部分匹配)。所以设计system prompt时最好保持严格稳定,不要在固定前缀里插入随机时间戳之类的动态内容,否则会把缓存命中率拉得很低。我见过有人把user id直接拼在system prompt开头,缓存基本全部失效,这纯属给自己增加成本。

6. 常见问题与排查技巧实录

6.1 问题速查表

我在实际部署和调优中遇到过不少问题,整理成下面这张速查表,方便你直接对照:

现象常见原因排查方向
推理时报显存OOMKV Cache增长超出显存预算降低max_seq_len、减少并发、启用KV Cache量化
并发稍高就崩每个请求独立KV Cache,累计占用过高换PagedAttention、开Prefix Caching、限制最大并发
首token延迟高Prefill阶段要计算完整prompt的KV开Prefix Caching、精简system prompt长度
长上下文生成速度反而更慢KV Cache太大,访存带宽成瓶颈减少KV头数(GQA)、量化KV、考虑稀疏注意力
回答总忽略中间文档内容Lost in the MiddleRAG结果重排、把关键信息放到开头或结尾
Prefix Caching命中率低前缀里有动态内容固定前缀、去掉时间戳/user id等动态字段
KV Cache量化后质量下降量化的缩放因子校准不充分按head/channel单独校准、改用更高精度量化

6.2 实操心得与避坑指南

第一,不要一上来就调KV Cache。先看权重占了多少、激活值占了多少,把tensorboard或者nvidia-smi显存监控打开,观察推理过程中显存的增长曲线。如果显存增长明显跟序列长度线性相关,基本就是KV Cache在吃显存,这时候再针对性优化。

第二,动手优化前先确认模型是否用了GQA。如果你的底座模型本身就GQA,KV Cache压力已经小了很多;如果是老式MHA模型,优先考虑换底座或微调时过渡到GQA结构,而不是在推理层疯狂塞量化。

第三,测性能和显存要分开测。测显存时用最大序列长度的单请求来压上限;测吞吐时一般用小并发中等长度模拟线上真实流量。很多人只测了并发,结果上线后遇到超长输入直接OOM,其实早点拿个超长序列压一下就知道了。

第四,长上下文场景下,把KV Cache优化和RAG重排序结合起来做。显存是否够看的是KV Cache优化到不到位;答案质量看的是Lost in the Middle有没有被规避。只有两头都顾到,长上下文才会真正落地可用。

第五,监控工具一定要有KV Cache指标。vLLM里有专门的metrics能看到每个请求的KV Cache使用量,别只看GPU利用率。我遇到过GPU利用率看起来跑满了,实际上大部分时间都在重复计算前缀KV的情况,开了前缀缓存后利用率反而下降,因为不用重复prefill了,但整体吞吐和延迟都变好了。

最后再分享一个小技巧:调试KV Cache相关问题时,写一个不带任何缓存的基线推理脚本,用来和带缓存的版本做对比。很多时候你以为的显存问题、速度问题,其实是别的原因(比如位置编码实现有bug、attention mask传错),有基线脚本在,定位起来快很多。我自己每次搭新框架,第一件事就是跑一遍"同序列长度、同batch"的朴素版和缓存版对照,这套路子帮我省掉了大量排查时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 5:54:39

LangChain4j实战:用Java Spring Boot开发大模型应用

1. 先说结论:Java工程师搞LLM开发,为什么终于不用再眼红Python了这几年做大模型应用,圈子里默认的姿势是Python。LangChain、LlamaIndex、AutoGen,清一色Python生态,导致很多Java后端工程师想做个AI问答、做个人知识库…

作者头像 李华
网站建设 2026/10/26 5:54:02

开源时间序列模型对比:从ARIMA到PatchTST的选型指南

时间序列预测,在运维监控、销量预估、库存调度这些场景里几乎是绕不开的一环。过去几年,开源生态里冒出来一大批时间序列模型,从经典统计模型到深度学习方法,从单变量到多变量,从轻量级到预训练大规模模型,…

作者头像 李华
网站建设 2026/10/26 5:53:42

深度学习如何实现视频广告无缝植入?技术解析与应用

1. 项目背景与核心突破在数字营销领域,视频广告植入技术一直面临着用户体验与商业效益难以平衡的难题。传统的前贴片广告容易引发用户跳过行为,而硬性插入的贴片广告又会影响内容观赏性。香港中文大学深圳分校的研究团队近期发布的这项新技术&#xff0c…

作者头像 李华
网站建设 2026/10/26 5:53:41

PHP宠物领养系统开发实战与架构解析

1. 项目背景与核心价值这个宠物领养系统源码项目最近在开发者社区引起了不小关注。作为一名参与过多个公益类系统开发的老程序员,我第一眼看到这个标题就意识到它的实用价值——不仅提供了完整的可运行代码,更重要的是它解决了一个真实的社会需求&#x…

作者头像 李华
网站建设 2026/10/26 5:53:41

一维卷积的伴随:从内积定义到PyTorch转置卷积与反向传播

做时序建模的人几乎都调过nn.Conv1d,但真正把它当成一个线性算子去看、去推它的伴随(adjoint)的人并不多。我第一次被这件事绊住,是在给一个自定义层写backward:前向是普通一维卷积,反向我用conv_transpose…

作者头像 李华
网站建设 2026/10/26 5:51:55

卷积神经网络(CNN)在图像处理中的核心原理与实战应用

1. 为什么卷积神经网络是图像处理的革命性技术2012年AlexNet在ImageNet竞赛中一战成名时,我正在实验室用传统方法处理医学影像。当看到CNN的识别准确率比第二名高出10.8个百分点,那种震撼至今难忘。卷积神经网络(CNN)彻底改变了计…

作者头像 李华