位置编码 入门

自注意力把序列当作一个集合:洗牌 token,每个 token 的 attention 输出 也不会变。语言不是集合 —— 词序是要紧的。Transformer 的解决办法是把位置信息注入每个 token。4 个短主题:为啥 attention 不知道顺序;正弦余弦编码(原始 Transformer 论文);学习式位置嵌入(GPT-2);以及RoPE(旋转位置编码) —— Llama、Qwen、DeepSeek 和现代大多数开源 LLM 用的方案。

01

为啥 attention 不知道顺序

把输入 token 洗牌,得到的就是洗了牌的输出。attention 看到的是一袋向量、不是一段序列。

我们一直在讲得好像 attention「知道」catsat 前面。 回看公式 softmax(Q · Kᵀ / √d_k) · V。注意它里面没什么: 位置索引。点积 Q[i]·K[j] 用的是两个 token 的内容。它不用它们在句子里哪儿。结果就是 attention 有一种叫置换等变(permutation equivariance)的性质: 如果你洗 input 的 token,output 的 token 就以同样洗过的顺序冒出来, 但向量本身不变。

把 token 顺序打乱 —— attention 看不出区别输入:"cat sat"inputcatpos 00.800.300.500.20satpos 10.100.600.900.40两个 token 的 embedding,原本的顺序。
1 / 4
没有位置信息时,自注意力是置换等变的:同样的 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 (现代折中,把两边的好处都拿过来)。

02

正弦余弦编码(原始 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。

正弦余弦位置编码 —— pos × dim 热图空网格 —— 12 个位置 × 8 维dim →pos ↓0123456701234567891011要填的是 PE(pos, dim) = sin 或 cos,自变量是 pos · freq(dim)。
1 / 4
每个 (pos, dim) 格子是 sin 或 cos,频率随 dim 变。相邻两个 dim 共享一个频率(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) 想改进的那种设计选择。

03

学习式位置嵌入(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]   // 加上去,跟正弦余弦一样
学习式位置嵌入 —— 一张查找表空表 —— max_len 行 × d_model 列P(max_len × d_model)01234567训练里会见到的每个位置都给开一行。
1 / 4
跟词嵌入一个套路:nn.Embedding(max_len, d_model)。每个位置一份自己的学到的向量。实现简单。超过 max_len 就外推不了。

数据结构跟词嵌入表完全一样 —— 同一个调用,只是「词表」换成了位置。 训练时,梯度下降把每一行塑造成「位置 p 对语言建模任务有用的那点信息」。 模型自己琢磨「位置的语言」,不需要手工公式。

它做对的部分。两个,都不小。

  • 简单。实现过词嵌入的人,实际上已经实现过位置嵌入了。 没有三角函数、没有频率调度、不需要多想。
  • 对任务最贴合。模型不被强迫接受设计者的「位置该长得像正余弦」 假设。它可以学到对手头数据最有用的位置模式。

它做错的部分。一个灾难性的、一个小一点的。

  • 不能外推,完全不行。表里就是 max_len 行。 如果模型训练时 max_len = 1024,推理时给它 1025 个 token, 没有那一行可查。位置 1024 从来没有自己的行,所以从来没收过梯度, 训练时也会数组越界。这一条性质 —— 不能处理比训练时见过的更长的序列 —— 就是把整个领域从学习式位置嵌入推走的根本原因。
  • 没有内建的距离结构。位置 100 和位置 101 是相邻的。 正弦余弦公式让它们在低频维度上几乎一致。学习式表只是两行任意的参数 —— 参数化里没有任何东西说「它们该相似」。实际中模型通常会学到比较平滑的几行, 但那是训练的副产物,不是内建归纳偏置。

现在谁还在用?没几个了。BERT 和 GPT-2 用过,继承自它们的 finetune 也用。但凡想处理长上下文的模型 —— 现在几乎等于「凡是正经的 LLM」—— 都改成了相对位置编码或 RoPE。

04

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]
RoPE —— 把 Q(和 K)按 p · θ 在 2D 对里旋转pos 0 的 Q —— 不旋转dim 2idim 2i+1QQ 的某一对 2D 维。pos 0 时画在 +x 轴上。
1 / 4
把每对 (2i, 2i+1) 维当成一个 2D 向量,按角度 p · θ_i 旋转。旋转完之后,⟨Q_m, K_n⟩ 只取决于 (m − n) —— 相对位置直接进了 attention 分数。

为啥这件事重要。两个旋转过的 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。