KV Cache 与 Prefill —— 大模型推理加速的核心

从 Decoder-only 说起

在第一篇里,我们详细拆解了原始 Transformer 的 Encoder-Decoder 架构。

但是:今天几乎所有的主流大模型(GPT、LLaMA、Qwen、DeepSeek……)用的都是 Decoder-only 架构。

为什么? 因为LLM 核心场景是对话生成,所以 Decoder-only 成了主流。

先快速清扫一个潜在的认知盲区:很多人以为Encoder和Decoder要成对使用。但如果我们把 Decoder拆开看,会发现 Decoder 本身就包含了 Encoder 的所有核心组件。所以Decoder本身就能作为完整的、可独立运行的模型。

快速复习一下decoder的组件:

  • 带掩码的自注意力: 让每个token只看到自己和左边的token

  • 前馈网络 FFN : 对每个向量独立做非线性变化,增加表达能力

  • 残差连接Add + 层归一化 LayerNorm : 保持梯度流动,稳定训练

  • 交叉注意力(可选): 查询 Encoder的输出

把交叉注意力拿掉,一个Decoder层就是:

输入 → 带掩码的自注意力 → 残差+LayerNorm → FFN → 残差+LayerNorm → 输出

这个结果,能处理序列(自注意力就是处理序列的),能生成新token(因果掩码保证每个位置只看左边,最后一个位置的输出天然适合做下一个token的预测)

一个具体的例子:

假设输入“今天天气真好,我们”,Decoder-only 的处理流程是:

  1. 输入嵌入:把每个 token 转成向量
  2. 位置编码:给每个位置加上顺序信息
  3. 进入 Decoder 层
    • 带掩码的自注意力:每个 token 只和它左边(包括自己)的 token 计算注意力。结果是:每个位置的输出只包含“左边上下文”的信息
    • 残差连接 + LayerNorm:把输入和注意力输出相加并归一化
    • FFN:对每个位置独立做变换,增强非线性
    • 残差连接 + LayerNorm:再次相加并归一化
  4. 重复多层:信息逐层抽象,越来越“高级”
  5. 取最后一个位置的输出:送到输出层(Linear + Softmax),得到词表上的概率分布
  6. 采样:选一个 token 作为预测结果

很多人误以为 Decoder-only 需要某种“循环神经网络式”的隐状态来记住历史,其实不需要,

因果自注意力本身就在做这件事:每一步,最后一个 token 通过注意力机制看到了所有历史 token,历史信息被动态地加权聚合到当前输出中。

那 prompt 的信息怎么传进去的?

Prompt 也是作为"已生成的 token"一起参与自注意力的。 所以 Decoder-only 的自注意力,就是在每一步让当前 token 去看所有已生成的 token(包括 prompt 和之前生成的输出)。KV Cache 就是这些已生成 token 的 K、V 的缓存。

小结一下,三个角度说明为啥是Decoder-only:

角度一:信息流向的错配

Encoder 的设计假设:你有完整的输入,可以看全貌再下判断。 注意力机制让每个 token 都能看到所有 token。

Decoder 的设计假设:生成时,未来的东西还没出现。 单向注意力强制每个 token 只能看到过去和当前。

而GenAI上是一个单向往前的任务:你只能根据已生成的内容决定下一个是什么。

Encoder 的双向注意力在这个场景下是作弊——它能看到还没生成出来的“未来”。

如果你强行用 Encoder 做生成,就需要手工构造 mask 来遮住未来 token,那它本质上还是变成Decoder。那为什么还要保留另一半?

角度二:计算冗余

假设你用完整的 Encoder-Decoder 做对话:

  • 用户输入:“天气真好”(4 个 token)
  • 模型输出:“我们出去玩吧”(7 个 token)

Encoder:处理 4 个 token,双向注意力,输出 4 个上下文向量。

Decoder:逐字生成 7 个 token。每一步都要对 Encoder 的 4 个输出做 Cross-Attention。

问题来了:Encoder 的输出在整个生成过程中是固定的,但 Decoder 在每步都重新“看”一遍它们。

Decoder-only 直接砍掉了 Encoder,让模型自己管理历史和当前输入。

角度三:多任务统一性

GPT 系列最早证明了一件事: 一个 Decoder-only 模型,通过不同的 prompt 格式,可以同时做:**

  • 续写(左边给几个词,往右写)
  • 对话(把历史对话拼在左边,右边继续)
  • 翻译( 请翻译成英文:你好 → Hello
  • 分类 (这句话的情绪是: → 模型补出“正面” )

Encoder 做不到这一点(它需要完整的输入输出对才能训练),
Encoder-Decoder 做多任务切换时,需要显式区分 encoder 和 decoder 的角色。

而 Decoder-only:所有任务都变成“根据左边的 tokens 预测右边的下一个 token”——高度统一,训练和推理逻辑一致。

自回归:Decoder-only 的生成模式

前面我们花了大量篇幅描述同一个过程:输入已有的 token,预测下一个,然后把预测结果追加到末尾,继续预测下一个。

这个过程有一个正式的名字:自回归(Autoregressive)

"自"指自己,"回归"在统计学里指"用过去预测未来"。

用例子来理解,假设模型要生成 今天 → 天气 → 真好 → , → 我们 → 去 → 公园

自回归生成的过程是:

第 1 步: 输入 [今天]                         → 预测下一个: 天气
第 2 步: 输入 [今天, 天气]                   → 预测下一个: 真好
第 3 步: 输入 [今天, 天气, 真好]             → 预测下一个: ,
第 4 步: 输入 [今天, 天气, 真好, ,]         → 预测下一个: 我们
第 5 步: 输入 [今天, 天气, 真好, ,, 我们]   → 预测下一个: 去
第 6 步: 输入 [今天, 天气, 真好, ,, 我们, 去] → 预测下一个: 公园
第 7 步: 输入 [今天, 天气, 真好, ,, 我们, 去, 公园] → 预测下一个: 。 

自回归的数学模型:

P(w₁, w₂, ..., wₙ) = P(w₁) × P(w₂|w₁) × P(w₃|w₁,w₂) × ... × P(wₙ|w₁,...,wₙ₋₁)

翻译成人话就是, --> 第一个词出现的概率 --> 已知第一个词的前提下,第二个词出现的概率 --> 已知前两个词的前提下,第三个词出现的概率 --> ……依次类推

朴素做法:每一步都重算全部

先想一想,Transformer 的每一层在做自注意力时,是怎么算的。

假设已经生成了 3 个 token [t1, t2, t3],当前层里:

  1. 每个 token 都生成自己的 Q、K、V
  2. 每个 token 的 Q 去和 所有 token 的 K 做点积,算注意力分数
  3. 用分数加权 所有 token 的 V,更新自己

用矩阵表示,这一步的输入是一个 [3, d] 的矩阵(3 个 token,每个 d 维)。

问题是: 到第 4 步,输入变成 [4, d],模型又从头算了一遍前面 3 个 token 的 K 和 V。

具体浪费了多少?

我们一步步展开看看:

步骤 输入 token 数 需要算的 K、V
第1步 1 K1、V1
第2步 2 K1、V1、K2、V2
第3步 3 K1、V1K2、V2、K3、V3
第4步 4 K1、V1K2、V2K3、V3、K4、V4

注意看加粗的部分——每一步都在重复计算之前已经算过的 K 和 V!

如果你生成 1000 个 token,第 999 步的时候,前面 998 个 token 的 K 和 V 已经被重复算了 998 次。

这就是朴素自回归推理的计算复杂度是 O(n²·d) 的原因——n 是序列长度,d 是向量维度。

KV Cache

KV Cache 的想法非常简单 :既然前面算过的 K 和 V 不会变,为什么不存下来直接复用?

你回想一下第一篇的内容:

  • Q(Query):当前 token 发出的查询——每一步都不一样
  • K(Key):token 的"标签",用来被匹配——算过一次就不会变了
  • V(Value):token 能贡献的内容——算过一次也不会变了

Q 是"我在找什么"——每生成一个新 token,它的 Q 都是新的,必须重新算。

但 K 和 V 是"我的标签"和"我的内容"——一个 token 一旦生成,它的 K 和 V 就固定了,不会因为后面来了新 token 而改变。

所以:把之前所有 token 的 K、V 缓存起来,每次只算新 token 的 K、V,新 token 的 Q 去和缓存里的所有 K 做匹配。

这就是 KV Cache —— 用空间(显存)换时间(计算)。


用了 KV Cache 之后的推理流程

有了 KV Cache,自回归生成变成了两个截然不同的阶段。

Prefill(预填充)阶段

这是第一次处理整个输入 prompt 的阶段。

当你输入一段 prompt 给模型(比如 "请用英文翻译这句话"),模型需要先"读懂"你的输入。

在 Prefill 阶段:

  1. 一次性处理整个 prompt 的所有 token
  2. 计算出每个 token 的 K 和 V
  3. 所有层的 K 和 V 存到缓存里
  4. 输出第一个生成 token
Prompt: "请用英文翻译这句话"
   ↓
一次性并行计算所有 token 的 K、V
   ↓
缓存 K1..K6, V1..V6  ← 所有 6 个 prompt token
   ↓
生成第一个 token: "Please"

Prefill 的关键特性:

  • 高并行度——所有 prompt token 一起算,充分利用 GPU
  • 计算密集——矩阵越大,GPU 利用率越高
  • 只做一次——prompt 不会变,不需要重复 prefill

你每次用 ChatGPT 时,那段"漫长的等待"其实就是 prefill 阶段。prompt 越长,prefill 越慢,但它只发生一次。

Decode(解码)阶段

Prefill 结束后,缓存里已经有了用户输入所有 token 的 K 和 V。现在模型要开始逐字生成输出了。

Decode 阶段每步只生成一个 token:

  1. 当前新 token → 算它的 Q、K、V
  2. Q × 所有缓存的 K → 算注意力分数
  3. 分数 × 所有缓存的 V → 更新当前 token
  4. 把新 token 的 K、V 追加到缓存
  5. 输出下一个 token,重复
当前缓存: [K1, K2, K3, K4, K5, K6]
          [V1, V2, V3, V4, V5, V6]

新 token 进入:
  算 Q6, K6, V6

注意力计算(只用了 Q6 和缓存里的所有 K/V):
  Q6 × [K1, K2, K3, K4, K5, K6] → 6 个分数
  softmax → 6 个权重
  权重 × [V1, V2, V3, V4, V5, V6] → 加权求和

输出: 第 6 个 token 的上下文表示

缓存更新:
  [K1..K6] → 追加 K6 → [K1..K6]
  [V1..V6] → 追加 V6 → [V1..V6]
  
注意: K6 和 V6 既是“刚算出来的”,也是“下一轮要用的历史”。

Decode 的关键特性:

  • 低并行度——每步只算一个 token,GPU 利用率低
  • 内存密集——瓶颈在于读取缓存中的 K、V,而不是计算
  • 每步只算一点点——但要走很多步

这就是为什么你感觉 AI 回复的时候"第一个字等很久,后面字出得快"——第一个字要等 prefill 做完,后面的字是 decode 阶段,每一步只算一个 token。

两个阶段的对比

特性 Prefill Decode(每一步)
输入 整个 prompt(多个 token) 1 个新 token
计算量 大(~prompt长度 × d²) 小(~1 × d²)
并行度 高(GPU 利用率高) 低(GPU 利用率低)
是否缓存 KV 是(首次缓存) 是(追加到缓存)
瓶颈 计算能力(Compute-bound) 内存带宽(Memory-bound)
用户感知 "等了很久才出第一个字" "一个字一个字地快速出现"

⚠️ 下面会有很多数字计算,但都是初小数学,主要是算经济账,仔细看看就看懂了

KV Cache 到底省了多少?

假设:

  • 模型有 L = 32 层
  • 每层有 H = 32 个注意力头
  • 每个头的维度 d_k = 128
  • 当前已生成 n = 1000 个 token

无 KV Cache 做法

在第 1000 步时,需要:

  1. 输入 [1000, d] 的矩阵
  2. 所有 1000 个 token 重新算 Q、K、V
  3. Q(1000×d) × K^T(d×1000) → 复杂度与 成正比

总计算量: 每层 O(n²·d + n·d²),其中 n=1000

有 KV Cache

在第 1000 步时,只需要:

  1. 输入仅仅 1 个新 token
  2. 只对这个新 token 算 Q、K、V
  3. 新 token 的 Q(1×d)去和缓存的 K(n×d)做点积

总计算量: 每层 O(n·d + d²),其中 n=1000

直观来说,生成长文本时,KV Cache 可以让推理速度提升几十倍到上百倍。

KV Cache 的代价:显存

KV Cache 虽然提升了速度,但需要额外的显存存储缓存数据。

占多少?

每层 KV Cache 大小 = 2(K 和 V)× n(token 数)× H(头数)× d_k(每个头的维度)

总 KV Cache = L(层数)× 每层KV Cahce大小(上面的公式) 

用数字感受一下:

以 LLaMA-70B 为例:

  • L = 80 层
  • H = 64 个头
  • d_k = 128(每个头 128 维)
  • 每个参数用 FP16(2 字节)

对于 n = 4096 个 token:

每层 = 2 × 4096 × 64 × 128 × 2 字节 = 128 MB
总  = 80 × 128 MB = 10 GB

对于 n = 32768(32K 上下文):

每层 = 2 × 32768 × 64 × 128 × 2 字节 = 1 GB
总  = 80 × 1 GB = 80 GB

80GB 显存——一块 H100 才 80GB!光 KV Cache 就占满了,模型权重还没放进去。

这就是为什么 长上下文推理极其昂贵——KV Cache 随着序列长度线性增长,而且每一层、每个头都要存一份。

实际应用中需要根据硬件条件在以下方面进行权衡:

  1. 最大缓存长度(影响能处理的序列长度)
  2. 批量大(影响并发处理能力)
  3. 精度选择(FP16 比 FP32 节省一半内存)
  4. 滑动窗口机制

优化 KV Cache

KV Cache 太大了,怎么办?

既然缓存的增长来自三个维度:层数 L、头数 H、序列长度 S,优化也围绕这三个方向展开。

MQA:最早的简化方案

MQA(Multi-Query Attention) 的想法很激进:所有头共享同一组 K、V。

原始多头注意力 (MHA):
  查询头:  Q1  Q2  Q3  Q4  Q5  Q6  Q7  Q8
  键/值头: K1  K2  K3  K4  K5  K6  K7  K8  ← 每个 Q 头有自己的 K
  缓存:    8 组 K、V

MQA (Multi-Query Attention):
  查询头:  Q1  Q2  Q3  Q4  Q5  Q6  Q7  Q8
  键/值头:         K_all        ← 所有 Q 头共用一组 K、V
  缓存:    1 组 K、V

效果:KV Cache 减少到原来的 1/H(H 是头数)。如果原来 8 个头,MQA 就把 KV Cache 缩小了 8 倍

但 MQA 有个问题:所有头共享 K、V 太粗暴了,模型表达能力可能会下降。

GQA:折中方案

GQA(Grouped Query Attention)在 MHA 和 MQA 之间取了个折中:把 Q 头分成几组,每组内共享 K、V。

GQA (Grouped Query Attention, G=2 组):
  查询头:  Q1  Q2  |  Q3  Q4  |  Q5  Q6  |  Q7  Q8
  键/值头:   K1,V1 |  K2,V2   |  K3,V3   |  K4,V4
  缓存:    4 组 K、V(比 MHA 少一半)

LLaMA-3 全系列都用 GQA!8B 模型用 8 个 K/V 头,70B 模型也才用 8 个 K/V 头。这使得 70B 模型的 KV Cache 甚至比某些 MHA 的 7B 模型还小。

GQA 的效果

  • 7B 模型:MHA 需要 32 组 KV 缓存,GQA 只需要 8 组 → 缓存减少 4 倍
  • 同样的显存,可以支持 4 倍长的上下文
  • 模型质量几乎不损失(实验证明 GQA 在多数任务上和 MHA 持平)

PageAttention(vLLM)

思路:解决显存碎片问题,不减少总量,提高利用率。

标准的KVcache分配是连续内存,每条序列的缓存是一整块连续空间,长度按最大可能序列分配,这会导致大量的碎片和浪费。

PagedAttention 把 KVCache 切成固定大小的“页”(page)

  • 不同序列的缓存可以分散存储
  • 按需分配,不用提前预留最大空间
  • 内存利用率大幅提升

这个优化不改变 KVCache 的理论大小,但显著提升了实际可用性代表模型/框架:vLLM、DeepSpeed-MII、HuggingFace TGI。

Pasted image 20260619094540.png

Prefill 和 Decode 在工程上的差异

写到这里,再深入一点:从硬件视角出发,你会发现它们在计算特性上截然不同。

Prefill:计算瓶颈

Prefill 阶段,一次性处理几百到几千个 token。输入矩阵很大[prompt_len, d],在 GPU 上执行的是大规模矩阵乘法(GEMM)。

这个场景下,GPU 的计算单元被充分利用,计算速度远快于从显存读取数据的速度。

Prefill 的速度受限于 GPU 的计算能力 ,而不是内存带宽。

工程含义:

  • Prefill 可以用 FlashAttention 等优化方案加速
  • Prefill 时间 ≈ O(prompt_len² × d),和 prompt 长度是二次关系
  • 这就是为什么超长 prompt 的第一个字会等很久

Decode:内存瓶颈

Decode 阶段,每步只处理 1 个 token。输入矩阵很小([1, d])。

但注意力计算需要读取缓存中的所有 K、V——这涉及大量显存读取(2 × n × H × d_k 字节)。

GPU 计算单元在等数据从显存传过来,处于"饥饿"状态。

Decode 的速度受限于 GPU 的内存带宽(GB/s),而不是计算能力。

工程含义:

  • Decode 每步的耗时 ≈ 读取 KV Cache 的时间
  • 序列越长,每步越慢(因为要读更多 K、V)
  • 优化方向:减少 KV Cache 大小(GQA/MQA)、更好的内存管理
指标 Prefill Decode
瓶颈类型 计算瓶颈 内存瓶颈
每次输入 token 数 N(prompt 长度) 1
GPU 利用率 高(~80-100%) 低(~5-20%)
耗时与序列长度关系 O(N²) O(N)
主要优化手段 FlashAttention、更快的矩阵乘法 GQA/MQA、KVCache 量化、PagedAttention
(也就是说,我们刚才介绍的GQA、MQA,都是针对Decode阶段的优化)

Flash Attention

标准注意力做计算的时候,会先算出一个很大的中间矩阵 ,这个矩阵存的是“每个 token 和所有其他 token 的相关分数”。

比如,你输入 1000 个 token ,这个矩阵就是 1000 x 1000 , 也就是100万, 如果输入10000个,矩阵就是1亿个数字

问题是: 这个矩阵被算出来后,要在显存里读来读去 —— 存进去,读出来,做softmax,再存进去,再读出来,乘以 V。 这些读写操作比计算本身还要慢。

标准注意力慢,不是因为算得慢,而是因为读写显存太多。

Flash Attention 做了两件事:

1、不把中间矩阵完整地算出来,而是切成小块,算一小块、马上处理掉、再算下一块。全程不存这个大矩阵。

2、在高速缓存将所有计算做完,GPU有两种内存 一种是显存HBM:容量大,但读写慢。 高速缓存SRAM:容量小,但读写快10倍以上。

Flash Attention 把每一小块数据从显存搬到高速缓存里,在高速缓存里完成所有操作(乘法、softmax、乘 V),只把最终结果写回显存。

KVCache 解决的是“重复计算历史 K/V”的问题。Flash Attention 解决的是“计算注意力时中间矩阵太大、读写太慢”的问题。两者在不同环节做优化,可以叠加使用。

Flash Attention 不影响模型输出的结果(数学上等价),只是让计算跑得更快。今天的 GPT、LLaMA、Qwen、DeepSeek 全都在用 Flash Attention。

总结

讲了那么多,回头用个表格总结下:

概念 解释
KV Cache 把已生成 token 的 K、V 缓存起来,每步只算新 token 的注意力,避免重复计算
Prefill 处理输入 prompt 的阶段,一次性并行算完所有 prompt token 的 K、V,完成首次缓存
Decode 逐 token 生成输出,每步只算一个 token,复用 KV Cache
GQA/MQA 让多个 Q 头共享 K、V,减少 KV Cache 大小,支持更长的上下文
Flash Attention 注意力计算中, 不将大矩阵完整算出来,而是分块处理、减少显存读写,让计算跑得更快,且结果和标准注意力完全一样。