词相同但顺序不同,注意力怎样辨别位置?从正弦编码到 RoPE
从自注意力的置换等变性出发,比较正弦与学习式绝对位置,并推导 RoPE 如何把相对位移写进 Query–Key 点积。
上一篇把自回归推理改造成 KV Cache:历史 Key/Value 可以复用,新 token 只需用自己的 Query 读取缓存。但那里留下了一个不能靠缓存解决的问题:新 token 明明位于全局位置 ,若每一步都把它当成位置 0,增量前向就不再等价于完整前向。
原因更基础:自注意力只比较内容,本身没有“第几个 token”的坐标。位置表示(Positional Representation)要补上的不是一个装饰性编号,而是让模型能够区分顺序、方向与距离。本文比较三种紧密相关的方法,并重点推导旋转位置编码(Rotary Position Embedding, RoPE)为何能把相对位移直接写进 Query–Key 点积。
01 没有位置时,注意力究竟看不见什么?#
设输入 ,忽略 mask 时:
若用同一个置换矩阵 重排 token 行,则 都被同样重排,结果满足:
这叫置换等变(Permutation Equivariance):输入行换顺序,输出行只会跟着换顺序。模型能看到有哪些 token,却没有天然坐标说明谁先谁后。
输入 A: [狗, 咬, 人] ─► 只按内容投影 Q/K/V ─► 输出 [o狗, o咬, o人]
输入 B: [人, 咬, 狗] ─► 以同样方式重排行 ─► 输出 [o人, o咬, o狗]
没有位置表示时,模型没有额外信号区分:
“狗在咬之前” 与 “狗在咬之后”text因果 mask 能阻止位置 读取未来 ,但它只规定“能连哪条边”,不告诉模型两个可见 token 相隔 1 步还是 100 步。位置表示与 mask 解决的是不同问题。
02 位置信息可以注入哪一处?#
三种常见思路的关键差异,不是名字,而是位置进入数据流的位置:
绝对位置:
token embedding [N,L,D] + position [L,D]
│
▼
hidden [N,L,D] ─► Q/K/V projection ─► attention
RoPE:
hidden [N,L,D] ─► Q/K projection ─► 按位置旋转 Q、K ─► QKᵀ
└───────► V 通常不旋转 ─────► 加权和
相对偏置(边界比较):
QKᵀ [N,H,L,S] + bias(查询位置 - 键位置) ─► softmaxtext| 方法 | 位置进入哪里 | 是否学习位置参数 | 直接表达什么 |
|---|---|---|---|
| 正弦绝对位置 | 加到输入 hidden | 否 | 每个位置的多频率绝对坐标 |
| 学习式绝对位置 | 加到输入 hidden | 是 | 训练数据自行塑造的离散位置向量 |
| RoPE | 旋转每个头的 Q 与 K | 否 | 绝对角度产生的相对 Q–K 几何关系 |
这三种方法都不自动处理 padding,也不替代 causal mask。位置只提供坐标;可见性仍由 mask 决定。
03 正弦位置编码怎样把整数变成多尺度坐标?#
原始 Transformer 对偶数维 定义多组频率:
位置 的第 个二维坐标对为:
高频对随位置快速旋转,擅长区分近邻;低频对变化缓慢,提供更大尺度的坐标。把所有频率拼起来得到 ,再与 token embedding 相加:
位置 p ─► [快频率 sin/cos | 中频率 sin/cos | 慢频率 sin/cos] ─► PE(p) [D]
token id ───────────────────────────────► embedding [D]
│ 相加
▼
X_p [D]text为什么每个频率要成对使用正弦和余弦?因为平移 对每一对坐标都是固定旋转:
对固定 ,右侧矩阵与绝对位置 无关。线性层因此有机会从绝对坐标中恢复相对偏移,但这种关系只是“可被利用”,并没有像 RoPE 那样直接约束 Q–K 点积。
04 用一个频率手算绝对坐标与相对距离#
取单个频率 :
| 位置 | |
|---|---|
| 0 | [0, 1] |
| 1 | [1, 0] |
| 2 | [0, -1] |
| 3 | [-1, 0] |
任意两个位置向量的点积为:
所以位置 (0,1) 与 (2,3) 虽然绝对编号不同,但位移都为 1,点积都等于 0。位置 (0,2) 与 (1,3) 的位移都为 2,点积都等于 。
真实编码用许多频率,不同频率会给同一位移不同相位。模型不是从单个数字读取“距离”,而是从多尺度相位组合中学习位置关系。
05 不依赖框架封装,生成正弦位置#
下面函数返回 [L,D],偶数列放正弦、奇数列放余弦。它故意把频率和角度留在代码中,便于检查每个张量。
import math
import torch
def sinusoidal_positions(
length: int,
model_dim: int,
*,
device: torch.device | None = None,
) -> torch.Tensor:
assert model_dim % 2 == 0
positions = torch.arange(
length, device=device, dtype=torch.float32
)[:, None] # [L,1]
pair_index = torch.arange(
0, model_dim, 2, device=device, dtype=torch.float32
) # [D/2]
frequencies = torch.exp(
-math.log(10000.0) * pair_index / model_dim
) # [D/2]
angles = positions * frequencies[None, :] # [L,D/2]
encoding = torch.empty(length, model_dim, device=device)
encoding[:, 0::2] = angles.sin()
encoding[:, 1::2] = angles.cos()
return encoding # [L,D]
token_hidden = torch.randn(2, 5, 8) # [N=2,L=5,D=8]
position = sinusoidal_positions(5, 8) # [5,8]
positioned = token_hidden + position[None, :, :] # [2,5,8]
assert positioned.shape == token_hidden.shape
torch.testing.assert_close(position[0, 0::2], torch.zeros(4))
torch.testing.assert_close(position[0, 1::2], torch.ones(4))python正弦表不需要梯度。生产模块若预先缓存它,可用 register_buffer ↗ 注册为 buffer:它不是可训练参数,但会跟随模块迁移设备;persistent=False 还可避免把可重建的大表写进 state_dict。
06 学习式绝对位置改变了什么?#
学习式绝对位置(Learned Absolute Position Embedding)直接建立参数表 ,其中 是最大位置数。第 行与 token embedding 相加:
from torch import nn
class LearnedAbsoluteInput(nn.Module):
def __init__(self, vocab_size: int, model_dim: int, max_length: int):
super().__init__()
self.max_length = max_length
self.token = nn.Embedding(vocab_size, model_dim)
self.position = nn.Embedding(max_length, model_dim)
def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
# token_ids: [N,L]
n, length = token_ids.shape
assert length <= self.max_length
position_ids = torch.arange(length, device=token_ids.device) # [L]
token = self.token(token_ids) # [N,L,D]
position = self.position(position_ids) # [L,D]
return token + position[None, :, :] # [N,L,D]
inputs = LearnedAbsoluteInput(vocab_size=1000, model_dim=64, max_length=512)
hidden = inputs(torch.randint(0, 1000, (3, 20)))
assert hidden.shape == (3, 20, 64)pythonPyTorch 2.13 的 nn.Embedding ↗ 接收任意形状的整数索引 (*),输出 (*, embedding_dim);其权重形状为 [num_embeddings, embedding_dim]。这里 token 表与 position 表都是查表,但行的语义完全不同。
学习式位置能让数据自行决定哪些位置应相似,却有一个硬边界:位置 根本没有参数行。直接增大 后,新行也未受训练。正弦编码可以计算未见过的整数位置,但“函数有值”不等于模型能可靠外推到任意长度。
07 RoPE 为什么旋转 Q/K,而不是加到 hidden?#
RoPE 把每个注意力头的 Query 和 Key 沿最后一维两两分组。对第 对维度、位置 ,用二维旋转矩阵:
其中 , 是每头宽度。对未加位置的内容向量 :
注意力分数变为:
最后一式只通过 依赖位置。若把 Query 与 Key 同时平移 :
点积不变。这就是 RoPE 的核心:每个 Q/K 仍按绝对位置旋转,但二者相乘时显式出现相对位移。
content q_p [d] ─► R(p) ─► rotated q_p ─┐
├─ dot / sqrt(d) ─► score(p,s)
content k_s [d] ─► R(s) ─► rotated k_s ─┘
共同平移:p→p+Δ, s→s+Δ
相对位移:(s+Δ)-(p+Δ)=s-p ─► score 的位置部分不变textValue 通常不旋转,因为 RoPE 的目标是改变“Query 与哪个 Key 相似”,而不是直接把位置混进被加权取回的内容。
08 用二维向量手算一次旋转点积#
只取一对维度,令 ,角频率 。
查询在 ,Key 在 :
旋转后点积为 。用相对形式计算:
再把二者都向右平移 4 个位置,得到 。相对位移仍是 2,所以点积仍为 。
注意,这不意味着 RoPE 分数只由距离决定。完整表达式仍含内容向量 ;RoPE 只是让位置对点积的作用具有相对结构。
09 用 PyTorch 写出透明 RoPE 与注意力数据流#
下面实现接受 Query/Key x [N,H,L,d] 和位置 position_ids [L] 或 [N,L]。角度先以 float32 计算,再把正余弦转换到输入 dtype,避免在长位置上过早用低精度累计角度误差。
import math
import torch
from torch.nn import functional as F
def apply_rope(
x: torch.Tensor, # [N,H,L,d]
position_ids: torch.Tensor, # [L] or [N,L]
base: float = 10000.0,
) -> torch.Tensor:
n, heads, length, head_dim = x.shape
assert head_dim % 2 == 0
if position_ids.ndim == 1:
assert position_ids.shape == (length,)
position_ids = position_ids[None, :].expand(n, -1) # [N,L]
else:
assert position_ids.shape == (n, length)
pair_index = torch.arange(
0, head_dim, 2, device=x.device, dtype=torch.float32
) # [d/2]
inv_freq = torch.exp(
-math.log(base) * pair_index / head_dim
) # [d/2]
angles = position_ids.to(torch.float32)[..., None] * inv_freq
cos = angles.cos().to(x.dtype)[:, None, :, :] # [N,1,L,d/2]
sin = angles.sin().to(x.dtype)[:, None, :, :] # [N,1,L,d/2]
even = x[..., 0::2] # [N,H,L,d/2]
odd = x[..., 1::2] # [N,H,L,d/2]
rotated_even = even * cos - odd * sin
rotated_odd = even * sin + odd * cos
return torch.stack(
(rotated_even, rotated_odd), dim=-1
).flatten(-2) # [N,H,L,d]
def rope_attention(
q: torch.Tensor, # [N,H,L,d]
k: torch.Tensor, # [N,H,S,d]
v: torch.Tensor, # [N,H,S,d]
query_positions: torch.Tensor,
key_positions: torch.Tensor,
*,
is_causal: bool,
) -> torch.Tensor:
q = apply_rope(q, query_positions)
k = apply_rope(k, key_positions)
return F.scaled_dot_product_attention(
q, k, v,
is_causal=is_causal,
dropout_p=0.0,
) # [N,H,L,d]
q = torch.randn(2, 4, 6, 8)
k = torch.randn(2, 4, 6, 8)
v = torch.randn(2, 4, 6, 8)
positions = torch.arange(6)
output = rope_attention(
q, k, v, positions, positions, is_causal=True
)
assert output.shape == (2, 4, 6, 8)pythonPyTorch 2.13 当前的 scaled_dot_product_attention ↗ 接收 Query [N,...,H_q,L,E]、Key [N,...,H,S,E]、Value [N,...,H,S,E_v]。RoPE 不改变这些形状,只在调用 SDPA 前旋转 Q/K;SDPA 本身不会替你添加 RoPE。
教学实现每次重建频率和角度,便于观察但有额外开销。生产实现通常缓存一定长度的 cos/sin 表,在需要更长位置时扩展,并针对设备、dtype 与编译策略管理缓存生命周期。
10 RoPE 与 KV Cache 怎样保持等价?#
完整因果前向中,第 个 Key 应先用 旋转。增量解码时,缓存已有长度为 past_length,新 token 的位置必须从这里开始:
# new_q/new_k/new_v: [N,H,L_new,d],decode 时 L_new 通常为 1
past_length = 0 if cache is None else cache.key.size(-2)
position_ids = torch.arange(
past_length,
past_length + new_q.size(-2),
device=new_q.device,
)
rotated_q = apply_rope(new_q, position_ids)
rotated_new_k = apply_rope(new_k, position_ids)
if cache is None:
key = rotated_new_k
value = new_v
else:
key = torch.cat([cache.key, rotated_new_k], dim=-2)
value = torch.cat([cache.value, new_v], dim=-2)
# cache.key 保存“已经按各自全局位置旋转”的 Key。
# 历史 Key 不能在每一步按新位置重复旋转。pythonprefill 位置: 0 1 2 3 ─► 各自旋转 K ─► cache length 4
decode 新位置: 4 ─► R(4) 旋转 q4,k4 ─► append k4
decode 新位置: 5 ─► R(5) 旋转 q5,k5 ─► append k5
错误做法 A:每步新 token 都使用 R(0)
错误做法 B:cache 中历史 K 在每步又旋转一次text测试时应关闭 dropout,比较完整 causal forward 与“prefill + 逐 token cache”的所有位置输出。只比最终 argmax 可能掩盖小但系统性的错位。
11 左 padding、拼接与打包为何最容易错?#
位置 id 不能总由一条共享的 arange(L) 机械生成。
设两条序列左侧补齐:
token: [PAD, PAD, 我, 爱, ML]
valid: [ 0, 0, 1, 1, 1]
语义位置: [ -, -, 0, 1, 2]
token: [你, 也, 爱, ML, 吗]
valid: [ 1, 1, 1, 1, 1]
语义位置: [ 0, 1, 2, 3, 4]text若直接给两个样本都用 [0,1,2,3,4],第一条真实 token 从位置 2 开始。某些训练方案允许这种绝对偏移,某些模型则要求首个真实 token 从 0 开始;训练和推理必须一致。可按有效 mask 生成每行位置:
valid = torch.tensor([
[False, False, True, True, True],
[True, True, True, True, True],
])
position_ids = valid.long().cumsum(dim=-1) - 1
position_ids = position_ids.clamp_min(0)
# [[0,0,0,1,2],
# [0,1,2,3,4]]pythonpadding 位置最终仍要由 attention mask 禁止作为 Key,损失也要忽略其标签。这里把 padding 的 position id 暂设 0,只是避免非法索引,不会自动让 padding 无效。
文档拼接、检索片段、滑动窗口和 packed sequence 还要明确:位置是全局连续、每段重置,还是保留原文坐标。没有唯一答案,但训练、缓存、mask 与推理服务必须使用同一契约。
12 一条可执行的调试路径#
- 先做无位置对照。 关闭位置表示和 dropout,同时重排输入与输出,验证自注意力近似满足置换等变,确认实验对象正确。
- 打印三份张量。 token ids、valid mask、position ids 必须分别检查;不要从形状相同推断语义相同。
- 做共同平移测试。 固定同一组内容 Q/K,把查询与键位置同时加 ;RoPE 点积应在数值容差内保持一致。
- 做相对位移测试。 只移动 Key 位置,确认点积确实随 改变,而不是 RoPE 函数根本没接到注意力前。
- 验证偶奇配对。 最后一维必须按
(0,1),(2,3),...配对;错误地把前半维与后半维配对会得到另一种旋转布局,权重不能混用。 - 比较完整前向与 KV Cache。 检查
past_length、新 position id 和已旋转 Key 的追加轴,逐位置assert_close。 - 测试左右 padding。 同一真实序列换一种 padding 方式后,若模型契约要求语义位置从 0 开始,则真实 token 输出应保持接近。
- 测试边界长度。 学习式绝对位置在
L=max_length与L=max_length+1应有明确行为,不要等线上才遇到越界。 - 用 float32 计算长位置角度。 若误差随位置增长,先排查低精度角度、错误缩放和缓存重复旋转,再怀疑注意力内核。
13 最常见的“形状正确,位置却错了”#
- 只加 token embedding。 attention 能运行,却没有顺序坐标。
- 以为 causal mask 已经编码距离。 它只阻止看未来,不区分相隔多远。
- token id 当 position id。 重复词得到重复位置,语义完全颠倒。
- 学习式 position 表越界后临时取模。 不报错,但远位置被映射成错误的旧坐标。
- RoPE 在 hidden 上旋转一次。 标准 RoPE 作用于每层每头的 Q/K,不能用一次输入旋转替代。
- 旋转了 Q 却没旋转 K。 相对位移恒等式不成立。
- 连 Value 一起旋转并误称标准 RoPE。 这改变了被取回内容的坐标系,是不同设计。
- 训练右 padding、推理左 padding 却仍共享
arange(L)。 真实 token 的位置发生整体偏移。 - KV Cache 每步从 0 开始。 新 token 的 RoPE 角度错误,完整与增量前向失配。
- 缓存未旋转 K,读取时按当前步统一旋转历史 K。 历史 token 的各自位置被抹掉。
- 把“公式能算更长位置”当成可靠长上下文。 模型可能从未学过那些相位、距离或注意力模式。
14 三类方法会在哪里失败?#
| 方法 | 主要优点 | 主要边界 |
|---|---|---|
| 正弦绝对位置 | 无位置参数,可生成任意整数位置 | 间接要求模型从 hidden 中学习相对关系 |
| 学习式绝对位置 | 灵活,能拟合训练数据的位置模式 | 最大表长固定,未训练行没有可靠语义 |
| RoPE | Q–K 点积显式依赖相对位移 | 超训练长度仍可能外推失效,角度与缓存要一致 |
三者都可能学到训练数据中的位置捷径:例如标签总出现在开头,模型可能依赖绝对位置而不是内容。随机裁剪、片段偏移或适合任务的数据增强能缓解部分问题,但不能取代正确的评估。
长上下文尤其容易出现“能运行但质量下降”:显存允许把序列从 2K 扩到 32K,不代表位置表示、注意力分布和训练数据支持这种距离。应按长度分桶报告损失、检索准确率和位置敏感测试,而不是只验证没有 OOM。
15 与相近方法的边界#
| 方法 | 如何影响注意力 | 本文是否展开 |
|---|---|---|
| Shaw 相对位置表示 | 将相对距离向量写进注意力关系 | 仅作边界比较 |
| T5 相对位置偏置 | 按距离桶给 attention logits 加偏置 | 否 |
| ALiBi | 按头给距离施加线性惩罚 | 否 |
| RoPE | 旋转 Q/K,使点积含相对位移 | 是 |
| Position Interpolation/Scaling | 改写 RoPE 位置或频率以适配更长上下文 | 下一阶段 |
相对位置偏置直接修改 [N,H,L,S] 的分数矩阵;RoPE 修改 [N,H,L,d] 的 Q/K 后再计算分数。两者都能表达相对结构,但参数化、内存访问与外推行为不同,不能只因都含“相对位置”就视为同一种实现。
16 今天真正需要记住什么?#
- 自注意力本身对 token 排列置换等变;causal mask 只限制可见边,不能单独表达完整的位置与距离。
- 正弦与学习式绝对位置都先进入 hidden,再影响 Q/K/V;前者是固定多频率坐标,后者是有限长度参数表。
- RoPE 对每层每头的 Q/K 成对旋转,利用 让注意力点积显式依赖相对位移。
- RoPE 通常不旋转 Value,也不改变 SDPA 张量形状;它发生在 Q/K 投影之后、点积之前。
- KV Cache 必须用全局 position id 旋转新 Q/K,并缓存已经按各自位置旋转的历史 Key。
- position ids、padding mask 与 token ids 是三份不同契约;左右 padding、拼接和打包时必须显式定义。
17 思考题与小练习#
- 取 、。分别计算
(p=1,s=2)与(p=5,s=6)的 RoPE 点积,验证共同平移后不变;再将第二组改为(p=5,s=7),观察相对位移变化。 - 修改
apply_rope,故意只旋转 Query、不旋转 Key。写一个共同平移测试并解释为什么会失败。 - 为长度 4 的完整 causal forward 与“前 2 token prefill + 后 2 token 逐步 decode”写等价测试。加入错误版本:每个 decode token 都使用 position 0,比较每个位置的最大绝对误差。
相关工作#
- Vaswani et al. (2017), Attention Is All You Need ↗:提出原始 Transformer 与固定正弦位置编码。
- Shaw, Uszkoreit & Vaswani (2018), Self-Attention with Relative Position Representations ↗:将相对距离表示直接引入自注意力关系。
- Su et al. (2021/2024), RoFormer: Enhanced Transformer with Rotary Position Embedding ↗:提出 RoPE 并推导其绝对旋转与相对点积性质。
- Raffel et al. (2020), Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer ↗:T5 使用按相对距离分桶的可学习偏置。
- Press, Smith & Lewis (2022), Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation ↗:提出 ALiBi,以按头线性距离偏置探索长度外推。
18 下一篇预告#
RoPE 的公式可以为任意位置生成角度,但模型在 2K 长度上训练,并不会自动在 32K 上可靠工作。下一篇将区分“位置函数可计算”与“模型真正会外推”,拆解 Position Interpolation、频率缩放以及长上下文评估为何必须按距离和位置分桶。