Transformer完整计算流程详解:从词嵌入到文本生成 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, 2i1) 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_len128): 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, maskNone): 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_model32, n_heads2, d_ff64, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.positional PositionalEncoding(d_model, max_len64) 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:iseq_len]) target_seqs.append(data[iseq_len]) inputs torch.tensor(input_seqs) targets torch.tensor(target_seqs)初始化模型和优化器model MiniTransformer(vocab_sizelen(vocab)) loss_fn nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.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(fEpoch {epoch1}, loss {loss.item():.4f})我实测下来这个小模型在CPU上大概几秒一轮300轮训练后loss能从早期2.0左右降到0.2以内。如果你发现loss下降很慢通常是学习率太小或层数太少但在这个迷你场景里主要看softmax前面logits是否需要多“烧”几轮。训练完成后可以做一个最基本的推理演示def generate(model, start_word, n_tokens4): 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)], dim1) 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_model32d_ff64总参数量大约几千个轻得跟玩具一样。但换成真实场景就完全不同了。以GPT-2 medium为例词表约五万d_model1024层数24总参数量约3.5亿。参数量大头通常分布在词嵌入层和FFN层注意力层反而是小头。很多轻量级优化工作比如ALBERT就是对Embedding做因式分解来砍参数量。如果你想把这套结构迁移到其他领域思路也是一样的视觉TransformerViT把图片切成patch每个patch当作一个“词”位置编码对应patch的网格位置Swin Transformer在几个局部窗口里做注意力再通过窗口移动让信息在更大范围流动时序预测Transformer把时间窗口内每个点当作一个token位置编码换成时间戳特征。变体再多核心计算过程没有变嵌入、位置编码、多头注意力、前馈网络、残差归一化这套骨架在几乎所有主流模型里都能看到。4. 训练与推理中的常见问题排查与实测心得4.1 高频问题速查表练手和调参时我踩过不少坑也帮很多人排查过类似问题。下面整理成速查表直接对号入座现象常见原因解决思路loss居高不下学习率太大/太小或没有用缩放点积注意力调lr检查是否除以√d_k用warmup训练时出现NaNlogits数值过大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跑图片分类你会发现文字和图像之间用的其实是同一套计算逻辑。