手把手教你玩转大模型——3. Transformer 的位置编码——模型怎么知道谁在前,谁在后?

上一章,我们把 Embedding 层拆开看了一遍:分词器把文本转换为 token ID,Embedding 再根据 ID,从一张可学习的表里取出对应向量。

不过,这里还剩下一个问题。

假设我们把下面两句话都按三个词来分:

我 喜欢 你

你 喜欢 我

两句话用到的词完全相同,意思却不一样。

对于同一张固定权重的 Embedding 表,"我"无论出现在第一个位置还是第三个位置,查出来的向量都是同一个。"你"和"喜欢"也是如此。

这些向量当然仍然按输入顺序排列在张量里。但数据存放在不同的位置,不等于后面的计算一定会利用这些位置的含义。

那么,模型怎么知道谁在前,谁在后?这就是本章要解释的问题。

一、为什么需要位置编码?

1. RNN 的计算过程本身带有顺序结构

在第一章的 LSTM 中,模型先读取第一个 token,再读取第二个,后面的状态依赖前面的状态。

因此,"我喜欢你"和"你喜欢我"会经历不同的状态更新过程。顺序已经参与了计算,不需要为了基本的顺序建模,再给每个输入位置额外添加一张位置表。

Transformer 的自注意力计算方式不同。在输入已知时,它可以并行计算多个位置的表示,不需要像 RNN 那样沿序列逐步传递隐藏状态。

2. 基础自注意力不会自动读取位置编号

先考虑没有位置编码、没有位置相关掩码的标准自注意力。它根据输入向量的内容,计算不同 token 之间应该如何相互参考。

如果把输入向量重新排列,输出也会相应地重新排列。同一个词不会仅仅因为从第一个位置移到第三个位置,就自动得到"我现在是第三个词"的信息。

这里的问题不在于"并行一定无法理解顺序",而在于这种基础计算没有直接使用位置编号。

为了让模型利用序列顺序,我们需要把位置信息引入计算。

本章先讨论最直观的一种方式:为每个位置准备一个向量,再把它加入对应 token 的表示。

范围说明:因果掩码本身会引入"只能看前面"的方向约束,所以不能笼统地说所有 Transformer 都完全没有顺序线索。另外,训练时能够并行计算多个位置,不意味着自回归生成时能一次生成全部未知 token;生成仍然通常逐步进行。

二、位置编码是什么?

广义的位置编码,是向模型引入 token 的绝对位置或相对位置关系的一类方法。

两种位置概念可以这样区分:

概念 例子
绝对位置 这个 token 位于序列中的第 5 个位置
相对位置 这个 token 在另一个 token 前面 2 个位置

本章先实现两种绝对位置编码:固定的正弦/余弦编码,以及可学习的位置表。它们都会为每个位置提供一个与输入表示维度相同的向量。

假设某个位置上的词向量为 et e_t et,该位置的位置向量为 pt p_t pt,那么输入可以写成:
xt=et+pt x_t=e_t+p_t xt=et+pt

同一个词出现在不同位置时, et e_t et 可以相同,而 pt p_t pt 不同。这样,送给后续网络的向量就带上了位置线索。

这里的"位置"按 token 计算,不一定对应汉字、单词或字符。采用子词分词时,一个单词可能占用多个位置。

为什么不直接加上 0、1、2、3?

使用整数位置作为输入特征是可以设计的,并不是数学上行不通。

但如果把位置编号直接加到词向量的每一个维度,所有维度收到的都是同一种变化,而且数值会随位置持续增大。如果用序列长度归一化,又会让同一个位置在不同长度的序列中得到不同数值。

位置编码提供了一种更有结构的选择:把位置表示成一组数,在多个维度上提供不同的位置特征。下面的正弦方案还把每个分量限制在 −1,1-1,1 −1,1 内,并同时提供变化快、变化慢的信号。

三、正弦/余弦位置编码

1. 公式在做什么?

《Attention Is All You Need》采用了以下固定位置编码:
P E(pos,2i) =sin⁡ ( pos10000 2i/ dmodel ) PE_{(pos,2i)}=\sin\left(\frac{pos}{10000^{2i/d_{model}}}\right) PE(pos,2i)=sin(100002i/dmodelpos)
P E(pos,2i+1) =cos⁡ ( pos10000 2i/ dmodel ) PE_{(pos,2i+1)}=\cos\left(\frac{pos}{10000^{2i/d_{model}}}\right) PE(pos,2i+1)=cos(100002i/dmodelpos)

其中:

  • pospos pos 是从 0 开始的位置索引。
  • ii i 是维度对的编号,对应第 2i2i 2i 和第 2i+12i+1 2i+1 维。
  • dmodel d_{model} dmodel 是模型表示的维度,这里的位置向量与输入向量使用相同维度。

每一对维度使用相同的频率,一个取 sin,一个取 cos;不同维度对使用不同频率。

例如,设 dmodel =4 d_{model}=4 dmodel=4,那么位置 pospos pos 的向量就是:
PE(pos)=sin⁡(pos),cos⁡(pos),sin⁡(pos/100),cos⁡(pos/100)PE(pos)=\\sin(pos),\\cos(pos),\\sin(pos/100),\\cos(pos/100) PE(pos)=sin(pos),cos(pos),sin(pos/100),cos(pos/100)

前几个位置如下,数值保留四位小数:

位置 第 0 维 第 1 维 第 2 维 第 3 维
0 0.0000 1.0000 0.0000 1.0000
1 0.8415 0.5403 0.0100 1.0000
2 0.9093 -0.4161 0.0200 0.9998

可以看出,前两维变化比较快,后两维变化比较慢。多个频率组合起来,为不同位置提供可区分的数值表示。

这里不需要把每一维解释成某个明确的语义属性。它们提供的是位置特征,模型会在训练中学习怎样使用。

2. 为什么使用多个频率?

只看一个正弦分量,不同位置可能得到相同或接近的数值。单一频率提供的位置特征也比较单薄。

组合多个频率,可以同时提供不同变化尺度的信号:高频分量对较小的位置变化更敏感,低频分量则随位置缓慢变化。

在这组公式中,维度对的编号越小,频率越高;编号越大,频率越低。

但不要把它理解成"某一维专门负责局部,另一维专门负责全文"。模型如何利用这些分量,是训练得到的结果。

3. 为什么把 sin 和 cos 配成一对?

除了提供不同的分量,这样配对还有一个有用的数学性质。

令某一对维度的频率为 ω\omega ω。当位置从 pospos pos 移动到 pos+kpos+k pos+k 时,根据三角函数加法公式:
sin⁡((pos+k)ω) cos⁡((pos+k)ω) = cos⁡(kω) sin⁡(kω) −sin⁡(kω) cos⁡(kω) sin⁡(posω) cos⁡(posω) \begin{bmatrix} \sin((pos+k)\omega)\\ \cos((pos+k)\omega) \end{bmatrix}= \begin{bmatrix} \cos(k\omega)&\sin(k\omega)\\ -\sin(k\omega)&\cos(k\omega) \end{bmatrix} \begin{bmatrix} \sin(pos\omega)\\ \cos(pos\omega) \end{bmatrix} sin((pos+k)ω)cos((pos+k)ω)=cos(kω)−sin(kω)sin(kω)cos(kω)sin(posω)cos(posω)

对于固定的位移 kk k,中间的变换只取决于位移和频率,不取决于起点 pospos pos。

这意味着,固定距离的位置移动具有一致的数学结构,为模型学习相对位置关系提供了条件。它不是保证模型自动学会距离的证明,但能解释为什么要采用这种成对设计。1

4. 用 PyTorch 实现

下面的函数支持指定设备、输出类型,以及位置起点。三种参数各有用途:设备和类型用于与输入张量保持一致,offset 用于从非零位置开始计算。

本章 Python 代码按顺序运行即可;最后的绘图部分额外需要 Matplotlib。

python 复制代码
import math
import torch
import torch.nn as nn


def positional_encoding(
    seq_len,
    d_model,
    *,
    offset=0,
    device=None,
    dtype=torch.float32,
):
    if seq_len < 0 or d_model <= 0 or offset < 0:
        raise ValueError("seq_len、offset 必须非负,d_model 必须为正。")
    if not dtype.is_floating_point:
        raise TypeError("位置编码的输出类型必须是浮点数。")

    # 用 float32 计算三角函数,最后再转换为需要的输出类型。
    positions = torch.arange(
        offset, offset + seq_len, device=device, dtype=torch.float32
    ).unsqueeze(1)

    dimensions = torch.arange(
        0, d_model, 2, device=device, dtype=torch.float32
    )
    frequencies = torch.exp(-math.log(10000.0) * dimensions / d_model)
    angles = positions * frequencies.unsqueeze(0)

    pe = torch.empty(seq_len, d_model, device=device, dtype=torch.float32)
    pe[:, 0::2] = torch.sin(angles)

    # d_model 为奇数时,cos 分量比 sin 分量少一个。
    pe[:, 1::2] = torch.cos(angles[:, : d_model // 2])

    return pe.unsqueeze(0).to(dtype=dtype)


encoding = positional_encoding(50, 512)
print(encoding.shape)  # torch.Size([1, 50, 512])

返回值第一维是 1,便于之后与 (batch_size, seq_len, d_model) 形状的输入相加。广播机制会让批次中的每条序列使用相同的位置编号。

这个函数使用固定公式,没有需要训练的位置参数。实际工程中,可以预先计算并缓存常用长度的编码;若封装到模型模块里,通常可以使用 register_buffer 保存固定编码。

5. 能计算更远位置,不等于模型能理解更长文本

正弦公式不依赖一张有限长度的位置查找表,因此可以继续为更大的位置编号计算编码。在实际浮点计算中,当然仍然有数值精度和内存等限制。

但即使成功生成了更远位置的向量,也不代表模型能在超出训练长度的文本上保持原有表现。

位置编码能否计算、网络结构能否运行、模型是否有效,是三个不同的问题。 不能把"公式可以继续代入"直接写成"模型能够可靠外推"。

四、可学习的位置编码

正弦位置编码由公式决定。另一种做法,是让每个位置拥有一行可训练的向量。

这与上一章的 Embedding 很像:

查找表 用什么索引? 查到什么?
token embedding token ID 这个 token 的向量
position embedding 位置 ID 这个位置的向量

假设最多支持 max_len 个位置,每个位置向量有 d_model 维,那么位置表的形状就是 (max_len, d_model)。

python 复制代码
class LearnablePositionalEncoding(nn.Module):
    def __init__(self, max_len, d_model):
        super().__init__()
        if max_len <= 0 or d_model <= 0:
            raise ValueError("max_len 和 d_model 必须为正。")

        self.max_len = max_len
        self.d_model = d_model
        self.encoding = nn.Embedding(max_len, d_model)

    def forward(self, x, offset=0):
        # x: (batch_size, seq_len, d_model),浮点张量。
        if x.ndim != 3 or x.size(-1) != self.d_model:
            raise ValueError("x 必须具有 (batch_size, seq_len, d_model) 形状。")
        if not x.is_floating_point():
            raise TypeError("x 必须是浮点张量。")

        seq_len = x.size(1)
        if offset < 0 or offset + seq_len > self.max_len:
            raise ValueError("请求的位置超出了位置表范围。")
        if x.device != self.encoding.weight.device:
            raise ValueError("请先把位置编码模块和输入移到同一设备。")

        pos_ids = torch.arange(
            offset, offset + seq_len, device=x.device, dtype=torch.long
        ).unsqueeze(0)

        positions = self.encoding(pos_ids).to(dtype=x.dtype)
        return x + positions


device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
pos_enc = LearnablePositionalEncoding(100, 512).to(device)
x = torch.zeros(1, 10, 512, device=device)
print(pos_enc(x).shape)  # torch.Size([1, 10, 512])

训练时,这张位置表会和其他模型参数一起,通过损失函数的梯度更新。它的参数量为:
max_len× dmodel max\len\times d{model} max_len×dmodel

它也有明确限制:超出表长的位置没有对应行;即使预留了更多行,没有接受有效训练的位置向量,也不会自动获得可靠的位置表示。

可以通过扩表、插值或额外训练等方式处理更长输入,但这些属于后续扩展,不是这段基础查表代码自带的能力。

offset 则用于控制位置起点。例如,带缓存的生成过程已经处理了 20 个 token,下一个 token 的位置通常应从 20 开始,而不是重新使用位置 0。这里先保留这个接口,缓存本身留到后面再实现。

五、位置向量为什么与词向量相加?

对于本章的两种绝对位置方案,常见用法是:
X′=X+PEX'=X+PE X′=X+PE

相加后,输入仍然是 d_model 维,因此后续层可以继续使用原来的输入宽度。

相加操作本身不增加可训练参数,但可学习的位置表会增加参数。 正弦公式和可学习位置表在这里需要分清。

拼接也可以设计。如果词向量和位置向量都是 d_model 维,拼接后就会变成 2 * d_model 维,需要调整后续层或增加投影。如果位置向量维度不同,增加的维度也就不同,不能一概说拼接一定翻倍。

相加不会把信息混在一起吗?

会叠加,而且单看一个和向量,不能唯一恢复任意两组原始向量。

但模型的任务不是先把它们完整拆回去。词向量、位置表示和后续网络是在既定结构下共同参与训练的,网络学习从叠加后的表示中提取对预测有用的信息。

同一个词位于不同位置时,加上的位置向量不同;不同词位于同一位置时,词向量不同。这样的组合向模型提供了内容和位置两类线索。

这说明相加是一种实用的结构选择,并不意味着它保证无损,或者一定优于所有其他结合方式。

把 token embedding 和位置编码接起来

下面用两个简单的 ID 序列演示。它们包含相同的 ID,但顺序不同。

python 复制代码
token_embedding = nn.Embedding(10, 8).to(device)
input_ids = torch.tensor([
    [1, 2, 3],
    [3, 2, 1],
], dtype=torch.long, device=device)

token_vectors = token_embedding(input_ids)
pe = positional_encoding(
    seq_len=input_ids.size(1),
    d_model=token_vectors.size(-1),
    device=token_vectors.device,
    dtype=token_vectors.dtype,
)
x_with_position = token_vectors + pe

print(token_vectors.shape)     # torch.Size([2, 3, 8])
print(pe.shape)                # torch.Size([1, 3, 8])
print(x_with_position.shape)   # torch.Size([2, 3, 8])

# 两个位置上的 token 都是 ID 1,原始词向量相同。
print(torch.allclose(token_vectors[0, 0], token_vectors[1, 2]))  # True

# 但它们分别位于位置 0 和位置 2,加入位置后不再相同。
print(torch.allclose(x_with_position[0, 0], x_with_position[1, 2]))  # False

这个例子只演示向量如何组合,没有训练模型,也不能据此判断模型已经理解语序。

原始 Transformer 还会把 token embedding 乘以 dmodel \sqrt{d_{model}} dmodel ,其输入相加部分写成:
X′=Embedding⁡(ids) dmodel +PEX'=\operatorname{Embedding}(\text{ids})\sqrt{d_{model}}+PE X′=Embedding(ids)dmodel +PE

这是原论文的具体配置。1 上面的最小例子直接使用 X+PEX+PE X+PE,重点是观察位置带来的变化。实际搭建模型时,缩放应与初始化和整体结构保持一致,不能把它当作所有 Transformer 都必须执行的一步。

还要注意,本章统一使用 (batch_size, seq_len, d_model) 排列。第一章 LSTM 示例使用的是 (seq_len, batch_size, emb_dim),不能不检查维度就直接把本章代码接过去。

如果输入有 padding,位置编码也不会自动让网络忽略这些位置。后续注意力和损失计算仍然需要相应的屏蔽处理。

六、扩展:相对位置方法与 RoPE

1. 相对位置方法

绝对位置回答"当前是第几个 token";相对位置回答"两个 token 相隔多远,谁在谁前面"。

不少相对位置方法会直接把距离信息加入注意力计算,而不是为每个 token 都加一个绝对位置向量。具体实现可以使用相对位置向量、相对距离偏置等形式。2

例如,模型可以区分"前面一个 token"和"后面一个 token",而不必只依赖它们各自的绝对位置编号。

但"相对位置"是一个方法类别。是否截断距离、是否把距离划分成不同区间、引入多少参数和计算开销,都取决于具体设计,不能统一概括成"更适合长文本"。

2. 旋转位置编码 RoPE

RoPE(Rotary Position Embedding)通常把注意力中的 Query 和 Key 向量按二维分组,再根据 token 所处位置对这些分量施加旋转。

这种旋转可以用复数表示,也可以用实数运算实现,并不要求程序必须使用复数类型。

它的关键性质是:不同位置的 Query 和 Key 分别旋转后,在它们的点积中,会出现与相对位置差有关的项。3

因此,RoPE 不等同于"再造一个位置向量,加到词向量上"。它把位置信息引入了注意力所使用的表示。

Query、Key 和点积注意力会在后面的章节展开,这里先知道两者的联系即可。RoPE 可以为长上下文方案提供基础,但它本身不保证超出训练长度后仍有良好表现,仍需结合训练长度和位置处理策略评估。

3. 几种方法怎样比较?

方法 主要特点 需要注意的限制
正弦/余弦绝对位置编码 固定公式,无可训练位置参数,可计算更远位置 频率结构预设;可计算不等于可可靠外推
可学习的绝对位置表 每个位置的向量随任务学习 普通查表受表长限制,未训练位置缺少有效学习
相对位置方法 显式利用 token 间的相对位移 距离范围、参数量和计算开销因实现而异
RoPE 通过旋转使 Q、K 点积包含相对位置信息 长度外推仍依赖训练和位置处理方案

这张表比较的是实现特点,不是从差到好的排名。RoPE 也与相对位置建模密切相关,这些类别并非完全互斥。

七、把不同频率画出来

公式看得抽象,可以选出几个维度,把位置变化画成曲线。

python 复制代码
import matplotlib.pyplot as plt

pe_for_plot = positional_encoding(200, 64)[0].cpu().numpy()
selected_dims = [0, 8, 16, 32]

fig, axes = plt.subplots(4, 1, figsize=(9, 7), sharex=True)
for ax, dim in zip(axes, selected_dims):
    ax.plot(pe_for_plot[:, dim])
    ax.set_ylabel(f"dim {dim}")
    ax.set_ylim(-1.1, 1.1)
    ax.grid(alpha=0.25)

axes[0].set_title("Sinusoidal positional encoding")
axes[-1].set_xlabel("Token position")
fig.tight_layout()
plt.show()

这里选取的都是 sin 分量。你会看到,编号较小的维度变化较快,编号较大的维度变化较慢。由于公式使用弧度,某些曲线看起来也不会像"每个位置正好走完整数分之一周期"那样整齐。

多个频率共同构成了位置表示。模型最终怎样利用这些特征,还需要通过任务训练来决定。

到这里,输入端的两个问题就连起来了:token embedding 提供内容的初始表示,位置机制提供位置或顺序线索。接下来进入注意力机制时,我们再看模型如何用这些表示,决定一个 token 应该参考序列中的哪些部分。

参考资料

  1. Vaswani 等:Attention Is All You Need,第 3.4---3.5 节。
  2. Shaw 等:Self-Attention with Relative Position Representations。
  3. Su 等:RoFormer: Enhanced Transformer with Rotary Position Embedding。
  4. Press 等:Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation,进一步讨论训练长度之外的泛化问题。
相关推荐
ADark1 小时前
FDE 入门 · 03|Palantir 二十年前埋下的伏笔
人工智能
海宇数科1 小时前
Go数据工程:利用海宇婚恋风险报告优化实名制严肃婚恋交友合规体验
人工智能·go
嘎嘎风1 小时前
MiniMind 学习笔记之 06 一张图、一百行:MiniMind 的骨架
算法·源码
xiwc1 小时前
我用 MCP + 多 Agent 搭了一条自动化内容发布流水线
人工智能·mcp
lucas_AI1 小时前
Hinton 下场写 RSI 论文:今天一年的 AI 进步,未来可能只要 5 周
人工智能·llm
用户7341681035481 小时前
只盯被动响应做状态监测?论“控制信号”在变工况故障诊断中的重要作用
算法
黑妹天下第一乖1 小时前
第 05 讲:阿加犀 AidCV 图像处理加速与 OpenCV 一致开发实战
开发语言·图像处理·人工智能·嵌入式硬件·数码相机·opencv·计算机视觉
天远API1 小时前
PHP数据工程:利用天远名下车辆车牌查询A优化智慧物业一户多车登记合规体验
人工智能
Ivanqhz1 小时前
MQA、GQA、稀疏/滑动窗口注意力及混合范式
人工智能·算法·机器学习