Transformer完整计算过程拆解:从文字输入到生成下一个Token的全链路详解

发布时间:2026/11/2 2:55:13
Transformer完整计算过程拆解:从文字输入到生成下一个Token的全链路详解

搞懂Transformer的完整计算过程,我建议别直接啃源码,先把从文字到生成这条主线数据流摸清楚。网上讲Transformer的文章很多,有一部分直接对着论文逐行翻译,还有一部分只画了架构图没有把每一步的实际数值变化讲透。这篇我按"一条数据进去之后每一步到底发生了什么"来拆,从你输入一句话开始,一直到模型吐出下一个词为止,把中间所有计算环节过一遍。

适合谁看?如果你正在学Transformer打算手写实现,或者想搞清楚位置编码、注意力机制这些模块在整条链路里的真实位置,再或者做时序预测、图像生成想迁移Transformer结构但总觉得雾里看花,这篇应该能帮你把骨架立起来。我不做源码级别的逐行注释,重点放在每一步计算的来龙去脉。

1. 先在大脑里画一张数据流图:一个token是怎么变成下一个token的

Transformer的本质是一个序列转换函数。你输入一段文字,它输出一段文字,中间的隐藏状态经历多次变换。第一次接触的人容易被"编码器-解码器""自注意力""多头"这些名词带偏,迷失在局部模块里,忘了整条主线。所以我建议先建立全局视角,把Transformer看成一个完整的数据加工流水线。

假设你输入"人工智能正在改变世界"这句话。模型并不会直接处理字符串,而是按如下流程走:

第一步,分词器把这句话切成token序列。这个序列的长度记为n,每个token都有自己的编号。比如"人工智能"可能被切成"人工""智能"两个token,"正在"一个token,"改变""世界"各一个token,最后还要加上一个句子结束符。不同的分词器切法不一样,但核心逻辑相同:把文本变成一串离散的整数ID。

第二步,每个token ID通过查表变成一个向量。这个查找表就是嵌入矩阵,形状是vocab_size × d_model,d_model是隐藏维度,经典值是512。经过这步,你手里有了一张形状为n × 512的矩阵,每一行代表一个token的初始语义表示。

第三步,给这个矩阵加上位置信息。因为Transformer没有循环结构,它不知道"改变"出现在"正在"后面还是前面,所以必须人为注入位置编码。加完之后,矩阵还是n × 512,但每一行已经同时携带了"是什么词"和"在哪个位置"两重信息。

第四步,这个带位置信息的矩阵被送入一堆Transformer层。每层做的事情高度一致:自注意力层让每个位置都能看到其他位置,前馈网络对每个位置独立做非线性变换,中间夹着残差连接和层归一化。堆叠6层、12层甚至更多之后,矩阵形状仍然是n × 512,但每一行的含义已经从"词嵌入"变成了"结合了整个上下文语境的向量"。

第五步,如果是生成任务,解码器侧会先对已生成的token做同样的嵌入和位置编码,然后在每一层里做两件事:一个带掩码的自注意力(只能看当前位置之前的token),一个交叉注意力(去看编码器输出的矩阵)。

第六步,解码器最后一层的输出,形状还是n × 512。下一步是通过一个输出投影矩阵把它映射成词汇表大小的logits向量,形状是vocab_size。然后对logits做softmax得到概率分布,最后采样得到下一个token的ID,把这个ID拼到输入序列尾部,循环执行,直到输出结束符或达到最大长度。

这就是完整的主线。你可以把这个过程类比成一条流水线:分词是原材料切割,嵌入是给每块材料贴标签,注意力是让每个工位都能看到所有材料的信息,前馈网络是每个工位独立加工,最后的logits和采样是质检包装。后面所有章节都是在细化这条流水线上某一台机器的内部结构。

2. 嵌入与位置编码:单词怎么变成数字,位置信息怎么塞进去

很多讲Transformer的文章会把嵌入层一笔带过,好像它就是一个查表操作,没什么可讲的。但嵌入层有两个关键细节值得认真理解:第一个是token ID到向量的映射本质上是可学习的查表,第二个是位置编码的选择直接影响模型对顺序的感知能力。

先看嵌入矩阵。它的形状是vocab_size × d_model,意思是词汇表里每一个token ID对应一行512维的向量。训练初期这些向量是随机初始化的,训练过程中通过反向传播不断更新。你输入token ID为"1234"的词,实际做的事就是取嵌入矩阵的第1234行。这个过程也叫lookup,它没有计算量,纯粹是内存访问。但是,如果词汇表很大(比如50万词),这个矩阵会占用大量显存,这是实际工程中需要考虑的问题。

再来看位置编码。原版Transformer用的是正弦余弦函数,公式是:

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

其中pos是token在序列里的位置(从0开始),i是向量里的维度下标(从0到255),d_model是512。也就是说,位置编码是一个和词嵌入同样形状的矩阵n × 512,每一行对应一个位置的正弦余弦值。把词嵌入和位置编码逐元素相加,得到的就是真正喂给模型的输入。

为什么要用正弦余弦而不是直接用一个可学习的向量?论文里给出的解释是,正弦余弦让模型可以通过线性变换学会相对位置关系,因为对任意固定偏移k,PE(pos+k)可以表示为PE(pos)的线性函数。这一点对于训练时没见过特别长序列的模型来说很关键,泛化能力更好。

实际操作中,你会在哪个环节踩坑?第一个坑是忘记在嵌入层乘sqrt(d_model)。Transformer的原始实现里,词嵌入在相加之前会乘以sqrt(d_model),这么做的原因是让嵌入向量和位置编码的数值量级匹配——嵌入向量的值经过训练后通常落在较小范围内,而位置编码的值域是[-1,1],直接相加可能会导致嵌入信息被稀释。第二个坑是位置编码的维度切分,很多人在实现时把正弦和余弦交错排列搞混,结果位置信息出现扭曲。

我建议你用一段小代码验证一下位置编码的实际效果:

import numpy as np import matplotlib.pyplot as plt def get_position_encoding(seq_len, d_model): pe = np.zeros((seq_len, d_model)) for pos in range(seq_len): for i in range(0, d_model, 2): pe[pos, i] = np.sin(pos / (10000 ** (2 * i / d_model))) pe[pos, i + 1] = np.cos(pos / (10000 ** (2 * i / d_model))) return pe pe = get_position_encoding(100, 512) # 看第0行和第1行第0维的值,验证正弦余弦周期性 print(pe[0, :5]) print(pe[1, :5])

跑一下你就会直观感受到,不同位置向量的差异主要体现在较低维度上,高维度的值几乎不变。这也是为什么很多后续工作直接改用可学习的位置嵌入——大部分时候效果比自己设计的位置函数更好,而且更省心。

不过,如果你用像GPT那样的Decoder-only架构,做生成任务时有个常见选择,就是改用可学习的位置嵌入(Learned Positional Embedding)。BERT就是这么干的,效果很不错。原因在于可学习嵌入可以让模型根据训练数据自适应地调整位置关系的表达,参数规模不大但可控性更强。原版正弦余弦的优势在于不需要额外参数、可以外推到更长序列,但在实践中如果训练和推理长度差距过大,外推效果并没有想象中那么理想。

3. 注意力机制的计算细节:Q、K、V到底在算什么

核心中的核心来了。Transformer之所以能取代LSTM成为主流序列模型,关键在于注意力机制让任意两个位置之间的交互距离变成了常数——不管两个词相隔多远,计算路径都只需要一步。LSTM则必须把信息一步步传过来,长距离依赖容易衰减。

先建立一个直观认知。自注意力做的事情是:对于序列里的每一个token,计算它与序列里所有token(包括自己)的关联强度,然后按强度加权汇总所有token的信息,作为这个token的新表示。Q、K、V三个角色就是实现这个功能的具体机制。

具体的矩阵运算步骤如下:

第一步,生成Q、K、V。输入矩阵X(形状n × d_model)分别乘上三个权重矩阵W_Q、W_K、W_V(形状都是d_model × d_k,其中d_k是每个头的维度)。结果Q、K、V的形状都是n × d_k。

X的每一行是一个token的向量,W_Q、W_K、W_V都是可学习的参数。Q代表"我想从别的token那里获取什么信息",K代表"我能提供什么信息",V代表"我实际的信息内容"。这三个角色是同一个输入通过不同线性变换得到的三种不同表示。

第二步,计算注意力分数。将Q与K的转置相乘,得到n × n的矩阵,第i行第j列的值表示第i个token对第j个token的注意力分数(未归一化)。这个分数越大,说明第i个token越关注第j个token。

第三步,缩放。将分数矩阵除以√d_k(d_k是每个头的维度)。为什么要缩放?这是论文里反复强调的一个细节:当d_k较大时,Q和K的点积值会变得很大,导致softmax的梯度变得非常小,模型难以训练。除以√d_k是为了把点积的方差稳定在大致1的量级。

第四步,softmax。对缩放后的矩阵每一行做softmax,让该行所有值加起来等于1,使注意力分数变成合法的权重分布。这一步执行后矩阵的形状还是n × n。

第五步,加权求和。将softmax后的注意力权重矩阵与V相乘,输出形状为n × d_k。最终输出中第i行是序列里所有token的信息按注意力权重加权后的结果,这就是第i个token经过注意力层后的新表示。

这里有个值得深挖的地方:为什么要除以√d_k,而不是直接除以d_k或者其他数?这是有严格的数学推论的。假设Q和K的每个元素都是均值为0、方差为1的独立随机变量,那么两个向量点积的结果方差就是d_k。也就是说,点积值的标准差是√d_k。除以√d_k后,方差重新回到1,梯度在softmax传播时不会消失或爆炸。你可以用numpy做一个小实验验证,当d_k从64增大到256时,如果不缩放,softmax的输入值会逐渐变大,最终导致输出的分布越来越尖锐。

另外,实际工程中会有两个注意力Mask需要考虑,这在实现时非常容易出错。

第一个是Padding Mask。由于同一批次内的序列长度不同,短序列需要补齐到同样长度,补齐的位置需要让注意力权重强制为0。做法是先把这些位置的注意力分数设成一个非常大的负数,这样softmax后权重几乎为0。这在融入嵌入、前向编码器时都会用到。

第二个是因果Mask,Decoder生成时使用的。它的作用是保证在位置i的token只能看到i及其之前的位置,不能看到未来。实现上非常简单:构造一个上三角矩阵,把上三角部分全设成一个很大的负数,这样softmax时未来位置的权重为0。

实际操作时,你会在计算注意力分数矩阵时一次性把Mask加上就行了,这一个点很多人容易搞混。

4. 多头注意力:为什么切头比单头强,训练一个头学不到的东西

单头注意力的问题在于,注意力模式往往过于单一。比如给定的句子中,有些token主要盯着句法相关的词,有些token需要盯着语义相关的词,如果只能有一个注意力分布,模型可能被迫在这些模式之间做"平均",结果捕风捉影。

多头机制的做法是,不直接把n × d_model的输入算成一个注意力,而是保持同样数量,只把Q、K、V在维度方向切成h份(典型h=8),每一份独立计算注意力。每个头的维度是d_k = d_model / h = 512 / 8 = 64。8个头并行计算,每个头输出n × 64,最后在维度方向拼接回n × 512。

为什么要这样做?观察一下8个头的注意力模式可视化结果通常会比较直观。实际操作中大家会发现,有些头的注意力比较集中在相邻词上,有些头会比较远的关键词,还有人专门研究过头与头之间的冗余度。这些都说明多头机制让模型可以从多个语义空间同时提取信息,这是单一注意力难以做到的。

在工程实现上,还有一个非常省事的技巧:把多头计算转换成一次大的矩阵乘法。具体做法是把W_Q、W_K、W_V拼成一个大矩阵,形状从d_model × d_k扩展为d_model × (h*d_k),输入X乘这个大矩阵后,得到的n × (h*d_k)结果再reshape成h × n × d_k。这样就不需要写循环去处理每个头,直接用矩阵运算库高效执行。

拼接之后,还要过一个输出投影矩阵W_O(形状d_model × d_model),目的是对头的输出做一个线性融合。所以注意力子层完整流程是:

MultiHead(Q, K, V) = Concat(head_1, ..., head_h) * W_O 其中 head_i = Attention(XW_Q^i, XW_K^i, XW_V^i)

从参数上看,多头注意力并不比单头贵太多。W_Q、W_K、W_V三个矩阵各是d_model × d_model,W_O也是d_model × d_model,总共4个方阵,参数量与d_model²成正比。但你得到的表达能力却大幅提升,这就是"分而治之"在深度学习里的典型应用。

到这里你可能有个疑问:既然每个头的输入都是同一个X,那怎么保证各个头学到不同模式?答案是随机初始化和梯度更新的路径差异。即便输入相同,因为W_Q、W_K、W_V初始值不同,每个头一开始就朝着不同方向学习,训练过程中自然分化为不同的功能。当然,有人在实践中发现有些头会趋同,这是正常的,不必刻意干预。

5. 残差连接、层归一化与前馈网络:每一层的完整加固流程

Transformer层不是只由注意力组成的。如果你想搞懂模型的稳定训练逻辑,残差连接和层归一化这两个组件在其中的作用一样关键。很多讲Transformer的文章把它们当成背景板直接忽略,但实际中一旦去掉这两项,深层Transformer基本没法训练。

先看标准Transformer层的完整顺序:

x = x + MultiHead(LayerNorm(x)) x = x + FFN(LayerNorm(x))

这是原版论文里Post-Norm的做法。也有Pre-Norm的做法,先把LayerNorm放在子层之前,顺序是x = x + MultiHead(LayerNorm(x))。两种顺序各有取舍,Pre-Norm更稳定,Post-Norm在深层模型上更难训练。现代很多大模型(比如GPT系列)用的是Pre-Norm + 多层堆叠的组合,因为它对学习率不那么敏感。

这里把每个组件的计算逻辑拆开来说。

残差连接。它解决的核心问题是梯度消失。深度网络层数变多后,反向传播的梯度在多层之间连乘会趋于0,前面的层基本学不到东西。残差连接把输入直接加到输出上,梯度可以从输出直接传到输入,相当于给梯度开了一条高速通道。它的实现极简:

output = sublayer(x) + x

层归一化。它是对每一条token向量做归一化。给定一个token的向量x(长度d_model),计算该向量的均值和方差,然后用(x - mean) / sqrt(var + eps)把它变成均值为0、方差为1的分布,最后再乘上一个可学习的缩放参数γ(初始为1)和偏移参数β(初始为0)。注意这一步和BatchNorm不同:它不依赖batch里的其他样本,每个token独立归一化,所以不受batch大小影响,这对变长序列非常友好。

前馈网络。每个token都经过一个相同的两层全连接网络,第一层把维度从d_model扩展到4*d_model(中间维度通常是2048),经过ReLU激活后再通过第二层把维度压缩回d_model:

FFN(x) = max(0, xW_1 + b_1)W_2 + b_2

这个FFN的作用是给模型引入非线性变换能力。注意力本身是线性操作(加权平均是线性的),如果不加FFN,堆再多层也只是在做线性变换,模型的表达能力会受到限制。FFN在每个位置独立处理,token之间不交互,所以也被称为"position-wise"网络。

原始Transformer里的激活函数是ReLU,后来很多模型把它换成了GELU或SwiGLU,效果更好。GPT系列用的就是GELU的近似变体。如果你自己从头实现Transformer,推荐直接使用GELU,它比ReLU更平滑,在深层模型中训练更稳定。

补充一个工程细节:矩阵的维度变化是怎么走的。输入X是n × 512,自注意力内部的维度变化是n × 512 -> n × 512,头维度的拆分和拼接在内部完成。FFN的变化是n × 512 -> n × 2048 -> n × 512。所以每一层的输入输出形状完全一致,这保证了我们可以像叠积木一样堆叠任意多Transformer层而不用关心维度匹配问题——这是一个非常优雅的设计。

6. 解码器与交叉注意力:生成时模型到底在盯着看什么

到目前为止,前面讲的都是Encoder的处理流程。对于纯粹的Encoder模型(如BERT),任务形态是理解类任务,输入一句话,输出一个向量表示。但对于生成任务,比如"人工智能正在改变"后面接什么词,你需要用Decoder结构来做生成。

Decoder和Encoder最大的区别是它内部多了两个机制:因果自注意力与交叉注意力。

先看因果自注意力。在Decoder的每一层,输入是"已经生成的token序列"。比如模型当前已经看到了"人工智能正在改变",要预测下一个token。这个输入序列经过嵌入和位置编码后进入Decoder的最底层。这里的自注意力只能看当前位置以及之前的token,不能看未来。为什么?因为在推理时未来还没生成出来,训练时如果让它看到后文,模型会"作弊"——它会学会直接复制后面的token,而不是真正学习预测。

实现因果掩码的代码非常简单:

import numpy as np def create_causal_mask(seq_len): mask = np.triu(np.ones((seq_len, seq_len)), k=1).astype(float) # 上三角为1,下三角为0 # 把1设为-1e9,0设为0,这样softmax时上三角权重几乎为0 return mask * -1e9

记得把-1e9加到注意力分数矩阵上,而不是直接乘。这是因为softmax对极小的负数的响应几乎为0,但对0与-1e9的区分非常明确。

然后是交叉注意力。这是Decoder和Encoder通信的桥梁。在每层Decoder中,交叉注意力的情况是这样的:Q来自Decoder上一层的输出,代表"我正在生成的位置想从原文中获取什么信息";K和V来自Encoder最后一层的输出,代表"原文中的每个词能提供什么信息,实际内容是什么"。通过交叉注意力,模型在每一步生成时都能有选择地读取原文的相关部分。

在实际计算时,Decoder每一层的交叉注意力输入输出形状为n × 512,K和V的形状为m × 512,其中m是源序列长度。注意力分数矩阵的形状是n × m,表示每个目标位置对每个源位置的注意力权重。

还有一点值得注意:在自回归生成中,我们需要把刚生成的token追加到输入序列末尾,重新进入Decoder计算。但由于注意力机制已经缓存了之前所有token的K和V,我们可以只计算新增token的Q、K、V,然后把它追加到缓存中,不用重新计算整条序列。这就是KV Cache加速生成的原理。如果你在实践中用Transformers库跑生成任务时开了较大的max_new_tokens,就会发现KV Cache对推理速度的优化很大。

这里还顺带解释一下为什么生成是"自回归"的:模型每次只预测一个token,然后把这个token拼到序列末尾,再预测下一个token。每一步都依赖前一步的输出,没有并行性,所以生成速度远低于训练。理解了这个机制,再看那些"流式输出"的大模型应用,你就知道它们只是在为每次生成步骤做流式传输,本质还是逐token输出的。

7. 从logits到文字:最后一步的输出投影与采样策略

当Decoder最后一层输出隐状态后,生成流程还没结束。还需要把它映射成词汇表大小的向量,再做softmax得到概率分布,最后采样出下一个token。很多讲架构的文章到这里就草草带过了,但这个环节是实际产品效果差异最大的地方之一。

输出投影。隐状态的形状是n × 512,输出投影矩阵的形状是vocab_size × 512(或者512 × vocab_size,取决于实现方向)。两者的矩阵乘法结果是一个n × vocab_size的向量——但实际生成时我们只关注最后一个token那一行,因为它对应的就是"预测下一个词"的logits。之前的token行虽然也在这个矩阵里,但它们在生成时不会被使用。

有个细节:很多现代模型做输出投影时直接复用输入嵌入矩阵的转置,这种技巧叫Weight Tying(权重共享)。这样既减少了参数量,又能在语义层面约束输出空间和输入空间的一致性。原版Transformer没这样做,但BERT、GPT系列等很多模型都用了。

Softmax与温度采样。得到logits之后,还不能直接取最大值,因为生成任务需要一定随机性。经典做法是先把logits除以温度系数temperature,再做softmax:

probs = softmax(logits / temperature)

温度的作用是调整分布的"尖锐程度"。温度低(比如0.1),分布趋近于one-hot,几乎总是选最大概率的token,生成结果更稳定、更保守。温度高(比如1.5),分布更平坦,模型愿意尝试更多可能性,生成结果更随机、更天马行空。

另外两个常用策略是多建议补充:top-k采样与top-p(核采样)。top-k的做法是只保留概率最高的k个token,然后把其余token的概率设为0,再重新归一化。top-p的做法是不断累加概率最大的token直到累加和达到一个阈值p(比如0.95),其余全部过滤。在实际使用体验中top-p的生成质量通常比top-k更自然,因为它根据分布动态调整候选集合大小,而不是固定k个。

从token到字符串。最后一步是查解码器输出的token ID,用分词器把ID转回文字。这个"文字"可能是子词、词块或中文的单个字词,取决于分词器的粒度。把这个token追加到输入序列尾部,进入下一轮循环,直到生成[EOS](结束符)或达到预设的最大长度。

这里想强调一下,整个博文从第一章到这一章,你跟着走一遍,应该能体会到"从文字到生成"这条主线里每个组件发挥的作用。很多细节初学时容易忽略,比如缩放因子、mask方向、温度参数,每一个对最终效果都有实实在在的影响。

我个人在实际项目中得到的体会是:与其急于跑通一个现成的大模型Demo,不如先花时间把这一套前向计算的每一步在纸上推导一遍,用一个小矩阵手动算一次注意力分数,你会发现很多困惑瞬间就通了。等到你需要做模型并行或调优生成效果时,打下的这个底子会带来很大的帮助。