☰
手写Transformer核心架构:Self-Attention与位置编码实战
2026/10/21 7:38:08 网站建设 项目流程

这两年只要聊到大模型,就绕不开Transformer。不管你是想做自然语言处理、多模态,还是单纯想搞懂GPT、BERT这类模型到底是怎么工作的,最后都会落到同一个地方:弄明白Transformer这个核心架构。但很多初学者的切身体会是,论文看不懂、代码拷下来跑不动、跑通了也不知道改哪里。这篇文章就准备解决这个问题,从Self-Attention的原理讲起,到Encoder-Decoder的结构拆解,再用PyTorch手搓一个可以训练的Transformer小模型,把Padding Mask、位置编码、训练推理这些实战里绕不开的细节一并说清楚。适合想真正学透这套架构、不想只是“调包侠”的读者。

1. 为什么现在还在学Transformer:三大痛点与一个反直觉的答案

大多数人自学Transformer的过程,基本可以总结成三种痛苦。第一种是看原论文时被多头注意力、位置前馈网络、LayerNorm这些东西轮番轰炸,每个词都认识,合在一起就不知道在说什么。第二种是下载开源项目,能训练但不知道每一步在干嘛,想改个参数都不知道该动哪里。第三种是觉得自己看懂了概念,但一旦要自己动手写一个,就完全不知道怎么组织代码。这三个痛点本质上是同一个问题:你缺的不是资料,而是把Transformer拆开再拼起来的能力。

Transformer到底解决了什么问题?在它出现之前,序列建模主要靠RNN(LSTM是它的变体)。RNN的问题在于,处理一个句子时只能按顺序逐步看过去,比如要理解“小明把球递给小红,然后她笑了”里的“她”指小红,模型需要记住前面出现过的信息,而且这个信息经过多个时间步传递后会不断衰减,这就是所谓的长距离依赖问题。即便LSTM加了门控机制,长距离效果也不理想,而且只能串行处理,GPU并行计算的优势完全发挥不出来。

Transformer给出了一个反直觉的答案:我不需要维持一个内部的记忆状态,我把当前这个词和句子里所有其他词的关系直接算出来。这句话里“她”和谁最相关?“她”和“小红”的相关性最高,那模型就把“小红”的信息加权送到“她”当前的位置。就这么简单。它相当于让每一步处理都能看到整个句子,而不是一点点累计记忆。这也是“Attention Is All You Need”这个标题的真正意思——不需要循环结构,只需要注意力机制就足够了。

这个设计带来的好处是,整个序列可以被同时并行计算,训练速度比RNN快很多,同时长距离依赖问题被天然缓解。明白这一点之后,再回头看各种开源代码,你会发现大模型其实没有传说中那么神秘,本质上就是在Transformer的基础上叠更多层、加更多参数、用更多数据。这篇文章的定位就是先把这个地基打牢,后面的路都会顺畅很多。

2. Self-Attention是怎么“听懂”上下文的:从Query/Key/Value到缩放点积

2.1 把注意力理解成一次最精准的信息检索

学习Transformer的第一步是理解Self-Attention(自注意力)。开头那句话“把当前词和句子里所有其他词的关系直接算出来”,具体要怎么做呢?这里借用一个检索的场景类比,你会更容易理解。

假设你有三个概念:

  • Query(查询):你现在想知道的问题,相当于在数据库里输入搜索内容。
  • Key(键):数据库里每一条内容的标题或索引,用来和Query做匹配。
  • Value(值):数据库里真正的内容条目,是最终要取出来的东西。

放到句子“小明把球递给小红,然后她笑了”里,模型处理到“她”这个词时,会用一个“查询向量”去和句子里每个词的“键向量”做匹配,算出一个相似度分数。分数最高的词是“小红”,然后就把“小红”对应的“值向量”取出来,和当前词的信息融合在一起。这样,“她”这个词的表示里就包含了指向“小红”的含义,模型也就知道“她”指的是谁了。

你可能会问,一个词为什么要同时有三个向量?这其实是Transformer的一个设计巧思。同一个词在不同的“角色”下发挥不同的作用。当它作为“当前要处理的词”时,它是Query;当它作为“被参考的词”时,它是Key;当它的信息真正被提取使用时,它是Value。通过三个不同的线性变换,模型可以让同一个词在不同上下文里展现出不同的状态,表达能力就上来了。

2.2 缩放点积注意力公式里的三个细节

有了上面的直觉,Self-Attention的公式就好读多了:

Attention(Q, K, V) = softmax(Q * K^T / sqrt(d_k)) * V

这个公式里每个部分都有它的意义:

  1. 点积 Q * K^T 表示计算每个位置之间两两的相似度。维度是 (seq_len, seq_len),也就是一个句子长度为 n 的话,我们要算一张 n×n 的注意力权重表,这个矩阵就是模型“理解”上下文关系的关键。

  2. 除以 sqrt(d_k)(d_k 是每个头的维度)是为了缩放。为什么必须做这一步?如果向量维度过大,点积后的数值会比较大,进入softmax后梯度会变得很小,训练基本走不动。除以 sqrt(d_k) 可以把方差拉回一个比较稳定的范围,保证softmax的梯度不会早早饱和。

  3. softmax 的作用是把分数归一化成权重,让所有位置的权重加起来等于1。它做的事情是把“谁和谁关系大”变成“有多少比例的信息该从谁那里取”。

我见过不少人第一次跑代码时直接照抄公式,然后发现自己写的注意力权重出了问题,最后发现是忘了除以 sqrt(d_k)。这个细节看起来小,影响却非常大,建议你动手实现时反复对照公式确认。

2.3 为什么说Self-Attention比RNN更适合处理长文本

对比RNN,Self-Attention最大的优势是任意两个位置之间的信息传递路径长度为1。在RNN里,“小明”的信息要传给“她”,中间需要经过“把”“球”“递给”“小红”等多个时间步,路径很长,信息容易丢失。而在Transformer里,“她”可以直接通过注意力分数连接到“小明”,路径一步到位。

这条路劲短的代价是什么呢?是 O(n^2) 的计算复杂度。句子很短时无所谓,但句子变长到几千上万词的时候,n×n 的注意力矩阵会占满显存。这也是后来很多优化方向要解决的问题,比如稀疏注意力、滑窗注意力。不过作为初学者,先把这个标准结构吃透就够了,那些优化都是在标准结构上做的文章。

3. 架构全景拆解:Encoder堆栈、Decoder堆栈和那一圈残差连接

3.1 一个Transformer层里到底放了哪些组件

原文里Transformer是Encoder-Decoder结构,BERT用的是Encoder部分,GPT用的是Decoder部分。初学者最容易忽略的是,这两个部分虽然都由很多层堆叠而成,但每一层内部的职责是有分工的。

一个标准的Encoder层长这样:

  1. Multi-Head Attention(多头注意力):负责让每个位置去融合整个序列的信息。
  2. Add & Norm:把原始输入和注意力输出加在一起,然后做LayerNorm。
  3. Feed-Forward Network(前馈网络):一个两层的全连接网络,对每个位置单独做非线性变换。
  4. 再来一个 Add & Norm:同样把前面的结果和输入相加再归一化。

也就是说,每一层是“注意力子层 + 前馈子层”的组合,外面包着残差连接和归一化。Decoder层内部结构相同,但在注意力部分有一个关键区别:Decoder会用到两层注意力——一层是Masked Self-Attention(掩码自注意力),用来处理已经生成的部分;另一层是Cross-Attention(交叉注意力),用来从Encoder的输出里取信息。

3.2 多头注意力到底在“复制粘贴”什么

单个注意力算出来的关系,可能只捕捉一种维度的相似度(比如语法关系)。但句子里的关系是多维的:语义相关、语法相关、指代相关。为了同时捕捉这些不同维度的关系,Transformer把Q、K、V各拆成h份(原论文h=8),每一份独立做上面的注意力计算,最后再拼起来。

你可以这么理解:8个头就像8个不同的“搜索专家”,每个专家关注不同的角度。有的专家擅长看“主语和谓语是不是匹配”,有的专家擅长看“这个代词指代谁”,有的专家擅长看“前后数字的一致性”。最终把8个专家的结论拼接在一起,得到更丰富的上下文表征。

实操时我会建议,拆头数 h 的选择要和你单头维度 d_k 配合好。比如 embedding 维度是 512,拆成8头,每头维度就是64。如果设置得不合理,比如单头维度太小,每个头学到的信息量不够,模型效果会明显下降。

3.3 Add & Norm 的作用和LayerNorm与BatchNorm的区别

残差连接(Add)解决的是深层网络梯度消失的问题。没有残差,20层网络训练起来会非常困难;有了它,梯度可以绕过某一层直接传到前面层,缓解深层崩溃。这也是为什么后续大模型动辄几十层上百层还能稳定训练。

LayerNorm(Norm)做的事情是把每一层输出的均值拉回0、方差拉回1,让训练更稳定。为什么不用BatchNorm做这个任务?因为在NLP里,每个句子的长度不一样,batch内样本差异大,BatchNorm对batch整体统计均值和方差会不稳定。LayerNorm则是针对每个样本单独做归一化,不受batch内其他样本的影响,在变长序列上表现更稳。这里也建议初学者在写代码时别想当然地用BatchNorm,老老实实用LayerNorm,否则你会遇到玄学般的效果波动。

3.4 Decoder里为什么要有Mask

Decoder在做的是“基于已经生成的内容,预测下一个词”。训练时它需要防止偷看答案。假设当前已经生成了“我”“爱”,要预测下一个词,那模型绝对不能提前看到“你”。Masked Self-Attention的原理就是,在计算注意力分数时,把所有“未来位置”的分数设为一个极小的负数,softmax之后这些位置的权重就趋近于0。

Cross-Attention则不一样,它的Query来自Decoder当前层,Key和Value都来自Encoder的输出。这一层的意义是让Decoder生成内容时,能随时参考原始输入的信息。比如机器翻译里,源语言的句子信息就是通过这个交叉注意力传进Decoder的。

这里附一个对比表格,你可以在后续编码时对照着检查逻辑:

组件输入来源作用是否Mask
Encoder Self-Attention源序列自身让每个词融合全文信息不需要
Decoder Masked Self-Attention已生成的目标序列融合已生成信息,防止看到未来需要
Decoder Cross-AttentionQuery来自Decoder,Key/Value来自Encoder参考源序列信息不需要

4. 位置编码:为什么“顺序”要被塞进向量里

4.1 没有顺序感知的Transformer会怎样

注意力机制本身是一个“集合”操作:它只关心哪些位置之间有关系,但不关心位置本身谁先谁后。把句子里的词顺序打乱,注意力计算的内部结果矩阵仍然会存在,但含义完全不同了。比如“我打你”和“你打我”,字的集合完全一样,但意思相差十万八千里。Transformer如果完全不知道顺序,看到这两句话会认为它们是等同的。

所以Transformer必须注入“位置”信息。原论文使用了正弦和余弦函数来生成位置编码(Positional Encoding)。编码向量的维度需要和词嵌入向量维度一致,这样两个向量可以直接相加:

PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

其中 pos 是位置索引,i 是维度索引。为什么要选择这种稀奇古怪的函数组合?

4.2 正弦位置编码的数学直觉

核心思路是:使用不同频率的正弦余弦函数,不同维度上的编码不同,这样模型可以通过函数组合隐式感知到位置关系。它有两个明显好处:

第一,函数值范围在 [-1, 1] 之间,不会因为序列很长导致数值爆炸,和词向量相加也不会破坏原有语义。

第二,任意两个位置的编码可以通过三角函数公式建立相对位置关系。比如 sin(α+β) = sinα cosβ + cosα sinβ,这意味着模型可以通过线性变换从位置pos的编码推算出pos+k的编码,从而获取相对位置信息。这是可学习位置编码做不到的(可学习位置编码每个位置独立学习,不含这种数学上的相对关系)。

后来很多模型(比如BERT)用的是可学习位置编码,效果也不错,因为位置关系可以在训练中隐式学到。但读论文时你会发现原版Transformer固定用正弦编码,这个区别值得了解:固定编码的好处是泛化性更好,遇到比训练时更长的序列,输入位置超过了训练见过的最大长度,它依然能算出相对合理的位置编码;可学习编码在超长序列场景会差一些。

4.3 位置编码是加还是拼接

初学者经常问,位置信息为什么用加法而不是拼接?如果拼接,词向量维度变成两倍,后面的线性层维度也要调整,参数增加很多。加法不增加参数,而且位置向量和词向量正交或接近正交时,可以在同一个向量空间里“分离”出语义信息和位置信息。把两个向量相加,相当于在原有语义向量上加上一个偏移量,模型后续层通过矩阵运算可以把这个信息解耦出来。原论文和相关实验都表明加法是足够有效的选择,后来代码也基本都是这么写的。

5. 从零手写一个可训练的Mini版Transformer

5.1 为什么推荐从代码入手而不是直接上开源库

我的建议是,哪怕你以后要直接用库,也一定要自己手写一遍核心结构。不是因为开源库不好,而是Transformer这种结构只有自己从零搭一遍,才能真正理解各个模块之间互相纠缠的维度关系。比如注意力输出形状是 (batch, heads, seq_len, head_dim),但最终要合并成 (batch, seq_len, embed_dim),不推一遍代码真的容易懵。

下面我给的例程是训练一个“加小数”的任务:给定一个整数序列(比如 [3, 6, 2]),模型要把所有数字加起来输出和(11)。任务很简单,但足够验证注意力机制是否有在好好工作,而且跑得快,一个CPU训练几分钟就能收敛。

5.2 环境准备和数据生成

建议直接用本机Python 3.9以上的环境,装好PyTorch 2.x。我们不打算跑大模型,所以CPU也够用。先写一个生成数据集的函数:

import random import torch from torch import nn import math def generate_data(batch_size, max_len=6, max_num=50): xs, ys = [], [] for _ in range(batch_size): length = random.randint(2, max_len) nums = [random.randint(1, max_num) for _ in range(length)] xs.append(nums) ys.append(sum(nums)) return xs, ys

这个任务的目标是让模型学会“读完全部数字后输出一个数字”。看起来简单,但它需要模型把每个位置的数字信息都聚合起来,然后通过输出位置产生和,刚好能检验注意力机制。

为了方便模型处理,我们给每个样本补成相同长度,并增加一个代表“结束/开始”的标记,把编码后的值直接送进模型:

class SumDataset(torch.utils.data.Dataset): def __init__(self, size): self.xs, self.ys = generate_data(size) def __len__(self): return len(self.xs) def __getitem__(self, idx): x = self.xs[idx] y = self.ys[idx] return torch.tensor(x, dtype=torch.long), torch.tensor(y, dtype=torch.float)

这里的输入序列是原始的整数列表,目标是一个浮点数。为了简化,我不把“+”符号做成语言序列,而是让模型以序列首位的特殊标记位置作为输出位置(这和实际语言模型“在指定位置聚合信息”的原理是一致的)。

5.3 位置编码和进制编码的实现

给每个整数生成嵌入向量,同时把位置信息加进去。为了扩展性,我使用了一个简单的可学习位置编码,代码更直观:

class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=100): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer('pe', pe.unsqueeze(0)) def forward(self, x): seq_len = x.size(1) return self.pe[:, :seq_len, :]

这里注册成 buffer,不会参与梯度更新,也不用每次前向都重新计算。如果你希望位置编码参与训练,可以改成 nn.Parameter,但原版Transformer的这个位置编码是固定的。

5.4 多头注意力实现的前后顺序

多头注意力是整个项目最核心的模块。下面代码严格按公式:先分头,再算缩放点积注意力,最后合并:

class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() assert d_model % n_heads == 0 self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_heads self.w_q = nn.Linear(d_model, d_model) self.w_k = nn.Linear(d_model, d_model) self.w_v = nn.Linear(d_model, d_model) self.w_out = nn.Linear(d_model, d_model) def split_heads(self, x): batch, seq_len, _ = x.size() x = x.view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) return x def forward(self, x, mask=None): batch, seq_len, _ = x.size() Q = self.split_heads(self.w_q(x)) K = self.split_heads(self.w_k(x)) V = self.split_heads(self.w_v(x)) scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = torch.softmax(scores, dim=-1) output = torch.matmul(attn, V) output = output.transpose(1, 2).contiguous().view(batch, seq_len, -1) return self.w_out(output)

这里的 mask 是一个形状为 (batch, 1, 1, seq_len) 的矩阵,用来保证padding位置不参与注意力。如果不做这个mask,模型训练时会因为padding位置占了很多注意力权重而导致效果变差。

为什么padding mask时必须用一个极小值 -1e9 而不是直接置0?因为直接置0会让softmax把权重重新分配到其他位置,但权重总和还是一1,这依旧会把一部分注意力分配给无效位置,不利于模型聚焦有效位置。使用 -1e9 加上 softmax 会让这些位置的权重趋近于0,达到真正的忽略效果。

5.5 拼装Encoder层和整个模型

Encoder层由一个多头注意力、一层前馈网络、两个LayerNorm和一个残差连接组成:

class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.attention = MultiHeadAttention(d_model, n_heads) self.norm1 = nn.LayerNorm(d_model) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model) ) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): attn_out = self.attention(x, mask=x != 0) x = self.norm1(x + attn_out) ffn_out = self.ffn(x) x = self.norm2(x + ffn_out) return x

这里我直接用x != 0生成mask,方便测试,但正式代码里建议用更明确的padding_idx来控制。

我把这个项目里的Encoder简化为只有一层,实际模型中通常叠加多层。默认 d_model=64,n_heads=4,d_ff=128。模型最后接一个平均池化和线性层输出预测值:

class SumTransformer(nn.Module): def __init__(self, vocab_size, d_model=64, n_heads=4, d_ff=128): super().__init__() self.embed = nn.Embedding(vocab_size, d_model) self.pos = PositionalEncoding(d_model) self.encoder = EncoderLayer(d_model, n_heads, d_ff) self.out = nn.Linear(d_model, 1) def forward(self, x): x = self.embed(x) + self.pos(x) x = self.encoder(x) pooled = x.mean(dim=1) return self.out(pooled).squeeze(-1)

训练时使用均方误差损失:

model = SumTransformer(vocab_size=101) # 数字0~50够用,留富余 optimizer = torch.optim.Adam(model.parameters(), lr=0.001) loss_fn = nn.MSELoss() for epoch in range(30): for xs, ys in dataloader: pred = model(xs) loss = loss_fn(pred, ys) optimizer.zero_grad() loss.backward() optimizer.step() print(f"epoch {epoch}, loss {loss.item():.4f}")

实测这个模型在CPU上训练30个epoch后,loss能降到非常低,基本能正确计算出所有测试样例的和。你还可以顺手做个小实验:把数字顺序随机打乱,模型仍然能输出正确的和,说明它确实在聚合全局信息,而不是靠顺序记忆。

5.6 用这个Mini模型验证原理的两点心得

第一,训练任务一定要简单到能立刻判断“模型有没有在学”。加法任务比训练一个翻译模型快多了,十几轮就能看见loss下降,适合教学。

第二,把多头注意力的数量从1改成4,你能明显看到收敛加速。这个实验直观展示了多头背后的意义——不同的头分别学习不同维度的关系,信息更丰富,模型表现就更好。类似地,你也可以删掉位置编码试试,模型loss会明显上升,可以亲手验证“Transformer需要位置感知”这个前提。

6. 训练和推理里的几个“坑”,以及我绕过的办法

6.1 Padding Mask是刚需,别偷懒省略

很多人把数据补成等长后就直接丢进模型,结果发现attention矩阵里padding位置占据了一部分权重,有效位置的注意力被稀释。尤其是序列长度差异大的时候,这个效应会被放大。解决办法就是前面代码里的 mask,把 padding 位置的得分置成 -1e9。记住一句话:只要有Padding,就要有Mask。

6.2 学习率过大导致loss震荡怎么处理

Transformer对学习率非常敏感。我见过不少新手用 lr=0.1 跑Transformer,loss 像过山车一样上下弹,还以为模型有问题。原论文用的是热身(warmup)策略:前一部分训练步数学习率从0慢慢升到预定值,然后再以特定方式衰减。现在Adam优化器通常能容忍 lr=0.001,但更稳定的做法是设置 warmup 之后再用默认调度器。如果你发现loss震荡剧烈,先检查学习率,别急着改架构。

6.3 推理时的自回归过程与训练有什么区别

训练阶段Decoder可以用teacher forcing,即一次性把所有目标词都输入模型,通过mask避免看到未来。推理阶段没有标准答案,必须一个词一个词生成:先输入起始标记生成第一个词,然后把第一个词拼在输入后面再生成第二个词,依此类推。这也是为什么你在网上看到GPT的采样代码都会有循环生成的过程。

6.4 长序列显存爆炸时怎么降开销

Self-Attention是 O(n^2) 空间复杂度,序列长度翻倍,显存占用量直接翻四倍。如果任务序列很长,可以选择滑窗注意力或者使用Flash Attention类库,它们能显著减少中间矩阵存储。作为初学者需要明白,这不是模型“变聪明”,而是工程上牺牲了一部分全局注意力换来了更高的显存效率。

6.5 从“跑通”到“改得动”的三步进阶路径

如果这份Mini Transformer你已经跑通了,并且能随手改位置编码、多头数量、层数,下一步建议按这个顺序进阶:先读一遍HuggingFace的BERT源码,对照找出和我们的实现对应的模块;然后尝试复现一个简化的GPT风格的Decoder模型,加入文本生成任务;最后再去跑大型开源模型。这一个流程走下来,你对Transformer的理解会比大多数人扎实得多。

就我个人的体感,Transformer最难的点不在公式,而在“每一层前后张量的形状变化”。写代码的过程就是强迫自己把一个个形状搞懂的过程。这个Mini项目我反复拿来做实验,每次都能发现新的细节,所以强烈建议你也这样跑一遍,而不是只看文章。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询