Attention 的核心计算是对一组 token 加权求和——和数学上的"集合"操作同构:把同一组 token 打乱顺序,每个 token 看到的上下文完全不变。这叫 permutation-invariant(置换不变)。对 RNN/CNN 这不是问题(它们的卷积窗 / 时间步天然带顺序),但对纯 Attention 来说,"我爱你" 和 "你爱我" 在它眼里就是同一个 bag of tokens。
所以必须额外往输入里注入"我在第几位"这一信号。这就是位置编码(Positional Encoding,PE)。今天看四种:固定正弦(2017 原版)、可学习绝对位置(GPT-2/BERT 用过)、RoPE(Llama / Qwen / DeepSeek 当今主流)、ALiBi(外推友好的偏置方案)。它们对应一条很清楚的演化主线:从"加在输入上的标签",到"嵌进 attention 计算里的几何"。
像给每个位置发一张多频段的"时钟读数"卡片——位置 0 是全零,位置 1 是各频率指针走一格,位置 1000 是各指针走 1000 格。低频指针变化慢、负责"段落级远距离",高频指针变化快、负责"邻近精细"。后端类比:相当于在每条记录里塞了多分辨率的复合时间戳(年/月/日/秒),模型自己挑用哪个尺度。
痛点:Attention 是置换不变的,必须从外部告诉模型每个 token 的位置。最朴素的做法是把"位置 i"编码成一个 d 维向量 加到 token embedding 上。问题是怎么编。Attention Is All You Need(Vaswani 2017)给了一个没有可学习参数的优雅方案:
PE(pos, 2i) = sin(pos / 100002i/d)
PE(pos, 2i+1) = cos(pos / 100002i/d)
每个符号:pos 是 token 在序列里的位置(0,1,2,…),i 是 embedding 维度索引(0…d/2−1),d 是 embedding 总维度。把维度成对分组,每对用一个 (sin,cos) 配对,波长从 2π 几何增长到 10000·2π。低 i 高频 → 邻近敏感;高 i 低频 → 远距离敏感。
为什么用 sin/cos?关键性质:对任意固定偏移 k,PE(pos+k) 都可以用一个只依赖 k 的线性变换从 PE(pos) 得到(三角函数的和角公式)。这给模型一个"潜在的相对位置可学习性"——只要它愿意,就能从两个绝对编码里隐式提取相对距离。另一个好处:训练时没见过的 pos 也能算(公式直接代),理论上可外推,但实测外推效果一般。
import torch, math def sinusoidal_pe(max_len, d): pe = torch.zeros(max_len, d) pos = torch.arange(max_len).unsqueeze(1) # (L, 1) # 几何分布的频率:维度越大频率越低 div = torch.exp(torch.arange(0, d, 2) * (-math.log(10000.0) / d)) pe[:, 0::2] = torch.sin(pos * div) # 偶数维放 sin pe[:, 1::2] = torch.cos(pos * div) # 奇数维放 cos return pe pe = sinusoidal_pe(max_len=512, d=128) print(pe.shape) # (512, 128) # 使用时:x = token_embedding + pe[:seq_len] ← 加,不是拼
把"位置"当成一张和 token 同等地位的查找表:词表里有 vocab_size 个词向量,位置表里有 max_len 个位置向量,训练时一起学。后端类比:相当于多建一张"位置维表",按 row_id 关联回主表——简单粗暴但够用。
痛点:正弦编码是"人拍脑袋设计的频率",没人能保证 sin/cos 就是最优的位置基底。可学习方案直接放弃假设:"既然神经网络擅长学,那位置编码也让它自己学"。
机制:建一个 nn.Embedding(max_len, d),里面 max_len 行、每行是一个 d 维向量。输入第 i 个位置时查表取出第 i 行,加到 token embedding 上。BERT、GPT-1/2 都用这种方案。关键限制:你必须在训练时就固定 max_len(如 BERT 是 512),跑序列长度 = 511 没问题,跑 513 就完全没对应的向量——模型连"那个位置是什么样"都没学过,不能外推。这也是 BERT 系列在长文档上必须做 chunk 的根本原因。
import torch, torch.nn as nn class LearnedPE(nn.Module): def __init__(self, max_len=512, d=128): super().__init__() # 一张 (max_len, d) 的可学习查找表,和词向量同等地位 self.pos = nn.Embedding(max_len, d) def forward(self, x): # x: (batch, L, d) L = x.size(1) ids = torch.arange(L, device=x.device) # 0,1,...,L-1 return x + self.pos(ids) # 广播加到每个 token pe = LearnedPE(max_len=512, d=128) print(pe(torch.randn(2, 128, 128)).shape) # (2,128,128) OK # 喂 L=600 会 IndexError → 没有第 600 行,根本不能外推
max_position_embeddings + position_embedding_type=absolute,基本就是这种方案——上下文窗硬上限,超了直接报错。这种模型扩窗只能重训。不在输入上加标签,而是把 Q、K 向量按位置旋转一个角度。两个 token 做点积时,旋转差 = 它们的相对距离——位置信息直接以"两向量夹角差"的几何形式存在 attention 里。后端类比:从"在每条记录里塞时间戳字段"升级到"按时间戳给坐标做旋转",关联运算自然就带上了时间差。
痛点:前两种 PE 是绝对位置,但语言关系本质上是相对的——"形容词修饰它后面紧跟的名词"和"主谓相距两个词"这种规律和"在句子第几位"无关。把绝对位置加到 token 上,模型还得自己费劲从两个绝对位置反推相对距离。能不能直接编码相对距离?
RoPE(Su Jianlin 等,RoFormer 2021)的洞察:不动 token embedding,改 Q 和 K。把 d 维 Q / K 向量两两配对成 d/2 个 2D 平面,每个平面绕原点旋转一个角度 m·θi(m 是 token 位置,θi 是该平面的固有频率,跟正弦 PE 一样几何分布)。核心公式(一个 2D 平面):
RoPE(q, m) = Rm · q 其中 Rm = [[cos(mθ), −sin(mθ)],
[sin(mθ), cos(mθ)]]
每个符号:q 是这一对维度的 2D 子向量,m 是 token 位置,θ 是这对维度的频率(高维度低频率,跟正弦同样设计)。Rm 是绕原点的二维旋转矩阵。
关键性质(也是 RoPE 的魔法):旋转后做点积,
(Rm q)T (Rn k) = qT RmT Rn k = qT Rn−m k
右边只依赖 n−m(相对距离)——绝对位置在做完点积后自动消掉,剩下只剩相对位置。一个看似不起眼的旋转,干净地把"绝对编码"变成了"相对距离编码"。Llama、Qwen、DeepSeek、Mistral 全部在用。
import torch def apply_rope(x, theta_base=10000.0): # x: (batch, seq, d) , d 必须偶数; 这里给出最小可读版本 b, L, d = x.shape half = d // 2 # 每对维度的固有频率,几何分布:高维度低频 freq = 1.0 / (theta_base ** (torch.arange(0, half).float() / half)) pos = torch.arange(L).float() ang = torch.outer(pos, freq) # (L, d/2) 每个位置 × 每个频率 cos, sin = ang.cos(), ang.sin() x1, x2 = x[..., :half], x[..., half:] # 把 d 拆成两半配对 # 二维旋转:(x1, x2) → (x1·cos − x2·sin, x1·sin + x2·cos) out1 = x1 * cos - x2 * sin out2 = x1 * sin + x2 * cos return torch.cat([out1, out2], dim=-1) # 实际使用:在 attention 内对 Q 和 K 各 apply 一次,V 不动 q = torch.randn(1, 8, 64); q_rot = apply_rope(q) print(q_rot.shape) # (1, 8, 64) 形状不变,位置已嵌入几何
更激进:根本不要位置编码这种"独立信号",直接在 attention 的相似度分数上按距离扣分——隔得越远,penalty 越大。后端类比:相当于在 SQL 的 ORDER BY 后面追加 − DISTANCE_PENALTY,让排序里天然带"近的优先",不必额外建一张位置维表。
痛点:前面所有方案——正弦、可学习、RoPE——都要先构造一个位置向量再融进计算。ALiBi(Press 2022)问了一个反向问题:能不能不要 PE 向量,直接在 attention score 上加一个距离相关的 bias 就完事?
机制:朴素 attention 算 score(i,j) = qi·kj/√d。ALiBi 改成:
score(i, j) = qi·kj/√d − mh · |i − j|
每个符号:|i−j| 是两个 token 的相对距离(在因果模型里只看 j ≤ i),mh 是每个 head 固定的斜率(slope)——论文给的是几何序列(如 1/2, 1/4, 1/8, …, 1/256),不同 head 用不同斜率:斜率大的 head 强烈关注邻近,斜率小的 head 能看更远。token embedding 不动、Q/K 不动,只在 softmax 前的分数上减一个跟距离成正比的数,就完成了位置注入。
为什么外推强?因为 bias 只是 m·|i−j|,对任意大的 i−j 都有定义且行为一致——模型没见过 8192 长度,但 "距离 8000 比距离 4000 的 penalty 更大" 这个单调规律它已经学过。论文报告:在 1024 上训练、直接推 2048,质量持平甚至好过专门训到 2048 的正弦 PE 模型。BLOOM、MPT 等模型采用。
import torch def alibi_bias(seq_len, n_heads): # 每个 head 一个固定斜率,几何分布:论文里 head h 的斜率 ~ 2^(-8h/n) slopes = torch.tensor([2 ** (-8.0 * (h + 1) / n_heads) for h in range(n_heads)]) # 距离矩阵 |i - j| i = torch.arange(seq_len).unsqueeze(1) j = torch.arange(seq_len).unsqueeze(0) dist = (j - i).float() # 因果模型里只取 j<=i 部分 # 每个 head 一个 (L,L) 偏置,广播到 (n_heads, L, L) bias = slopes[:, None, None] * dist[None, :, :] return bias # 加到 softmax 前的 score 上 bias = alibi_bias(seq_len=8, n_heads=4) print(bias.shape) # (4, 8, 8) 4 个 head 各一张 # 训练用 1024,推理直接喂 8192 也能跑 → 外推友好