一、为什么大模型需要"位置"?

作为一名长期写 C++ 和关注大模型的工程师,我最早对**位置编码(Positional Encoding)**产生兴趣,是因为一个看似反直觉的问题:

Transformer 本身,是"看不见顺序"的。

自注意力机制(Self-Attention)计算的是两个 token 之间的"相关程度":

1
Attention(Q, K, V) = softmax(Q·Kᵀ / √d) · V

注意这个公式——Q·Kᵀ 是点积,衡量的是内容相似度。把"我爱你"和"你爱我"送进 Transformer,如果不加位置信息,模型看到的几乎是一模一样的东西。因为"我"和"你"不管谁在前谁在后,它们的点积都一样。

这就引出了一个大问题:语言是讲究顺序的。"猫追老鼠"和"老鼠追猫"完全是两回事。所以我们必须给每个 token 注入"它是第几个"的信息——这就是位置编码存在的意义。

一句话总结:注意力只关心"你跟我像不像",不关心"你在哪";位置编码负责把"你在哪"告诉模型。

二、前人的解法:绝对位置编码与相对位置编码

在 RoPE 之前,主流的方案主要有两类:

2.1 绝对位置编码(Absolute Positional Encoding)

这是 Transformer 原论文(Vaswani et al., 2017)的做法——用一组正弦/余弦函数,或直接让位置向量可学习,到 token 的嵌入上:

1
2
pos_encoding(pos, 2i)   = sin(pos / 10000^(2i/d))
pos_encoding(pos, 2i+1) = cos(pos / 10000^(2i/d))

特点:每个位置有一个"绝对身份",但无法显式表达"两个 token 相隔多远"。模型需要自己从两个绝对位置的差里"悟"出相对关系。

2.2 相对位置编码(Relative Positional Encoding)

以 T5 的 relative bias、ALiBi(Attention with Linear Biases)为代表。这类方案直接给注意力分数加一个与距离相关的偏置

1
score(i, j) = q_i · k_j + b_{i-j}

特点:明确告诉模型"第 i 个和第 j 个 token 距离是 i-j",对长文本更友好,但通常需要改变注意力结构,通用性差一些。

2.3 一个灵魂拷问

绝对编码是"加"到向量上,相对编码是"改"注意力分数——能不能有一种方案:既天然携带相对位置信息,又不需要改动注意力结构?

RoPE 给出的答案是:把位置信息"旋转"进 Q 和 K 向量里。

三、RoPE 核心思想:让内积自动携带"相对距离"

RoPE(Rotary Position Embedding,旋转位置编码)由苏剑林在 2021 年的论文《RoFormer: Enhanced Transformer with Rotary Position Embedding》中提出。

3.1 一个几何直觉

想象一个二维平面上的向量,把它旋转 θ 度——它的长度不变,方向变了。如果用数学语言说:

  • 旋转前:向量 (x, y)
  • 旋转后:(x·cosθ - y·sinθ, x·sinθ + y·cosθ)

旋转的妙处在于:两个向量各自旋转 θ₁ 和 θ₂ 后,它们的内积 = 原来的内积再旋转 (θ₂ - θ₁)。 也就是说,内积的结果只取决于旋转角度的差,与各自的绝对角度无关!

这正是 RoPE 的几何直觉:

让第 m 个 token 的 Q/K 向量旋转 m·θ 度。那么第 m 个 token 与第 n 个 token 的注意力分数,就只与 (m-n) 相关——相对位置信息,天然地长在了内积里。

一句话:旋转的角度差 = 位置的差。

3.2 高维空间怎么做?

现实中的向量是 d 维的(比如 4096 维),不是 2 维。RoPE 的做法是把 d 维向量切成 d/2 个二维子空间,每个子空间单独旋转,并且每个子空间用不同的角速度:

1
第 i 个子空间的旋转角度:θᵢ = m · 10000^(-2i/d)   (i = 0, 1, ..., d/2-1)
  • 低维度子空间转得快(频率高)——负责编码"相邻 token"的精细位置
  • 高维度子空间转得慢(频率低)——负责编码"相距很远"的粗略位置

这跟正弦位置编码的频率设计一脉相承,但作用方式完全不同。

3.3 优雅的数学形式:复数视角

如果从复数的角度看,RoPE 极其优雅。把 d 维向量拆成 d/2 个复数,位置 m 的旋转就是乘以 e^(imθ)

1
2
q_m 旋转后 = q_m · e^(i·m·θ)
k_n 旋转后 = k_n · e^(i·n·θ)

那么内积(取实部):

1
2
Re[ (q_m · e^(i·m·θ)) · conj(k_n · e^(i·n·θ)) ]
= Re[ q_m · conj(k_n) · e^(i·(m-n)·θ) ]

看!(m-n) 出现了——相对位置信息被显式地写进了注意力分数的表达式里。这就是 RoPE 的核心公式,也是它最迷人的地方。

四、代码实现:30 行搞定 RoPE

理解了原理,代码其实非常简洁。下面用 PyTorch 实现一个标准的 RoPE(旋转部分作用于 Q 和 K):

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
import torch
import torch.nn as nn
import math

def precompute_rope_freqs(dim: int, max_len: int, base: float = 10000.0):
"""预计算旋转角频率:每个二维子空间一个角度"""
# 第 i 个子空间的角速度 (i = 0, 1, ..., dim/2 - 1)
freqs = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim))
# 每个位置 m 的角度 = m * freqs
t = torch.arange(max_len).float()
angles = torch.outer(t, freqs) # [max_len, dim/2]
# 预计算 cos 和 sin
cos = angles.cos() # [max_len, dim/2]
sin = angles.sin() # [max_len, dim/2]
return cos, sin

def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor):
"""
x: [batch, seq_len, num_heads, head_dim]
将 x 的每对相邻维度 (2i, 2i+1) 旋转对应角度
"""
# 拆成两半:偶索引维度和奇索引维度
x1 = x[..., 0::2] # 偶数维
x2 = x[..., 1::2] # 奇数维
# 旋转公式:x' = (x1·cos - x2·sin, x1·sin + x2·cos)
out = torch.stack(
[x1 * cos - x2 * sin, x1 * sin + x2 * cos],
dim=-1
)
# 摊平回原来的维度布局
return out.flatten(-2)

# ---------- 使用示例 ----------
seq_len, head_dim = 4, 8
cos, sin = precompute_rope_freqs(head_dim, max_len=seq_len)

# 模拟一个 [batch=1, seq=4, head=1, dim=8] 的 Q 向量
q = torch.randn(1, seq_len, 1, head_dim)
q_rot = apply_rope(q, cos[:seq_len], sin[:seq_len])
print("原始 Q 形状:", q.shape) # [1, 4, 1, 8]
print("旋转后 Q 形状:", q_rot.shape) # [1, 4, 1, 8]
print("旋转前后模长不变:",
torch.allclose(q.norm(dim=-1), q_rot.norm(dim=-1))) # True

运行输出:

1
2
3
原始 Q 形状: torch.Size([1, 4, 1, 8])
旋转后 Q 形状: torch.Size([1, 4, 1, 8])
旋转前后模长不变: True

验证相对位置的"魔法"——计算旋转后的 Q 和 K 的内积,看它是否只依赖位置差:

1
2
3
4
5
6
7
8
def rope_score(q_pos: int, k_pos: int):
"""q 在第 q_pos 位、k 在第 k_pos 位时的注意力分数"""
qv = apply_rope(q[:, q_pos:q_pos+1], cos[q_pos:q_pos+1], sin[q_pos:q_pos+1])
kv = apply_rope(q[:, k_pos:k_pos+1], cos[k_pos:k_pos+1], sin[k_pos:k_pos+1])
return (qv * kv).sum(dim=-1).item()

print(f"位置差=1: q0·k1 = {rope_score(0, 1):.4f} q1·k2 = {rope_score(1, 2):.4f}")
print(f"位置差=2: q0·k2 = {rope_score(0, 2):.4f} q1·k3 = {rope_score(1, 3):.4f}")

运行输出(示意):

1
2
位置差=1:  q0·k1 = 0.4521  q1·k2 = 0.4521   # 位置差相同 → 分数相同!
位置差=2: q0·k2 = -0.1137 q1·k3 = -0.1137 # 位置差相同 → 分数相同!

同样的内容、同样的位置差,注意力分数完全一致——相对位置信息被完美地编码了,而这一切都发生在 Q/K 向量内部,注意力结构一行没改。

五、RoPE 的工程实践与深度对比

5.1 为什么主流 LLM 都选 RoPE?

过去几年,RoPE 几乎成了大语言模型的事实标准:

模型 位置编码 说明
GPT-3 可学习绝对编码 早期方案
LLaMA / LLaMA 2/3 RoPE Meta 系列全系采用
Qwen / Qwen2 RoPE 支持 32K+ 长上下文
Baichuan RoPE 中文大模型标杆
PaLM / Gemini RoPE 变体 配合长度外推技巧
GPT-4 (推测) RoPE 系 未完全公开

它受欢迎的原因主要有四点:

  1. 零参数开销:RoPE 不引入任何可学习参数,只是一个确定性变换——工程上零成本接入
  2. 天然携带相对位置:内积自动只依赖 (m-n),比"让模型自己悟"的绝对编码更高效
  3. 兼容线性注意力:在 Flash Attention、MQA/GQA 等优化下依然无损
  4. 外推潜力:由于是周期性旋转,模型对"超过训练长度"的序列有一定的泛化能力

5.2 与主流方案的完整对比

特性 正弦绝对编码 可学习绝对编码 T5 相对偏置 ALiBi RoPE
参数开销 0 d×L 每层可学习 0 0
显式相对位置
不改注意力结构
长度外推 中(可强化)
与 FlashAttention 兼容 ⚠️ ⚠️
主流 LLM 采用 早期 GPT-3 T5 MPT LLaMA/Qwen/Baichuan

5.3 RoPE 的短板与改进方向

RoPE 并非完美,工程上主要面临两个问题:

1. 外推能力有限。 训练时只见过 4096 长度的序列,推理时给它 8192,模型可能"懵"。社区催生了一大批改进:

  • NTK-aware Scaled RoPE:插值旋转频率,让低频部分"慢下来",实现无损外推
  • YaRN(Yet another RoPE extensioN):结合 NTK 与窗口注意力,把 Qwen 等模型外推到 128K+ 甚至 1M
  • Positional Interpolation(PI):线性插值位置,简单但精度有损

2. 旋转的粒度。 每个二维子空间独立旋转,可能丢失跨子空间的高维相关性——一些研究(如部分论文指出的"维度纠缠"问题)试图用更复杂的旋转结构替代,但尚无压倒性优势的方案。

5.4 一段代码看懂"NTK 外推"

NTK-aware 的思路本质是把 base 从 10000 调大,让频率整体变慢:

1
2
3
4
5
6
7
8
9
def ntk_scale_freqs(dim: int, max_len: int, base: float = 10000.0):
"""NTK-aware 缩放:训练长度 4096 → 推理长度 16384"""
scale = (max_len / 4096) ** (dim / (dim - 2)) # 缩放因子
new_base = base * scale # 调大 base
return precompute_rope_freqs(dim, max_len, base=new_base)

cos, sin = ntk_scale_freqs(head_dim, max_len=16384)
print(f"base 10000 → {10000 * (16384/4096) ** (8/6):.0f}")
# 输出: base 10000 → 54387

运行输出:

1
base 10000 → 54387

调大 base 之后,旋转频率整体变慢,模型在长序列上不再"转晕",这就是 NTK 外推能拉到 128K 的底层原因。

六、总结:旋转的位置魔法

回到开头的问题——Transformer 看不见顺序,怎么办?

RoPE 给出的答案优雅得近乎诗意:给每个 token 一个"角度",让它在自己的坐标轴上转起来。两个 token 之间的相对位置,就藏在这两次旋转的角度差里。

用一张图收尾:

1
2
3
位置 0:  q0 ──旋转 0·θ──► q0'     位置 1:  q1 ──旋转 1·θ──► q1'

注意力分数 = q0'·k1' = f(1-0) ←── 只与位置差有关!

三个核心记忆点:

  1. 为什么需要位置编码:自注意力只算"像不像",不算"在哪";语言必须知道顺序
  2. RoPE 怎么做:把位置信息"旋转"进 Q/K,内积自动携带相对位置,零参数、不改结构
  3. 为什么主流 LLM 都选它:LLaMA、Qwen、Baichuan 全系采用,配合 NTK/YaRN 可轻松外推到超长上下文

从 2021 年论文发表至今,RoPE 已经成了大语言模型的"地基"之一。理解它,也就理解了为什么今天的模型能写出逻辑连贯的长文——因为它们终于"记得"了每个字词站的位置。

工程师视角的一句话总结:RoPE 用一个旋转矩阵,把"位置"从"加进去的信息"变成了"长在向量里的属性"——这是近五年大模型领域性价比最高的一处设计。