上一篇结尾留了三个问题:谁和我相关?相关程度是多少?我该从对方那里读什么?
Self-attention 允许 sequence 内的位置之间直接建立通路,不再需要沿着 recurrent state 一步步搬运。但"允许直接交互"和"知道怎么交互"是两件事——architecture 把路打通了,到底是谁在决定走哪条路、读多少、读什么?
这篇想沿着这条线继续拆。最终会走到一个公式:
$$ \operatorname{Attention}(Q,K,V) = \operatorname{softmax}\!\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$以前工作中对这个公式并不陌生,但当时没有留下系统笔记,时间久了,就渐渐说不清里面每一块到底对应什么计算角色了。这次重新走一遍,发现这个公式不是被凭空发明的——每解决一个具体问题,就自然多出一块。
“注意力"这个词反而碍事#
Attention 这个名字很容易把人引到"模拟人类注意力"的方向上。我以前也用"人看图会聚焦在重要区域"这个类比给别人讲过。但这次重新梳理之后,我觉得这个比喻在理解 architecture 时弊大于利——它容易让人盯着"聚焦"和"重要性"这两个词,而忽略真正在发生的计算过程。
更准确的理解是把 attention 看成 content-dependent dynamic read,动态信息读取:
1当前 requester 有一个信息需求
2 ↓
3面对一组 candidates
4 ↓
5根据当前内容动态决定应该从谁读取
6 ↓
7从对应 candidate 处取回真正的信息后面整篇文章都会用这套语言:requester、candidate、routing、payload。不是因为这些词更学术,而是它们比"注意力"更贴近 attention 层里实际发生的事。
三个角色,不是三个矩阵#
Query、Key、Value 很容易被记成"三个需要背的术语”,然后等着看到 \(Q = XW_Q\) 这样的公式时才反应过来它们是矩阵。但在理解它们的 computation role 时,公式反而不该第一个出场。
我发现用 service discovery / RPC 的类比更容易把角色讲清楚。一个分布式系统里有很多 service,某个 caller 发出一次请求时,至少牵涉三个不同的东西:
- Query 对应 request descriptor——我现在需要什么?caller 对自己当前需求的描述。
- Key 对应 service discovery metadata / routing descriptor——别人应该通过哪些特征判断是否调用我?每个 candidate 暴露出来的匹配标签。
- Value 对应 RPC response payload——一旦决定调用我,我真正返回什么?
我后来想清楚的一点是:Query / Key / Value 首先是三个 computation roles(计算角色),然后才是三个 tensor 或 matrix。 角色定义了它们各自在整个 read 过程中承担的职责,矩阵只是承载这些角色的载体。
当然,attention 层不是真的 RPC 系统。这个类比只是为了建立直觉——一个 read 过程需要"请求方描述需求"“候选方暴露可匹配的特征"“候选方提供真正的 payload"这三件事各有各的用途。
为什么 Key 和 Value 要分开#
这是我卡过的一个地方。直觉上,一个 token 已经有一个 representation 了,为什么不能用同一份 representation 既负责匹配、又负责真正传递信息?
答案是:可以这么设计,但把两者分开会增加 representational flexibility。
核心思想是 routing representation 和 payload representation 不必相同。还是用 service discovery 的类比:service registry 里暴露的 metadata(版本号、能力标签、负载状态)和这个 service 被调用后真正返回的 response,显然不需要是同一份数据。前者用于判断"这个 service 值不值得调用”,后者用于"调用之后取回什么”。
映射到 attention 里:
- Key 决定"我是否应该被选中"——它是 candidate 朝外暴露的匹配接口;
- Value 决定"如果选中了我,我真正贡献什么"——它是 candidate 的实际 payload。
Key / Value separation 是 routing / payload separation。 把"怎么被找到"和"找到之后提供什么"解耦,让模型在两个方向上可以独立学习最优的 representation。
每个 token 同时是 client 也是 service#
在普通的搜索或检索系统里,角色是分开的:用户发出 Query,文档提供 Key / Value。requester 和 candidate 是两拨不同的实体。
但 self-attention 不一样。同一个 sequence 中的每个 token position 都同时承担三个角色:
- 作为 requester,它有自己的信息需求;
- 作为 candidate,它出现在其他 token 的候选列表里;
- 作为 information provider,被选中后它要提供真正的 payload。
用工程化一点的说法:每个 token 同时既是 client,也是 service registry 中的 entry,同时也是 service provider。 这是 self-attention 和 cross-attention(比如早期 encoder–decoder attention)在结构上最显著的区别——不再有"一方只查询、另一方只被查"的划分。
Hidden state 长什么样#
在写 \(Q = HW_Q\) 之前,先把 \(H\) 本身说清楚,因为容易和 RNN 的 hidden state 混。
某一层的 sequence representation 可以写成:
$$ H \in \mathbb{R}^{n \times d_{\text{model}}} $$\(n\) 是 sequence length,即 token positions 的数量;\(d_{\text{model}}\) 是每个位置当前 representation 的 feature dimension。一行 \(h_i = H[i,:]\) 就是当前层中第 \(i\) 个 token position 的 hidden representation。
如果带 batch,shape 变成 [batch_size, sequence_length, d_model],但 batch 维度在理解 attention 机制时可以暂时忽略。
和 RNN 不同的是,这里的 hidden state 只是"网络内部这一层的中间 representation",不意味着像 RNN 那样沿时间 recurrently 传递。同一个词出现两次,也是两个不同的 token occurrences,分别占据两个 positions,各自有独立的 \(h_i\)。
同一个 state,三个 learned views#
现在可以自然地写出:
$$ q_i = h_i W_Q, \quad k_i = h_i W_K, \quad v_i = h_i W_V $$但我不想把它描述成"把输入分别乘三个矩阵"。更准确的理解是:同一个 underlying state \(h_i\),因为需要承担三个不同的 computation roles,所以被变换成三个 learned functional views。
1h_i → requester view → q_i
2h_i → candidate view → k_i
3h_i → payload view → v_ione state, three roles, three learned views。
这里的 \(W_Q, W_K, W_V\) 是 learned linear transformation。它们不是从 \(h_i\) 里面挑几个最大的 feature,也不是人工规定某一维代表语法、某一维代表位置。每个输出维度都是原有 feature dimensions 的一个 learned linear combination:
$$ q_{i1} = w_{11} h_{i1} + w_{21} h_{i2} + w_{31} h_{i3} + \cdots $$也就是说,projection 的作用是:对原有 feature dimensions 做 learned linear combination,重新构造一个更适合当前 computation role 的 representation。这和 representation learning 的思想是一致的——不是挑选原始特征,而是学习一种新的坐标系。
两种完全不同的"权重"#
这是我学习过程中真实混淆过的地方。
我曾经把 \(W_Q, W_K, W_V\) 和 attention weights 混着叫"attention 的权重"。后来发现这两个东西根本不在同一个层面。
Projection weights \(W_Q, W_K, W_V\):
- 模型参数,训练时学到;
- 作用于 feature dimensions——把 \(d_{\text{model}}\) 维的 hidden state 变换成 \(d_k\) 或 \(d_v\) 维的 functional view;
- 对所有 token positions 共享同一套参数;
- 不需要非负,不需要和为 1,不经过 softmax。
Attention weights \(\alpha_{ij}\):
- 依赖当前输入动态产生,每次 forward 都不一样;
- 作用于不同 sequence positions 的 Values——表示 token \(i\) 当前应该从 token \(j\) 读取多少;
- 经过 softmax,非负且对每个 requester 求和为 1。
\(W_Q / W_K / W_V\) 在 feature dimension 上塑造角色;attention weights 在 token dimension 上决定信息路由。 前者是 feature transformation,后者是 token-to-token routing。
不同 projection 出来的 Q 和 K,凭什么能做点积#
我在这里真的停下来想过。\(q_i\) 和 \(k_j\) 来自不同的 projection——\(W_Q\) 和 \(W_K\) 是两套独立的参数。两个不同线性变换的输出,做点积有什么意义?
先看数学上的合法性。如果 \(h_i \in \mathbb{R}^{d_{\text{model}}}\),\(W_Q, W_K \in \mathbb{R}^{d_{\text{model}} \times d_k}\),那么 \(q_i, k_j \in \mathbb{R}^{d_k}\)。维度相同,\(q_i k_j^T\) 得到一个 scalar——数学上合法。
但维度相同只能证明"可以算",不能证明"这个点积有语义"。
真正关键的是训练过程。\(W_Q\) 和 \(W_K\) 和 dot-product matching 和最终的 task loss,是联合端到端训练的。于是:
- \(W_Q\) 会学着把 requester 的需求编码成适合 matching 的 representation;
- \(W_K\) 会学着把 candidate 的属性编码成与 Query 协同工作的 representation。
类比到 service discovery:client request descriptor 和 service metadata 不需要经过同一个 encoder,也不需要长得一样,但它们必须遵守同一个 matching contract——在点积这个 matching operator 下,高 score 意味着相关,低 score 意味着不相关。
所以 Q/K 是角色不同、生成方式不同,但针对同一个 matching operation 联合学出来的一对 representations。dot product 本身不理解语义;architecture 规定 dot product 是 matching operator,training 学习如何构造适合这种 operator 的 Q/K representation。
点积到底在算什么#
回到 compatibility score(兼容性分数)的语义。对于 requester \(i\) 和 candidate \(j\):
$$ s_{ij} = q_i \cdot k_j = \sum_r q_{ir} k_{jr} $$它把多个维度上的匹配 evidence 聚合成一个 scalar。
但这里有个容易滑过去的误解:点积经常被说成"衡量相似度"。在 attention 里,更准确的词是 compatibility。模型问的不是"token \(i\) 和 token \(j\) 是不是长得像",而是"对于 token \(i\) 当前的信息需求,token \(j\) 是否是合适的信息来源"。这是 requester–candidate compatibility,不是单纯的 semantic similarity。
“the cat sat on the mat” 里,sat 和 cat 语义上不相似,但 sat 作为 requester 可能非常需要 cat 提供的 agent 信息——这种需求不是相似度,而是功能匹配。
从一个 Query 到 QK^T#
\(QK^T\) 写成矩阵乘法之后很紧凑,但我第一遍看到时反而没有直觉。从一个 Query 出发拆更自然。
对于 \(q_i\),它需要与所有 candidate 做 matching:
$$ q_i \cdot k_1, \quad q_i \cdot k_2, \quad \dots, \quad q_i \cdot k_n $$得到一行 routing scores:token \(i\) 对整个 sequence 的 raw compatibility。
然后所有 Query 都执行同样的操作——\(q_1\) 得到一行,\(q_2\) 得到一行,一直到 \(q_n\)。把这些行堆起来,就自然出现了:
$$ QK^T \in \mathbb{R}^{n \times n} $$其中 \((QK^T)_{ij} = q_i \cdot k_j\),语义是:token \(i\) 作为 requester,对 token \(j\) 作为 candidate 的 raw compatibility score。
这张表拆开看:
row \(i\) 是 token \(i\) 当前想从 sequence 中读谁的一整行评分。column \(j\) 是 token \(j\) 作为 candidate,对不同 requester 分别有多匹配。同一个 candidate 可能对 requester A 很重要,对 requester B 几乎没用——这正是 content-dependent routing 的含义。
对角线上的 \(s_{ii} = q_i \cdot k_i\) 表示 token \(i\) 自己也是自己的 candidate。Self-attention 并不意味着"只读取其他 token"——自己也在候选集里。
为什么 raw score 不能直接当 read weight#
假设某个 Query 算出来的一行 scores 是:
1[-1.8, 0.7, 4.2, 2.3]如果直接拿这些 raw scores 做加权:\(-1.8 v_1 + 0.7 v_2 + 4.2 v_3 + 2.3 v_4\),有三个问题。
第一,raw score 的绝对值没有直接的 read semantics。4.2 不表示"读取 4.2 份信息",它只是 relative compatibility——表示比其他 candidate 更匹配,但这个数字的绝对大小没有读取含义。
第二,score 可以为负。负数乘 Value 意味着把 Value 的方向翻转后再加入,这在几何上有意义,但不是"少读取一点"这种自然的 routing 语义。
第三,scale 不受约束。不同输入、不同维度设置下,score 的绝对范围可以变化很大。一组是 \([-1, 0, 2, 1]\),另一组可能是 \([-50, 0, 100, 50]\),ranking 一样但 magnitude 完全不同。
所以需要把这些 raw compatibility logits 转成真正适合作为 routing coefficient 的东西。
softmax 做了什么#
$$ \alpha_{ij} = \frac{\exp(s_{ij})}{\sum_{j'} \exp(s_{ij'})} $$softmax 是沿着每个 Query 对应的那一行做的。固定 requester \(i\),在所有 candidate \(j\) 之间分配 routing weight,使得 \(\sum_j \alpha_{ij} = 1\)。
它做了三件事。
把无界的 logits 映射成正数——exp 保证输出恒正,不再有"翻转 Value 方向"的问题。
归一化成 relative weights——不管原始 scores 的 scale 是大是小,最后的 routing weights 总和为 1。可以用一个 attention budget 的心智模型来理解:每个 requester 有固定的"预算"为 1,按 compatibility 分配给各个 candidate。
引入 candidate competition——softmax 的分母让所有 candidate 之间形成竞争关系。如果某个 candidate 的 score 越来越高,它的 routing weight 变大的同时,其他 candidate 的 relative weight 也会下降。这和 raw score 的行为不同:raw score 里某个 candidate 的值增大,不影响其他 candidate 的值。
1candidate 1: 5%
2candidate 2: 10%
3candidate 3: 75%
4candidate 4: 10%这里"75%“只是 routing weight 的直觉类比,不应该进一步解释成"75% 的语义来源于 candidate 3”。
乘 V 才真正搬信息#
到 softmax 为止,实际上只完成了 routing decision——决定了从谁读、读多少。但信息还没有被真正搬运。
真正携带 payload 的是 \(v_j\)。对于 token \(i\):
$$ o_i = \sum_j \alpha_{ij} v_j $$用 service discovery 的类比来对应整个过程:
- Query + Key = service discovery / routing——找到谁值得调用
- attention weights = request allocation——按匹配度分配调用权重
- Value = RPC response payload——被调用方真正返回的内容
Q/K 决定 read whom / read where;V 决定 read what。
为什么不是 argmax 选最匹配的那一个,而是 weighted sum?
如果只选择 score 最高的 candidate,那是 hard routing——只从一个位置读取。而 soft weighted sum 允许主要读取某个 token,同时从其他几个次要相关的 token 获取补充信息:
10.05 v₁ + 0.10 v₂ + 0.75 v₃ + 0.10 v₄更重要的是,weighted sum / softmax 是连续可微的。argmax 不可导——梯度在选择那一步就断了,“该看哪里"这件事本身学不了。soft 加权让梯度直接流回每个 \(\alpha_{ij}\),再流回 score 的参数,整个 routing mechanism 可以通过 gradient descent 端到端训练。和上一轮里 early attention 用 weighted sum 的原因一脉相承。
attention output 的 shape#
这是另一个容易模糊的地方。
$$ Q, K \in \mathbb{R}^{n \times d_k}, \quad V \in \mathbb{R}^{n \times d_v} $$$$ QK^T \in \mathbb{R}^{n \times n} $$softmax 后 shape 不变,还是 \(\mathbb{R}^{n \times n}\)。再乘 \(V \in \mathbb{R}^{n \times d_v}\):
$$ O \in \mathbb{R}^{n \times d_v} $$对于某个 token,\(o_i \in \mathbb{R}^{d_v}\)——这是 token \(i\) 根据自己的 Query,从整个 sequence 中动态读取后得到的一个 \(d_v\) 维 read result。
注意:\(o_i\) 不是下一层完整的 hidden state。更准确的区分是——\(h_i\) 是进入 attention 前这个 position 当前已有的信息,\(o_i\) 是这次 attention 从整个 sequence 动态读取回来的信息。完整的 Transformer 里两者还会通过 residual connection 等方式整合,但这篇不展开。
这里还能看到一个小细节:\(d_k\) 和 \(d_v\) 理论上不必相同。\(d_k\) 服务于 matching——它是 Query 和 Key 的 interaction space 的维度;\(d_v\) 服务于 payload——它决定每个 candidate 能提供多大的信息向量。这进一步说明 routing representation 和 payload representation 是不同的职责,独立的维度设置只是这种解耦的自然结果。
为什么还要除以 √d_k#
最后一块拼图:
$$ \frac{QK^T}{\sqrt{d_k}} $$这一步不是新的 architecture semantics,主要解决的是 optimization / numerical scale 问题。
假设 \(q_i\) 和 \(k_j\) 的每一维近似均值为 0、方差为 1。那么:
$$ q_i \cdot k_j = \sum_{r=1}^{d_k} q_{ir} k_{jr} $$是 \(d_k\) 个近似独立的"均值 0、方差 1"的随机变量的乘积之和。每一项 \(q_{ir} k_{jr}\) 的方差约为 1,\(d_k\) 项求和后总方差约为 \(d_k\),标准差约为 \(\sqrt{d_k}\)。
也就是说,\(d_k\) 越大,dot-product logits 的典型 magnitude 越大。
而 softmax 对 logit scale 非常敏感。拿 \([1, 2, 3]\) 和 \([10, 20, 30]\) 做 softmax——两组 ranking 完全一样,但后者的输出会极度尖锐,接近 \([0, 0, 1]\)。这就是 softmax saturation:大部分 weight 集中在一个 candidate 上,gradient 几乎消失,优化变得困难。
除以 \(\sqrt{d_k}\) 就是在补偿 dot-product magnitude 随 dimension 增大的趋势,让 logits 的 scale 不会因为 \(d_k\) 的选择而剧烈变化。可以理解成 dimension-aware logit scaling,或者一种 temperature control:temperature 太低 softmax 变成近似 argmax,routing 退化成 hard selection;temperature 合适则维持 soft routing 的灵活性。
完整公式#
到这里,每一块都有了各自的来历,可以拼回去了:
$$ \operatorname{Attention}(Q,K,V) = \operatorname{softmax}\!\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$逐项翻译:
- \(Q\):requester 当前需求的 learned view
- \(K\):candidate 的 matching representation
- \(QK^T\):所有 requester × candidate 的 routing score table
- \(\div \sqrt{d_k}\):控制 dot-product logits 的 scale,防止 softmax saturation
- softmax:把 compatibility score 变成正的、归一化的、彼此竞争的 relative routing weights
- \(V\):candidate 真正提供的 payload
- routing weights \(\times V\):真正执行 weighted information read
single-head self-attention 可以理解为一个 fully differentiable、content-dependent 的 soft information routing mechanism:根据当前内容动态决定从哪里读、读多少、读什么,而整个决策过程本身也是可训练的。
写在最后#
回过头来看这个公式,我现在脑子里跑的不再是"Q 乘 K,除根号 d,softmax,再乘 V"这种操作序列,而是:
1当前 requester 想要什么?
2 ↓
3所有 candidates 分别有多匹配?
4 ↓
5形成 routing score table
6 ↓
7控制 score scale
8 ↓
9转换成 soft routing weights
10 ↓
11按照这些 routing weights
12真正从 Values 中读取 payload这篇只走到了 single-head 的部分。但一个自然的后续问题已经浮出来了:一组 \(W_Q, W_K, W_V\) 只定义了一个 matching / routing space。如果不同类型的信息需求(句法关系、语义关联、位置关系)需要不同的 routing 策略,一个 space 够吗?这会引出 Multi-Head Attention——但那是下一篇的事了。
参考资料#
- Vaswani et al., Attention Is All You Need, 2017 — https://arxiv.org/abs/1706.03762
- Bahdanau et al., Neural Machine Translation by Jointly Learning to Align and Translate, 2014 — https://arxiv.org/abs/1409.0473
- MIT 6.S191, Introduction to Deep Learning — https://introtodeeplearning.com/
- 从 RNN 到 Self-Attention:信息为什么一定要沿着 State 一步步传递?
- 从 RNN / LSTM / GRU 到早期 Attention:为什么"压缩历史"最终变成了"按需读取”
- 去掉 RNN 之后,顺序去哪了?