1. 自回归生成

LLM 本质上就是一个 next-token predictor:给定一段 prompt,预测下一个 token。所有长篇回答,都是 token by token 生成的。初学者需要注意,token 与词/字符之间并不完全等价,模型的处理单位是 token,即模型的输入输出都是 token,而不是一个词或一个字符。(注:若仅关注原理部分,不关注 tokenization 的内部实现机制,粗糙地将 token ≈ 词,关系也不大)

模型在预测下一个 token 时,不是直接吐出一个确定的词,而是输出全词表的概率分布。比如,用户输入"今天天气真",模型经过一系列计算得到全词表的概率分布,再经过采样得到一个 token:

1
2
3
4
5
6
7
8
输入:"今天天气真"
"好"   → 0.62
"不错" → 0.15
"热"   → 0.08
...(其余 token 瓜分剩余概率)

==》
next token: "好"

模型一次只产出一个 token,把第 i 步的输出作为第 i+1 步的输入,用伪代码表示,大致如下:

1
2
3
4
5
while not finished:
    logits = model.forward(tokens)        # ① 前向: 算出词表上 N 个分数
    probs  = softmax(logits)              # ② 变概率
    next_token = sample(probs)            # ③ 采样选一个
    tokens.append(next_token)             # ④ 拼上去

示例:

1
2
3
4
5
第1步: 输入 "今天天气真"          → "好"
第2步: 输入 "今天天气真好"         → ","
第3步: 输入 "今天天气真好,"        → "适合"
第4步: 输入 "今天天气真好,适合"     → "出门"
第5步: 输入 "今天天气真好,适合出门"  → "[结束]"

这种“把第 i 步的输出作为第 i+1 步的输入”的生成模式就是自回归生成(auto-regressive generation)。用形式化的方式来描述,即模型预测的是 P(token_i+1 | [token_0, token_1, …, token_i]),条件是全部的前文,没有前文就没有下一步,这是一个严格串行的过程。

由于“auto-regressive generation”机制本身的限制,每一步都需要对整段序列从头算一遍,而绝大部分前缀和上一步完全相同,因此就涉及大量的重复计算,KV Cache 就是为了解决这个问题而出现的。

2. 注意力机制

2.1. Self-Attention

理解注意力(Attention)机制是理解 KV Cache 的前提。本节从”一个 token 凭什么能用上前文信息”出发,推导出什么是 Attention,什么是 Q, K, V。

我们已经知道了模型的处理单位是 token,但 token 并不能拿来计算,因此把每个 token 变成一个向量(向量的维度记为 hidden_size 或 d_model,这在后续计算显存时会用到)。每个向量表征了对应 token 的含义,对于语义相近的 token,它们在向量空间上的“距离”就更近。

词表里的每个 token 都有对应的初始向量,但光有初始向量还不够,因为同一个 token 在不同的语境下含义是不一样的。来看一个经典的例子:

1
2
句子A: "我啃了一口苹果"  → 这里"苹果"指水果
句子B: "我把小米手机换成了最新的苹果手机"  → 这里"苹果"指公司/品牌

“苹果”这个 token 的初始向量是同一个(在输入的 embedding table 里就一个),但它真正的含义取决于上下文——A 句被「啃」「一口」决定了是水果,B 句被「小米」「手机」决定了是品牌。

所以模型必须做一件事:让每个 token 的向量根据它的上下文修正一遍(注:严谨的说,由于因果性关系,这里应该只有“上文”)。“苹果”要去看看前文有没有「啃」或「手机」,然后把自己的向量往「水果」或「品牌」的方向调整。这个“让每个 token 去前文里收集相关信息、更新自己”的机制,就是注意力(Attention)

词表中每个 token 对应的初始向量是怎么来的?

这里需要注意:每个 token 对应的初始向量不是由 text-embedding-3/bge 等外部 embedding 模型向量化而来的,而是 LLM 内部的一个“零件”。这是两套东西,不要和 RAG 那套搞混了!

LLM 内部 token embedding RAG 用的 embedding model
是什么 LLM 的第一层,内置查找表 独立的完整模型
粒度 每个 token 一向量 每段句子/文档一向量
用途 喂给后续层,预测下一个 token 用于计算语义相似度,做向量检索
谁训练的 和 LLM 联合训练,是 LLM 的一部分 单独训练,专为相似度优化
能否单独拿到 一般拿不到(藏在模型内部) 调 API 的产物

LLM 的第一层就是一张巨大的嵌入表(embedding table),其本质是一个矩阵,形状是 [词表大小 × hidden_size]。比如词表 5 万、hidden_size 4096,这张表就是 50000 × 4096 的矩阵,即为词表里每一个 token 预存了一行向量。查 token 对应的初始向量,就是按 token 的编号去表里取出对应的那一行。没有复杂计算,就是一次查表。

1
2
3
  "苹果" 的 token id = 8231
  → 去嵌入表里取第 8231 行 → [0.12, -0.43, ..., 0.07]  (4096个数)
  → 这就是"苹果"的初始向量 x

注:几个遗留点,待深入~

  1. 嵌入表中每个 token 的向量是如何训练出来的?
  2. 查表的 x 只编码「是什么」,不含「在第几位」。然而,位置信息非常关键,猫追狗 ≠ 狗追猫。经典做法是加位置向量(主流方案是 Rotary Position Embedding(RoPE)),最终进注意力的向量既含「是什么」也含「在哪」。
  3. 只有第1层 x 来自查表:从第2层起,每层的输入 x = 上一层输出(已融合上下文)。所以 Q=x·W_Q 每层都用,但每层 x 不同、每层 W 也不同(各层独立一套)。// 注:不太理解。

回到本文讨论的主线。“苹果”要去前文收集信息,它得回答两个独立的问题:

  • 问题1:前文哪些 token 跟我相关?“苹果”想找「啃」「手机」这种能定义它的词,而不是「我」「了」这种虚词。这需要一个匹配过程,即我拿着我的“需求”去和前文每个 token 的「特征」比一比,看谁对得上。
  • 问题2:相关的 token 具体把什么信息给我?找到「手机」之后,「手机」要贡献出「这是个科技品牌」的实际信息,加到「苹果」身上。

需要注意的是,“一个 token 用来被匹配的特征”和“它实际要贡献的信息”是两回事,不是同一个向量。这就是为什么需要把一个 token 的向量投影成不同用途的三份,也就是 Q, K, V。

  • Query:我的“需求/问题”。当前 token 拿出一个向量,表示我在找什么样的上下文,就像在搜索框里输入的查询词。→ 记作 Q。每个要更新自己的 token,都会生成一个 Q。
  • Key:每个 token 的“标签/索引”。前文每个 token 都拿出一个向量,表示我是什么、我能匹配什么样的查询,就像图书馆每本书的索引标签。→ 记作 K。每个 token 都会生成一个 K,供别人来匹配。
  • Value:每个 token “真正要贡献的内容”。前文每个 token 还拿出另一个向量,表示如果有人关注到我,我实际提供这些信息,就像书的正文内容。→ 记作 V。每个 token 都会生成一个 V,在被关注到时贡献出去。

这就是 Q, K, V 的基本含义。了解了它们的基本含义之后,再来看它们是如何计算的。

每个 token 的向量(设为 x)分别乘以三个不同的权重矩阵得到它的 Q, K, V:

1
2
3
Q = x · W_Q
K = x · W_K
V = x · W_V

其中,W_Q、W_K、W_V 是模型训练学出来的。一旦训练完成,参数就固定了,推理阶段不再变化。发布的模型权重,即那些几十GB上百GB的文件,就是包含了上文提到过的嵌入表,以及各层的 W_Q、W_K、W_V 参数。

我们以“苹果”为例,计算它的注意力。现在让“苹果”去前文所有 token 里收集信息:

第 1 步:计算相关度(Q 和每个 K 做dot product)。把Q(“苹果”)和前文每个 token 的 K(含当前 token 自己的 K) 逐一做点积,点积结果是一个数,越大表示越匹配、越相关。

1
2
3
4
5
  Q("苹果") · K("我")   → 0.1   (不相关)
  Q("苹果") · K("最新") → 0.3
  Q("苹果") · K("小米") → 1.2
  Q("苹果") · K("手机") → 4.5   (高度相关!)
  Q("苹果") · K("苹果") → 1.5

TODO 需添加图示

第 2 步:归一化成权重。把上面这些分数通过 Softmax 变成一组加起来等于 1 的权重。

1
2
3
4
5
  "我"   → 0.01
  "最新" → 0.03
  "小米" → 0.16
  "手机" → 0.60    ← 60% 的注意力放在"手机"上
  "苹果" → 0.20

这组权重就是注意力分配——“苹果”决定把 60% 的注意力给“手机”。

第 3 步:按权重混合 V,得到输出。用这组权重去加权平均前文每个 token 的 V,得到“苹果”这个位置的注意力输出:

1
"苹果"的新向量 = 0.01×"我"的V + 0.03×"最新"的V + 0.16×"小米"的V + 0.60×"手机"的V + 0.20×"苹果"的V

结果:“苹果”的向量被大量注入了“手机”的 V,它的含义就成功地从“孤立的苹果”更新成了“上下文中的苹果”,即科技品牌信息。

这个过程,即对应这个著名的注意力公式:

$$ \text{Attention}(Q, K, V) = \text{Softmax}(Q \cdot {K^T} / \sqrt{d_k}) \cdot V $$

其中, $1/\sqrt{d_k}$ 被称为缩放因子(scaling factor)。

2.2. Multi-Head Attention

Attention is all you need 论文中对多头注意力的介绍:

Instead of performing a single attention function with d_model-dimensional keys, values and queries, we found it beneficial to linearly project the queries, keys and values h times with different, learned linear projections to d_k, d_k and d_v dimensions, respectively. On each of these projected versions of queries, keys and values we then perform the attention function in parallel, yielding d_v-dimensional output values. These are concatenated and once again projected, resulting in the final values.

Multi-head attention allows the model to jointly attend to information from different representation subspaces at different positions. With a single attention head, averaging inhibits this.

多头注意力是一种改进的注意力机制。它不再是对全维度的 Q、K、V 进行单一的注意力计算,而是将它们分别投影到多个较小的维度空间,并行地执行注意力。

具体而言,多头注意力的过程如下:

  1. 线性投影:将 d_model 维度的 Q、K、V 分别通过 h 组不同的线性投影映射到 d_k, d_kd_v 维度。
  2. 并行注意力计算:在每一组投影后的向量上,并行地计算注意力函数,产生 d_v 维度的输出。
  3. 拼接与最终映射:将所有头产生的输出值拼接在一起,并再次通过一个线性映射,得到最终的结果。

概括起来就是:拆 → 并行算 → 拼。

多头注意力的计算公式如下:

$$ \text{MultiHead}(Q, K, V) = \text{Concat}(head_1,..., head_h) W^O $$

其中, $head_i = \text{Attention}(QW^{Q}_i, KW^{K}_i, VW^{V}_i)$ ,即上一节介绍的注意力公式,只不过每个头都有自己独立的投影矩阵 $W^{Q}_i, W^{K}_i, W^{V}_i$。并且,将 h 个头拼接在一起后,还需要再通过输出矩阵 $W^O$ 进行投影,对多头注意力中的信息进行融合。

假设模型维度为 $d_{model}$,单头注意力直接将输入投影到 $d_{model}$ 空间:

$$ Q = XW_Q, ~ K = XW_K, ~ V = XW_V $$

其中,$W_Q, W_K, W_V$ 是 [d_model × d_model] 的矩阵。假设输入 prompt 有 1000 个 token,输入 $X$ 则是 [1000 × d_model] 的矩阵。

对于多头注意力而言(以 h 个头为例),每个头有自己独立的投影矩阵 $W^{Q}_i, W^{K}_i, W^{V}_i$。其中,$W^{Q}_i, W^{K}_i$ 是 [d_model × d_k] 的矩阵,$W^{V}_i$是 [d_model × d_v] 的矩阵,通常 d_k = d_v = d_model / h。 $W^O$ 是最终的输出投影矩阵,它的形状是 [d_model × d_model]。

相比于单头注意力,多头注意力允许模型捕捉在不同位置的不同子空间的信息。例如,有的头专注于处理长距离依赖(如识别动词短语的搭配),有的头专注于指代消解,还有的头能反映出句子的语法和语义结构

3. 为什么K和V可以缓存复用

在注意力机制中,Q 是“发问的那一方”,K, V 是“被查询的那一方”。假设当前已经生成了序列[今 天 天 气 真] 这 5 个 token,现在要生成第 6 个 token。此时,发问者只有一个,即最新的 token “真”,只需要算它一个 Q 即可,因此 Q 没什么值得可缓存的。而被查询的一方是全部历史 token,它们的 K, V 在每一步都要被反复访问,因此 K, V 才是值得缓存的对象。

前面分析的是“为什么 K, V 值得被缓存,而 Q 没必要缓存”,这是缓存的动机。那么,为什么 K, V 可以被缓存?这是另一个问题。在 token 生成时,位置 i 的 token 只能看到其自身以及前序 token(即位置 1~i),对于任意位置 i 的 token,它的 K 和 V 仅与其自身和前序 token 有关。因此,一旦某位置 token 的 K 和 V 计算完,就不再变化,可以被反复利用。这解释了为什么 K 和 V 可以被缓存。

4. 因果掩码

1、什么是因果掩码(Causal Mask)?

Causal Mask 是一种上三角遮罩矩阵,用于在自注意力计算中阻止模型"偷看"未来的 token,确保模型在预测第 i 个位置时,只能依赖第 0 到 i 个位置的信息。

2、它的作用是什么?

场景一:训练时,一次喂一整句,必须 mask。

这是 Causal Mask 存在的根本原因。要理解它,先得搞清楚训练和生成的一个巨大差异。训练不是一个 token 一个 token 生成的。我们知道训练的目标是“预测下一个 token”,但训练时如果也像推理那样一个一个生成,会慢到无法接受,海量数据根本训不完。所以训练用了一个聪明的并行技巧。拿训练句子 “今天天气真好” 举例,模型其实想同时学会 6 个填空:

1
2
3
4
5
6
  看到 "今"            → 应预测 "天"
  看到 "今天"          → 应预测 "天"
  看到 "今天天"        → 应预测 "气"
  看到 "今天天气"      → 应预测 "真"
  看到 "今天天气真"     → 应预测 "好"
  看到 "今天天气真好"   → 应预测 (下一个)

每一行是一个独立的「预测下一个 token」任务。训练时,模型把整句“今天天气真好”一次性全部输入,在一次前向计算里同时完成这 6 个预测。

然而,此时却存在一个致命的问题。整句一次性输入,意味着在做注意力时,每个位置都能物理地看到句子里所有其他位置。以第 3 个预测任务为例:看到"今天天" → 应预测"气"。这一行里,位置 3 的"天"在算注意力时,如果不加限制,它能看到位置 4 的"气"、位置 5 的"真"、位置 6 的"好"——未来的答案就在输入里。模型会直接偷看到答案,它学到的不是根据前文推断下一个字,而是把右边那个字抄过来——这种模型一旦拿去真实生成(真实推理显然“看不到未来”)就立刻废掉。这种情况叫标签泄漏(Label Leakage),Causal Mask就是用来规避此类问题

为了让「一次性并行训练」和「真实一个个生成」行为一致,必须人为地禁止每个位置看向它右边。也就是对当前位置右侧的 token 进行 mask,使其概率为 0。Causal Mask 让并行训练在数学上等价于串行生成——这就是它存在的根本意义。

场景二:推理的 prefill 阶段,也要 mask。prefill 时,模型把整个 prompt 一次性并行输入,算出每个位置的 K/V 填进 cache。注意“一次性并行输入整段”和训练时喂整句是同一种情形——prompt 内部也必须遵守因果性——位置 i 的 K/V 输出理应只由它和它左边的 token 决定。如果 prefill 时位置 i 偷看了右边,它算出的 K/V 就是污染的,和「该 token 单独从左往右出现时应有的 K/V」不一致,后续 decode 用这份脏 cache 就会出错。所以 prefill 阶段同样要施加 Causal Mask。

叠加了Causal Mask 的因果注意力:

$$ \text{Attention}(Q, K, V) = \text{Softmax}(Q \cdot {K^T} / \sqrt{d_k} + \text{Mask}) \cdot V $$

其中,Mask 矩阵定义为:

$$ \text{mask}_{i,j} = \begin{cases} 0, & \text{if i ≥ j (当前及前序位置)} \\ -∞, & \text{if i < j (未来位置)} \end{cases} $$

加上 −∞ 后,经过 softmax 运算,这些位置的权重会变为 0,相当于完全屏蔽。

5. 推理的两个阶段

5.1. LM head

LM Head(Language Model Head) 是模型的最后一层,作用是把最后一个 Transformer 层输出的语义向量线性投影成词表上每个 token 的分数。本质就是一个矩阵乘法。同义的别名还包括 output projection、unembedding、output embedding。

1、LM Head 在推理流程中的位置

1
2
3
4
5
6
7
8
token id ──嵌入表(输入embedding)──► x
    ├─ 第1层 Transformer
    ├─ 第2层
    ├─ ...(N层, 逐层精炼语义)
    ├─ 第N层输出
    ├─ final LayerNorm
    └─ 【LM Head】 ── logits ──► Softmax ──► 采样 ──► next-token

注意两点:

  • LM Head 在所有 Transformer 层之后,是模型的“最后一公里”。LM Head 不属于注意力机制,它没有 Q/K/V,也不产生 KV Cache。它只是一个纯粹的线性变换。
  • 严格说,LM Head 之前通常还有一个最终归一化(final LayerNorm/RMSNorm),把末层向量整理一下再投影。这是个细节,知道有这一步即可。

2、LM Head 机制

设最后一层的输出向量是 h(长度 hidden_size,比如 4096),词表大小是 V(比如 128000)。LM Head 就是一个形状为 [hidden_size × V] 的矩阵 W_lm。输出的结果是一个长度 V 的向量,每一维对应词表里一个 token 的得分:

1
2
3
4
5
6
logits =          h · W_lm
(V维)   (hidden_size)·(hidden_size × V)

示例
[1 × 128000] = [1 × 4096] · [4096 × 128000]
logits = [ token_0: 2.1,  token_1: -3.4, ...,  token_127999: 0.5 ]

需要强调的是,LM Head 的输出的结果叫 logits,它包含以下性质:

  • 是任意实数,可正可负、无上下界。
  • 不是概率,加起来不等于 1。
  • 只是相对得分,分越高表示模型越倾向这个 token。

那么,如何理解 W_lm 这个矩阵在做的事情呢?W_lm 的每一列,可以理解是每个 token 的特征向量,h · W_lm 就是在算 h 和词表里每个 token 的特征向量做点积,哪个 token 的特征向量与 h 方向最接近,它的 logit 就最高。换句话说,LM Head 在做一次“用最终语义向量 h 去和全词表所有 token 比对相似度”的检索,最相似的 token 就是模型最想输出的。

3、LM Head 与输入嵌入层的镜像关系

输入嵌入层是形状为 [V × hidden_size] 的矩阵,通过 token id 查表获得相应的向量(token -> 向量);而 LM Head 是形状为 [hidden_size × V] 的矩阵,通过矩阵计算(再结合 softmax,采样)把向量变回成 token(向量 -> token)。正因为这种镜像关系,一些模型让两者共享同一套参数(LM Head 直接用嵌入表的转置),这就叫 weight tying(权重绑定)。不展开,简单了解一下即可。

4、LM Head 的体量

LM Head 的参数量 = hidden_size × V。代入真实数字来看:

1
2
hidden_size = 4096, 词表 V = 128000
LM Head 参数 = 4096 × 128000 ≈ 5.2 亿

单是 LM Head 就有 5 亿参数,对小模型来说,这可能占总参数的相当一大块。且词表越大,这个矩阵越重。这也是 weight tying 在小模型上特别划算的原因。此外,计算上也不小,每产出一个 token,都要做一次 [1×4096]·[4096×128000],即约 5 亿次乘加运算,只为得到这一个位置的 logits。理解这一点对 prefill 和 decode 中究竟该优化哪里可能更有感知。

5.2. Prefill

先约定一个统一的例子:

  • 用户 prompt:“今天天气真”(5 个 token)
  • 模型:简化成 3 层(真实是几十层,流程一样)
  • 任务:生成回答,假设生成 “好” → “。” → EOS

prefill 阶段是把整段 prompt 一次性灌进去,完成两个使命:1. 把 prompt 的 5 个 token 全部消化进 KV Cache;2. 生成第一个 next-token。我们逐步拆开来看。

第 1 步:整段查表,得到 5 个初始向量。5 个 token 一起做嵌入查表,并注入位置信息,得到 5 个向量,打包成一个形状为 [5 × hidden_size] 的矩阵一起进入模型。

1
[今, 天, 天, 气, 真]  ──查表+位置──►  X = 5个向量的矩阵 [5 × hidden_size]

第 2 步:逐层处理,每层内部 5 个位置并行计算。

进入第 1 层。注意这里和 decode 最大的不同,即 5 个位置同时算:

1
2
3
4
5
6
7
8
第1层:
① 一次性算出 5 个位置各自的 Q、K、V
② 把这一层的 K、V 存进 KV Cache ─► cache 初始化, 一次填5个位置
③ 做注意力: 5 个位置的 Q 各自和「自己及左边」的 K 做匹配
   ==》这里用到 Causal Mask: 位置3只能看位置1~3, 不能看4、5
④ 输出 5 个位置的新向量 [5×hidden_size], 喂给第2层

第2层、第3层重复同样的事(各自算自己的 Q/K/V、各自把 K/V 存进 cache、各自做注意力)。每一层都往 cache 里塞了 5 个位置的 K/V。

走完 3 层后,每层都存好了 5 个 token 的 K、V,即完成了 KV Cache 的初始化

第 3 步:只取最后一个位置,产出第一个 next-token。3 层走完得到 5 个输出向量,但生成下一个 token 只需要最后一个位置向量。取末位"真"的输出向量 h,经过 h → LM head → logits → Softmax → 采样 流程,最终输出下一个 token “好”。至此,第一个 next-token 诞生。前面 4 个位置 [今, 天, 天, 气] 的输出向量,在纯推理时不走 LM head(算了也没用),它们的价值体现在「为 cache 贡献了 K/V」和「让位置 5 能看到它们」。

注:这里提到的“输出向量 h”是否就是“该 token 的 V”?不是的!一个 Transformer 层 = 注意力 + 后续处理,Q/K/V 仅存在每一层的注意力子模块内,而 h 则表示最终的输出向量。一个 Transformer 层内部,数据流是这样的(假设输入向量记为 x ):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
本层输入 x
  ├─【注意力子模块】
  │    Q = x·W_Q,  K = x·W_K,  V = x·W_V     ← V 在这里诞生
  │    注意力输出 = softmax(Q·Kᵀ/√d)·V         ← V 在这里被"用掉"(加权混合)
  │         ↓
  │    记作 attn_out
  ├─ 残差相加:  x + attn_out                  ← 注意力输出加回输入
  │         ↓
  ├─【前馈网络 FFN 子模块】                     ← 又一轮非线性变换(和注意力无关)
  │    再做一次残差相加
  │         ↓
  └─ 本层输出(即,下一层的输入 x)

看清楚两个关键点:

  1. V 只活在注意力子模块内部。它被 softmax(Q·Kᵀ) 加权混合后变成 attn_out,然后 V 就退场了。
  2. attn_out 还不是 h,它后面还要过残差 + 前馈网络 FFN才得到这一层的输出。而这只是一层。

跨多层来看,模型有很多层(假设 3 层)。每一层都有自己独立的一套 Q/K/V,也都有自己的输出。数据流是这样的:

1
2
3
4
5
6
7
8
9
  输入 x⁰
  第1层:  算 V¹ → 注意力 → 残差 → FFN → 输出 x¹   (V¹ 早被用掉了)
  第2层:  算 V²(由 x¹ 重新算)→ ... → 输出 x²      (V² 也被用掉了)
  第3层:  算 V³(由 x² 重新算)→ ... → 输出 x³
    └─ x³ 在最后位置的那个向量 = h   ← 这才是 h

Prefill 一句话总结。Prefill 把整段 prompt 打包成矩阵,逐层并行计算,每层把全部 token 的 K/V 写进 cache,最后只用末位向量产出第一个 token。一次前向处理 N 个位置。

5.3. Decode

Decode 阶段每次只吐出一个token,循环往复。Prefill 交出了第一个 token “好” 和一个装着 5 个 token K/V 的 cache。Decode 接手,开始 auto-regressive generation。

我们接着看生成第 2 个 token的完整步骤。

第 1 步:把上一步产出的新 token 查表,拿到对应的原始向量(形状 [1 × hidden_size])。只有 “好” 这一个新 token 进入模型,这是和 prefill 的根本区别—— prefill 一次进 N 个 token,decode 一次只进 1 个。

1
  "好"  ──查表+位置(第6个位置)──►  x = 1个向量 [1 × hidden_size]

第 2 步:逐层处理,但每层只算一个位置的 Q/K/V

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
第1层:
① 只算"好"这1个位置的 Q、V、K
         q: [1×d]   k: [1×d]   v: [1×d]   ← 只有1个
② 把"好"的 k、v 追加到本层 cache 末尾
         本层 cache:  5个 → 变成 6个 K/V
③ 做注意力:用"好"的 1 个 q,去和 cache 里全部 6 个 K 匹配、混合 6 个 V
         (历史的5个K/V直接从cache取,不重算)
④ 输出"好"这1个位置的新向量 [1×hidden],喂给第2层

第 2、3 层同理:各自只算 "好" 的 q/k/v、各自把 k/v 追加进本层 cache、各自用这个 q 去查本层的全部历史 K/V。

Prefill 每层处理 [5×d],Decode 每层处理 [1×d];prefill 是批量写入 cache,decode 是追加一行 + 读取全部。

第 3 步:产出下一个 token。“好"的输出向量 h → LM head → logits → Softmax → 采样 → “。”

第 4 步:循环,直到采样抽中 EOS 停止。

一句话总结。Decode 阶段每步只送一个新 token,逐层只算它一个位置的 Q/K/V,把它的 K/V 追加进 cache,用它的 Q 去查 cache 里全部历史 K/V 做注意力,产出下一个token,然后循环。 每次前向,只处理 1 个位置,但要读越来越长的 cache。

5.4. 从资源角度看 Prefill 和 Decode

假设用户输入的 prompt 有 1000 个 token,Prefill 阶段可以同时处理 1000 个 token,并行计算。因此 Prefill 属于 compute-bound 任务;而 Decode 阶段一次只能生成 1 个 token,因此属于 memory-bound 任务。

从具体的指标来看,Prefill 阶段主要决定了 TTFT(Time To First Token),即用户发送请求到收到第一个 token 的时延;Decode 阶段则主要决定了 TPOT(Time Per Output Token),每输出 token 间隔,即模型的吐字速度。此外,还有一个指标 ITL(Inter-Token Latency),也需要了解一下。

指标 全称 对应阶段 衡量范围 包含内容 用户感知
TTFT Time To First Token Prefill 从提交 Prompt 到收到第一个输出 token 分词 + 嵌入 + 全部 Transformer 层前向传播 “为什么等了这么久才出第一个字?”
TPOT Time Per Output Token Decode 模型纯推理生成一个 token 所需时间 仅单次 decode 阶段的前向传播 (纯技术指标)
ITL Inter Token Latency Decode 从上一个 token 输出 到下一个 token 输出 TPOT + 排队延迟 + 网络传输 + 批处理等待 “每个字之间的等待久不久?”

6. KV Cache 的代价——显存

KV Cache 存什么?

  • 每一层有独立的 K 和 V,模型有很多层
  • 每一个注意力头有独立的 K 和 V,一层中含有多个注意力头
  • 序列里的每个 token 都要存 K 和 V
1
KV Cache容量 = [K和V] × [模型层数] × [头数] × [头维度] × [序列长度] × [每元素大小] 

其中,头数 × 头维度 = hidden_size(d_model);序列长度则是 prompt + 所有已生成的 token,随着生成不断增大——这也是爆显存的关键变量。

我们以 Llama-2 7B 为例,计算一下KV Cache占用的显存大小。

Llama-2 7B 模型规格:32层,hidden_size = 4096(32头 × 128维),精度为FP16(即每个元素大小为2字节),模型权重约 14 GB。

单 token 占用 = 2 × 32 × 4096 × 1 × 2 = 524,288 字节 ≈ 512 KB = 0.5 MB

4K上下文占用 = 0.5 MB × 4096 = 2048 MB = 2 GB

显存的总占用量 = 每 token 占用 × 序列长度 × 并发数。模型一旦固定,每 token 占用就固定,显存的总占用量分别与序列长度和并发数成正比。A100 的显存是 80GB,减去模型权重的 14GB,剩下的容量差不多能够支持并发执行32个 4K上下文的请求。

我们总是想着支持更长的上下文、更大的并发,但这会导致 KV Cache 显存占用倍数增长。越大的 KV Cache 不仅占地方,还会拖慢速度(cache 越大 decode 越慢)。为此,优化 KV Cache 是推理加速中的一项重要工作。相关的工作包括 MQA/GQA/MLA、量化、PagedAttention、Cache驱逐策略等等,这部分内容不在此展开。