ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Transformer完整计算流程详解:从词嵌入到文本生成

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, 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
训练时出现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的生成链路,实际跑起来是这样的:

  1. 输入文本切分成token,映射成ID;
  2. 模型前向计算,得到最后一个位置的logits;
  3. 对logits除以温度系数temperature,温度越高,分布越平滑,生成越随机;
  4. 用top-k/top-p裁剪候选词,过滤掉低概率尾巴;
  5. 在剩余候选词里采样或直接取最大概率词;
  6. 拼回输入序列,回到第2步,直到遇到结束符或达到最大长度。

温度参数是我最常用的调试旋钮。设得过低,比如0.1,模型会变成复读机;设得过高,比如1.5,输出会越来越“放飞自我”。实际产品里很多默认0.8左右,然后根据场景微调。

另外一点很容易被忽视:模型的内存开销和序列长度是平方关系。因为注意力需要计算seq_len×seq_len的相关性矩阵,序列翻倍,显存开销大概变四倍。这也是为什么很多本地部署的朋友一加载长上下文就觉得卡顿。

我个人的体会是,Transformer看着复杂,真正理解它的标志是你能在不看源码的情况下,把一张输入表一路推演到输出的概率分布。别怕写代码,动手写一个最小实现,再把注意力权重可视化出来,看看哪些词被模型重点关联,你会瞬间明白这套架构为什么这么能打。如果本文对你有帮助,还可以把同样的骨架换成视觉Transformer跑图片分类,你会发现文字和图像之间,用的其实是同一套计算逻辑。

返回列表