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或者多查询注意力MQAnum_heads 要换成 KV 头数num_kv_heads这时候缓存会显著变小。举个具体的例子。假设一个 70 亿参数的模型32 层hidden_size 为 4096采用 GQAKV 头数为 8头维度 128FP16 精度。那么单 token 单层的 KV Cache 是2 × 8 × 128 × 2 4096 字节 4KB。32 层加起来就是 128KB 每 token。如果序列长度是 8192batch_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 是 4KB32 层就是 128KB。序列长度 32K 时每生成一个 token 要读128KB × 32K 4GB的数据。如果显存带宽是 2TB/s光读缓存就要 2 毫秒。这还没算计算时间。当序列继续增长读取时间会线性上升最终成为主导延迟的因素。这也是为什么有些优化方案会强调“减少 KV 头数”或者“压缩 KV 维度”。GQA 和 MQA 之所以能加速不只是因为缓存变小了还因为每次要读的数据变少了带宽压力降低了。MLAMulti-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 开起来、把量化校准做扎实就能解决大部分问题不需要动模型结构。