位置编码 入门
自注意力把序列当作一个集合:洗牌 token,每个 token 的 attention 输出 也不会变。语言不是集合 —— 词序是要紧的。Transformer 的解决办法是把位置信息注入每个 token。4 个短主题:为啥 attention 不知道顺序;正弦余弦编码(原始 Transformer 论文);学习式位置嵌入(GPT-2);以及RoPE(旋转位置编码) —— Llama、Qwen、DeepSeek 和现代大多数开源 LLM 用的方案。
为啥 attention 不知道顺序
把输入 token 洗牌,得到的就是洗了牌的输出。attention 看到的是一袋向量、不是一段序列。
我们一直在讲得好像 attention「知道」cat 在 sat 前面。 回看公式 softmax(Q · Kᵀ / √d_k) · V。注意它里面没什么: 位置索引。点积 Q[i]·K[j] 用的是两个 token 的内容。它不用它们在句子里哪儿。结果就是 attention 有一种叫置换等变(permutation equivariance)的性质: 如果你洗 input 的 token,output 的 token 就以同样洗过的顺序冒出来, 但向量本身不变。
具体到「the cat sat」:你喂模型 「cat sat」还是 「sat cat」, attention 出来的就是同一对输出向量 —— 只是分别跟不同的 token 关联。 对一个语言模型来说,这是灾难性的。「狗咬人」跟 「人咬狗」必须给出不同的表示, 不然模型啥都干不了。
简单的解决办法。在 input 进 attention 之前, 往每个 token 上叠一个跟它位置有关的向量。也就是说,attention 的输入是
x[i] = token_embedding(token_i) + position_encoding(i)
这下 attention 的输入就不再是纯 token 内容了 —— 位置已经烙进向量里。 后面的 Q、K、V 投影会继承这个信息。即使 attention 自己从来不读位置索引, 它在每个位置看到的内容本身就已经编码了「这是序列的哪个槽」。
怎么样才算一个好的位置编码?大家一般会想要几条性质:
- 每个位置独一无二。不能有两个位置看上去一样 —— 编码得有信息。
- 有界。如果编码值随位置无界增长,训练就不稳定了。
- 带距离感。理想情况下,编码让相近位置看起来相似、 远距离位置看起来不同 —— 模型不用每对绝对位置从头记忆「3 个 token 之前」 这种抽象概念,而能学到通用的「相对距离」。
- 外推。训练时只见过长度 1024,推理时给 2048 还能用吗? 这正好是后面要讲的三种方案区别最大的地方。
接下来三节就是「怎么搞出有用的位置编码」的三个答案 —— 正弦余弦 (聪明的数学、没学习参数)、学习式(查表就行)、RoPE (现代折中,把两边的好处都拿过来)。
正弦余弦编码(原始 Transformer)
手工设计的多频率正余弦图样。零个学习参数。
2017 年的「Attention Is All You Need」论文提出了一种固定的、不需要学习的位置编码, 完全由正弦和余弦组成。公式是:
PE(pos, 2i) = sin( pos / 10000^(2i / d_model) ) PE(pos, 2i+1) = cos( pos / 10000^(2i / d_model) )
对每个位置 pos ∈ [0, max_len)、每个维度 d ∈ [0, d_model), 给出一个数 —— pos · freq 的 sin 或 cos,频率取决于维度索引。 相邻两维 (2i, 2i+1) 共享一个频率:一个是 sin,另一个是 cos。
关键洞察是多尺度的频率。低维(靠前的列)变化快 —— 它们区分相邻位置; 高维(靠后的列)变化慢 —— 它们只在大跨度上区分位置。 把整个 d_model 维向量合起来看,每个位置就是一份独一无二的 「多频率签名」—— 跟整数的二进制编码思路类似,只是用正弦余弦代替比特位。
为啥偏偏是这个公式?两个很漂亮的性质:
- 线性组合编码相对位置。因为三角恒等式sin(a + b) = sin a cos b + cos a sin b, 位置 pos + k 的编码可以写成位置 pos 编码的一个固定线性变换。 原则上,模型可以用一个权重矩阵学到「把 attention 向后移 k 个位置」。
- 没有最大长度。公式对任意位置都有定义,包括比训练时见过的 长得多的位置。实际上正弦余弦能外推到更长的序列 —— 虽然没那么理想, 因为模型还是得学着解读那些没见过的远位置。
怎么用。编码直接加到 token embedding 上, 然后再进第一层 attention:
x[pos] = embed(token_pos) + PE[pos] // 形状相同,逐元素相加
机制就这一步。然后 attention 照常跑。位置信息就装在每个 x[pos] 向量里跟着走。 attention 计算内部不做任何特殊处理 —— 它还是那个不知道顺序的操作, 只不过输入向量本身不再无视顺序了。
不那么完美的地方。把位置加到内容上,有点粗糙。 它假定模型在向量被加起来之后,还能区分「这个向量的 cat 性」和 「这个向量的位置 3 性」。实际上能用,但这正是新方法(RoPE、ALiBi) 想改进的那种设计选择。
学习式位置嵌入(GPT-2)
每个位置自带一行 embedding。让优化器自己琢磨这一行该长啥样。
BERT、GPT-2 和早期一堆 Transformer 把正弦余弦公式扔了,换了个朴素得多的做法: 把位置当 token 一样对待。开一张(max_len × d_model) 的学习式 embedding 表,每一行就是一个位置的编码。 按位置索引去查。完。
P = nn.Embedding(max_len, d_model) // 模型参数 x[pos] = embed(token_pos) + P[pos] // 加上去,跟正弦余弦一样
数据结构跟词嵌入表完全一样 —— 同一个调用,只是「词表」换成了位置。 训练时,梯度下降把每一行塑造成「位置 p 对语言建模任务有用的那点信息」。 模型自己琢磨「位置的语言」,不需要手工公式。
它做对的部分。两个,都不小。
- 简单。实现过词嵌入的人,实际上已经实现过位置嵌入了。 没有三角函数、没有频率调度、不需要多想。
- 对任务最贴合。模型不被强迫接受设计者的「位置该长得像正余弦」 假设。它可以学到对手头数据最有用的位置模式。
它做错的部分。一个灾难性的、一个小一点的。
- 不能外推,完全不行。表里就是 max_len 行。 如果模型训练时 max_len = 1024,推理时给它 1025 个 token, 没有那一行可查。位置 1024 从来没有自己的行,所以从来没收过梯度, 训练时也会数组越界。这一条性质 —— 不能处理比训练时见过的更长的序列 —— 就是把整个领域从学习式位置嵌入推走的根本原因。
- 没有内建的距离结构。位置 100 和位置 101 是相邻的。 正弦余弦公式让它们在低频维度上几乎一致。学习式表只是两行任意的参数 —— 参数化里没有任何东西说「它们该相似」。实际中模型通常会学到比较平滑的几行, 但那是训练的副产物,不是内建归纳偏置。
现在谁还在用?没几个了。BERT 和 GPT-2 用过,继承自它们的 finetune 也用。但凡想处理长上下文的模型 —— 现在几乎等于「凡是正经的 LLM」—— 都改成了相对位置编码或 RoPE。
RoPE —— 旋转位置编码
不加位置,改成转 Q 和 K,旋转角度编码位置。点积自然变成相对的。
前两种方法都是在万事之前往 token embedding 上叠一个位置向量。RoPE(Su 等,2021)走了完全不同的路:别动 token embedding。 在 attention 计算里头,把旋转作用到 Q 和 K 上。旋转角度跟 token 的位置有关。 就这。
具体讲:把 Q(和 K)每一对相邻维 (2i, 2i+1) 当成一个 2 维向量。 位置为 p 的 token,把这个 2 维向量旋转 p · θ_i, 其中每对角频率是
θ_i = 1 / 10000^(2i / d_model)
(是的,跟正弦余弦完全一样的多频率调度,只是改作他用)。 一对维度的旋转矩阵就是标准 2 维旋转:
[q'_2i ] [cos(p·θ_i) -sin(p·θ_i)] [q_2i ] [q'_2i+1] = [sin(p·θ_i) cos(p·θ_i)] [q_2i+1]
为啥这件事重要。两个旋转过的 2 维向量的点积有一个漂亮性质: 把两个旋转同一个角度,它们的点积不变;旋转不同角度, 点积是这两个角度差的函数。所以如果 Q 在位置 m、K 在位置 n:
⟨ rot(Q, m·θ) , rot(K, n·θ) ⟩ = 关于 (m − n)·θ 的某个函数
RoPE 之后的 attention 分数只取决于相对位置、跟绝对位置无关。 模型再也不用单独学「位置 4378 是啥意思」—— 它只看到位置差。 这正是原始 Transformer 设计者希望模型自己学到的归纳偏置, 现在直接烙进了数学里。
为啥现在大家都用 RoPE。几个原因:
- 白送一份相对位置。不多加参数、不多加计算 —— 只是 对 Q 和 K 作了旋转。每个 attention 头自动在分数里看到相对位置。
- 长上下文泛化。因为没有固定查找表,RoPE 原则上在任何位置都能用。 配上小巧的内插技巧(Position Interpolation、NTK-aware scaling、YaRN), 训练时只见过 4K token 的模型,可以扩展到 32K、128K、甚至 1M, 只需要很短的 fine-tune。
- 只作用在 Q 和 K 上,不作用在 V 上。一个不那么显眼但很有用的不对称:V 不旋转, 所以混进输出的「内容」本身不直接带位置信息。位置只影响谁关注谁,不影响传递什么。这比把位置叠进 value 通路要干净。
- 经验上就是更好。2021 年的 RoPE 论文里收益不太大, 但长上下文时代到来后,它一举成了主流 —— 2022 年起几乎每个开源 LLM 都用它。 Llama 1、2、3。Qwen。Mistral。DeepSeek。PaLM。Gemma。基本是个齐全的清单。
再把全局画一遍。每个 attention 层里头:
Q = x · W_Q, K = x · W_K, V = x · W_V Q ← RoPE(Q, positions) K ← RoPE(K, positions) A = softmax( Q · Kᵀ / √d_k ) out = A · V
多两行,不多任何参数。结果是一个原生就懂相对位置的 attention 块, 没有什么 max-length 写死,稍加调教就能优雅外推。这就是现代每个开源 LLM 都用它的原因。
走到哪了。19 篇 primer 下来,现代 Transformer 一个 attention 块 从头到尾的数据路径我们都搭起来了:分词 → embedding → RoPE 旋转过的 Q/K 跨头切分 → 缩放点积注意力 → concat + W_O。下一篇 primer 是 Transformer 块剩下的部分 —— layer norm、残差连接,以及围着 attention 的前馈 MLP。