1. 自回归生成
LLM 本质上就是一个 next-token predictor:给定一段 prompt,预测下一个 token。所有长篇回答,都是 token by token 生成的。初学者需要注意,token 与词/字符之间并不完全等价,模型的处理单位是 token,即模型的输入输出都是 token,而不是一个词或一个字符。(注:若仅关注原理部分,不关注 tokenization 的内部实现机制,粗糙地将 token ≈ 词,关系也不大)
模型在预测下一个 token 时,不是直接吐出一个确定的词,而是输出全词表的概率分布。比如,用户输入"今天天气真",模型经过一系列计算得到全词表的概率分布,再经过采样得到一个 token:
|
|
模型一次只产出一个 token,把第 i 步的输出作为第 i+1 步的输入,用伪代码表示,大致如下:
|
|
示例:
|
|
这种“把第 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 在不同的语境下含义是不一样的。来看一个经典的例子:
|
|
“苹果”这个 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注:几个遗留点,待深入~
- 嵌入表中每个 token 的向量是如何训练出来的?
- 查表的 x 只编码「是什么」,不含「在第几位」。然而,位置信息非常关键,猫追狗 ≠ 狗追猫。经典做法是加位置向量(主流方案是 Rotary Position Embedding(RoPE)),最终进注意力的向量既含「是什么」也含「在哪」。
- 只有第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:
|
|
其中,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) 逐一做点积,点积结果是一个数,越大表示越匹配、越相关。
|
|
TODO 需添加图示
第 2 步:归一化成权重。把上面这些分数通过 Softmax 变成一组加起来等于 1 的权重。
|
|
这组权重就是注意力分配——“苹果”决定把 60% 的注意力给“手机”。
第 3 步:按权重混合 V,得到输出。用这组权重去加权平均前文每个 token 的 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 进行单一的注意力计算,而是将它们分别投影到多个较小的维度空间,并行地执行注意力。
具体而言,多头注意力的过程如下:
- 线性投影:将 d_model 维度的 Q、K、V 分别通过 h 组不同的线性投影映射到 d_k, d_k 和 d_v 维度。
- 并行注意力计算:在每一组投影后的向量上,并行地计算注意力函数,产生 d_v 维度的输出。
- 拼接与最终映射:将所有头产生的输出值拼接在一起,并再次通过一个线性映射,得到最终的结果。
概括起来就是:拆 → 并行算 → 拼。
多头注意力的计算公式如下:
$$ \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 个填空:
|
|
每一行是一个独立的「预测下一个 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 在推理流程中的位置
|
|
注意两点:
- 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 的得分:
|
|
需要强调的是,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。代入真实数字来看:
|
|
单是 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] 的矩阵一起进入模型。
|
|
第 2 步:逐层处理,每层内部 5 个位置并行计算。
进入第 1 层。注意这里和 decode 最大的不同,即 5 个位置同时算:
|
|
走完 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 ):
|
|
看清楚两个关键点:
- V 只活在注意力子模块内部。它被 softmax(Q·Kᵀ) 加权混合后变成 attn_out,然后 V 就退场了。
- attn_out 还不是 h,它后面还要过残差 + 前馈网络 FFN才得到这一层的输出。而这只是一层。
跨多层来看,模型有很多层(假设 3 层)。每一层都有自己独立的一套 Q/K/V,也都有自己的输出。数据流是这样的:
|
|
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 个。
|
|
第 2 步:逐层处理,但每层只算一个位置的 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
|
|
其中,头数 × 头维度 = 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驱逐策略等等,这部分内容不在此展开。