Transformer这个词,这几年几乎成了AI圈里的基础设施。无论你打开ChatGPT,还是翻各种各样的开源大模型,内部架构里的那根“脊梁”,都是Transformer。很多朋友入门的时候都有同一个困惑:论文里全是矩阵公式,实际代码又封装得特别深,看完之后似乎明白了主干,但脑子里的那条数据流依然是断的。这篇文章我想做一件事:带着你从“一串文字”出发,一步步走完Transformer的完整计算过程,直到模型把“下一个词”生成出来。
它到底能做什么?简单说,就是让模型在阅读一段话时,能够同时看到所有词,并按照相关性把信息揉在一起,然后猜出下一个最该出现的词。适合谁看呢?如果你正在啃原版论文、准备手写Transformer代码,或者只是好奇大模型是怎么吐出文字的,这篇都能帮上忙。我会尽量说人话,把每一步的“为什么”也讲清楚,而不是只扔公式给你背。
1. 先从整体看:Transformer如何把文字变成另一段文字
1.1 Transformer到底在解决什么问题
在讲计算过程之前,得先知道Transformer当年出现的背景。2017年之前做自然语言处理,主流是RNN和LSTM。它们的思想很直观:从左往右一个一个地读词,每读一个词就更新一个“记忆向量”。问题在于——句子长了以后,最早的信息很容易被淹没,而且因为必须按顺序读,训练时没法并行,非常慢。
Transformer干脆换了个思路:不再一个词一个词地循着顺序理解,而是让每个词在同一时刻和句子里其他所有词做一次“两两交互”。这个交互就是核心的自注意力机制(Self-Attention)。
打个生活化的比方:RNN像一个人在会议室里挨个传纸条,信息获取圈一圈一圈地串;Transformer则像开了一场全员参与的投票会,每个人发言之前先看清楚在场所有人的观点,再综合出一个结论。这样一来,长距离依赖不再是问题,而且所有位置的计算可以同时进行,GPU终于能真正并行起来。
1.2 从文字到生成的四个阶段
Transformer从输入到输出,宏观上可以拆成四个阶段。不管你是做文本生成、翻译、分类还是图像分割,只要用的是Transformer家族,跑数据时基本都是沿着这条链路走:
| 阶段 | 名称 | 核心操作 | 输出示例 |
|---|---|---|---|
| 一 | 分词与数值化 | 把文字切分成token,映射到整数ID | “我 爱 NLP” → [12, 87, 560] |
| 二 | 嵌入与位置增强 | token映射成稠密向量,并加入位置信息 | 每个token变成d_model维向量 |
| 三 | 多层信息交换 | 注意力+前馈网络反复处理,融合上下文 | 每个位置带着全局信息的表示 |
| 四 | 概率生成 | 接一个线性层+Softmax,输出下一个词的分布 | 从词表选出概率最高的词 |
如果任务只是分类,那走到第三阶段后接一个池化或分类头就够了。而“生成”这个动作,主要发生在第四阶段:模型在每轮推理时,把已经生成的所有词重新送进网络,预测“下一个词是谁”,然后把它拼到句子末尾,再重复整个流程。
这个过程最反直觉的地方在于:模型其实一次只预测一个词,你看到的那一大段流畅文本,是它反复把“自己刚刚说过的话”当作输入,逐步“滚”出来的。理解了这一点,看后面所有计算细节都会顺畅很多。
2. 模块级深度拆解:词嵌入、位置编码与注意力到底在算什么
2.1 词嵌入:给每个token一张“身份证”
模型不认识文字,只认识数字。最简单的做法是给词表里的每个词一个编号,one-hot编码成向量。但one-hot向量有两个致命问题:维度太大,且完全无法表达词与词之间的语义相似性。
所以现代模型都用“可学习的嵌入矩阵”来做这件事。假设词表大小是V,每个词想用一个d_model维的向量表示,那么嵌入层就是一个形状为V×d_model的矩阵。输入token的整数ID,本质上就是在查这张表:把对应行抽出来,就得到了这个词的起始向量。
以GPT系列常用的配置为例,d_model往往是768、1024甚至更大。为什么不是12?因为词之间的语义关系非常复杂,比如“国王”和“王后”的差异、性别关联、领域关联等,需要高维空间才能铺开表达。这里有一个实操经验:词嵌入维度不宜过小,否则模型会很难区分大量低频词之间的细微差别;但也不宜一味求大,因为嵌入层是参数量的大头(词表大小×d_model),对中文这种大词表场景尤其需要平衡。
2.2 位置编码:给模型装上“顺序感”
自注意力机制本身是全并行的,它天然不知道“谁在前谁在后”。如果把“我爱你”三个词调换顺序重算,注意力结果可能完全一样,这对语言理解是致命的。
Transformer原文用了正弦位置编码,给每个位置生成一个固定的d_model维向量,然后加到词嵌入上。公式长这样:
PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))其中pos是token在句子里的位置,i是维度下标。注意这里用了一个很巧妙的设计:维度越高,三角函数里的频率越低,相当于每个位置都有一个从“高频细节”到“低频轮廓”的独特指纹。
为什么不直接用1、2、3这样的整数?因为整数序列的差异是线性的,但语言中的位置关系并不是线性的。而正弦/余弦编码让模型可以通过线性变换轻松学到“第3个词和第5个词的相对距离是2”这类信息。
实操中,现在很多模型也流行用可学习位置编码或旋转位置编码(RoPE)。RoPE是一个特别实用的升级:它把位置信息揉进注意力计算的向量旋转里,这样模型外推到更长序列时的表现比原始绝对位置编码稳得多。你在部署长文本模型时如果遇到长度外推问题,优先检查是不是绝对位置编码在拖后腿。
2.3 多头注意力:句子里每个词都在“找队友”
注意力机制是Transformer的灵魂,也最容易让人看懵。别怕,拆开看就好。
每个token除了有一个词嵌入向量外,还会通过三个不同的线性变换矩阵,生成三个新向量:Query(查询)、Key(键)、Value(值)。
三个名字很抽象,用图书馆找书的例子来理解:
- Query是“你脑子里的需求”,比如你要找一本关于深度学习的书;
- Key是“每本书封面上贴的标签”,用来匹配你的需求;
- Value是“书本身的正文内容”,一旦匹配上,你要提取的就是它。
一个词在计算注意力时,会用自己的Query去和句子中所有词的Key做点积,得到相似度分数,再经过Softmax归一化成权重,最后把所有词的Value按权重加权求和,得到这个词的新表示。
下面是单头注意力的数学表达:
Attention(Q, K, V) = softmax(QK^T / √d_k) V这里有个大量读者忽略的关键细节:为什么除以√d_k?因为Q和K的维度越大,点积结果的方差也就越大。如果数据直接进softmax,数值大的区域梯度会变得极小,模型很难训练。除以√d_k之后,分数被归一化到稳定区间,softmax的保护梯度就保住了。实测中,去掉这个缩放因子,很多小模型的训练曲线会直接“崩掉”或者严重震荡。
再说多头。多头注意力就是把Q、K、V各切成h份,每一份用独立的线性投影计算注意力,最后再把结果拼起来再过一次线性层。之所以要“多头”,是因为不同的头能学到不同的关注模式:一个头可能关注语法搭配,一个头可能关注指代关系,一个头可能关注相邻词语义,这种“多个视角并行”的能力是单头很难替代的。
2.4 前馈网络、残差连接与层归一化
注意力模块负责让词与词之间交换信息,但它本质上是线性变换的组合,缺少非线性表达能力。所以每个Transformer块里跟着一个前馈网络(FFN),一般就是两层线性变换夹一个ReLU或GELU激活函数,对每个位置的向量独立做一次非线性变换。
在实际代码里,Transformer块的基本结构是:
x = x + MultiHeadAttention(LayerNorm(x)) x = x + FFN(LayerNorm(x))这里有两个标配设计,很多人抄代码没细想。
- 残差连接(Add):让梯度在深层网络中有一条“抄近道”的高速公路。即使有几十层Transformer,前层的信息也能直接传导到后层,训练稳定性大幅提升。
- 层归一化(LayerNorm):对每个token的维度方向做归一化,让激活值保持在稳定的量级。和BatchNorm不同,LayerNorm不依赖batch内其他样本,所以很适合处理变长序列和单样本推理。
我见过不少新手把LayerNorm放在注意力之前还是之后折腾半天,其实Pre-Norm(先归一化再进子层)和Post-Norm(子层之后再归一化)是两种流派:Pre-Norm训练更稳,Post-Norm在超大模型里表现略优但更容易崩。自己写代码练手推荐Pre-Norm,省心。
3. 手写一个迷你Transformer:从零跑通完整计算流程
3.1 构建最小可运行的Transformer代码
光讲原理不写代码,等于隔靴搔痒。下面我用PyTorch实现一个“麻雀虽小、五脏俱全”的迷你Transformer,专注演示计算过程。你不需要GPU,一个小CPU训练几分钟就能看到效果。
先导入必要的库:
import torch import torch.nn as nn import torch.optim as optim import math定义位置编码。这里用原始论文的正弦编码,直接生成一个“位置索引对应向量”的表:
class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=128): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len).unsqueeze(1).float() 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) pe = pe.unsqueeze(0) # 形状 [1, max_len, d_model] self.register_buffer('pe', pe) def forward(self, x): return x + self.pe[:, :x.size(1)]注:register_buffer能让位置编码跟随模型一起迁移到GPU,而且不会被当作可学习参数更新。
实现多头注意力。我先写一个单头注意力,再把它封装成多头版本,这样便于你对照公式一步步看懂:
class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() assert d_model % n_heads == 0 self.d_k = d_model // n_heads self.n_heads = n_heads self.q_linear = nn.Linear(d_model, d_model) self.k_linear = nn.Linear(d_model, d_model) self.v_linear = nn.Linear(d_model, d_model) self.out_linear = nn.Linear(d_model, d_model) def forward(self, q, k, v, mask=None): batch_size, seq_len, _ = q.size() # 线性投影后拆头 q = self.q_linear(q).view(batch_size, seq_len, self.n_heads, self.d_k) k = self.k_linear(k).view(batch_size, seq_len, self.n_heads, self.d_k) v = self.v_linear(v).view(batch_size, seq_len, self.n_heads, self.d_k) # 交换维度:head提到前面 q = q.transpose(1, 2) # [B, n_heads, seq_len, d_k] k = k.transpose(1, 2) v = v.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_weights = torch.softmax(scores, dim=-1) context = torch.matmul(attn_weights, v) # [B, n_heads, seq_len, d_k] # 合并头 context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, -1) return self.out_linear(context)关键点在于scores的计算。q先和k的所有位置做点积,得到形状[B, n_heads, seq_len, seq_len]的相关性矩阵。这其实就是“每个词和所有词的相关度”。后面的缩放、掩码、softmax、加权求和,每一步都能对应上第2.3小节的公式。
然后定义前馈网络和编码器层:
class FeedForward(nn.Module): def __init__(self, d_model, d_ff): super().__init__() self.fc1 = nn.Linear(d_model, d_ff) self.fc2 = nn.Linear(d_ff, d_model) self.relu = nn.ReLU() def forward(self, x): return self.fc2(self.relu(self.fc1(x))) class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads) self.ffn = FeedForward(d_model, d_ff) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) def forward(self, x): # Pre-Norm残差结构 x = x + self.self_attn(self.norm1(x), self.norm1(x), self.norm1(x)) x = x + self.ffn(self.norm2(x)) return x注意到这里自注意力模块的Q、K、V都来自同一个输入x,这正是“自注意力”的含义。如果做机器翻译,编码器的输出K、V会喂给解码器的交叉注意力,而Q才来自解码器内部;但语言模型的生成任务只需要自注意力。
最后组装一个完整的迷你模型:
class MiniTransformer(nn.Module): def __init__(self, vocab_size, d_model=32, n_heads=2, d_ff=64, num_layers=2): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model) self.positional = PositionalEncoding(d_model, max_len=64) self.layers = nn.ModuleList([ EncoderLayer(d_model, n_heads, d_ff) for _ in range(num_layers) ]) self.norm = nn.LayerNorm(d_model) self.output = nn.Linear(d_model, vocab_size) def forward(self, x): x = self.embedding(x) x = self.positional(x) for layer in self.layers: x = layer(x) x = self.norm(x) logits = self.output(x) # 每个位置都输出下一个词的概率 return logits注意,这个简化实现没有区分编码器和解码器。对于纯语言模型,直接用输入序列预测下一个词,这种结构足够说明计算过程。logits的形状是[batch_size, seq_len, vocab_size],模型在长度为“当前输入”的每一个时间步都在预测“下一个词是谁”。
3.2 训练一个“会说话”的迷你模型
为了让计算过程变得可见,我构造一个非常小的“词表”,只有几个字,然后训练它学会某个特定位置该接什么词。这里用一个人为设定的文本:
text = "我 爱 自 然 语 言 处 理 我 爱 学 习 神 经 网 络" tokens = text.split() vocab = sorted(set(tokens)) word2idx = {w: i for i, w in enumerate(vocab)} idx2word = {i: w for i, w in enumerate(vocab)}把文本转成ID序列并构造训练样本:输入前N个token,目标是后面一位的token。这里不做batch,直接串行训练:
data = [word2idx[w] for w in tokens] seq_len = 4 # 用前4个词预测第5个词 input_seqs = [] target_seqs = [] for i in range(len(data) - seq_len): input_seqs.append(data[i:i+seq_len]) target_seqs.append(data[i+seq_len]) inputs = torch.tensor(input_seqs) targets = torch.tensor(target_seqs)初始化模型和优化器:
model = MiniTransformer(vocab_size=len(vocab)) loss_fn = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.01) for epoch in range(300): optimizer.zero_grad() logits = model(inputs) # [样本数, seq_len, vocab_size] pred = logits[:, -1, :] # 只看最后一个位置的预测 loss = loss_fn(pred, targets) loss.backward() optimizer.step() if (epoch + 1) % 50 == 0: print(f"Epoch {epoch+1}, loss = {loss.item():.4f}")我实测下来,这个小模型在CPU上大概几秒一轮,300轮训练后loss能从早期2.0左右降到0.2以内。如果你发现loss下降很慢,通常是学习率太小或层数太少,但在这个迷你场景里,主要看softmax前面logits是否需要多“烧”几轮。
训练完成后,可以做一个最基本的推理演示:
def generate(model, start_word, n_tokens=4): model.eval() src = torch.tensor([word2idx[start_word]]).unsqueeze(0) with torch.no_grad(): for _ in range(n_tokens): logits = model(src) next_idx = logits[:, -1, :].argmax(dim=-1) src = torch.cat([src, next_idx.unsqueeze(0)], dim=1) return [idx2word[i] for i in src.flatten().tolist()] print(generate(model, start_word="我"))这就是最原始的贪心解码:每一步都取概率最大的词,拼进输入,再预测下一个。因为语料太少,它翻来覆去只会说那几句,但计算流程和大模型完全一致。真实场景里还会加Temperature、Top-k或Top-p采样来增加随机性,这些策略都不改变Transformer的前向计算过程,只是改变了“从概率分布里挑哪个词”这一步。
3.3 参数、参数量与变体选择
用上面这个结构,你可以快速估算参数量:词表大小V、d_model、层数L、n_heads和d_ff共同决定。以我的小模型为例,V大概只有十几个,d_model=32,d_ff=64,总参数量大约几千个,轻得跟玩具一样。
但换成真实场景就完全不同了。以GPT-2 medium为例,词表约五万,d_model=1024,层数24,总参数量约3.5亿。参数量大头通常分布在词嵌入层和FFN层,注意力层反而是小头。很多轻量级优化工作,比如ALBERT,就是对Embedding做因式分解来砍参数量。
如果你想把这套结构迁移到其他领域,思路也是一样的:
- 视觉Transformer(ViT):把图片切成patch,每个patch当作一个“词”,位置编码对应patch的网格位置;
- Swin Transformer:在几个局部窗口里做注意力,再通过窗口移动让信息在更大范围流动;
- 时序预测Transformer:把时间窗口内每个点当作一个token,位置编码换成时间戳特征。
变体再多,核心计算过程没有变:嵌入、位置编码、多头注意力、前馈网络、残差归一化,这套骨架在几乎所有主流模型里都能看到。
4. 训练与推理中的常见问题排查与实测心得
4.1 高频问题速查表
练手和调参时,我踩过不少坑,也帮很多人排查过类似问题。下面整理成速查表,直接对号入座:
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| loss居高不下 | 学习率太大/太小,或没有用缩放点积注意力 | 调lr,检查是否除以√d_k,用warmup |
| 训练时出现NaN | logits数值过大,softmax溢出 | 减小学习率,检查输入有无异常值,必要时做梯度裁剪 |
| 生成长时间重复 | 贪心解码陷入重复循环 | 加temperature、top-k/top-p,或no_repeat_ngram_size |
| 训练很慢 | 序列太长、头数太多无意义 | 缩短max_len,检查d_model是否过大,用梯度累积控制batch |
| 长文本外推崩坏 | 位置编码不擅长超出训练长度 | 换RoPE、ALiBi等相对位置编码方案 |
4.2 实操心得:我栽过的几个跟头
第一个跟头是忘记缩放。刚开始手写注意力的时候,我照着公式写QK^T,但漏了除以√d_k。训练一个简单的复制任务,loss卡在1.5附近下不去,怎么调学习率都没用。后来逐行对公式才发现问题。加了缩放之后,loss立刻顺畅下降。这个细节太容易忽略,强烈建议你写代码时把这个因子单独抽成一个常量,而不是藏在计算里。
第二个跟头是初始化。Transformer对参数初始化相当敏感。如果词嵌入和输出投影的初始化尺度太夸年,前期loss会震荡得很厉害。实操时,PyTorch的nn.Linear默认初始化在这种小模型上还算能跑,但如果你自己实现定制层,最好用Xavier或者Kaiming初始化,并且注意方差不要太大。
第三个跟头是数据太少导致“死记硬背”。上面那个迷你语料其实模型就是在背句子。如果你真想验证模型理解上下文而非死记,可以构造一个“序列反转”或“相邻词预测”这类任务,然后故意换掉语料顺序测试。一个常见结论:Transformer在有足够训练数据时才能展现泛化能力,数据量太小,再强的架构也白搭。
4.3 从训练到推理:生成逻辑里那些看不见的细节
很多人以为模型部署完就能直接对话,其实生成逻辑本身也是一门功夫。ChatGPT那种流畅效果,靠的不仅是训练好的Transformer,还包括采样策略、重复惩罚、长度控制、系统提示词等一大堆工程技巧。
一条基于Transformer的生成链路,实际跑起来是这样的:
- 输入文本切分成token,映射成ID;
- 模型前向计算,得到最后一个位置的logits;
- 对logits除以温度系数temperature,温度越高,分布越平滑,生成越随机;
- 用top-k/top-p裁剪候选词,过滤掉低概率尾巴;
- 在剩余候选词里采样或直接取最大概率词;
- 拼回输入序列,回到第2步,直到遇到结束符或达到最大长度。
温度参数是我最常用的调试旋钮。设得过低,比如0.1,模型会变成复读机;设得过高,比如1.5,输出会越来越“放飞自我”。实际产品里很多默认0.8左右,然后根据场景微调。
另外一点很容易被忽视:模型的内存开销和序列长度是平方关系。因为注意力需要计算seq_len×seq_len的相关性矩阵,序列翻倍,显存开销大概变四倍。这也是为什么很多本地部署的朋友一加载长上下文就觉得卡顿。
我个人的体会是,Transformer看着复杂,真正理解它的标志是你能在不看源码的情况下,把一张输入表一路推演到输出的概率分布。别怕写代码,动手写一个最小实现,再把注意力权重可视化出来,看看哪些词被模型重点关联,你会瞬间明白这套架构为什么这么能打。如果本文对你有帮助,还可以把同样的骨架换成视觉Transformer跑图片分类,你会发现文字和图像之间,用的其实是同一套计算逻辑。