大模型跑推理,最直观的感受就是“出字速度”。同样是7B规模的模型,有的人部署后每秒只能吐十几个token,有的人却能跑到每秒五六十个token,差距就藏在KV Cache这类细节里。很多刚接触大模型部署的朋友,以为换张更大的显卡、堆更高的算力就能解决一切,实际一测才发现,瓶颈往往不在矩阵运算本身,而在自回归生成过程中那些被反复计算、又必须保留的历史信息。KV Cache就是把这部分“历史账本”缓存起来,让每一步生成都只算增量,而不是从头再来。
这篇文章我会从自回归解码到底在算什么讲起,把KV Cache的原理、显存开销、工程落地和常见坑一次性讲透。适合正在做大模型推理加速、服务化部署,或者想深入理解Transformers推理机制的朋友参考。哪怕你只是用HuggingFace跑过几次生成,看完后也能明白为什么generate函数里会有那些看似不起眼的缓存参数。
1. 先弄明白推理为什么慢:自回归的“重复劳动”
1.1 一次只吐一个Token的真相
大模型生成文本,不是像人一样一口气说完一整句话。它本质上是做完形填空——每次都根据“已经生成的所有内容”,预测下一个最可能的token,然后把这个token拼到已有的序列末尾,再进行下一次预测。
这个流程在Transformer解码器里,意味着每一轮都要跑一遍完整的前向计算:输入的是当前完整的token序列,比如“今天天气真”,模型输出是下一个token的概率分布,然后选出“好”,下一轮输入就变成了“今天天气真好”,再预测下一个……直到碰到结束符或者达到最大长度限制。
这里有个容易被忽视的关键点:每一轮输入序列都在变长,从1个token一直加到几百上千个。如果每一轮都把整条序列重新输入一遍、重新计算所有位置的注意力,那计算量会随着序列长度呈二次方增长。更糟糕的是,前面已经算过的内容,比如“今天”这个token对后面所有token的注意力贡献,其实每一轮都在重复计算,结果却是一模一样的。
这就是自回归推理慢的第一个根源:大量重复计算。KV Cache就是专门针对这个“重复劳动”做的优化方案。
1.2 注意力机制里的K、Q、V到底在干嘛
要理解KV Cache为什么能起作用,得先回到Transformer的注意力机制本身。注意力机制的核心是让序列里的每个token都能去“关注”序列里的其他token,从而捕捉上下文关系。
具体到Self-Attention,输入是Q(查询)、K(键)、V(值)三组向量。Q代表“我在找什么信息”,K代表“我拥有什么信息”,V代表“我实际携带的信息内容”。注意力分数的计算过程大致是这样:
- 当前token的Q,与序列中每个token的K做点积,得到注意力权重,衡量“当前token应该关注哪些历史token”;
- 把这些权重经过softmax归一化;
- 再乘以对应的V,加权求和,得到当前token的上下文表示。
放到生成场景里:当模型在预测第N个token时,它需要计算第N个token的Q,与前面N-1个token的K、V做注意力运算。每一层Transformer层都要做这件事,Transformer有多少层,就得重复多少次。
问题是,前N-1个token的K和V,在第1轮、第2轮、第3轮……一直到第N-1轮的时候都已经分别算过了。它们不会因为新来了一个token而变化——因为token的embedding是固定的,经过同样的权重矩阵变换后,K和V的数值也是确定的。既然结果一样,为什么还要每轮重算一遍?
理论上完全可以预先把历史token的K、V保存下来,新token来了之后只需要算它自己的K、V,然后跟缓存里的历史KV做注意力计算。这就是KV Cache的朴素思想:用显存换计算,用缓存换速度。
1.3 缓存前后计算量的直观对比
用一个具体例子感受一下差距。假设输入序列长度是S,每层注意力头的维度是d,一共有L层。每一轮解码,模型需要处理的是一个长度为S的序列。
没有KV Cache时,每一轮都要对整条序列初始化K、V并计算注意力,单轮计算量大约是O(S²·d·L)。随着S不断增大,这个计算量是指数恶化的趋势。
有了KV Cache之后,每一轮只需要对新增的1个token做一次K、V计算和注意力映射——也就是只算O(S·d·L)的量级。虽然S在增长,但S本身处于已经缓存的历史token数量,这部分计算被提前摊掉了。
如果硬要说一个直观感受:同样是生成长度为512的文本,不做KV Cache时,最后一轮的前向计算量几乎是第一轮的512倍,整个生成过程的总计算量大致是序列长度的三次方级别;用了KV Cache以后,单轮计算只跟当前序列长度成正比,总计算量近似降到平方级别。在长文本生成场景下,这个差距可以轻松达到一两个数量级。
这也是为什么所有主流推理框架,从HuggingFace Transformers到vLLM、TensorRT-LLM,无一例外都内置了KV Cache机制。它不是可选项,而是刚需。
2. KV Cache接入后,显存账单怎么算
2.1 不被注意的显存大头
KV Cache解决了计算重复的问题,但它有一个非常现实的代价——显存占用。很多人部署模型时只盯着模型权重的显存占用,比如一个7B的模型,加载FP16权重约14GB,觉得一张24GB的卡绰绰有余。结果并发一开、序列一长,直接OOM,一脸懵。
问题就出在KV Cache上。它随着并发请求数和序列长度动态增长,而且增长速度比很多人想象中快得多。
这里给出一个KV Cache显存占用的估算公式:
单个请求的KV Cache大小 = 2(K和V两组)× L(层数)× S(序列长度)× H(注意力头数)× D(每个头的维度)× 精度字节数
举个例子,一个7B模型常见的配置是32层、32个注意力头、每个头128维,也就是hidden size为4096。如果同时处理16个并发请求,每个请求的序列长度约2048,使用FP16精度(2字节):
单请求KV = 2 × 32 × 2048 × 32 × 128 × 2 ≈ 1.07GB
等等,这里算错了,重新算一下。应该是:
2 × 32层 × 2048序列长度 × 4096(hidden size)× 2字节 ≈ 2 × 32 × 2048 × 4096 × 2 = 2,147,483,648字节 ≈ 2GB
对,关键是把每层的head数和head维度合并成hidden size来算,因为K和V本质上都是hidden_size维的向量,每个token在每个层里各一份K、一份V。所以单请求KV Cache约2GB,16个并发请求就是32GB——比模型本身还大一倍多。
这个数字说明什么问题?KV Cache并不是什么可忽略的“小缓存”,在长上下文、高并发的场景下,它的显存占用可以轻松反超模型权重,成为推理显存开销的头号选手。
2.2 Prefill与Decode:两种完全不同的阶段
了解KV Cache在显存里的生命周期,得先分清推理过程的两大阶段。
第一阶段叫Prefill,也就是预填充阶段。这个阶段处理的是用户的完整输入提示词(Prompt),比如用户一次问了300个token的问题,模型需要一次性对这些token做并行的前向计算,算出它们的K、V并写入缓存。这个阶段计算密集度高,GPU利用率高,延迟主要取决于提示词长度。
第二阶段叫Decode,也就是解码阶段。这个阶段是逐个生成token的过程,每一步只有1个新token参与计算,需要跟缓存里的所有历史KV做注意力运算。这个阶段对显存带宽的敏感度远高于对算力的敏感度——因为整个模型的权重数据和KV Cache数据都要从显存里读一遍,算的东西却很少。这也是为什么解码阶段GPU利用率往往看起来不高,但速度依然能优化的核心原因。
KV Cache对这两个阶段的影响不同。Prefill阶段需要一次性为整段Prompt预留KV空间,同时完成计算和写入;Decode阶段则需要在每一轮生成前确保有足够的显存空间容纳新生成的KV。一个完善的推理引擎必须同时处理好这两个阶段的显存分配策略。
这里有个实操建议:Prefill阶段耗时和Prompt长度强相关,想降低首token延迟,除了KV Cache之外,还可以考虑对Prompt做截断或摘要压缩;而Decode阶段的吞吐优化,核心是减少KV Cache的显存碎片和提升显存带宽利用效率。
2.3 显存预算与并发容量的权衡
部署推理服务时,KV Cache的大小直接决定了系统能支撑多少并发。很多公司买显卡、配服务的时候,习惯只按模型权重大小来估算,结果上线后并发一上来就OOM,搞得焦头烂额。
正确的显存预算公式大概是:
单卡总显存 = 模型权重显存 + 激活值显存 + KV Cache显存 + 推理引擎运行开销(CUDA context、框架缓冲等)
其中KV Cache预留多少,直接决定并发上限。常见的经验做法是:在总显存中扣除模型权重和必要的运行开销后,把剩余显存的大部分留给KV Cache池。比如一张80GB的A100,7B模型权重约14GB,CUDA context和激活值预留个10GB左右,剩下约50GB都划给KV Cache池——这样单卡并发能力可以做到几十路。
有人可能会问:为什么不能把所有显存都动态瓜分?因为KV Cache池需要预先分配一段连续或分块管理的空间,推理引擎才能在请求进来时快速分配、用完后及时回收。如果完全动态申请释放,会产生大量碎片,性能和稳定性都受影响。
从实际操作来看,不少推理框架提供了KV Cache显存占比的配置参数,例如vLLM里的gpu_memory_utilization。我一般建议设为0.85到0.9之间,太低浪费显存、降低并发,太高则可能因为CUDA context和其他开销不足导致初始化失败。这个值需要根据具体业务的实际请求长度和并发数来反复调。
3. 缓存存在哪、怎么存、怎么管
3.1 引擎里的KV Cache是怎么管理的
KV Cache不是一个简单的“大数组”存下了事,工程上远比想象中复杂。核心问题在于:不同请求的序列长度不同、即将生成的token数也不同,如果给每个请求都预分配一个最大长度的连续空间,内存浪费会极其严重。
以vLLM为例,它借鉴了操作系统中虚拟内存的分页思想,提出了PagedAttention机制。核心做法是把KV Cache切分成固定大小的块(block),每个块能容纳固定个数的token的KV数据。请求刚开始时只分配少量块,后续生成的KV逐渐追加到新块中,块之间通过索引连接,不需要物理连续。
这个设计和操作系统里的分页如出一辙。程序申请内存时,操作系统分配的物理页框也不需要连续,靠页表映射到连续的虚拟地址空间即可。vLLM用同样的思路管理KV Cache,从而极大减少了显存碎片和浪费。
切换到工程视角,这带来的直接收益是:可以支持更大的batch并发,可以灵活处理变长的输入输出。实测里,同样是24GB显存跑7B模型,用HuggingFace Transformers的朴素实现可能只能并发跑两三个请求,但用vLLM配合PagedAttention,并发可以干到十几甚至更高,吞吐提升非常明显。
3.2 直接对接HuggingFace时的缓存行为
如果你只是用HuggingFace Transformers库跑推理,它内部其实也做了KV Cache,只是工程上没有PagedAttention那么激进。调用model.generate()时,默认就会使用KV Cache,只是很多使用者根本没意识到这一点。
有个常见的参数值得大家关注:use_cache。在部分模型配置里,这个参数默认是False,尤其是一些偏研究场景的代码示例里。如果推理时忘了打开,模型会退回“每轮全量重算”的模式,生成速度直线下降。所以如果发现生成特别慢,先检查一下use_cache是不是被关掉了。
还有一个细节是past_key_values,也就是缓存了上一轮KV的变量。在手动实现生成循环或者做更精细控制时,需要把它保存下来并传回model.forward()。很多自己写生成逻辑的朋友都在这上面踩过坑——忘了把past_key_values传进去,或者传了个空的初始化值,结果KV Cache形同虚设,速度原地踏步。
实际上,HuggingFace在新版本里把很多缓存逻辑隐藏在内部实现了,普通调用者不太需要手动管。但如果要做流式输出、多轮对话的增量推理,或者接入自定义采样逻辑,理解past_key_values的传参方式还是很有必要的。
3.3 多轮对话场景的缓存复用
KV Cache还有一个常见应用场景是多轮对话。每轮对话都会带上完整的历史消息,如果不做任何优化,对话长度会越来越长,每轮推理都要从头处理一遍历史上下文。
聪明的做法是:把历史轮次计算好的KV Cache保存下来,新一轮对话时,只需要计算新输入部分(用户新的提问)的KV,再加上历史缓存一起做注意力计算即可。
听起来很简单,实际做的时候有一个小坑:很多模型的对话模板会在不同轮次间插入特殊token(比如系统、用户、助手的角色标记),这些特殊token的KV也需要正确写入缓存。如果模板拼接方式和缓存管理方式不匹配,轻则输出质量下降,重则直接报错。
另一个更隐蔽的坑是:如果模型使用了位置编码(比如RoPE)和注意力掩码,历史KV在跨轮次复用时的位置信息必须保持一致。简单说就是,第1轮的第5个token在第2轮仍然是“第5个token”的位置,不能因为新输入追加在后面就改变了历史token的相对位置编码。
多轮KV Cache的工程实现如果不熟悉,最容易出现的现象就是首轮回复正常,到第二轮开始生成内容变得莫名其妙——大概率是缓存的位置信息或者注意力掩码处理出了偏差。
4. KV Cache的进阶优化方向:量化、压缩和共享
4.1 量化KV Cache:显存减半的诱惑
KV Cache的显存占用跟精度直接相关:FP16是2字节,INT8是1字节,FP8也是1字节,如果做到INT4只需要0.5字节。把KV Cache从FP16压到INT8,理论上显存占用直接减半,能装下的并发请求数几乎翻倍。
实际量化KV Cache不是简单地截断数值,而是要保留注意力计算的精度。注意力计算涉及Q和K的点积运算,以及对V的加权求和,如果量化误差太大会直接影响生成质量。常见的做法是对K、V分开量化,甚至按注意力头分组量化,每个头单独算缩放因子,减少离散化带来的误差。
在工程实践中,FP8量化KV Cache的效果非常理想,大多数场景下生成质量和FP16几乎没有肉眼可见的差别。INT8则需要稍微注意长文本场景下的累计误差。INT4虽然显存省得更多,但质量退化会明显一些,一般只用在显存极度紧张的场景。
需要注意,KV Cache量化并不是所有模型都支持。现在主流的推理引擎比如vLLM、TensorRT-LLM都提供了一些量化选项,但不同版本的实现成熟度差异很大。我的建议是:先跑通FP8,效果稳定再考虑INT8,尽量别一上来就激进量化,免得排错排到怀疑人生。
4.2 缓存条目的生命周期:到底留多久
KV Cache的管理还有一个很容易被忽略的问题:缓存的内容什么时候该释放。
这是一个典型的资源生命周期问题。请求结束后,该请求对应的KV Cache应该被回收,供下一个请求使用。但如果是一个在线对话服务,需要保留用户的上下文缓存,那缓存的生命周期就要跨越多个请求。
工程上常见做法是给KV Cache池加TTL(存活时间)和LRU(最近最少使用)淘汰策略。用户在一定时间内有活跃交互,缓存就保留;超过一段时间没说话,就释放缓存,把显存腾出来给其他用户。
这个策略在真实业务里很关键:如果缓存保留时间太长,缓存池被长期占用,新用户进不来,整体吞吐反而下降;保留时间太短,用户回来对话时缓存已经没了,需要重新处理历史上下文,恢复对话延迟升高。
一个值得参考的经验值:对话场景的KV Cache闲置有效期一般设为5到10分钟。单位时间活跃用户少的时候可以适当延长,高峰时段调短以保吞吐。这需要根据业务的实际数据反复调,没有通用最优解。
4.3 前缀共享缓存:同样的开头只算一次
还有一类场景特别适合KV Cache的进一步优化:多轮对话、Agent工具调用、代码补全里,往往会以一段相同的系统提示词和指令开头。比如每个请求前面都有一段800字的“你是专业助手”系统提示,那这段提示词的KV在每个请求里都被重新计算一遍,纯属浪费。
前缀共享缓存的思路就是:把这段公共前缀的KV Cache算一次,存起来,任何以相同前缀开头的请求都直接复用,不需要重新计算。
实现这个方案需要先做前缀匹配,比如用哈希或Trie树结构维护前缀树,命中缓存前缀的请求可以跳过所有公共部分的前向计算。这在长Prompt重复率高的场景里收益非常可观——有些Agent类服务甚至能把平均首token延迟降低一半以上。
不过前缀共享的实现复杂度比普通KV Cache高不少。你需要考虑可变前缀的长度匹配、缓存失效时机、以及序列长度对齐问题。如果业务场景本身Prompt千变万化,没有稳定的公共前缀,这套优化收益很有限,不必硬上。
5. 常见问题排查:KV Cache配置与运行中的坑
5.1 显存不足与并发下降
最常见的KV Cache问题就是显存不足。日志里出现CUDA out of memory,或者推理时并发数一高就报错,大概率是KV Cache池分配出了问题。
排查思路可以按这个顺序来:
- 确认模型权重显存是否符合预期。如果模型加载了多个副本,先检查是否有重复加载。
- 确认gpu_memory_utilization(或类似参数)是否设置过低。有些推理引擎默认只使用总显存的一小部分,需要手动调高。
- 确认序列长度上限设置。max_model_len或max_seq_len设得过高,会极大占用KV Cache预留空间。按实际业务峰值设置,别贪大。
- 确认是否开了KV量化。同样显存下,开启KV量化能把容量翻倍,很多情况下是救命的配置。
还有一个小技巧:在线推理服务上线前,最好用真实业务负载做压力测试,观察不同并发和序列长度下的显存峰值。别等线上OOM再去找原因,那种环境下排错成本很高。
5.2 首token延迟和生成速度不达预期
如果显存没问题,但生成速度仍然很慢,需要区分是Prefill阶段慢还是Decode阶段慢。
Prefill阶段慢,主要看Prompt是否过长、batch里的请求是否过长。可以通过开启continuous batching(连续批处理)来优化——让不同长度的请求动态拼接、动态退出,而不是固定等一个batch全部生成完。vLLM和TensorRT-LLM都原生支持这个能力,能显著提升整体吞吐。
Decode阶段慢,通常是显存带宽受限。这个时候单纯增加算力意义不大,更应该考虑:
- 降低KV Cache精度,减少每次读取的数据量;
- 使用GQA(分组查询注意力)的模型架构,这类模型本身就是为降低KV Cache读取带宽设计的;
- 增加并发,让每次模型权重读取被更多请求摊薄,提升吞吐。
这里额外提一句,很多人在实践里会发现增加并发后每个请求的延迟并没有线性变差,整体吞吐却明显上升。这就是Decode阶段带宽受限的一个典型特征:权重读取成本被多个请求分摊了。
5.3 输出质量和数值波动问题
KV Cache的引入虽然不影响理论精度,但在实际中确实可能遇到生成质量下降的现象。这通常跟量化有关,尤其是INT4或更低精度的KV量化,在长上下文下可能出现注意力分布偏移。
排查方法很简单:把KV Cache切回FP16,用同样的输入和随机种子生成一遍,对比输出结果。如果质量恢复,那就是量化精度问题;如果输出依然异常,那就不是KV Cache的事,需要检查模型本身或者其他推理路径。
针对量化引起的质量问题,有几个缓解手段:
- 换用更细粒度的量化方案,比如按通道而非整层量化;
- 对重要token(比如注意力分数高的token)使用更高精度存储;
- 调整量化缩放因子的校准数据集,让分布更接近真实推理场景。
在我自己的实践中,FP8的KV量化大多数模型都能无感使用,INT8稍微挑模型,但通常也能接受。真正遇到明显质量问题的,基本都是INT4方案,或者校准数据集跟实际业务偏差过大。
5.4 动态形状处理不当导致缓存失效
动态形状问题是一个比较隐蔽的坑。推理服务在遇到变长输入时,如果处理不当,会导致KV Cache频繁重新分配,性能断崖式下降。
比如一个请求本来生成了100个token,后面又因为某种原因要做beam search或者采样回溯,KV Cache可能就要回滚到之前的某个状态。如果缓存管理不支持高效的rollback机制,只能整体释放重算,那KV Cache的收益就大打折扣了。
好在主流推理框架已经把这套机制内置了。vLLM的PagedAttention天然支持高效的缓存回滚,因为它以块为单位管理,回滚时只需要丢弃尾部块。但如果你是自己写的高性能推理服务,这个点一定要注意:设计KV Cache池时,必须考虑到beam search、并行采样等场景下的块回收与重用机制。
5.5 并发请求与缓存池命中率之间的关系
最后讲一个系统工程视角上的权衡:并发太高,单个请求能分到的KV Cache空间就少,可能需要在序列中途就强制截断或走降级路径;并发太低,KV Cache池利用率不足,吞吐又上不去。
这里的关键指标是显存中KV Cache池的利用率。实践中可以用推理引擎暴露的metrics观察缓存池命中率、块分配成功率等指标。如果发现块分配失败次数在增加,意味着缓存池太小或者请求的序列长度长得太快,需要动态调整并发配额或max_model_len。
另一个容易被忽视的做法是给不同优先级的请求设置不同的KV Cache配额。比如普通用户并发高但每个请求短,VIP用户允许更长的上下文。引擎层面如果支持per-request的KV Cache配额配置,灵活度会大很多。
总之一句话:KV Cache的优化没有一劳永逸的配置,它必须结合模型规模、显存容量、业务请求模式和SLA要求综合调优。
6. 一些过来人的心得
KV Cache表面上看是“缓存历史计算结果”,真正落地时会发现它牵一发而动全身。显存调度、并发管理、精度量化、多轮对话缓存复用,每一个环节都可能成为性能瓶颈。
我在实际项目里的体会是,KV Cache很难靠单个参数调出理想效果,还是要从系统角度整体优化。先确认模型架构是否适合目标场景(比如长文本优先选GQA架构),再用推理引擎的成熟能力把KV Cache管好,最后根据业务负载做量化调优,按这个顺序走基本不会走偏。
最后分享一个非常实用的小技巧:做压力测试时,不要只看平均生成速度,一定要拆开看Prefill阶段和Decode阶段的独立耗时。这两个阶段对KV Cache的依赖方式完全不同,混在一起看会掩盖真正的瓶颈。很多你觉得是KV Cache配置有问题的情况,拆开一看其实是Prefill阶段batch太长,跟Decode阶段的KV读取关系不大。
KV Cache这个概念本身已经不算新了,但随着长上下文模型和Agent应用的流行,它被越来越多的人重新审视和优化。希望这篇文章能帮你少踩一些坑。