Newshot

理解现代 LLM 中的位置嵌入

Translation(model translation, not the source's own text)

本文通过介绍 RoPE 等经典与现代方法,解释 LLM 如何编码和处理 token 位置信息。这本质上是一段完整的旅程,我们会借助可视化示例、少量数学和一个小型 PyTorch 实现来梳理这些机制。当然,随后我们还会看看现代架构如何调整或省略显式位置编码。

下面可以预览 2026 年开放权重模型使用了哪些位置嵌入选择。我们会在介绍底层机制后讨论这些选择。

图 1 — 2026 年图库拼贴
图 1 — 2026 年图库拼贴

但为什么要花这么多时间撰写/阅读这个相对次要的构建模块呢?LLM 持续演进,而在我准备今年一篇关于最新 LLM 架构趋势的新文章时,我意识到自己至今还没有在任何文章中介绍过 LLM 使用的位置信息方法。因此,与其让另一篇文章失控,我觉得把它作为即将到来的 LLM 架构与趋势分析的独立构建模块发布可能更合理。

此外,多年来,许多读者一直希望我写一篇关于相对位置嵌入(也就是 RoPE)的教程,作为 GPT 中所用可学习绝对嵌入的后续。它在我 TODO 清单上已经很久了,所以我终于重新翻出旧草稿并整理出这份逐步讲解,内容将涵盖

  • LLM 中的不同位置方法;
  • (终于)RoPE 是如何工作的;
  • 近期开放 LLM 使用哪些方法。

请注意,本文假设你对基本 LLM 架构有一定熟悉,并且很适合作为《Build a Large Language Model (From Scratch)》的后续阅读。如果注意力内部的概念与计算,尤其是 query、key 和 value 向量,还有些模糊,我建议查看我的自注意力逐步讲解来复习:

理解并编码 LLM 中的自注意力、多头注意力、因果注意力和交叉注意力
理解并编码 LLM 中的自注意力、多头注意力、因果注意力和交叉注意力

理解并编码 LLM 中的自注意力、多头注意力、因果注意力和交叉注意力

这篇文章也非常技术化,对某些人来说可能过于详细。不过,我确实希望把这一基础内容发布出来,供未来文章参考。别担心,下一篇会回到更轻松的风格。

PS:我开始尝试 Substack 新的“perks”系统。作为对支持者的感谢,我添加了来自架构图库的图片导出和元数据,供你在自己的分析中使用。

PPS:如果你已经熟悉位置嵌入的工作原理,可以直接跳到第 6 节“现代架构中的位置选择”。

请注意,本文最好在网页浏览器而不是电子邮件客户端中阅读。我建议在此处查看网页视图,它左侧还有导航栏。

1. 位置信息在何处进入 LLM

让我们从整体架构开始。tokenizer 将输入文本编码为 token ID。接下来,LLM 的输入 token 嵌入层将每个 ID 映射为一个数字向量。我们通常把该向量中的这些条目称为“特征”(类似于经典机器学习)。随后,LLM 内部的 Transformer 块使用注意力和前馈层修改这些向量。最后,模型的输出层为下一个 token 生成 logit 分数(转换为概率)。

下图展示了这一路径在 GPT-2(一个相对经典的 LLM)和 MiniMax-M3(今年发布的较新 LLM;可作为大多数其他近期 LLM 的占位示例)中的情况。可以看到,这些架构彼此差别不大,但在进入位置信息部分之前,我们先关注橙色块(token 嵌入层)及其上方的 transformer 块;本次说明不需要涉及其他架构差异。

图 2 — token 嵌入架构
图 2 — token 嵌入架构

如上图所示,token 嵌入层将每个 token ID 映射为一个向量。随后再加入位置信息。具体来说,正如我们稍后会更详细讨论的,GPT-2 会在修改后的嵌入进入第一个 transformer 块之前,为每个 token 嵌入加上一个位置向量。另一方面,MiniMax-M3 则在每个 transformer 块中,通过注意力内部一种称为 RoPE 的方法加入位置信息。

我们稍后会更详细地讨论这些方法,但首先从整体图景开始,说明为什么需要或要加入显式位置信息。

1.1 为什么要加入位置信息?

以下面两个输入句子作为简单示例:

  • “The red fox jumps over the gray fox.”
  • “The gray fox jumps over the red fox.”

改变词序会改变是哪只狐狸在跳跃,如下图所示。

图 3 — 词序
图 3 — 词序

在本文的示例中,为简单起见,我们会把每个单词视为一个 token。(如你所知,真实的分词器可以把单词拆成更小的子词 token。)于是第一个句子有八个 token 位置,从 0 到 7。

图 4 — token ID 与位置
图 4 — token ID 与位置

        # Assume the following small vocabulary.
# Real vocabularies typically have 50k to 200k entries
vocab = {
    "The": 0,
    "red": 1,
    "fox": 2,
    "jumps": 3,
    "gray": 4,
    "over": 5,
    "the": 6,
}
      

(如果你对词表是如何构建的感兴趣,可以查看我在 GitHub 上的 tokenizer 资料。)

注意,“fox”在输入句子中出现两次,两种情况都会得到相同的 token ID 2。并且,由于它们具有相同的 token ID,无论它们在输入句子中的位置如何,它们都会从嵌入层得到相同的嵌入向量。

现在,让我们考虑一个没有位置编码(也没有因果掩码)的常规自注意力机制;其中每个输入 token 都可以关注输入中的其他所有 token。在这种情况下,交换任意两个词,比如“red”和“gray”,仍然会在每个 token 对之间得到相同的嵌入和注意力分数。

GPT 风格模型确实使用因果(自回归)掩码,它把每个 token 限制为只能关注自身和更早的 token。有趣的是,这种因果掩码给计算带来了一些隐式顺序信息。在我们的示例中,“jumps”在第一个句子里可以关注“red”,在第二个句子里可以关注“gray”。因此,理论上,即使没有显式位置嵌入,因果 LLM 也已经能够区分这些句子。(我们稍后在讨论“NoPE”概念时会回到这个想法。)

理解现代 LLM 中的位置嵌入

尽管如此,GPT-2 和大多数较新的 LLM 包含显式位置信息,因为它为注意力提供了关于位置的直接信号。这是因为,尽管因果掩码决定了哪些词元可访问并提供隐式顺序信息,但它无法区分例如相隔一个位置和相隔十个位置。因此,显式位置方法仍然有用(甚至必不可少),因为它们提供了该信息。

如果这一切听起来令人困惑,别担心,它更像是一个概述,我们将在剩余文章中更缓慢地逐步讲解这些概念。

2. 向每个词元嵌入添加位置向量

我们简要讨论了通过因果注意力掩码实现的隐式位置信息(稍后我们将更详细地重新讨论这一点)。然而,我们现在转换话题,讨论添加位置信息的显式方式。

2.1 GPT-2 中学习到的绝对位置嵌入

为了说明将显式的、学习到的位置向量用于输入嵌入,让我们以 GPT-2 作为具体示例。GPT-2 在训练期间学习一个位置向量表。下图展示了这些向量在架构中的使用位置,即在词元嵌入查找之后、第一个 Transformer 块之前。

图 5 — gpt2 架构
图 5 — gpt2 架构

上图中描绘的位置嵌入层遵循与词元嵌入层相同的实现,即它们具有完全相同的输出维度,以便得到的输入向量可以相互相加。下面的代码用 GPT-2 代码实现的摘录展示了这一点:


        class GPTModel(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.tok_emb = nn.Embedding(cfg["vocab_size"], cfg["emb_dim"])
        self.pos_emb = nn.Embedding(cfg["context_length"], cfg["emb_dim"])
        self.drop_emb = nn.Dropout(cfg["drop_rate"])

        self.trf_blocks = nn.Sequential(
            *[TransformerBlock(cfg) for _ in range(cfg["n_layers"])])

        self.final_norm = LayerNorm(cfg["emb_dim"])
        self.out_head = nn.Linear(
            cfg["emb_dim"], cfg["vocab_size"], bias=False
        )

def forward(self, in_idx):
        batch_size, seq_len = in_idx.shape
        tok_embeds = self.tok_emb(in_idx)
        pos_embeds = self.pos_emb(torch.arange(seq_len, device=in_idx.device))
        x = tok_embeds + pos_embeds  # Shape [batch_size, num_tokens, emb_size]
        x = self.drop_emb(x)
        x = self.trf_blocks(x)
        x = self.final_norm(x)
        logits = self.out_head(x)
        return logits
      

用数学术语来说,我们可以将其写成如下形式。令 m 为位置索引,xm 为该位置上的词元 ID。第一个块的输入是

  • Etoken 是嵌入层,
  • Pm 是由 m 选择的位置信息向量,
  • 而 hm 是得到的求和嵌入与位置向量。

这两个向量具有相同的宽度,因此我们逐元素相加它们的条目。下面的图 6 用一个小的数值示例直观地展示了这一点。

图 6 — 绝对嵌入
图 6 — 绝对嵌入

理解现代 LLM 中的位置嵌入

在上图中,位置 0 处,将 token 嵌入 [1, 1, 1] 加到位置嵌入 [1.1, 1.2, 1.3] 上,得到 [2.1, 2.2, 2.3]。顺便说一句,图中的取值是为了让示意保持简单;在真实示例中,两个嵌入层(表)都会用小的随机值初始化,并在训练过程中学习。

因此,我们在本节中介绍的是所谓的绝对位置方法,因为位置嵌入 Pm 取决于 token 自身的位置 m。在前面的示例句子中,两个“fox” token 最初具有相同的 token 嵌入,但由于我们加上了不同的位置向量 P2 和 P7,它们在进入第一个 transformer 块之前就具有不同的值。 consequently,注意力机制内部的 query、key 和 value 向量随后也会接收到已经包含位置信息的表示。

注意,原始 GPT-2 实现有 1,024 个学习到的位置向量。这意味着它只能处理 1024 个或更少 token 的输入。如果我们想扩展上下文窗口,就必须扩展这个嵌入表。而且由于嵌入表(层)在训练期间会像其他任何层一样更新,上下文长度必须在训练开始之前就确定下来。

扩展上下文会非常昂贵,因为表可能变得非常大。例如,在 GPT-2 Medium 的情况下,在 1,024 个位置时,位置嵌入层权重仅占总权重的 0.30%。然而,如果我们将上下文长度增加到如今常见的 100k 范围,它们将占模型总权重的 >22%,这会非常不经济。

2.2 原始 transformer 中的固定正弦位置编码

2017 年原始的“Attention Is All You Need” transformer 早于 GPT,它使用相同的加法机制,但用固定的正弦和余弦函数计算其位置向量。下面的图 7 展示了在编码器和解码器堆栈之前进行的这种加法。编码器处理输入序列,解码器生成输出序列。(这里引入它的原因是,正弦和余弦函数在之后讨论 RoPE 时会再次变得相关。)

图 7 — 原始 transformer 架构
图 7 — 原始 transformer 架构

原始 transformer 还会在添加位置向量之前,将其 token 嵌入按 √(dmodel) 缩放,其中 dmodel 是嵌入宽度。

因此,虽然上一节中的 GPT-2 有公式

而原始 transformer(图 7 所示)中使用的公式是:

这种按 √(dmodel) 的缩放是一个次要的技术细节,但对于好奇的人来说,transformer 论文中没有解释这个缩放因子背后的动机,但正如我多年前在 Twitter thread 上与同事讨论的那样,它很可能是为了在初始化时让 token 嵌入和正弦位置编码处于可比较的数值尺度上,这样当我们把它们相加时,位置向量就不会主导求和结果。

在 GPT-2 的情况下,这种缩放并不必要,因为原始 GPT-2 代码将 token 嵌入初始化为初始标准差 0.02,将位置嵌入初始化为初始标准差 0.01,因此它们已经大致处于相同的尺度上。

现在,更有趣的一点是位置嵌入 Pm 是如何计算的。

具体来说,每个位置向量具有以下条目:

Equation 4: \begin{aligned} P_{m,2i} &= \sin\left(\frac{m}{10000^{2i/d_{\text{model}}}}\right), \\ P_{m,2i+1} &= \cos\left(\frac{m}{10000^{2i/d_{\text{model}}}}\right). \end{aligned}
Equation 4: \begin{aligned} P_{m,2i} &= \sin\left(\frac{m}{10000^{2i/d_{\text{model}}}}\right), \\ P_{m,2i+1} &= \cos\left(\frac{m}{10000^{2i/d_{\text{model}}}}\right). \end{aligned}

如前所述,m 表示位置。索引 i 用于索引向量内的一对特征。

这里,一个特征是位置向量中的一个数。例如,假设我们有一个包含 8 个值的小向量(dmodel = 8),其条目编号为 0 到 7。(在原始论文中,小模型是 512,较大模型是 1024,而不是 8,但那样会很难可视化。)我们将这些分成四对,因此 i 从 0 到 3。

图 8 — 正弦特征对
图 8 — 正弦特征对

这里,索引 2i 和 2i + 1 选择对应于第 i 对的两个条目。

如前文公式所示,位置 m 处的角度随后被输入正弦或余弦函数,以分别得到 Pm, 2i 或 Pm, 2i + 1,其计算如下:

图 9 — 角度与频率
图 9 — 角度与频率

图 10 展示了在位置 m = 2 时,我们 8 特征示例中所有四对的这一计算。

![图 10 — 正弦计算](https://magazine.sebastianraschka.com/p/fl_progressive:steep/https%3A%2F%2Fsubstack

下图针对一对正弦/余弦展示了这一点。旋转是一种线性变换,而两个三位置的平移使用的是相同的旋转。

图 13 — 正弦相对平移
图 13 — 正弦相对平移

3. 用 RoPE 编码相对位置

因此,上述正弦方法仍被视为一种绝对位置编码,因为加到每个 token embedding 上的向量表示的是该 token 从序列开头算起的位置。

例如,如上一张图所示,位置 2 和 5 处的 token 分别收到位置向量 P2 和 P5。如果我们把这些 token 移到位置 102 和 105,它们仍然相隔三个位置。但每个 token 会得到不同的位置向量,因为其绝对位置 m 已经改变。

因此,即使 token embedding 保持不变,它们的注意力分数也可能改变。正弦结构仍然为模型提供了一种学习相对关系的方式。

无论如何,下面要讨论的 RoPE 将这种相对关系直接构建进注意力计算中。RoPE 由 Su 等人在 2021 年的 RoFormer 论文中提出,代表 Rotary Position Embedding(旋转位置编码),被认为是一种相对位置方法。

3.1 在 RoPE 中把旋转移入注意力内部

在注意力中,每个 token 当前的表示会被投影为一个 query、一个 key 和一个 value 向量。Query-key 点积决定注意力分数。经过掩码和 softmax 后,得到的权重决定 value 向量如何混合。如需复习,请参阅我的《Understanding and Coding Self-Attention, Multi-Head Attention, Causal-Attention, and Cross-Attention in LLMs》一文:

Understanding and Coding Self-Attention, Multi-Head Attention, Causal-Attention, and Cross-Attention in LLMs

RoPE 利用 token 的位置在其 query 和 key 向量进行点积之前对它们进行旋转。value 向量不经过这一旋转。下面的图 14 展示了在较新的 MiniMax-M3 中 RoPE 的应用位置,其中 QK-Norm 在 RoPE 之前对 query 和 key 进行归一化。

图 14 — minimax m3 架构
图 14 — minimax m3 架构

如上图所示,该 LLM 架构没有在初始 token embedding 旁显式添加位置向量。其显式位置信号被注入到注意力内部。

旁注:MiniMax-M3 只对每个 query 和 key head 的一部分进行旋转,这种变体称为“partial RoPE”(部分 RoPE),我们将在后面的第 6.1 节中介绍。但为了先理解基本的 RoPE 操作,让我们用一个小例子逐步讲解该方法。

3.2 在 RoPE 中从 token 表示到特征对

注意力通常将其 query、key 和 value 特征划分为多个 head,并行处理。因此,整个机制通常被称为 Multi-head Attention(多头注意力)(注意 Grouped Query Attention 也使用多个 head)。这里,每个注意力 head 计算自己的注意力分数和加权和。然后 RoPE 作用于每个 query 和 key head 内的成对数字。

为了具体说明,下面的图 12 展示了一个 token 表示经过学习到的 query 和 key 投影的过程,这些投影通过权重矩阵 WQ 和 WK 计算。在这个玩具示例中,每个投影产生 16 个特征,这些特征被重塑为两个 head,每个 head 有八个特征。在下图中,每个小单元格包含一个标量特征,即向量中的一个数值条目(由于篇幅原因未显示具体数字)。

图 15 — 输入与配对
图 15 — 输入与配对

理解现代 LLM 中的位置嵌入

对于第一个八特征头,[1, 2, 3, 4, 5, 6, 7, 8],我们将前半部分中的每个特征与后半部分中对应的特征配对。这样得到 (1, 5)、(2, 6)、(3, 7) 和 (4, 8)。这种对半拆分约定用于 LLM 实现中,包括我的 Qwen3 实现。(我们将在实现基本操作之后,再把它与原始的、另一种相邻特征配对方法进行比较。)

每一对都会得到一个旋转角度,该角度由 token 位置 m 和该对的频率 θi 决定。对应的查询对和键对使用相同的频率。下面的图 16 展示了我们如何旋转这些对、将更新后的特征放回其原始位置,并计算查询-键点积。

下一节将更慢地逐步讲解这些图。

3.3 在 RoPE 中旋转一对

对于下面的例子,我们将使用与之前相同的两个位置。查询来自位置 m = 5 处的 “the”,键来自位置 n = 2 处的第一个 “fox”。因为在这个例子中,键在查询之前出现,所以因果掩码允许查询关注它。

为了说明位置带来的影响,并保持其他一切不变,我们假设两对都从相同的两个值 (1, 5) 开始。

图 17 — 查询对特写
图 17 — 查询对特写

此外,对于这个例子,我们使用每个 token 位置 θ = 20°(每个位置 π/9 弧度)。在位置 2,我们将键的箭头逆时针旋转 2 × 20° = 40°。在位置 5,我们将查询的箭头旋转 5 × 20° = 100°。

图 18 — 位置 2 和 5 处的旋转
图 18 — 位置 2 和 5 处的旋转

为了说明上图正在发生什么,例如,将 cos 40° ≈ 0.766 和 sin 40° ≈ 0.643 代入图 16 的旋转中,可得到新的键坐标:

然后,将 40° 改为 100°,使用与上面相同的方程,旋转后的查询对变为 q′ ≈ (−5.098, 0.117)。但重点在于,两个箭头仍然具有长度 √26。只有旋转发生变化,但向量在变换后保持相同长度。

因此,对于位置 m 处的查询对 (a, b),这里的两个操作是。

对于位置 n 处的键对,我们使用带有 nθ 的相同方程。

(后面的第 4 节将展示 RoPE 如何计算每个特征对的角频率。)

作为本节的结尾,对于那些更习惯阅读代码的人,到目前为止展示的关于 RoPE 的一切都可以写成如下代码:


        import torch

query_head = torch.tensor([1., 2., 3., 4., 5., 6., 7., 8.])
key_head = query_head.clone()

# Split-half pairing gives (1, 5), (2, 6), (3, 7), and (4, 8).
query_pairs = torch.stack(query_head.chunk(2), dim=-1)
key_pairs = torch.stack(key_head.chunk(2), dim=-1)

# Take the first feature pair from each head.
q = query_pairs[0]
k = key_pairs[0]

m = 5  # Query position (in our example, "the")
n = 2  # Key position (in our example, the first "fox")
theta = torch.deg2rad(torch.tensor(20.0))  # Radians per token position

def rotate_pair(pair, angle):
    a, b = pair
    c, s = torch.cos(angle), torch.sin(angle)
    return torch.stack((a * c - b * s, a * s + b * c))

k_rot = rotate_pair(k, n * theta)  # 2 × 20° = 40°
q_rot = rotate_pair(q, m * theta)  # 5 × 20° = 100°

print(f"Original key:   ({k[0]:g}, {k[1]:g})")
print(f"Original query: ({q[0]:g}, {q[1]:g})")
print(f"Rotated key:    ({k_rot[0]:.3f}, {k_rot[1]:.3f})")
print(f"Rotated query:  ({q_rot[0]:.3f}, {q_rot[1]:.3f})")
      

得到的值是:


        Original key:   (1, 5)
Original query: (1, 5)
Rotated key:    (-2.448, 4.473)
Rotated query:  (-5.098, 0.117)
      

如果我们旋转 8 个特征的查询(和键)向量中的全部 4 对,就得到下面的向量。(稍后我们会回到如何从旋转一对变成旋转所有对。)


        Original key:   (1, 2, 3, 4, 5, 6, 7, 8)
Original query: (1, 2, 3, 4, 5, 6, 7, 8)
Rotated key:    (-2.448, 1.577, 2.951, 3.994, 4.473, 6.125, 7.021, 8.003)
Rotated query:  (-5.098, 0.928, 2.877, 3.986, 0.117, 6.256, 7.051, 8.007)
      

3.4 相对偏移如何改变 RoPE 中的注意力分数

请注意,如上所示,查询头和键头各有 8 个特征,我们之前将它们分成 4 对、每对两个元素,例如 (1, 5)、(2, 6) 等等。为简单起见,这里我们只跟踪每个头中的第一对 (1, 5),它由索引 0 和 4 处的条目构成:


        Original key:   (1, 5)
Original query: (1, 5)
Rotated key:    (-2.448, 4.473)
Rotated query:  (-5.098, 0.117)
      

如上所示,在我们的示例中,这些条目在旋转前在两个头中的值都是 (1, 5)。

另外,正如上面的代码那样,让我们用记号 q =(q1, q2) 和 k =(k1, k2) 表示这两对。为了计算它们的点积,使用标准点积公式,我们将对应坐标相乘后相加:

旋转之前,这些对的点积为 1 × 1 + 5 × 5 = 26。使用上一张图 18 中四舍五入后的坐标,可以得到旋转后第一对的贡献:

我们可以通过用两个向量的长度(图 18 中的箭头)以及它们之间的夹角 ϕ 来表示点积,从而看出原因。

在两种情况下,向量长度都是 √26。查询(位置 5)旋转了 100°,键(位置 2)旋转了 40°。这些向量(箭头)从相同方向开始,因此旋转后它们相隔 60°。它们精确的点积是 26 × cos(60°) = 13。

现在让我们看看,如果我们只把键从位置 2 移到位置 3,但保持其起始坐标 (1, 5) 不变,会发生什么。键的旋转现在从 2 × 20° = 40° 变为 3 × 20° = 60°。查询仍然旋转 100°,因此箭头之间的夹角缩小到 40°,点积从 13 变为 26 × cos(40°) ≈ 19.92。所以,将键移动一个位置使其更近,即使我们从相同的特征值开始,也改变了点积。

这些点积为什么重要?在注意力中,我们将所有特征对的贡献相加,得到查询-键点积。RoPE 可以改变这些点积,从而改变 softmax 分配给值向量的权重。

在上面的注意力公式中,Q′ 和 K′ 是包含一个头的旋转后查询 q′ 和键 k′ 的矩阵,V 包含值,d 是头维度。

3.5 为什么同时平移两个位置会在 RoPE 中保持注意力分数不变

既然我们已经说明了点积的计算,而这是注意力机制的核心,那么让我们回到位置 m = 5 和 n = 2。如果我们把两个位置都向右平移三个位置,就得到 m = 8 和 n = 5。每次旋转都增加 60°(查询从 100° 变为 160°,键从 40° 变为 100°),然而,向量之间的夹角保持不变。这个特征对的点积仍然是 13。

图 19 — 位置偏移的点积
图 19 — 位置偏移的点积

关键在于,旋转角度之间的差值是

如果我们保持原始查询向量和键向量不变,并将两个 token 位置平移相同的量,RoPE 会给两个向量加上相同的旋转。它们之间的夹角保持不变,因此它们的点积保持不变。请注意,下面的图 20 使用了比实际示例(每个位置 20°)更小的角度,以便让图更易读。

理解现代 LLM 中的位置嵌入

图 20 — 相对位置
图 20 — 相对位置

上面的例子和图 20 说明了 RoPE 背后的主要思想。我们用每个 token 的位置来旋转注意力模块中对应的查询和键。在查询-键点积中,重要的是一个向量相对于另一个向量旋转了多少。对于每个特征对,这种旋转差异只取决于两个 token 之间的偏移量。

为了在整体图景中说明这一点,在图 21 中,每一行收集一个查询的分数。如果我们考虑第 5 行,即“the”的查询,以及第 2 列,即“fox”的键,那么两者之间的注意力分数就是查询向量和键向量各自的旋转版本之间的点积。

再说一次,重点是什么?让我们再次考虑“The red fox jumps over the gray fox.”。如果我们在句子前加入 100 个 token(例如,想象一个系统提示),第一个“fox”和第二个“the”会从位置 2 和 5 移到 102 和 105。然而,任意长度的前缀都会同等地位移这两个位置,因此它们仍然恰好相隔三个位置。这意味着,每个向量(箭头)都获得完全相同的额外旋转,这保持了它们之间的夹角以及它们的点积(假设我们保持未旋转的查询向量和键向量固定;在真实模型中,新的 100 个 token 前缀会通过改变 token 关注的内容来改变这些向量)。

图 22 在注意力分数矩阵中追踪了这一比较。右侧面板显示了加入前缀后句子的行和列。在我们的玩具示例中,第一对仍然对点积贡献 13。

你可能还记得,在原始 transformer 的正弦编码中,固定偏移也对应于每个正弦/余弦对内的一次固定旋转。但这种共享旋转只应用于位置向量(加到输入向量上),而不是注意力机制内部的键向量和查询向量。

图 23 比较了这一位移在我们迄今介绍的三种方法中改变了什么。

图 23 — 位置位移比较
图 23 — 位置位移比较

3.6 附加:在 RoPE 中将各对组合成一个完整的头

为了看清相对偏移如何影响点积,我们聚焦于查询和键中的一个特征对。现在让我们把四对全部放在一起,看看 RoPE 如何在一个注意力头中旋转完整的八维特征向量。(如果技术细节太多,请随意跳过本节。)

图 24 从图 17 结束的地方继续。我们旋转四对中的每一对,然后把结果按查询原本的特征顺序放回。

图 24 — 四对旋转
图 24 — 四对旋转

上面的图 24 展示了旋转后的查询 q′(位于底部)。类似地,在键的位置重复同样的步骤,就能得到旋转后的键 k′。

回想第 3.3 节中的两个方程。对于输入值为 (ai, bi) 的查询对 i,设 ci = cos(mθi)、si = sin(mθi)。它们分别是该对在查询位置 m 处的旋转角的余弦和正弦:

下面的图 25 把四对之间的同样四次旋转表示为一次矩阵乘法,q′ = Rmq。

图 25 — 完整旋转矩阵
图 25 — 完整旋转矩阵

4. 在 RoPE 中计算旋转频率?

一个完整的注意力头会使用多个频率,使得一些特征对随位置变化得更剧烈(用 RoPE 的话说就是“快”),而另一些特征对变化得更平缓(用 RoPE 的话说就是“慢”)。这与原始 transformer 的正弦编码中使用多个频率的目的相同。

然而,标准完整 RoPE 不是由我们手动分配这些频率,而是对于一个具有 d 个特征的头,为其 d/2 个特征对分别计算一个频率,如下所示:

  • d 是头维度(它必须是偶数,以便所有特征都能成对排列)。
  • i 是特征对的索引;它的范围从 0 到 d/2−1。
  • b 是选定的基值(超参数),决定频率的间隔。
  • θi 是第 i 个特征对在每 token 位置上的频率,单位为弧度。

原始的 RoFormer 论文使用 b = 10,000,借用了原始 transformer 的频率公式,并用头维度 d 代替嵌入宽度 dmodel。

例如,当头维度 d = 8 且基值 b = 10,000 时,我们得到频率 [1, 0.1, 0.01, 0.001] 弧度/每位置。

5. 附加:在 PyTorch 中实现 RoPE

如果你想在完整模型中查看 RoPE,我建议看看我从零实现的端到端 Qwen3。

由于代码可以成为解释和理解概念的有效媒介,如果你感兴趣,下面的小节将带你了解我们目前已经涵盖的核心部分。否则,请随意快进到第 6 节,关于现代架构中的位置选择。

因此,对于以下代码片段,我们将使用前面提到的相同的 split-half 旋转约定(交错约定也会涵盖,不用担心)。首先,我们会将其应用于一个查询向量,然后在注意力机制中将其用于查询和键。

5.1 计算并应用旋转

首先,让我们复用图 24 中那个具有 8 个特征的查询。为方便起见,下面是其旋转前的四个特征对。

图 24 的细节——原始查询对
图 24 的细节——原始查询对

在更早的非编码示例中,为了让数学更简单,我们为第一个特征对选择了每个 token 位置 20° 的频率。在这里,我们将使用上面第 4 节中的原始 RoPE 公式 θi = b−2i/d,其中头维度 d = 8,基值 b = 10,000。

图 26 展示了这两个函数如何协同工作来应用这些旋转。

图 26——PyTorch 逐步讲解
图 26——PyTorch 逐步讲解

如上图所示,首先,rope_params 决定每个特征对旋转多远,返回两个“表”,分别存储这些角度的余弦和正弦。或者换句话说,rope_params 计算每个旋转角的余弦和正弦,这取决于 token 的位置和特征对的频率。


        import torch

def rope_params(head_dim, positions, base=10_000):
    assert head_dim % 2 == 0
    indices = torch.arange(0, head_dim, 2, device=positions.device)
    inv_freq = base ** (-indices.float() / head_dim)
    angles = positions.float()[:, None] * inv_freq[None, :]
    return angles.cos(), angles.sin()
      

rope_params 函数中的 indices 数组包含第 4 节频率公式中的 2i 值:

对于我们的八特征查询,indices 为 [0, 2, 4, 6],也就是说,每个特征对一个 2i 值。假设我们使用常见的基值 b = 10,000。并且按照我们之前的示例,我们选择较小的头维度 d = 8。在这种情况下,inv_freq 随后会计算四个特征对频率 θi = b−2i/d,如下所示:

如上所示,得到的 inv_freq 对于四个特征对中的每一个,都有一个频率 θi(以弧度为单位)/每 token 位置。

接下来,angles 然后将每个对的频率 θi 与 token 位置 m 相乘,计算出旋转角度 mθi。在我们的示例中,positions = [2],我们得到:

最后,rope_params 取每个角度的余弦和正弦,并将它们输出为两个所谓的余弦表和正弦表,即 cos 和 sin。在这两个表中,每一行是一个 token 位置,每一列是一个特征对。由于这里我们只使用 token 位置 2,每个表的形状为 (1, 4),如下所示单行:

对于图 26 中的示例,每个表有一行对应 token 位置 2,四列,每列对应一个特征对。图 27 显示了每个对的旋转角度以及两个表中相应的条目。

图 27 — cos sin 表
图 27 — cos sin 表

简而言之,rope_params 计算每个旋转角度的余弦和正弦,这取决于 token 的位置和特征对的频率。

在实际场景中,如果我们向 rope_params 传入 1,024 个 token 位置(上下文大小为 1024)以及 128 的头维度,它会返回两个张量 cos 和 sin,每个形状为 (1024, 64),因为具有 128 个特征的头有 64 对。

这些表没有批次或头维度,我们将每个 (1024, 64) 表在批次和头维度上进行广播。因此,如果我们有 32 个头的多头注意力或分组查询注意力,token 位置 2 处的特征对 0 在所有 32 个头中使用相同的旋转角度。每个头旋转自己的特征值,因此旋转后的查询在不同头之间仍然可以不同。此外,相同的条目可以在使用相同位置和频率的查询、键和头之间重用。简而言之,我们只需要为每个模型针对最大支持上下文大小以内的所有位置(预)计算这些表一次。

技术上,也可以将这些表与模型权重一起存储;但它们的计算成本极低,将它们存储在磁盘上有些小题大做。

接下来,rope_split_half 使用这些余弦和正弦来旋转注意力内部查询和键向量中的实际特征。


        def rope_split_half(x, cos, sin):
    first, second = x.float().chunk(2, dim=-1)
    return torch.cat((
        first * cos - second * sin,
        first * sin + second * cos,
    ), dim=-1).to(x.dtype)
      

再次考虑简单的 8 特征示例,上面代码片段中的 .chunk 将查询拆分为 [1, 2, 3, 4] 和 [5, 6, 7, 8],并将每对中的两个条目对齐。例如,每个半部分的第一槽给出 (1, 5),第二槽给出 (2, 6),依此类推。torch.cat 内部的两个表达式使用第 3.3 节中的公式计算旋转后的半部分:

请注意,这里用 ai 表示的所有内容对应查询的前半部分,用 bi 表示的所有内容来自后半部分。torch.cat 内部的第一个表达式计算所有 ai′ 值,第二个计算所有 bi′ 值。

然后,我们将两半拼接起来,通过 torch.cat 将每个值放回其原始索引位置。

以下是生成前面图 26 中旋转查询的代码。所示的 reshape (1, 1, 1, 8) 只是将简单的玩具数组转换为预期格式:一个批次项、一个头、一个 token 和八个特征。


        positions = torch.tensor([2])
cos, sin = rope_params(8, positions)

q = torch.arange(1., 9.).reshape(1, 1, 1, 8)
q_rot = rope_split_half(q, cos, sin)
print(q_rot[0, 0, 0])
      

对于多个位置,每个 token 只需从 cos 和 sin 表中选取自己的行。通过广播,PyTorch 在所有批次项和注意力头上重用这些相同的表。

5.2 在因果注意力中使用 RoPE

到目前为止,我们已经编写了 RoPE 工具函数。现在让我们在实际场景中使用它,即注意力机制。这里,我们对两个 8 特征头和四个 token 位置使用标准频率调度。我们使用随机的 query、key 和 value 张量以保持示例自包含,但如前所述,你还可以选择查看端到端的 Qwen3 从零实现,其中使用了预训练模型产生的真实 query、key 和 value。


        torch.manual_seed(123)

q = torch.randn(1, 2, 4, 8) # 1 batch, 2 heads, 4 tokens, 8 head dim
k = torch.randn(1, 2, 4, 8)
v = torch.randn(1, 2, 4, 8)
positions = torch.arange(4)

cos, sin = rope_params(8, positions)
q_rot = rope_split_half(q, cos, sin)
k_rot = rope_split_half(k, cos, sin)
scores = q_rot @ k_rot.transpose(-2, -1) / q.shape[-1] ** 0.5

future_mask = positions[None, :] > positions[:, None]
masked_scores = scores.masked_fill(future_mask, -torch.inf)
weights = torch.softmax(masked_scores, dim=-1)
context = weights @ v
      

注意,只有 q 和 k 通过 RoPE,而 v 不通过。得到的原始注意力分数存储在一个形状为 (1, 2, 4, 4) 的张量中,即每个 query 位置一行,每个 key 位置一列,由于我们有四个 token 且每个 token 都关注其他每个 token,因此是 4x4。

然后,使用常规的注意力代码实现,因果掩码将未来 key 的分数替换为负无穷,因此它们在 softmax 之后被置零。得到的 context 张量形状为 (1, 2, 4, 8);其中 4 是因为我们有四个 token,8 是因为嵌入维度原本为 8。

我们还可以验证关于相对偏移的说法成立,即给每个位置加 3 应该改变位置但保持 query-key 偏移不变:


        cos_shifted, sin_shifted = rope_params(8, positions + 3)
q_shifted = rope_split_half(q, cos_shifted, sin_shifted)
k_shifted = rope_split_half(k, cos_shifted, sin_shifted)
scores_shifted = (
    q_shifted @ k_shifted.transpose(-2, -1) / q.shape[-1] ** 0.5
)
torch.testing.assert_close(scores, scores_shifted)
# Should return True
      

5.3 另一种特征配对约定

在本文中我多次提到了分半约定。原始 RoPE 论文使用的是另一种交错约定,原始 LLaMA 实现也是如此(但如前所述,现在大多数代码实现使用分半约定,因为在代码中,使用逐元素的 sine 和 cosine 操作可以更容易地一次性旋转所有对。)

为了说明两种布局之间的差异,下面的图 28 在两行中使用了相同的 8 特征向量。

图 28 — 特征配对
图 28 — 特征配对

交错实现使用相同的两个方程,但选择交替的特征并对输出进行交错。


        def rope_interleaved(x, cos, sin):
    even, odd = x.float()[..., 0::2], x.float()[..., 1::2]
    return torch.stack((
        even * cos - odd * sin,
        even * sin + odd * cos,
    ), dim=-1).flatten(-2).to(x.dtype)
      

但在数学上,当我们考虑特征顺序时,这两种布局是等价的。因此,将条目 [1, 2, 3, 4, 5, 6, 7, 8] 重排为 [1, 5, 2, 6, 3, 7, 4, 8] 可以使分半配对相邻,配对为 (1, 5)、(2, 6) 等。如果我们对 query 和 key 都应用相同的重排,就会得到相同的点积。


        order = torch.tensor([0, 4, 1, 5, 2, 6, 3, 7], device=q.device)
q_rot_interleaved = rope_interleaved(q[..., order], cos, sin)
k_rot_interleaved = rope_interleaved(k[..., order], cos, sin)
scores_interleaved = (
    q_rot_interleaved @ k_rot_interleaved.transpose(-2, -1)
    / q.shape[-1] ** 0.5
)
      

        torch.testing.assert_close(q_rot[..., order], q_rot_interleaved)
torch.testing.assert_close(k_rot[..., order], k_rot_interleaved)
torch.testing.assert_close(scores, scores_interleaved)
# This should all return True
      

输出保留了重排后的特征,因此我们以相同顺序将它们与分半输出进行比较。然而,将两种布局应用于原始向量会得到不同的分数。

6. 现代架构中的位置选择

自最初的 LLaMA 模型以来,RoPE 一直是大多数(开放权重)LLM 中的常用方案。当然,专有模型也可能使用它,但对于专有模型,总是很难百分之百确定地说什么。

然而,近年来,许多架构已不再在每个 transformer block 中使用完整的 RoPE。在以下小节中,我们将讨论其中一些变体。

例如,我们在上文各节中讨论并实现的完整 RoPE 实现会旋转每个 query 和 key head 中的每个特征对。

在下面小节的以下示例中,我们将看到模型只旋转一个 head 内的部分特征,其他模型在某些 transformer block 中跳过 RoPE,还有一些模型使用不同方法,将位置信息直接加入注意力分数。

6.1 部分 RoPE

正如全文所讨论的,常规“完整”RoPE 会旋转每个 query 和 key head 中的所有维度。部分 RoPE 是一种变体,只旋转一个子集,而保持其余维度不变。

例如,MiniMax-M3 的配置在每个 128 维 head 内使用 64 个旋转维度。这给出 32 个旋转特征对和 64 个未改变维度。下图展示了其效果。

图 29 — 部分
图 29 — 部分

请注意,如果我们跳过第一个 transformer block,未旋转的特征仍可能包含从更早的 transformer block 继承的位置信息。

无论如何,部分 RoPE 的意义何在?它节省一些内存。例如,在上面的 MiniMax-M3 示例中,旋转一半特征也将预计算余弦和正弦表的大小减半。因此,使用部分 RoPE 时,每个表现在只需要 32 列,而不是 64 列。但是,请注意 KV cache 仍为每个缓存 token 存储完整的 key 和 value 向量。

对于 MiniMax-M3 的 1M-token 上下文,旋转 128 个特征中的 64 个会使预计算余弦和正弦表的大小减半;如果这些表以 BF16 存储,可节省 0.134 GB(128 MiB)。如果表本身以 NVFP4 存储,则可节省 0.038 GB(36 MiB),包括 block scales。

请注意,在 1B 到 8B 模型的实验中,Fractional Rotation, Full Potential? 论文发现,旋转每个 head 约 10% 的特征通常能获得与完整 RoPE 相似的训练损失和基准准确率。然而,完整 RoPE 在某些评估中表现更好。

因此,在我看来,部分 RoPE 几乎不值得。

6.2 NoPE 层

NoPE 在 The Impact of Positional Encoding on Length Generalization in Transformers 论文中被提出,意为没有显式位置编码。其想法或观察是,没有任何显式位置方法的注意力层仍然具有因果掩码,其中 query 只能关注自身和更早的 token。而这种因果掩码隐式地提供了位置信息。

让我们使用前面示例中的前四个 token:The red fox jumps。在图 30 中,位置 2 的 fox 的 query 可以关注 The、red 和它自身。位置 1 的 red 的 query 可以关注 The 和它自身。

图 30 — NoPE 前缀掩码
图 30 — NoPE 前缀掩码

所以,下一个问题是:模型如何将因果掩码用作位置信号?这里有一个改编自该论文的简单示例。

假设值向量的某个特征对于 The 为 1,对于其他三个 token 为 0。由于输入只包含一个 The,每个可见前缀都恰好包含一个 1。如果所有未被掩码的键获得相同的注意力分数,softmax 会给每个可见 token 分配相同的权重。

因此在 fox 处,注意力对 1、0 和 0 求平均,得到 1/3。在 jumps 处,它对四个值求平均,得到 1/4。换句话说,这个输出特征等于前缀长度的倒数,后续层可以将其用作位置信号。

NoPE 在实践中有效吗?NoPE 论文的研究人员从头训练了一个 107M 参数的仅解码器小型 transformer,在复制、排序和加法等任务上做消融实验。

当他们用比训练时更长的示例测试这个小模型时,有趣的是,NoPE 在十项任务中总体排名第一,领先于 RoPE。NoPE 论文的研究人员还在代码上训练了一个更大的 1.3B 参数模型,进行了单独的实验。在这里,NoPE 也能处理超出训练长度的上下文,但在测试的最长长度上,其下一个 token 预测变差了。

如今一些开放权重 LLM 甚至在所有层中都使用 NoPE。然而,现在更常见的做法是模型在至少某些层中省略 RoPE,这里的层指 transformer 块。

举个具体例子,SmolLM3(3B 参数)在三个层中应用 RoPE,在第四层中跳过,并在 36 层中重复该模式。有趣的是,SmolLM3 的作者提到这种层模式改善了长上下文性能。

(另请注意,任何后续层,即使可能跳过旋转,仍然会接收来自前面 transformer 块的输入,因此其查询和键仍然从早期层获得某种位置信息。)

6.3 学习的相对位置偏置

在我们进入概览并查看现代架构使用什么之前,还有另一种方法需要介绍。例如,Thinky 的 Inkling 在其滑动窗口注意力和全局注意力层中都使用了学习的相对位置偏置。它计算查询和键之间通常的缩放点积,然后在注意力分数计算中、softmax 之前,向该分数加上某个数。这个数取决于查询 token 的隐藏状态以及键往回多少个位置。

让我们再次使用我们的示例句子:“The red fox jumps over the gray fox。”(这是最后一次,我保证!)在下图中,位置 5 的“the”是查询,位置 2 的“fox”是键。Inkling 随后使用查询和键之间三个位置的间隔以及“the”的隐藏状态来计算偏置,然后将其加到缩放点积上。

这并非全新的想法。你们中的一些人可能还记得 2019 年 Google 广受欢迎的 T5(Text-to-Text Transfer Transformer),它使用了这个想法的一个更简单的版本。

6.4 这些选择在近期模型中的表现

现在,在所有这些铺垫之后,有趣的问题是:哪个架构使用哪种方法?近期的趋势是什么?

为了回答这个问题,我按列出的发布日期对我的 LLM Architecture Gallery 中的条目进行了分组,并将 2025 年的发布与 2026 年快照中更早和更晚的发布进行了比较。每个画廊卡片只计一次,因此一个架构的多个变体都可能对结果有贡献。LLM Architecture Gallery

正如我们在上面的图 35 中所看到的,在每个 softmax 注意力层中使用 RoPE 仍然是最常见的选择。然而,其使用率从 2025 年到 2026 年有所下降,然后在 2026 年下半年也有所下降,这可能是一个趋势。

因此,混合使用 RoPE 和 NoPE 的模型越来越多。这些群体仍然很小,所以很难说这些趋势是否会持续。

仅使用 NoPE 的条目均为带有 softmax 注意力层的循环混合模型,这些模型不使用显式位置编码。“其他”类别在此指的是 xLSTM 7B(2025)和 Inkling(2026)。xLSTM 没有 softmax 注意力层。Inkling 在 softmax 之前向注意力分数添加了学习到的偏置,正如我们在第 6.3 节中所见。

在使用 RoPE 的模型中,部分 RoPE 从 2025 年到 2026 年变得更加普遍。它从 2025 年 RoPE 模型中的少数派,变为 2026 年两个组别中的多数派,如图 36 所示。

如果您想查看 2026 年组别中的某个具体模型,下面的图 37 展示了 2026 年图库总览,各个架构按位置类型标注。您也可以在此处交互式浏览图库。

7. 最终思考

那么,回顾一下,我们讨论了 GPT-2 和原始 transformer,它们在第一个 transformer 块之前向每个 token 嵌入添加位置向量。然后,现代 LLM 转向了 RoPE,它通过旋转查询和键在注意力内部编码相对位置。令人惊讶的是,RoPE 至今仍是最流行的位置嵌入方法。

然而,最近 Inkling 从 T5 中借鉴了一个想法,在 softmax 之前向注意力分数添加学习到的相对位置偏置。然后我们还有 NoPE-RoPE 架构,它在某些层中省略了 RoPE。

这可能是无聊的消息,但如前所述,RoPE 仍然是主流的位置嵌入方法,我认为这肯定会延续到 2027 年。不过,也许我们会在 2027 年看到更多类似 Inkling 设计的架构出现。

虽然这听起来可能“无聊”,但我很高兴终于在这么多年后发表了这篇关于 RoPE(以及其他位置信息方法)的文章,这样我就可以把它加入我的 LLM“基础知识”解释系列中。

如果您对 LLM 的其他架构构建块感兴趣,我仍然强烈推荐从 GPT-2 风格的模型开始,这将使您以最少的阻力对构建块有扎实的理解。然后,您可以在此基础上叠加其他方法(例如 RoPE)。

如果您想自己构建那个 GPT-2 风格的模型,我的第一本《Build a Large Language Model (From Scratch)》一书逐步介绍了下图所示的所有构建块。

对于从 GPT-2 基线分支出来的方法,我在下面收集了一些相关博客文章,从自注意力的复习开始:

  • 自注意力、因果掩码和多头注意力:Understanding and Coding Self-Attention, Multi-Head Attention, Causal-Attention, and Cross-Attention in LLMs
  • 现代注意力变体(GQA、MLA、滑动窗口注意力、稀疏注意力和混合模型):A Visual Guide to Attention Variants in Modern LLMs
  • KV 缓存。Understanding and Coding the KV Cache in LLMs from Scratch
  • SwiGLU 前馈层和归一化(LayerNorm 和 RMSNorm):From GPT-2 to gpt-oss: Analyzing the Architectural Advances
  • 专家混合层和专家路由。The Big LLM Architecture Comparison
  • 残差连接和 DeepSeek 的流形约束超连接(mHC):Recent Developments in LLM Architectures: KV Sharing, mHC, and Compressed Attention
  • 在 Qwen3 中整合部分组件:Understanding and Implementing Qwen3 From Scratch 逐步介绍了如何在 PyTorch 中实现密集和专家混合版本。

感谢阅读和支持我的工作!

PS:我开始尝试 Substack 的新“perks”系统。作为对支持者的感谢,我添加了架构图库的图像导出和元数据,供您在自己的分析中使用。

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Hacker News
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗