文章

从 QKV 到 KV Cache:理解 Transformer 注意力与推理加速

从 Q/K/V 的投影含义、多头注意力的独立 softmax,到 KV Cache 与 KV Pool 的推理加速原理,系统梳理 Transformer 注意力机制。

从 QKV 到 KV Cache:理解 Transformer 注意力与推理加速

围绕 Transformer 里的 Q/K/V,最容易混淆的点通常有三个:

  • Q、K、V 到底是词向量,还是矩阵?
  • Attention 加权求和之后得到的表示,是不是这个 token 自己的 V?
  • 推理阶段的 KV Cache 缓存的到底是什么,为什么不会让输出固定死?

这篇文章把这些问题按一条主线串起来:先从相似性理解 Attention,再看 Self-Attention 为什么需要 Q/K/V,接着理解 Multi-Head Attention 为什么不是简单拆维度,最后理解 KV Cache 和 KV Pool 为什么能加速推理。

阶段要解决的问题关键结论
Attention 基础注意力到底在算什么?先算相似度,再归一化成权重,最后对信息做加权求和
Self-Attention为什么一个序列要“自己看自己”?每个 token 通过看同一句里的其它 token 更新自己的上下文表示
Q/K/V 投影为什么同一个 token 要变成 Q、K、V 三份表示?同一个 \(X\) 在查询、被匹配、贡献内容时需要展示不同侧面
Attention 输出加权求和得到的是不是自己的 V?不是,是所有 V 按注意力权重混合后的上下文表示
参数共享所有 token 共用一套 W 会不会乱?不会。W 是共享规则,输入 \(X\) 不同,输出 Q/K/V 也不同
Multi-Head多头是不是只是一个大矩阵切开?线性投影可等价成大矩阵,关键在 split 后独立 attention
KV Cache推理时到底缓存什么?缓存历史 K/V 中间结果,不缓存 W 矩阵,也不缓存最终答案
采样分叉生成路径变了 cache 还能复用吗?公共前缀可复用,分叉后的 KV 必须各自维护
  1. 先从相似性理解 Attention
    1. 为什么点积可以表示相似性
    2. 从两个向量推广到两个序列
    3. 从原始分数到注意力权重
    4. 权重乘以 X2:得到关注后的新表示
    5. Attention 的公式
    6. Self-Attention:让序列自己看自己
    7. 从 Self-Attention 到 Q/K/V
    8. 缩放点积注意力:一个稳定性补充
  2. Q、K、V 三个投影空间
    1. Query:信息过滤器
    2. Key:信息索引页
    3. Value:信息精选集
  3. Attention 输出不是自己的 V
  4. 原始词向量有没有被更新
  5. Wq、Wk、Wv 是共享的
  6. Multi-Head:大矩阵和多套小矩阵是同一件事
  7. Multi-Head 的核心在独立交互
    1. ① Softmax 隔离:一次大竞争变成多次小竞争
    2. ② 子空间隔离:Head 之间不串线
    3. ③ 避免特征平均化:先专精,再综合
  8. KV Cache 缓存的不是权重矩阵
    1. 为什么不缓存 Q
  9. KV Cache 的形状
  10. KV Pool 和 PagedAttention
  11. 命中 KV Cache 会不会让输出固定
  12. 采样分叉后,KV Cache 还能复用吗
  13. 影响输出是否固定的因素
  14. 总结

先从相似性理解 Attention

在进入 Q/K/V 之前,先不要急着写矩阵乘法。Attention 的核心不是公式,而是一个很朴素的问题:

对当前位置来说,哪些位置和我更相关?我应该从它们那里拿多少信息?

所以 Attention 的第一步不是“加权求和”,而是先回答“谁和谁相似”。而在向量空间里,最常用的一种相似性度量就是点积。

为什么点积可以表示相似性

两个向量的点积可以写成:

\[x \cdot y = \|x\|\|y\|\cos\theta\]

其中 \(\theta\) 是两个向量的夹角。

这条公式的直觉很重要:

情况\(\cos\theta\)点积大小直觉
两个向量方向接近接近 \(1\)点积大两者语义/特征更相似
两个向量接近垂直接近 \(0\)点积接近 0两者关系弱
两个向量方向相反接近 \(-1\)点积为负两者特征相反或不匹配

举个更具象的例子。假设我们在做电影推荐,用户和电影都可以表示成 embedding。

用户 embedding 的每个维度表示一种偏好,电影 embedding 的每个维度表示一种属性:

维度用户向量含义电影向量含义
第 1 维喜不喜欢科幻是不是科幻片
第 2 维喜不喜欢动画画风是否偏动画 / 视觉风格化
第 3 维喜不喜欢轻松喜剧喜剧元素强不强
第 4 维喜不喜欢严肃剧情剧情严肃程度

假设某个用户向量是:

\[u = [0.9,\ 0.8,\ 0.2,\ 0.1]\]

这表示他很喜欢科幻和动画风格,不太偏好喜剧和严肃剧情。

再看两部电影:

\[m_1 = [0.8,\ 0.7,\ 0.1,\ 0.1]\] \[m_2 = [0.1,\ 0.1,\ 0.2,\ 0.9]\]

第一部电影更像“科幻 + 动画风格”,第二部电影更像“严肃剧情”。它们和用户的点积分数分别是:

\[u \cdot m_1 = 0.9 \times 0.8 + 0.8 \times 0.7 + 0.2 \times 0.1 + 0.1 \times 0.1 = 1.31\] \[u \cdot m_2 = 0.9 \times 0.1 + 0.8 \times 0.1 + 0.2 \times 0.2 + 0.1 \times 0.9 = 0.30\]

所以,用户和第一部电影的点积更大,表示它们在这些可解释维度上更匹配。点积本质上是在做一件事:把“用户在某个维度有多喜欢”和“电影在这个维度有多强”逐维相乘,再把所有维度的匹配程度加起来。

这就是为什么点积可以用来表示相似性:

情况点积贡献
用户喜欢科幻,电影也是科幻这一维贡献大
用户喜欢动画风格,电影也有动画风格这一维贡献大
用户不喜欢严肃剧情,但电影很严肃这一维贡献小
多个偏好维度都匹配总点积更大

这就是点积注意力的第一层含义:

用点积给 token 之间的相关性打一个原始分数。

从两个向量推广到两个序列

现在把一个向量推广到一组 token。

假设有两个序列:

  • \(X_1 \in \mathbb{R}^{n \times d}\):有 \(n\) 个 token,每个 token 是 \(d\) 维。
  • \(X_2 \in \mathbb{R}^{m \times d}\):有 \(m\) 个 token,每个 token 也是 \(d\) 维。

如果让 \(X_1\) 里的每个 token 都和 \(X_2\) 里的每个 token 计算相似度,就会得到一个分数矩阵:

\[S = X_1X_2^T\]

其中:

\[S \in \mathbb{R}^{n \times m}\]

矩阵 \(S\) 的每个格子都是一个点积:

\[s_{ij} = x_i \cdot y_j\]

它表示:\(X_1\) 里第 \(i\) 个 token 和 \(X_2\) 里第 \(j\) 个 token 的原始相关性分数。

把矩阵乘法画成格子会更直观。假设:

  • \(X_1\) 有 3 个 token:\(x_1, x_2, x_3\)。
  • \(X_2\) 有 4 个 token:\(y_1, y_2, y_3, y_4\)。

那么 \(X_1X_2^T\) 会得到一个 \(3 \times 4\) 的分数矩阵:

\(S = X_1X_2^T\)\(y_1\)\(y_2\)\(y_3\)\(y_4\)
\(x_1\)\(s_{11}\)\(s_{12}\)\(s_{13}\)\(s_{14}\)
\(x_2\)\(s_{21}\)\(s_{22}\)\(s_{23}=x_2 \cdot y_3\)\(s_{24}\)
\(x_3\)\(s_{31}\)\(s_{32}\)\(s_{33}\)\(s_{34}\)

高亮的格子 \(s_{23}\) 就表示:\(X_1\) 的第 2 个 token 对 \(X_2\) 的第 3 个 token 的原始注意力分数。

flowchart LR
  X2["x₂<br/>X1 的第 2 个 token"] --> Dot["点积<br/>x₂ · y₃"]
  Y3["y₃<br/>X2 的第 3 个 token"] --> Dot
  Dot --> S23["s₂₃<br/>第 2 行第 3 列"]

  style Dot fill:#fff3bf
  style S23 fill:#ffe3e3

到这里为止,我们只有原始分数。分数可以很大、很小、为负,而且不同 token 的分数范围也不一定好比较。它还不能直接表示“拿多少信息”。

从原始分数到注意力权重

为了把原始分数变成“比例”,需要对每一行做归一化。Transformer 里通常用 softmax:

\[A = \mathrm{softmax}(S)\]

其中每一行都满足:

\[\sum_j A_{ij} = 1\]

这一步非常关键。做完 softmax 后,\(A_{ij}\) 就不再只是“相似性分数”,而是“注意力权重”。

\(A = \mathrm{softmax}(S)\)\(y_1\)\(y_2\)\(y_3\)\(y_4\)行和
\(x_1\)\(a_{11}\)\(a_{12}\)\(a_{13}\)\(a_{14}\)\(1\)
\(x_2\)\(a_{21}\)\(a_{22}\)\(a_{23}\)\(a_{24}\)\(1\)
\(x_3\)\(a_{31}\)\(a_{32}\)\(a_{33}\)\(a_{34}\)\(1\)

其中 \(a_{23}\) 是 \(s_{23}\) 归一化后的结果。它表示:

在更新 \(x_2\) 这个位置时,应该从 \(y_3\) 那里拿多少比例的信息。

这一步就是“注意力”的核心。注意力不是说“只看某一个 token”,而是说“按比例看所有 token”。相关性越高的 token,权重越大;相关性越低的 token,权重越小。

权重乘以 X2:得到关注后的新表示

有了权重矩阵 \(A\) 之后,就可以对 \(X_2\) 做加权求和:

\[O = AX_2\]

展开到某个位置 \(i\),就是:

\[o_i = \sum_j A_{ij} y_j\]

继续看第 2 行,输出 \(o_2\) 是这样来的:

\[o_2 = a_{21}y_1 + a_{22}y_2 + \color{#d32f2f}{a_{23}y_3} + a_{24}y_4\]

这就是为什么 \(O\) 可以理解成“\(X_1\) 关注了 \(X_2\) 之后的新表示”:

  • \(x_2\) 本来只是 \(X_1\) 里的第 2 个 token 表示。
  • 它先和 \(X_2\) 里的所有 token 算相似度。
  • 相似度经过 softmax 变成权重。
  • 再按这些权重从 \(X_2\) 里混合信息,得到新的 \(o_2\)。

如果 \(a_{23}\) 很大,说明 \(x_2\) 在更新自己时主要吸收了 \(y_3\) 的信息;如果 \(a_{21}\)、\(a_{22}\)、\(a_{24}\) 较小,它们贡献就更少。

这就是 Attention 的基本结构:

阶段数学形式直觉
相似性打分\(S = X_1X_2^T\)谁和谁相关
归一化\(A = \mathrm{softmax}(S)\)每个信息源占多少比例
加权求和\(O = AX_2\)按比例从 \(X_2\) 汇总信息
flowchart LR
  Sim["相似性<br/>点积打分"] --> Norm["归一化<br/>softmax 权重"]
  Norm --> Sum["加权求和<br/>混合 X2 信息"]
  Sum --> New["新的 X1 表示"]

  style Sim fill:#fff3bf
  style Norm fill:#e3f2fd
  style Sum fill:#e8f5e9

Attention 的公式

有了前面的铺垫,Attention 的公式就很自然了。它其实就是把三件事写到一起:

步骤数学表达含义
打分\(S = \mathrm{score}(X_1, X_2)\)算 \(X_1\) 和 \(X_2\) 的相关性
归一化\(A = \mathrm{softmax}(S)\)把原始分数变成权重
汇总\(O = AX_2\)用权重从 \(X_2\) 里加权取信息

所以,最抽象的 Attention 可以写成:

\[\mathrm{Attention}(X_1, X_2) = \mathrm{softmax}(\mathrm{score}(X_1, X_2))X_2\]

如果 score 用点积,那么就是点积注意力:

\[\mathrm{Attention}(X_1, X_2) = \mathrm{softmax}(X_1X_2^T)X_2\]

这和上面的用户-电影例子是同一件事:

  • 用户向量和电影向量先算相似性分数。
  • 分数归一化成“我应该多关注哪部电影”的比例。
  • 再按比例把电影信息混合成新的用户表示。

放回 token 场景里也是一样:

  • \(X_1\) 是发起关注的一方。
  • \(X_2\) 是被关注的信息源。
  • \(\mathrm{softmax}(X_1X_2^T)\) 是 \(X_1\) 对 \(X_2\) 的注意力分布。
  • 最终输出 \(O\) 是“吸收了 \(X_2\) 信息之后的新 \(X_1\)”。

到这里,Attention 公式已经完整了。接下来 Transformer 只是把这个公式里的角色拆得更细:谁负责发起查询,谁负责被匹配,谁负责提供内容。这就引出了 Q、K、V。

Self-Attention:让序列自己看自己

刚才的公式是一般 Attention:

\[\mathrm{Attention}(X_1, X_2) = \mathrm{softmax}(X_1X_2^T)X_2\]

它表示:\(X_1\) 这组 token 去关注 \(X_2\) 这组 token。

Self-Attention 是它的一个特殊情况:\(X_1\) 和 \(X_2\) 是同一个序列。

\[\mathrm{SelfAttention}(X) = \mathrm{Attention}(X, X) = \mathrm{softmax}(XX^T)X\]

也就是说,Self-Attention 做的是:

同一个句子里,每个 token 都去看这个句子里的其它 token,再把相关信息汇总回来更新自己。

比如句子:

1
那只可爱的猫,它在沙发上睡觉

当模型处理“它”这个 token 时,它应该能从前面的“猫”那里拿到指代信息;处理“睡觉”时,它可能要关注“猫”这个动作主体;处理“可爱的”时,它又更像是在修饰“猫”。

这就是 Self-Attention 的价值:它让每个位置的表示不再只等于这个 token 自己,而是变成“吸收了整句上下文之后的新表示”。

如果把 Self-Attention 的分数矩阵画出来,它就是一个 token 对 token 的方阵:

\(XX^T\)token 1token 2token 3token 4
token 1自己看自己看 token 2看 token 3看 token 4
token 2看 token 1自己看自己看 token 3看 token 4
token 3看 token 1看 token 2自己看自己看 token 4
token 4看 token 1看 token 2看 token 3自己看自己

在 decoder-only 语言模型里,还会加 causal mask,让当前位置只能看自己和过去的 token,不能偷看未来 token。这个 mask 不改变 Attention 的核心逻辑,只是限制“哪些位置允许被看见”。

flowchart TD
  X["同一个序列 X"] --> Score["XX^T<br/>每个 token 对每个 token 打分"]
  Score --> Mask["可选:causal mask<br/>屏蔽未来位置"]
  Mask --> Softmax["softmax<br/>得到注意力权重"]
  Softmax --> Sum["权重 · X<br/>汇总上下文信息"]
  Sum --> O["新的 X 表示"]

  style Score fill:#fff3bf
  style Mask fill:#ffe3e3
  style Softmax fill:#e3f2fd
  style Sum fill:#e8f5e9

从 Self-Attention 到 Q/K/V

最朴素的 Self-Attention 可以写成:

\[\mathrm{SelfAttention}(X) = \mathrm{softmax}(XX^T)X\]

这个公式好理解,但它把同一个 \(X\) 同时塞进了三个角色:

位置在公式里的作用角色
第一个 \(X\)发起匹配“我想找什么”
第二个 \(X^T\)被拿来匹配“我能被怎样匹配”
最后的 \(X\)被加权求和“我真正贡献什么内容”

这就有一个问题:同一个原始表示 \(X\) 被迫同时承担三种工作。

但在语言里,这三种工作并不一样。以“它”和“猫”为例:

  • “它”作为查询者,需要表达“我要找一个前面的指代对象”。
  • “猫”作为被匹配者,需要表达“我是一个可被指代的动物名词”。
  • “猫”真正传给“它”的内容,可能是“猫”这个实体及其属性。

所以 Transformer 不再直接用同一个 \(X\) 做三件事,而是先把 \(X\) 投影成三份:

\[Q = XW^Q\] \[K = XW^K\] \[V = XW^V\]

于是 Self-Attention 从:

\[\mathrm{softmax}(XX^T)X\]

变成:

\[\mathrm{softmax}(QK^T)V\]

这一步非常关键:

组件作用
\(QK^T\)决定“谁和谁相关、看多少”
\(V\)决定“相关之后真正拿什么内容”

也就是说,Q/K/V 不是额外加出来的复杂概念,而是 Self-Attention 为了把“查询、匹配、取内容”这三件事拆开而引入的角色分工。

缩放点积注意力:一个稳定性补充

上面的逻辑已经是 Attention 的主体。拆出 Q/K/V 之后,Transformer 还会在点积分数上除以 \(\sqrt{d_k}\),避免分数过大。

于是 Transformer 里的缩放点积注意力可以写成:

\[\mathrm{Attention}(Q,K,V) = \mathrm{softmax} \left( \frac{QK^T}{\sqrt{d_k}} \right)V\]

为什么要除以 \(\sqrt{d_k}\)?

因为向量维度 \(d_k\) 越大,点积结果的数值范围通常越大。如果直接把很大的分数丢给 softmax,softmax 会变得非常尖锐:最大值接近 1,其它位置接近 0。这样梯度会更不稳定,模型也更容易过早只盯住少数位置。

缩放项的作用就是把分数拉回更合适的范围:

\[S = \frac{QK^T}{\sqrt{d_k}}\]

然后仍然是同样的两步:

\[A = \mathrm{softmax}(S)\] \[O = AV\]

所以,缩放点积注意力不是另一套完全不同的东西。它只是在“点积打分 -> softmax 归一化 -> 加权求和”这条主线上,多加了一个控制分数尺度的步骤。

到这里,公式链条已经完整:Attention 先解决“按相关性取信息”,Self-Attention 让序列自己更新自己,Q/K/V 则把查询、匹配、取内容拆成三个角色。下面再展开这三个角色各自到底在做什么。

Q、K、V 三个投影空间

前面已经从 Self-Attention 推出了 Q/K/V。现在换一个更直观的角度,看看这三个向量各自承担什么语义角色。它们可以继续用信息检索来理解:

角色直觉问题在注意力里的职责
Q(Query)我想找什么信息?当前 token 发出的查询
K(Key)我能被怎样匹配?每个 token 暴露出来的索引标签
V(Value)我被选中后贡献什么?真正流向输出的内容

更准确地说,Q、K、V 不是手工指定的三份信息,也不是凭空出现的向量,而是输入隐藏状态 \(X\) 经过三组可学习权重矩阵投影得到的结果:

\[Q = XW^Q\] \[K = XW^K\] \[V = XW^V\]

其中 \(X\) 可以理解为当前层输入的 token 表示,\(W^Q\)、\(W^K\)、\(W^V\) 是模型训练出来的参数。

这三组投影矩阵的意义在于:原始表示 \(X\) 是通用表示,而注意力机制需要同一个 token 在不同环节扮演不同角色。前面已经看到,朴素 Self-Attention 把同一个 \(X\) 同时用作查询、匹配和内容;Q/K/V 则把这三个角色拆开。

如果不做这三次投影,直接拿原始向量 \(X\) 互相点积,模型会丢掉一个很重要的能力:用不同视角理解同一个 token。

比如句子里有“吃”和“苹果”。在语言关系里,“吃”更像是在主动寻找一个可食用的宾语;“苹果”更像是在被动展示自己是一个可食用实体。这个关系不是对称的:

\[\mathrm{Score}(x_{\text{吃}}, x_{\text{苹果}})\]

\[\mathrm{Score}(x_{\text{苹果}}, x_{\text{吃}})\]

在语义上不应该被理解成同一种动作。前者更像“动词在找宾语”,后者不一定是“名词在找动词”。

这就是 \(W^Q\) 和 \(W^K\) 的价值:它们把同一个原始表示投影成“主动查询的一面”和“被动匹配的一面”。

矩阵投影出的空间负责提取的特征
\(W^Q\)Query 空间“我现在想找什么”
\(W^K\)Key 空间“我可以被什么问题匹配上”
\(W^V\)Value 空间“如果我被选中,我实际贡献什么信息”

如果用一个类比,原始向量 \(X\) 像一份完整简历。Q 是“我想找什么样的人”,K 是“别人搜索我时能看到的标签”,V 是“真正合作时我能提供的能力”。同一份简历,面对“找人”“被找”“贡献内容”三个场景,需要展示不同侧面。

flowchart LR
  X["输入表示 X"] --> WQ["W^Q:提取“我想找什么”"]
  X --> WK["W^K:提取“我能被怎样匹配”"]
  X --> WV["W^V:提取“我能贡献什么内容”"]
  WQ --> Q["Q"]
  WK --> K["K"]
  WV --> V["V"]

  style X fill:#fff3bf
  style WQ fill:#e3f2fd
  style WK fill:#e8f5e9
  style WV fill:#f3e5f5

Q/K/V 的核心不是“多做了三次矩阵乘法”,而是让同一个 token 在“查询”“被查询”“贡献内容”三个场景里展示不同侧面。

更细一点看,三个空间的分工并不一样。

Query:信息过滤器

Query 空间像过滤器。它从 \(X\) 里挑出当前 token 要发出的“问题”。

比如处理代词“它”时,模型可能需要提取的是“寻找前文指代对象”的特征,而不是字形或发音信息。

Key:信息索引页

Key 空间像索引页。它从 \(X\) 里挑出当前 token 可以被匹配的标签。比如“苹果”可能被投影出“实体名词”“可食用”“可能是指代对象”等特征。

Value:信息精选集

Value 空间像精选集。它不负责决定“谁看谁”,而负责决定“如果我被看见,我把什么内容传过去”。原始表示里可能混着词义、位置、语法、上下文残留等多种信息,\(W^V\) 的作用就是把将要流动的内容再加工一次。

所以可以先记住一句话:

\(W^Q\) 和 \(W^K\) 决定信息流动的方向和强度,\(W^V\) 决定真正流动的内容。

Attention 输出不是自己的 V

一个常见误解是:注意力加权求和之后得到的表示,就是当前 token 的 V 向量。

不是。Attention 输出是全句所有 V 的加权混合,不是当前 token 自己的 \(v_i\)。

标准缩放点积注意力是:

\[\mathrm{Attention}(Q,K,V) = \mathrm{softmax} \left( \frac{QK^T}{\sqrt{d_k}} \right)V\]

对某个位置 \(i\) 来说,它会用自己的 \(q_i\) 去和所有位置的 \(k_j\) 做匹配:

\[\alpha_{ij} = \mathrm{softmax} \left( \frac{q_i k_j^T}{\sqrt{d_k}} \right)\]

然后再用这些权重对所有位置的 \(v_j\) 加权求和:

\[o_i = \sum_j \alpha_{ij} v_j\]

因此,输出 \(o_i\) 不是当前位置自己的 \(v_i\),而是所有 token 的 V 按权重混合之后得到的上下文表示。

符号含义是否已经融合上下文
\(v_i\)当前 token 自己准备贡献的信息
\(o_i\)当前 token 从整个上下文里汇总出的新表示

这就是 Transformer 能处理多义词的关键。比如“苹果”在不同句子里会关注不同上下文,从而得到不同的上下文表示。

更形象地说,当前 token 的 Q 会去问全句所有 K:“谁和我现在要解决的问题有关?”得到权重之后,再从对应的 V 里拿内容。最终输出不是某个单独 token 的内容,而是一份按相关性混合后的上下文报告。

原始词向量有没有被更新

在一层 Transformer 里,Attention 输出通常不会直接替换原始输入,而是通过残差连接叠加回去:

\[H = \mathrm{LayerNorm}(X + \mathrm{Attention}(X))\]

随后还会经过前馈网络:

\[Y = \mathrm{LayerNorm}(H + \mathrm{FFN}(H))\]

所以更准确的说法是:当前位置的表示被逐层演化,而不是原始词向量被简单覆盖。

可以把它分成两类:

类型位置特点
静态 embedding词表 / 底层输入同一个词的基础向量相对固定
动态隐藏状态Transformer 层间流动会随着句子、层数、上下文不断变化

同一个词在词表里的 embedding 是固定的,但它在不同句子、不同层里的隐藏状态会不同。

Wq、Wk、Wv 是共享的

同一层内,所有 token 共用同一套 \(W^Q\)、\(W^K\)、\(W^V\)。如果一句话里有 100 个 token,它们都会经过同样的投影矩阵:

\[q_i = x_i W^Q\] \[k_i = x_i W^K\] \[v_i = x_i W^V\]

这并不会“学乱”,因为矩阵是共享规则,输入才决定输出。

可以把 \(W^Q\) 想象成一台加工机器。机器是同一台,但放进去的原料不同,产物也不同:

\[q_{\text{苹果}} = x_{\text{苹果}} W^Q\] \[q_{\text{猫}} = x_{\text{猫}} W^Q\]

同一套规则作用在不同输入上,会产生不同 Q。Q/K/V 向量是每个 token 独有的,生成这些向量的 W 矩阵才是共享的。

可以把 \(W^Q\) 看成一种通用加工方式:它不是记住“苹果应该怎么提问”,而是学习“什么样的输入特征应该被投影成什么样的查询特征”。

共享参数有两个重要意义:

意义如果不共享会怎样共享后的效果
支持变长序列第 1000 个位置可能需要第 1000 套矩阵任意位置都用同一套规则处理
学到通用语言规律每个位置各自为政,很难泛化“动词找宾语”“代词找指代对象”等规律可复用

位置差异由输入里的位置信息承担。也就是说,token embedding 加上 positional encoding 之后,进入矩阵的 \(x_i\) 已经包含了“是什么词”和“在什么位置”。

因此,共享矩阵解决的不是“每个词都一样处理”的问题,而是“用同一套抽象规则处理不同输入”的问题。

输入呈现的特征共享矩阵可能学到的投影规则
代词特征投影到寻找指代对象的方向
名词特征投影成容易被指代关系匹配的 K
谓词特征投影成寻找主语、宾语或补足信息的 Q
修饰词特征投影到修饰关系、情感倾向或属性补充空间

共享参数让模型学的是语言规律,而不是死记每个 token 在每个位置上的专属矩阵。

Multi-Head:大矩阵和多套小矩阵是同一件事

假设模型维度是 \(d_{\mathrm{model}} = 512\),head 数量是 8,那么每个 head 的维度是:

\[d_k = \frac{512}{8} = 64\]

从逻辑视角看,8 个 head 可以理解为 8 套小矩阵:

\[W^Q_1, W^Q_2, \ldots, W^Q_8\]

每个矩阵的形状是:

\[512 \times 64\]

每个 head 都看完整的 512 维输入,但只产出 64 维的 Q/K/V。

视角怎么理解重点
逻辑视角8 个 head,各自有一组 \(512 \times 64\) 的投影便于理解“多头各司其职”
工程视角把 8 个小矩阵横向拼成一个 \(512 \times 512\) 的大矩阵便于 GPU 一次性高效计算

从工程实现看,通常会把这些小矩阵横向拼成一个大矩阵:

\[W^Q = \left[ W^Q_1; W^Q_2; \cdots; W^Q_8 \right]\]

它的整体形状就是:

\[512 \times 512\]

于是可以先做一次大矩阵乘法:

\[Q = XW^Q\]

再把结果 reshape 成多个 head:

\[[\mathrm{batch}, \mathrm{seq}, 512] \rightarrow [\mathrm{batch}, 8, \mathrm{seq}, 64]\]

所以,“8 套矩阵”和“一个大矩阵再 split”并不矛盾。前者是逻辑视角,后者是工程视角。

关键点是:不是先把输入 \(X\) 的 512 维切成 8 份。每个 head 都能看到完整输入,只是各自投影出 64 维结果。

这一点非常容易误解。把错误理解和正确理解并排放在一起会清楚很多:

说法是否正确问题 / 含义
把输入 \(X\) 的 512 维先切成 8 段,每段给一个 head错误这样每个 head 只能看到局部输入
每个 head 都看完整 \(X\),但各自投影出 64 维结果正确每个 head 都能从完整信息里提取自己关心的特征
8 个 64 维结果最后 concat 回 512 维正确在总宽度不变的前提下获得多个观察角度

为什么每个 head 只输出 64 维?主要是控制计算量和输出维度。如果 8 个 head 每个都输出 512 维,拼接后就是 4096 维,后续层的参数量和计算量都会膨胀。让每个 head 输出 64 维,最后拼接仍然是 512 维,相当于在总宽度不变的前提下增加了多个观察角度。

可以用“观察员”类比:

机制类比输出
单头注意力1 个观察员看完整图片写一份 512 字报告
8 头注意力8 个观察员都看完整图片,但分工不同每人写 64 字,合起来仍是 512 字

有人看颜色,有人看构图,有人看局部细节,有人看远距离关系。每个人报告更短,但分工更清晰,最后合起来的信息结构更丰富。

Multi-Head 的核心在独立交互

如果只看线性变换这一步,一个 \(512 \times 512\) 的大矩阵确实可以被看成一次普通投影。

但 Multi-Head 的关键不在第一次矩阵乘法,而在 split 之后的独立注意力计算。

Multi-Head 之所以叫“多头”,真正的奥秘不在矩阵相乘的瞬间,而在相乘之后的非线性交互过程。

每个 head 都会单独执行:

\[\mathrm{head}_h = \mathrm{softmax} \left( \frac{Q_hK_h^T}{\sqrt{d_k}} \right)V_h\]

最后再拼接所有 head:

\[\mathrm{MultiHead}(Q,K,V) = \mathrm{Concat}(\mathrm{head}_1,\ldots,\mathrm{head}_8)W^O\]

也就是说,不同 head 有独立的 Q/K/V 子空间,也有独立的 softmax。

flowchart TD
  X["输入 X"] --> Linear["线性投影:Wq/Wk/Wv"]
  Linear --> Split["reshape / split 成多个 head"]
  Split --> H1["Head 1:独立 QK^T + softmax + V"]
  Split --> H2["Head 2:独立 QK^T + softmax + V"]
  Split --> H3["Head ...:独立注意力"]
  H1 --> Concat["concat"]
  H2 --> Concat
  H3 --> Concat
  Concat --> WO["输出投影 W^O"]
  WO --> Y["输出表示"]

  style Split fill:#fff3bf
  style H1 fill:#e3f2fd
  style H2 fill:#e8f5e9
  style H3 fill:#f3e5f5
  style WO fill:#ffe3e3

独立 softmax 很重要。它让不同 head 可以同时关注不同关系:

Head可能关注的关系直觉
Head 1主谓关系谁在做动作
Head 2指代关系“它”“这个”指向谁
Head 3修饰关系哪些形容词、副词在补充属性
Head 4…局部或长距离依赖邻近搭配、跨句关联等

如果所有特征都挤在一个大 softmax 里竞争,模型更容易只关注最显著的关系。多头机制把注意力分成多个子空间,让不同关系可以并行建模。

这个差异可以拆成三层。

① Softmax 隔离:一次大竞争变成多次小竞争

Softmax 是归一化操作,所有候选位置要竞争总和为 1 的注意力权重。

如果只有一个头,模型在某一层里更容易被迫在多个关系之间取舍:到底关注主语、宾语、修饰词,还是前文指代对象?

多头相当于把一次大竞争拆成多次小竞争:

单头多头
所有关系在同一个 softmax 里竞争每个 head 有自己的 softmax
容易只保留最显著关系可以同时保留多种关系
更像“只能选一个代表”更像“每个小组选一个代表”

Head 1 可以在自己的空间里选出主语,Head 2 可以在自己的空间里选出指代对象,Head 3 可以在自己的空间里关注修饰关系。

用一个简单类比:单头像全班只能选一个代表,最后最显眼的人胜出;多头像全班分成 8 个小组,每个小组选一个代表。这样选出来的不是“唯一最强信号”,而是一组不同维度上的代表。

② 子空间隔离:Head 之间不串线

虽然工程上可能是一个大矩阵,但 split 之后,Head 1 的 Q 只和 Head 1 的 K 做点积,Head 2 的 Q 只和 Head 2 的 K 做点积:

\[Q_1K_1^T,\quad Q_2K_2^T,\quad \ldots,\quad Q_hK_h^T\]

它不会变成:

\[Q_1K_2^T\]

这种隔离强制不同 head 在不同子空间里学习关系。否则所有特征都混在同一个空间里,模型容易把关系纠缠在一起;分头之后,模型更容易把“指代”“语法依赖”“局部搭配”“长距离语义”等关系拆开表示。

③ 避免特征平均化:先专精,再综合

Attention 最后会对 V 做加权求和。如果只有一个头,多个相关词的 V 很容易被揉成一个模糊平均值。多头则先让不同 head 各自提取一种相对清晰的关系,再通过输出矩阵 \(W^O\) 汇总:

\[\mathrm{Output} = \mathrm{Concat}(\mathrm{head}_1,\ldots,\mathrm{head}_h)W^O\]

这是一种“先专精,后综合”的结构。它不是简单把维度切开,而是在多个相对独立的注意力空间里各算一遍“谁该看谁、看多少、拿什么”,再把结果融合。

所以,如果只说:

\[1 \times 512 \quad \mathrm{input} \times 512 \times 512 \quad \mathrm{matrix} = 1 \times 512 \quad \mathrm{output}\]

这个描述只覆盖了线性投影那一瞬间。真正的 Multi-Head 还必须包含后面的阶段:

阶段操作为什么重要
1reshape / split 成多个 head把大投影结果拆成多个子空间
2每个 head 独立计算 \(QK^T\)各自判断“谁和谁相关”
3每个 head 独立 softmax各自竞争注意力权重
4每个 head 独立对 V 加权求和各自得到一种关系下的上下文表示
5concat 后再用 \(W^O\) 融合把多个专家结果综合起来

如果没有第 2 到第 4 步的独立交互,那就只是普通的大矩阵投影,不再具备 Multi-Head 的表达能力。

用代码看,这个结构通常长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# 定义一个 PyTorch 模块,用来实现 Multi-Head Attention
class MultiHeadAttention(nn.Module):
    # 初始化模块;d_model 是总隐藏维度,num_heads 是 head 数量
    def __init__(self, d_model, num_heads):
        # 调用 nn.Module 的初始化逻辑,让这个类具备 PyTorch 模块能力
        super().__init__()
        # 要求总维度能被 head 数整除,否则每个 head 无法平均分到相同维度
        assert d_model % num_heads == 0
        # 保存总隐藏维度,例如 512
        self.d_model = d_model
        # 保存 head 数量,例如 8
        self.num_heads = num_heads
        # 计算每个 head 的维度,例如 512 / 8 = 64
        self.d_k = d_model // num_heads
        # 定义 Q 投影层:把输入从 d_model 映射到 d_model,内部等价于一个大 W^Q
        self.W_q = nn.Linear(d_model, d_model)
        # 定义 K 投影层:把输入从 d_model 映射到 d_model,内部等价于一个大 W^K
        self.W_k = nn.Linear(d_model, d_model)
        # 定义 V 投影层:把输入从 d_model 映射到 d_model,内部等价于一个大 W^V
        self.W_v = nn.Linear(d_model, d_model)
        # 定义输出投影层:把 concat 后的多头结果再融合回 d_model 维
        self.W_o = nn.Linear(d_model, d_model)
    # 把一个形状为 [batch, seq, d_model] 的张量拆成多个 head
    def split_heads(self, x):
        # 取出 batch 大小和序列长度;最后一维应当是 d_model
        batch_size, seq_length, _ = x.size()
        # 改形状为 [batch, seq, heads, head_dim]
        x = x.view(batch_size, seq_length, self.num_heads, self.d_k)
        # 交换 seq 和 heads 维度,得到 [batch, heads, seq, head_dim],方便每个 head 独立 attention
        return x.transpose(1, 2)
    # 把多个 head 的结果合并回一个 d_model 维张量
    def combine_heads(self, x):
        # 读取当前形状:[batch, heads, seq, head_dim]
        batch_size, num_heads, seq_length, d_k = x.size()
        # 把形状变回 [batch, seq, heads, head_dim],contiguous 确保内存连续便于 view
        x = x.transpose(1, 2).contiguous()
        # 把 heads 和 head_dim 拼回 d_model,得到 [batch, seq, d_model]
        return x.view(batch_size, seq_length, self.d_model)
    # 对已经拆好 head 的 Q/K/V 执行缩放点积注意力
    def scaled_dot_product_attention(self, Q, K, V, mask=None):
        # 计算 QK^T / sqrt(d_k),形状为 [batch, heads, seq_q, seq_k]
        scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
        # 如果提供了 mask,例如 causal mask 或 padding mask,就进入屏蔽逻辑
        if mask is not None:
            # 把不允许关注的位置设成极小值,使 softmax 后权重接近 0
            scores = scores.masked_fill(mask == 0, -1e9)
        # 对最后一维 seq_k 做 softmax,把原始分数变成注意力权重
        probs = torch.softmax(scores, dim=-1)
        # 用注意力权重加权求和 V,得到每个 head 的上下文输出
        return torch.matmul(probs, V)
    # 前向传播;这里的 Q/K/V 参数通常都来自同一个 X,也可以来自不同序列
    def forward(self, Q, K, V, mask=None):
        # 先用 W_q 得到大 Q,再拆成 [batch, heads, seq_q, head_dim]
        Q = self.split_heads(self.W_q(Q))
        # 先用 W_k 得到大 K,再拆成 [batch, heads, seq_k, head_dim]
        K = self.split_heads(self.W_k(K))
        # 先用 W_v 得到大 V,再拆成 [batch, heads, seq_v, head_dim]
        V = self.split_heads(self.W_v(V))
        # 在每个 head 内独立计算 attention,得到 [batch, heads, seq, head_dim]
        attn_output = self.scaled_dot_product_attention(Q, K, V, mask)
        # 合并所有 head,再通过 W_o 做最终融合,输出 [batch, seq, d_model]
        return self.W_o(self.combine_heads(attn_output))

这里 nn.Linear(d_model, d_model) 看起来是一整个大矩阵,但 split_heads 之后,attention 是在 num_heads 维度上并行且相互隔离地完成的。这才是 Multi-Head 的关键。

KV Cache 缓存的不是权重矩阵

推理阶段经常说“缓存 KV”,这里缓存的不是 \(W^K\) 和 \(W^V\) 权重矩阵。

权重矩阵本来就是模型参数,加载模型后固定存在。KV Cache 缓存的是每一层、每个历史 token 已经计算出来的 K/V 向量。

这点很容易说混:

对象类比是否属于 KV Cache
\(W^Q, W^K, W^V\)工厂里的机器否,它们是模型参数
历史 token 的 K/V 向量机器加工出的半成品是,KV Cache 缓存的就是它们
最终生成的 token已经交付的成品否,它由 logits 和解码策略决定

权重矩阵当然也会常驻显存,但那是模型参数加载,不是 KV Cache。

对自回归生成模型来说,每次只生成一个新 token。假设已经有前缀:

1
I love

下一步要预测新 token 时,模型需要当前 token 的 Q 去看历史 token 的 K/V。如果没有缓存,每生成一步,都要重新计算历史 token 的 K/V。

但历史 token 的隐藏状态已经固定,对应的 K/V 也固定:

\[k_i = x_i W^K\] \[v_i = x_i W^V\]

只要 \(x_i\) 和权重不变,\(k_i\)、\(v_i\) 就不会变。因此它们可以缓存下来。

如果不用缓存,生成第 100 个 token 时,模型会反复为前 99 个 token 计算它们已经算过的 K/V。这些重复计算没有提供新信息,只是在浪费时间。KV Cache 的收益就在这里:历史 K/V 直接从显存中的缓存读出来,当前步只需要处理新 token。

每轮推理可以简化成:

sequenceDiagram
  participant New as 当前 token
  participant Proj as Q/K/V 投影
  participant Cache as KV Cache
  participant Attn as Attention
  participant Out as 输出分布

  New->>Proj: 计算当前 Q/K/V
  Proj->>Cache: 追加当前 K/V
  Proj->>Attn: 提供当前 Q
  Cache->>Attn: 提供历史 K/V
  Attn->>Out: 得到 logits

为什么不缓存 Q

因为 Q 是“当前查询”。过去 token 的 Q 已经用过了,后续新 token 不会再用过去的 Q 查询。后续真正会被反复访问的是历史 K/V:

向量是否缓存原因
过去 token 的 Q通常不缓存它是一次性查询,用完就没有后续价值
过去 token 的 K缓存未来每个新 Q 都要和它匹配
过去 token 的 V缓存未来每个新 Q 都可能从它这里取内容

换句话说,过去 token 的 Q 是一次性问题;过去 token 的 K/V 是会被未来反复翻阅的信息库。这也是它叫 KV Cache,而不是 QKV Cache 的原因。

KV Cache 的形状

KV Cache 本质上是一组张量缓冲区,常见形状可以写成:

1
[num_layers, 2, batch_size, max_seq_len, num_heads, head_dim]

其中:

维度含义
num_layers每一层 Transformer 都有自己的 K/V
2分别表示 K 和 V
batch_size当前批次里的请求数
max_seq_len缓存的最大序列长度
num_heads多头注意力的 head 数
head_dim每个 head 的维度

KV Cache 用显存换时间。它不会消除当前 token 和历史 token 的注意力计算,但能避免反复重算历史 token 的 K/V。

更精确地说,生成第 \(t\) 个 token 时,当前 token 仍然要和 \(t\) 个历史位置做注意力:

\[O(t)\]

但不用每一步都把长度为 \(t\) 的前缀完整重跑一遍。实际推理速度因此会显著提升。

这里有一个常见说法需要稍微精确一点:KV Cache 并不是把整个自回归生成从总复杂度 \(O(n^2)\) 变成 \(O(n)\)。因为第 \(t\) 步仍然要让当前 Q 看 \(t\) 个历史 K/V,整段生成累积起来仍然和上下文长度有关。它真正省掉的是“每一步都重新计算整个前缀的 K/V 和中间层表示”这部分重复前向计算。实际系统里,这个差异非常大,所以长文本生成会明显变快。

KV Pool 和 PagedAttention

普通 KV Cache 通常绑定在单个请求上。KV Pool 则把 K/V 缓存进一步系统化管理,使它可以被分页、复用或共享。

这类优化的背景是:长上下文推理时,KV Cache 会占用大量显存,而且不同请求长度不一,如果按固定大块连续内存分配,会造成浪费和碎片。

PagedAttention 的思路类似操作系统分页:

设计作用
把 KV Cache 切成固定大小的块减少连续大块显存分配压力
逻辑上保持连续序列模型仍按正常上下文理解
物理显存中分散存放降低碎片和浪费
相同前缀共享 KV 块多请求复用公共上下文
flowchart LR
  Req1["请求 A:公共前缀 + 私有后缀"] --> Pool["KV Pool / 分页缓存"]
  Req2["请求 B:公共前缀 + 另一个后缀"] --> Pool
  Pool --> Shared["共享前缀 KV 块"]
  Pool --> APrivate["A 的私有 KV 块"]
  Pool --> BPrivate["B 的私有 KV 块"]

  style Pool fill:#fff3bf
  style Shared fill:#e8f5e9
  style APrivate fill:#e3f2fd
  style BPrivate fill:#f3e5f5

所以,KV Pool 的本质不是改变注意力公式,而是把“历史 K/V 这些中间结果”从模型计算过程里抽出来,变成可管理的推理系统资源。

命中 KV Cache 会不会让输出固定

KV Cache 缓存的是素材,不是答案。

缓存里的 K/V 固定,表示历史信息库固定。但每一轮新 token 都会产生新的 Q:

\[q_t = x_t W^Q\]

然后用这个新的 Q 去查询缓存中的历史 K:

\[\alpha_{tj} = \mathrm{softmax} \left( \frac{q_t k_j^T}{\sqrt{d_k}} \right)\]

再对历史 V 加权求和:

\[o_t = \sum_j \alpha_{tj}v_j\]

因此,KV Cache 不会直接把输出固定成某个后缀。它只是避免重复计算历史 K/V。

可以把 KV Cache 想成一个固定档案库。历史 K/V 是档案本身;当前 token 的 Q 是新来的查询问题。档案没变,不代表每次查询问题都一样;查询问题变了,attention 权重和最终汇总结果也会变。

如果两次输入 token 完全相同、位置完全相同、模型权重完全相同,那么模型算出来的 logits 通常是固定的:

\[\mathrm{logits}_t = f_\theta(x_1,\ldots,x_t)\]

但最终采样出来的 token 是否固定,取决于解码策略。

如果使用 greedy decoding:

\[y_t = \arg\max_i \mathrm{logits}_{t,i}\]

那么结果是固定的。

如果使用采样:

\[y_t \sim \mathrm{softmax}\left(\frac{\mathrm{logits}_t}{T}\right)\]

其中 \(T\) 是 temperature。只要存在随机采样、top-p、top-k 或不同随机种子,即使 logits 一样,最终 token 也可能不同。

采样分叉后,KV Cache 还能复用吗

假设前缀是:

1
我爱北京天安门

这段前缀对应的 KV Cache 是固定的:

1
KV("我爱北京天安门")

接下来模型计算下一个 token 的 logits。如果使用不同采样策略,或者同一采样策略下随机种子不同,下一个 token 可能不同:

1
2
路径 A:我爱北京天安门 广场
路径 B:我爱北京天安门 前

这时 cache 的复用规则是:

片段是否可复用原因
KV("我爱北京天安门")可以共享这是两条路径的公共前缀
KV("广场") 以及它之后的 token只属于路径 A从“广场”开始上下文已经不同
KV("前") 以及它之后的 token只属于路径 B从“前”开始上下文已经不同

原因很直接:路径 A 下一步看到的上下文是“我爱北京天安门 广场”,路径 B 下一步看到的上下文是“我爱北京天安门 前”。从分叉 token 开始,隐藏状态、Q/K/V、attention 权重、logits 都已经进入不同路径。

可以把生成过程看成一棵前缀树:

flowchart TD
  Prefix["我爱北京天安门"]
  Prefix --> A["广场"]
  Prefix --> B["前"]
  A --> A2["..."]
  B --> B2["..."]

  style Prefix fill:#e8f5e9
  style A fill:#e3f2fd
  style B fill:#f3e5f5

公共前缀的 KV 可以共享,分叉后的 KV 必须各自追加。Beam search、prefix cache、PagedAttention 这类推理系统优化,处理的正是这种“共享公共前缀,分支独立增长”的问题。

影响输出是否固定的因素

可以把生成过程拆成两步:

阶段做什么是否确定
模型计算 logits对词表里每个候选 token 打分在理想条件下确定
解码器选择 token从 logits / 概率分布里选一个 token取决于采样策略

第一步在理想条件下是确定的。第二步可以是确定的,也可以是随机的:

类别影响因素
logits 计算模型权重、输入 token、位置编码、上下文、浮点精度、推理后端、隐藏 system prompt
token 选择greedy decoding、temperature、top-p、top-k、repetition penalty、random seed

所以,KV Cache 和“输出是否固定”不是一回事。KV Cache 决定的是是否复用历史中间结果;解码策略决定的是是否从固定概率分布里随机抽样。

总结

Q/K/V 这套机制可以压缩成一句话:

\(W^Q\) 和 \(W^K\) 决定信息流动的方向和强度,\(W^V\) 决定被选中的信息具体贡献什么内容。

更完整地看:

  • Attention 的底层逻辑是“相似性打分 -> softmax 归一化 -> 加权求和”。
  • Self-Attention 是让同一个序列里的 token 彼此关注,从而得到上下文相关表示。
  • Q/K/V 是输入表示 \(X\) 在不同语义空间里的投影。
  • Attention 输出不是当前 token 自己的 V,而是所有 V 的加权混合。
  • 原始词向量不会被简单替换,而是通过残差连接和多层堆叠逐步演化。
  • 同一层内 \(W^Q\)、\(W^K\)、\(W^V\) 被所有 token 共享,这是泛化能力的来源。
  • Multi-Head 的关键不是大矩阵乘法,而是拆分后每个 head 独立做 attention 和 softmax。
  • KV Cache 缓存的是历史 token 的 K/V 中间结果,不是权重矩阵,也不是最终答案。
  • KV Pool 和 PagedAttention 是把 KV Cache 当成系统资源进行分页、共享和复用。
  • 相同输入通常产生相同 logits,但最终 token 是否固定取决于解码策略和随机性。

从 QKV 到 KV Cache,其实是一条连续的线:训练时,注意力机制学习“谁该看谁、看多少、拿什么信息”;推理时,系统把已经稳定的历史 K/V 保存下来,让每个新 token 只专注于当前这一次查询。

本文由作者按照 CC BY 4.0 进行授权