1. 从一次对话卡顿说起:KV Cache 到底在解决什么问题
如果你用过任何基于 Transformer 架构的对话模型,大概率遇到过这样一种体验:刚开始聊的时候,回复速度飞快,字是一个一个往外蹦的;聊到几十轮之后,明显感觉模型“变迟钝”了,首字延迟从几百毫秒涨到好几秒,输出速度也肉眼可见地往下掉。很多人第一反应是“网络卡了”或者“服务端限流了”,但排查一圈发现网络没问题,服务端也没报错。真正的原因,往往藏在推理引擎内部一个叫KV Cache的机制里。
KV Cache,全称 Key-Value Cache,中文一般叫“键值缓存”。它不是某个具体模型的结构,而是自回归生成过程中一种通用的推理加速手段。要理解它为什么存在,得先回到 Transformer 解码器的工作方式上。自回归生成的核心特点是:每生成一个新 token,都要把它拼接到已有序列后面,然后重新做一次前向计算,预测下一个 token。如果没有缓存,那么生成第 N 个 token 时,前面 N-1 个 token 的 Key 和 Value 都要重新算一遍。这意味着生成一个长度为 L 的序列,总计算量大致是 O(L²) 级别的重复劳动——前面算过的东西,后面还要反复算。
KV Cache 的思路非常直接:既然每一层的 Key 和 Value 在生成过程中不会变化(因为前面的 token 已经固定了),那就把它们存下来,生成新 token 时只计算当前这个 token 的 Query、Key、Value,然后把新的 Key、Value 追加到缓存里,注意力计算时直接复用缓存中的历史 K、V。这样一来,每生成一个 token 的计算量从“和整个序列长度相关”降到“基本只和当前 token 相关”,总计算量从 O(L²) 降到接近 O(L)。这就是为什么有了 KV Cache 之后,长对话的生成速度能维持在一个可接受的范围内。
但问题也随之而来:缓存是要占显存的。KV Cache 的大小和序列长度成正比,和层数、注意力头数、头维度都成正比。序列越长,缓存越大;对话轮次越多,缓存越膨胀。当缓存大到一定程度,显存不够用了,推理引擎就不得不做取舍——要么把部分缓存换出到内存(导致延迟飙升),要么直接截断上下文(导致模型“失忆”),要么降低并发数(导致吞吐下降)。所以标题里说的“AI 越聊越慢”,本质上不是模型变笨了,而是 KV Cache 的显存开销和调度策略在长上下文场景下暴露出了瓶颈。
这篇文章面向的是对推理性能有实际关注的工程师、算法同学,以及需要做长上下文应用的产品和运维人员。我会从 KV Cache 的计算原理讲起,拆解它的显存占用公式,分析长上下文真正的成本构成,然后给出几套在实际项目中验证过的优化思路和踩坑经验。不管你是刚接触推理优化,还是已经在调 vLLM、TensorRT-LLM 这类引擎,应该都能从中找到能直接用的东西。
2. KV Cache 的计算逻辑与显存账本
2.1 自回归解码中 K、V 为什么可以缓存
要搞清楚 KV Cache 为什么成立,得先看注意力机制里 Q、K、V 三者的角色。在标准的自注意力计算中,输入序列的每个 token 会通过三个线性变换分别得到 Query、Key、Value 三个向量。注意力分数的计算方式是 Query 和所有 Key 做点积,再经过 softmax 得到权重,最后用这些权重对 Value 做加权求和。关键在于:对于已经生成的历史 token,它们的 Key 和 Value 只取决于该 token 自身的表示和模型参数,不会因为后面新来了 token 而改变。
这一点和 Query 不同。Query 是“当前 token 想查询什么”,它只和当前 token 有关。而 Key 和 Value 是“历史 token 能提供什么信息”,一旦这个 token 被生成出来,它的 K、V 就固定了。所以完全可以把每一层、每个历史 token 的 K、V 存起来,后续生成时直接读取,不需要重算。这就是 KV Cache 能够成立的根本原因。
用一个生活化的类比:假设你在做一个不断追加内容的会议记录。每来一个新发言人,你都要回顾之前所有人的发言来理解上下文。如果没有缓存,你每次都要把之前所有人的发言重新听一遍、重新整理一遍。而 KV Cache 相当于你把每个人的发言要点提前记在了笔记本上,新发言人来了,你只需要看笔记本上的要点,再结合新发言人的内容做判断。笔记本就是缓存,它让“回顾历史”这个动作从“重听录音”变成了“翻笔记”。
2.2 显存占用公式:每一层、每一个 token 都在吃显存
KV Cache 的显存占用可以用一个相对清晰的公式来估算。对于采用多头注意力(MHA)的模型,单层单 token 的 KV Cache 大小是:
2 × num_heads × head_dim × dtype_size其中 2 是因为要存 Key 和 Value 两份,num_heads 是注意力头数,head_dim 是每个头的维度,dtype_size 是数据类型占用的字节数(FP16 是 2 字节,FP32 是 4 字节,INT8 是 1 字节)。整个模型的 KV Cache 总大小则是:
2 × num_layers × num_heads × head_dim × seq_len × batch_size × dtype_size注意这里有个容易混淆的点:num_heads × head_dim 通常等于模型的隐藏维度 hidden_size。所以公式也可以写成2 × num_layers × hidden_size × seq_len × batch_size × dtype_size。但如果你用的是分组查询注意力(GQA)或者多查询注意力(MQA),num_heads 要换成 KV 头数(num_kv_heads),这时候缓存会显著变小。
举个具体的例子。假设一个 70 亿参数的模型,32 层,hidden_size 为 4096,采用 GQA,KV 头数为 8,头维度 128,FP16 精度。那么单 token 单层的 KV Cache 是2 × 8 × 128 × 2 = 4096 字节 = 4KB。32 层加起来就是 128KB 每 token。如果序列长度是 8192,batch_size 是 1,那么 KV Cache 总量就是128KB × 8192 ≈ 1GB。如果 batch_size 提到 16,那就是 16GB——这已经超过很多单卡的显存容量了。
这个账本说明了一个残酷的事实:长上下文场景下,KV Cache 的显存开销可能比模型权重本身还大。一个 7B 模型 FP16 权重约 14GB,而 32K 上下文、batch_size 为 8 时,KV Cache 可能达到 32GB 以上。这就是为什么很多推理引擎在长上下文场景下,显存瓶颈往往不在权重,而在缓存。
2.3 长上下文为什么让成本非线性上升
很多人以为上下文长度翻倍,成本就翻倍。实际远不止如此。KV Cache 的显存占用和序列长度是线性关系,但实际推理中的成本上升是非线性的,原因有几个层面。
第一,注意力计算本身是 O(L²) 的。虽然 KV Cache 把重复计算省掉了,但每生成一个 token,当前 Query 仍然要和所有历史 Key 做点积,这个计算量随序列长度线性增长。生成整个序列的总注意力计算量是 O(L²)。序列从 4K 涨到 32K,注意力计算量涨了 64 倍。
第二,显存压力导致调度策略变化。当 KV Cache 接近显存上限时,推理引擎会触发各种降级策略:把缓存换出到 CPU 内存(PCIe 带宽成为瓶颈,延迟飙升)、减少并发 batch(吞吐下降)、或者截断上下文(模型丢失信息)。这些降级不是线性的,往往在某个阈值之后突然恶化。
第三,长上下文对内存带宽的要求更高。每生成一个 token,都要把整个 KV Cache 从显存里读一遍(用于注意力计算)。序列越长,读的数据量越大。在显存带宽固定的情况下,读取时间线性增长。这就是为什么长上下文场景下,首字延迟(TTFT)和每 token 输出延迟(TPOT)都会明显上升。
所以标题里说的“长上下文真正的成本在哪”,答案不是单一的。它既是显存容量的成本,也是显存带宽的成本,还是调度复杂度的成本。只盯着其中一项做优化,往往解决不了根本问题。
3. 长上下文场景下 KV Cache 的三种典型瓶颈
3.1 显存容量瓶颈:缓存把权重挤出了显存
最直观的瓶颈就是显存不够用。推理引擎在加载模型权重之后,剩下的显存要分给 KV Cache、激活值、临时缓冲区等。在长上下文场景下,KV Cache 往往是最大的一块。当缓存需求超过剩余显存时,引擎只有几条路可走。
第一条路是预分配加截断。引擎在启动时就按最大序列长度预分配 KV Cache 空间,如果实际请求超过了这个长度,就直接截断或者拒绝。这种做法简单粗暴,但用户体验很差——聊到一半突然“失忆”,或者直接报错。
第二条路是动态分配加换出。引擎按需分配缓存块,当显存不够时,把部分缓存换到 CPU 内存。换出和换入都要走 PCIe,带宽有限,延迟很高。实测中,一旦触发频繁换出,每 token 延迟可能从几十毫秒涨到几百毫秒甚至更高。
第三条路是分页管理。这是目前主流引擎(如 vLLM 的 PagedAttention)采用的做法。把 KV Cache 切成固定大小的块(block),按需分配,逻辑上连续但物理上可以不连续。这样能减少显存碎片,提高利用率,但并不能突破显存总量的上限。
在实际项目中,我见过不少团队一开始按 4K 上下文做容量规划,后来业务要求支持 32K,结果发现同样的并发数下显存直接爆掉。这时候要么加卡,要么降并发,要么上量化。没有银弹。
3.2 显存带宽瓶颈:读缓存比算注意力还慢
显存带宽这个瓶颈容易被忽略,但在长上下文场景下往往比容量更致命。每生成一个 token,注意力层需要把当前层的整个 KV Cache 读出来,和当前 Query 做计算。这个读取量是2 × num_kv_heads × head_dim × seq_len × dtype_size每层。序列越长,读取量越大。
以之前那个 7B 模型为例,单层单 token 的 KV Cache 是 4KB,32 层就是 128KB。序列长度 32K 时,每生成一个 token 要读128KB × 32K = 4GB的数据。如果显存带宽是 2TB/s,光读缓存就要 2 毫秒。这还没算计算时间。当序列继续增长,读取时间会线性上升,最终成为主导延迟的因素。
这也是为什么有些优化方案会强调“减少 KV 头数”或者“压缩 KV 维度”。GQA 和 MQA 之所以能加速,不只是因为缓存变小了,还因为每次要读的数据变少了,带宽压力降低了。MLA(Multi-head Latent Attention)这类结构也是类似思路,把 KV 压缩到一个低维潜空间,读取量大幅下降。
3.3 调度与碎片瓶颈:缓存块管理不当导致利用率低下
即使显存容量和带宽都够,调度不当也会让性能大打折扣。传统做法是按最大序列长度给每个请求预分配一块连续的 KV Cache。但实际请求的长度是变化的,有的聊两句就结束,有的聊几十轮。预分配会导致大量显存被浪费在“预留但没用上”的空间里,同时连续分配容易产生碎片,最后明明总显存够,却找不到一块足够大的连续空间。
PagedAttention 的思路借鉴了操作系统的虚拟内存分页:把 KV Cache 切成固定大小的 block,每个请求维护一个 block 表,逻辑上连续,物理上可以分散。这样显存利用率能大幅提升,碎片问题也缓解了。实测中,同样的显存,采用分页管理后并发数能提升 2 到 4 倍。
但分页也不是没有代价。block 表的管理、block 的分配和回收、跨 block 的注意力计算,都会带来额外的开销。block 太小,管理开销大;block 太大,内部碎片多。通常需要根据实际序列长度分布来调这个参数。我一般建议先用默认值跑一轮,看显存利用率和延迟曲线,再决定要不要调。
4. 实测有效的 KV Cache 优化手段与选型逻辑
4.1 量化:把 FP16 缓存压到 INT8 甚至 INT4
量化是降低 KV Cache 显存占用最直接的手段。把缓存的 dtype 从 FP16 降到 INT8,显存直接减半;降到 INT4,再减半。但量化不是免费的,它会引入精度损失,可能影响生成质量。
实际项目中,KV Cache 量化需要关注几个点。第一,Key 和 Value 的量化难度不同。Key 通常比 Value 更敏感,因为 Key 参与注意力分数计算,量化误差会被 softmax 放大。所以有些方案只量化 Value,或者对 Key 用更细的量化粒度。第二,量化需要校准数据。用一批有代表性的输入跑一遍,统计 K、V 的分布,确定缩放因子。校准数据要和实际业务分布接近,否则量化误差会偏大。第三,量化后的反量化开销要考虑。INT8 计算需要先反量化到 FP16 再算,这个转换有开销,但在带宽瓶颈场景下,省下的读取时间通常能覆盖转换开销。
我实测过的一个场景:7B 模型,32K 上下文,FP16 缓存下并发只能开到 4,延迟已经接近不可用。换成 INT8 缓存后,并发开到 8,首字延迟从 3.2 秒降到 1.8 秒,生成质量在人工评估中基本没有可感知的下降。INT4 则更激进,并发能到 12,但部分需要精确回忆上下文的场景会出现细节丢失,需要根据业务容忍度来选。
4.2 注意力结构优化:GQA、MQA 与 MLA 的取舍
从模型结构层面减少 KV Cache,是最根本的优化。MHA(多头注意力)每个头都有独立的 K、V,缓存最大。MQA(多查询注意力)所有头共享一组 K、V,缓存降到 1/num_heads。GQA(分组查询注意力)折中,把头分成若干组,每组共享一组 K、V,缓存降到 num_groups/num_heads。
选型逻辑上,MQA 缓存最小、速度最快,但表达能力和生成质量可能受影响,适合对延迟极度敏感、对质量容忍度较高的场景。GQA 是目前主流大模型的默认选择,在质量和效率之间取得了较好的平衡。MLA 则更进一步,把 K、V 压缩到一个低维潜向量,推理时再投影回多头空间,缓存能压到比 MQA 还小,但实现复杂度高,对训练和推理的配套要求也高。
如果你是在做模型选型,而不是改模型结构,那重点看模型本身用的是哪种注意力。现在开源的 7B、13B 级别模型大多已经用上了 GQA,缓存压力比早期的 MHA 模型小很多。如果非要用 MHA 模型做长上下文,那量化几乎是必选项。
4.3 分页与调度:PagedAttention 的落地细节
PagedAttention 的核心是把 KV Cache 按 block 管理。每个 block 存固定数量 token 的 K、V,比如 16 个 token 一个 block。请求进来时,按需分配 block,写满一个再分配下一个。注意力计算时,按 block 表把逻辑上连续的 token 对应的物理 block 找出来,分别计算再拼接。
落地时有几个参数需要调。block_size 决定管理粒度和碎片程度,太小则 block 表大、管理开销高,太大则内部碎片多。一般 16 或 32 是比较常用的值。gpu_memory_utilization 决定引擎能用多少显存做缓存,设太高容易 OOM,设太低浪费显存,通常 0.85 到 0.92 之间比较稳妥。swap_space 决定换出到 CPU 内存的空间大小,如果业务序列长度波动大,可以适当调大,但要注意 PCIe 带宽限制。
还有一个容易被忽略的点:prefix caching。如果多个请求有相同的前缀(比如相同的系统提示词),可以把这部分 KV Cache 共享,避免重复计算和存储。在多轮对话场景下,历史轮次的前缀是天然共享的,开启 prefix caching 能显著降低首字延迟和显存占用。实测中,系统提示词较长时,prefix caching 能把首字延迟降低 30% 到 50%。
4.4 卸载与重计算:用时间换空间的边界
当显存实在不够时,可以考虑把部分 KV Cache 卸载到 CPU 内存,或者干脆不缓存、需要时重计算。这两种都是用时间换空间的策略,但适用场景不同。
卸载到 CPU 内存适合序列很长但访问有局部性的场景。比如最近生成的 token 访问频繁,久远的历史 token 访问较少,可以把久远的换出去。但注意力计算需要访问全部历史,所以换出后每次生成都要把需要的部分换回来,PCIe 带宽是硬约束。实测中,一旦换出比例超过 20%,延迟就会明显恶化。
重计算适合序列长度中等、计算资源相对充裕的场景。不存缓存,每次生成时把历史 K、V 重新算一遍。计算量是 O(L²),但省下了全部缓存显存。如果序列长度在 2K 以内,重计算的开销可能还能接受;超过 4K,计算时间就会成为瓶颈。所以重计算通常只作为兜底方案,或者用在预填充阶段。
5. 踩坑记录:那些文档里不会写的细节
5.1 预分配显存导致的“假 OOM”
早期我在一个项目里遇到过一个诡异现象:引擎启动时报 OOM,但看显存监控明明还有好几个 GB 空闲。排查后发现是预分配策略的问题。引擎在启动时按最大序列长度和最大并发数预分配 KV Cache,这个预分配量是按最坏情况算的,实际业务根本用不到那么多。结果就是“预留”把显存占满了,实际请求进来反而没空间。
解决办法是改用动态分配,或者把预分配的上限调低,配合换出策略兜底。但调低上限有风险,如果实际请求超过上限,要么截断要么换出,都会影响体验。所以更稳妥的做法是先统计实际业务的序列长度分布,按 P99 来设上限,而不是按理论最大值。
5.2 block_size 调参的连锁反应
PagedAttention 的 block_size 看起来只是个数字,但它会影响很多东西。我试过把 block_size 从 16 调到 32,显存利用率确实上去了,但延迟也上去了。原因是 block 变大后,注意力计算时每个 block 内的 token 多了,计算粒度变粗,小 batch 场景下并行度下降。反过来,block_size 调到 8,管理开销明显增加,CPU 占用上升,吞吐反而下降。
最后我的经验是:block_size 要和实际的平均序列长度、并发数一起看。序列长、并发高,block 可以大一点;序列短、并发低,block 小一点更灵活。没有万能值,得压测。
5.3 量化校准数据的坑
KV Cache 量化时,校准数据的选取非常关键。我见过有团队直接用随机文本做校准,结果量化后的模型在业务数据上表现很差。原因是随机文本的 K、V 分布和真实业务分布差异很大,缩放因子算出来不匹配。
正确的做法是用一批真实的、有代表性的业务输入做校准,覆盖不同的长度、不同的主题、不同的语言。校准样本量不用太大,几百到几千条通常就够,但分布要广。另外,校准要在目标精度下做,FP16 校准和 FP32 校准得到的缩放因子可能不同。
5.4 多轮对话中缓存复用的边界
多轮对话是 KV Cache 复用最自然的场景,但复用的边界需要小心处理。如果两轮对话之间用户修改了系统提示词,或者插入了新的工具调用结果,那前缀就变了,之前的缓存不能直接复用。有些引擎的 prefix caching 是基于 token 序列精确匹配的,前缀一变就失效。
另外,多轮对话中如果做了上下文截断(比如只保留最近 N 轮),那截断点之前的缓存要释放,截断点之后的缓存要保留。这个释放和保留的逻辑如果处理不当,会导致缓存泄漏或者缓存错位。我建议在应用层维护一个明确的对话状态,把系统提示词、历史轮次、当前输入分开管理,这样缓存复用的边界才清晰。
6. 从成本视角重新理解长上下文
把上面这些串起来看,长上下文的成本可以拆成四个维度:显存容量成本、显存带宽成本、计算成本、调度成本。容量成本决定你能开多长的上下文、多大的并发;带宽成本决定每 token 的延迟下限;计算成本决定预填充和生成的时间;调度成本决定显存利用率和碎片程度。这四个维度相互制约,优化其中一个往往会牵动其他几个。
比如量化能降容量成本,但可能增加反量化的计算成本;GQA 能同时降容量和带宽成本,但需要模型结构支持;PagedAttention 能降调度成本,但引入管理开销。所以做优化时不能只看单一指标,要看端到端的延迟和吞吐。
从趋势上看,KV Cache 的优化还在快速演进。MLA 这类结构把压缩做到了极致,分页和调度策略也在不断细化。但无论技术怎么变,核心逻辑不变:缓存是为了省计算,但缓存本身有成本,优化的本质是在计算、显存、带宽、调度之间找平衡。理解了这个平衡,再看各种优化方案,就不会觉得它们是零散的技巧,而是一套有内在逻辑的系统。
我个人在实际项目中的体会是,先把显存账本算清楚,知道瓶颈在哪,再选优化手段,比盲目上各种“加速技巧”有效得多。很多时候,把 block_size 调对、把 prefix caching 开起来、把量化校准做扎实,就能解决大部分问题,不需要动模型结构。