☰
Transformer架构从零详解:自注意力机制与动手搭建迷你模型
2026/10/20 13:27:17 网站建设 项目流程

1. 从“transformers”这个词说起:它到底指什么

如果你在技术社区里看到有人只甩出一个词“transformers”,大概率不是在聊那部科幻电影。这个词在当下的技术语境里,指向的是Transformer 架构——一种在自然语言处理、计算机视觉、语音识别等领域被广泛采用的神经网络结构。它最早出现在一篇讨论“注意力机制”的论文里,核心思想是抛弃传统的循环结构,用自注意力来捕捉序列中任意两个位置之间的关系。

我最初接触 Transformer 的时候,脑子里全是问号:为什么它能把循环神经网络和卷积神经网络都挤到一边?后来自己动手复现了一遍,才慢慢理解它的设计逻辑。简单说,传统循环网络处理长序列时,信息要一步步传递,前面的内容传到后面容易“失真”;而 Transformer 让每个位置直接和所有位置“对话”,信息传递路径短,并行计算也更容易。

这篇文章适合谁看?如果你刚听说 Transformer,想搞明白它到底怎么回事;或者你已经用过一些现成的模型,但对其内部机制一知半解;又或者你准备自己动手搭一个简单的 Transformer 来做实验——那这篇内容应该能帮到你。我会从核心原理讲起,然后拆解关键组件,再给出一套可复现的实操步骤,最后分享一些我在实际调试中踩过的坑和总结的经验。

提示:本文不涉及任何具体商业产品、真实机构或人物,所有案例均为虚构代称,仅用于说明技术原理。

2. 自注意力机制:Transformer 的发动机

2.1 为什么需要“注意力”

假设你读一句话:“小猫追着球跑,因为它很兴奋。”这里的“它”指代什么?人类很容易判断是“小猫”,但机器怎么知道?传统模型可能只根据邻近词来猜,但“它”和“小猫”之间隔了好几个词。自注意力机制就是让模型在处理每个词时,都能“看”到句子里所有其他词,并给它们分配不同的权重。

具体来说,每个词会生成三个向量:查询(Query)、键(Key)、值(Value)。查询代表“我在找什么”,键代表“我有什么”,值代表“我实际的内容”。然后计算查询和所有键的相似度,得到权重,再用这些权重对值加权求和。这个过程就像在图书馆里,你拿着一个检索需求(查询),去和每本书的标签(键)比对,找到最相关的书,然后把书里的内容(值)综合起来。

我第一次理解这个机制时,用了一个更生活化的类比:你在一个嘈杂的聚会上想听清朋友说话。你的耳朵会自动过滤掉背景噪音,把注意力集中在朋友的声音上。自注意力就是让模型学会这种“聚焦”能力。

2.2 缩放点积注意力的计算细节

实际计算时,查询和键做点积,然后除以一个缩放因子(通常是键向量维度的平方根),再经过 softmax 归一化,最后乘以值向量。为什么要缩放?因为如果维度很大,点积结果会很大,softmax 之后梯度会变得极小,训练容易卡住。这个细节在原始论文里被特别强调,我一开始忽略它,结果模型损失降不下去,后来加上缩放才正常。

用公式表示就是:

[ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]

其中 (d_k) 是键向量的维度。这个公式看起来简单,但每一步都有讲究。比如 softmax 是按行做的,每一行对应一个查询对所有键的注意力权重。如果你用代码实现,要注意维度对齐,否则很容易出错。

2.3 多头注意力:让模型从不同角度观察

单个注意力头只能捕捉一种关系模式。比如一个头可能关注语法结构,另一个头关注语义关联。多头注意力就是把查询、键、值分别投影到多个低维空间,各自独立计算注意力,最后把结果拼接起来再投影一次。这样模型就能同时从多个“视角”理解输入。

我试过在小型数据集上对比单头和多头,发现多头确实能提升效果,但头数不是越多越好。头数太多,每个头的维度就很小,反而可能损失信息。一般 8 头或 16 头比较常见,具体要看模型规模和任务复杂度。

注意:多头注意力的参数量会随头数增加而增加,如果数据量不够,容易过拟合。我在一个只有几千条样本的任务上用了 16 头,结果验证集效果比 8 头差了不少。

3. 位置编码:没有循环结构,怎么知道词的顺序

3.1 顺序信息为什么重要

Transformer 的自注意力是并行处理的,它不知道哪个词在前、哪个词在后。但语言是有顺序的,“猫追狗”和“狗追猫”意思完全相反。所以必须把位置信息注入到输入里。位置编码就是给每个位置生成一个向量,然后加到词嵌入上。

最早的做法是用正弦和余弦函数生成固定编码,不参与训练。公式里用不同频率的三角函数,让每个位置都有独特的模式。这种编码的好处是可以扩展到比训练时更长的序列,因为它是确定性的。

3.2 可学习的位置编码与相对位置

后来很多模型改用可学习的位置编码,也就是把位置向量当作参数一起训练。这样做在固定长度任务上效果不错,但遇到超长序列时,没见过的位置就没有对应的编码,泛化能力差。

再后来出现了相对位置编码,它不关心绝对位置,而是关注两个词之间的相对距离。比如“猫”和“追”之间隔了几个词,这个距离信息被编码进注意力计算里。相对位置编码在长文本任务上表现更好,因为它更符合语言的局部性特点。

我实际用下来,如果任务序列长度比较固定,可学习编码简单有效;如果序列长度变化很大,或者需要处理很长的文档,相对位置编码更稳。不过相对位置编码的实现复杂一些,需要修改注意力计算部分。

3.3 位置编码的实操注意事项

自己实现位置编码时,有几个点容易忽略。第一,位置编码的维度必须和词嵌入维度一致,否则没法相加。第二,如果使用正弦编码,要确保不同位置的编码不重复,否则模型无法区分。第三,位置编码通常不加偏置项,直接相加即可。

我踩过一个坑:在计算正弦编码时,把频率公式写错了,导致相邻位置的编码几乎一样,模型完全学不到顺序信息,训练损失一直很高。后来对照公式仔细检查才发现问题。所以建议写完位置编码后,先可视化一下,看看不同位置的编码是否真的有区分度。

4. 编码器与解码器:Transformer 的完整骨架

4.1 编码器堆叠:逐层提取特征

编码器由多个相同的层堆叠而成,每一层包含两个子层:多头自注意力和前馈网络。每个子层后面都有残差连接和层归一化。残差连接让梯度更容易传播,层归一化则稳定训练过程。

前馈网络其实就是一个两层的全连接网络,中间有激活函数。它的作用是对每个位置独立进行非线性变换,增加模型的表达能力。虽然结构简单,但参数量不小,尤其是中间层的维度通常是模型维度的几倍。

我注意到,编码器每一层都会输出一个同样长度的序列,但表示越来越抽象。底层可能关注局部语法,高层可能关注全局语义。这种层次化特征提取是 Transformer 强大的原因之一。

4.2 解码器的掩码自注意力

解码器除了编码器的那些组件,还多了一个交叉注意力子层,用来关注编码器的输出。但更重要的是,解码器的自注意力必须是掩码的,也就是每个位置只能看到它前面的位置,不能看到后面的。这是为了防止训练时“偷看”答案。

掩码的实现通常是在注意力分数矩阵上加一个很大的负数,让 softmax 之后这些位置的权重接近零。这个细节很关键,如果忘了加掩码,模型在训练时表现很好,但推理时会崩溃,因为它习惯了看到未来信息。

我第一次实现解码器时,就忘了掩码,结果训练损失降得很快,但生成出来的句子完全乱套。后来加上掩码,虽然训练慢了一点,但生成质量正常了。所以这个坑一定要避开。

4.3 残差连接与层归一化的顺序

原始论文里,残差连接是在子层之后,然后做层归一化,也就是“后归一化”。但后来很多实践发现“前归一化”更稳定,也就是先做层归一化,再进子层,最后加残差。两种方式我都试过,前归一化在深层模型里确实更容易训练,收敛更快。

不过前归一化也有代价,它改变了模型的输出分布,可能需要调整学习率等超参数。如果你用的是现成的框架,通常已经内置了这些选项,直接选默认的就行。但如果你想自己从头搭,建议先从前归一化开始,省去很多调参烦恼。

5. 动手搭建一个迷你 Transformer:从零到跑通

5.1 环境准备与依赖选择

要自己实现一个 Transformer,不需要太复杂的工具。Python 加上一个深度学习框架就够了。我习惯用 PyTorch,因为它的动态图写起来直观,调试也方便。安装好框架后,再准备一些基础库,比如用于数值计算的 NumPy,用于可视化的 Matplotlib。

如果你不想从零写所有层,也可以用现成的高级库,比如 Hugging Face 的 transformers。但我的建议是,至少自己手写一遍核心组件,这样才能真正理解内部机制。手写一遍之后,再用现成库,你会知道每个参数在干什么。

提示:手写时不要追求性能,先保证逻辑正确。用很小的维度,比如模型维度 64,头数 4,层数 2,这样跑得快,也容易调试。

5.2 实现多头注意力的关键步骤

实现多头注意力时,我通常分几步走。第一步,定义线性层,把输入投影到查询、键、值。第二步,把投影后的张量拆分成多个头,调整维度顺序。第三步,计算缩放点积注意力,注意加上掩码(如果需要)。第四步,把多头结果拼接,再经过一个线性层输出。

代码里最容易出错的是维度变换。比如拆分头的时候,要把序列长度和头数维度换位,计算完再换回来。我建议每做一步都打印一下张量形状,确保符合预期。另外,缩放因子别忘,否则训练不稳定。

import torch import torch.nn as nn import math class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() assert d_model % num_heads == 0 self.d_model = d_model self.num_heads = num_heads self.d_k = d_model // num_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_o = nn.Linear(d_model, d_model) def forward(self, query, key, value, mask=None): batch_size = query.size(0) Q = self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) K = self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) V = self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) 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) context = torch.matmul(attn, V) context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) output = self.W_o(context) return output

这段代码里,masked_fill用来处理掩码,把不该看的位置设成很大的负数。contiguous是为了确保张量在内存里连续,方便 view 操作。这些细节虽然小,但少了就会报错。

5.3 前馈网络与层归一化的实现

前馈网络就是两个线性层加一个激活函数。中间层的维度通常是模型维度的 4 倍,比如模型维度 512,中间层就是 2048。激活函数早期用 ReLU,后来很多模型用 GELU,效果更好。层归一化对每个样本的最后一维做归一化,和批归一化不同,它不依赖批次大小。

实现时,我习惯把前馈网络和层归一化封装成一个小模块,方便复用。残差连接就是直接把输入加到输出上,注意维度要一致。如果维度不一致,可以用线性层调整,但通常设计时就会保持一致。

class PositionwiseFeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super().__init__() self.linear1 = nn.Linear(d_model, d_ff) self.linear2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) self.activation = nn.GELU() def forward(self, x): return self.linear2(self.dropout(self.activation(self.linear1(x))))

层归一化可以直接用框架自带的nn.LayerNorm,它默认对最后一维归一化,正好符合需求。残差连接在 forward 里写x = x + sublayer(x)就行。

5.4 组装编码器层与解码器层

编码器层就是多头自注意力加前馈网络,每个子层都有残差和层归一化。解码器层多一个交叉注意力,查询来自解码器,键和值来自编码器输出。掩码方面,编码器不需要掩码(除非处理填充),解码器需要因果掩码。

组装时,我建议先写一个通用的子层连接函数,处理残差和归一化,这样代码更简洁。然后按顺序调用各个子层。最后把多层堆叠起来,就得到了完整的编码器和解码器。

跑通一个迷你 Transformer 后,你可以用一个小型翻译任务或文本分类任务来验证。比如构造一些简单的序列到序列数据,看看模型能不能学会复制或反转序列。这些任务虽然简单,但能快速暴露实现中的错误。

6. 训练 Transformer 时那些容易踩的坑

6.1 学习率预热与衰减

Transformer 对学习率很敏感。原始论文用了预热策略:一开始学习率线性增加,达到峰值后再按平方根倒数衰减。这样做是因为模型参数多,一开始就用大学习率容易震荡。我试过不用预热,结果损失曲线剧烈波动,很难收敛。

预热步数通常设为总步数的 10% 左右,峰值学习率在 1e-4 到 1e-3 之间。具体值要看模型大小和批次大小。如果批次大,学习率可以适当大一点。衰减阶段用余弦退火或者线性衰减都可以,我常用余弦退火,简单且效果稳定。

6.2 梯度裁剪与权重初始化

梯度裁剪是防止梯度爆炸的常用手段,通常把梯度范数限制在 1.0 或 5.0。Transformer 深层堆叠时,梯度容易变大,裁剪能提高训练稳定性。权重初始化也很关键,原始论文用了 Xavier 初始化,后来很多模型用更简单的正态初始化,但方差要调好。

我遇到过初始化不当导致训练一开始就发散的情况。后来统一用 Xavier 初始化,并加上梯度裁剪,就稳定多了。如果你用预训练模型微调,初始化通常不用操心,但从头训练时一定要重视。

6.3 批次大小与序列长度的权衡

批次大小和序列长度直接影响内存占用和训练速度。批次越大,梯度估计越准,但内存消耗也越大。序列越长,自注意力的计算量呈平方增长。所以要在两者之间权衡。

我的经验是,先确定硬件能承受的最大序列长度,然后在这个限制下尽量增大批次。如果内存不够,可以用梯度累积,模拟大批次的效果。另外,变长序列要用掩码处理填充部分,否则填充 token 会干扰注意力计算。

6.4 过拟合与正则化手段

Transformer 参数量大,容易过拟合。常用的正则化手段包括 dropout、权重衰减和标签平滑。Dropout 一般加在注意力权重、前馈网络中间层和残差连接后。权重衰减用 AdamW 优化器时通常设 0.01 左右。标签平滑把硬标签变成软标签,防止模型过于自信。

我在小数据集上训练时,dropout 设 0.1 到 0.3 效果不错,太高会导致欠拟合。权重衰减和标签平滑可以一起用,但要注意调整强度。如果验证集损失开始上升,就是过拟合的信号,可以增加正则化或减少模型规模。

7. 从理解到应用:Transformer 的延伸方向

7.1 预训练与微调范式

现在很少有人从头训练一个大 Transformer,更常见的做法是用预训练模型微调。预训练阶段用大量无标注数据,通过自监督任务让模型学习通用语言表示。微调阶段用少量标注数据,针对具体任务调整参数。

这种范式的好处是省时省力,而且效果通常比从头训练好。我做过对比,同样的小数据集,微调预训练模型比从头训练准确率高十几个百分点。但微调也有技巧,比如学习率要设小一点,通常 1e-5 到 5e-5,避免破坏预训练学到的知识。

7.2 模型压缩与加速

Transformer 推理速度慢,尤其是长序列。常见的加速手段包括知识蒸馏、量化和剪枝。知识蒸馏用大模型教小模型,让小模型模仿大模型的输出分布。量化把浮点参数变成低精度整数,减少内存和计算量。剪枝去掉不重要的连接或头。

我试过对注意力头做剪枝,发现有些头确实冗余,去掉后效果几乎不变,但速度提升明显。不过剪枝需要仔细评估,不能盲目剪,否则可能损害关键能力。量化对推理加速很直接,但要注意精度损失,通常用动态量化或量化感知训练来缓解。

7.3 跨模态应用

Transformer 不只能处理文本,还能处理图像、音频甚至多模态数据。视觉 Transformer 把图像切成小块,当作序列输入。语音 Transformer 把音频特征序列化。多模态模型则把不同模态的特征对齐到同一空间,实现图文匹配、视频理解等任务。

我在一个图文检索任务里用过多模态 Transformer,把图像特征和文本特征分别编码,然后用交叉注意力融合。效果比传统方法好,但训练数据要求高,需要大量配对数据。如果数据不够,可以先分别预训练,再联合微调。

8. 一些个人体会与后续可以尝试的方向

回头看,Transformer 最吸引我的地方是它的通用性。同一个架构,稍作调整就能用在很多领域。但它的成功也不是偶然,自注意力机制解决了长距离依赖问题,多头设计增加了表达能力,残差和归一化保证了训练稳定。这些设计环环相扣,缺一不可。

如果你刚入门,我建议不要一上来就啃大模型,先手写一个小型 Transformer,跑通一个简单任务。遇到报错就查文档、打印形状、可视化中间结果。这个过程虽然慢,但理解得扎实。等手写版跑通了,再用现成库,你会发现那些 API 设计得多么合理。

后续可以尝试的方向很多。比如把位置编码换成相对位置,看看长文本效果有没有提升;或者试试不同的注意力变体,比如线性注意力,降低计算复杂度;还可以探索 Transformer 在非序列数据上的应用,比如表格数据或图数据。每个方向都有不少值得挖掘的细节。

最后分享一个小技巧:调试 Transformer 时,先把模型设得特别小,比如 2 层、4 头、维度 32,然后用一个极简任务(比如序列反转)验证。如果这个小模型都学不会,那肯定是实现有问题。等小模型跑通了,再逐步放大,这样排查问题会容易得多。

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

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

立即咨询