探秘Transformer系列之(4)--- 编码器 & 解码器
0x00 摘要
对于机器翻译,Transformer完整的前向计算过程如下图所示(与总体架构那章的流程图相比较,此处重点突出了编码器和解码器内部以及之间的关联)。图左侧是编码器栈,右侧是解码器栈,这两个构成了 Transformer 的"躯干"。具体流程如下。
- 将输入序列转换为嵌入矩阵,再加上位置编码(表示每个单词的位置)之后构成word embedding,然后把word embedding输入解码器。此步骤对应下图的标号1。
- 编码器接收输入序列的word embedding并生成相关隐向量。编码器是并行处理,因此只会进行一次前向传播。编码器栈内部通过自注意力机制完成了对源序列的特征的提取,得到了源序列内部元素之间的彼此相关性,保留了高维度潜藏的逻辑信息。或者说,自注意力负责基于其全部输入向量来建模每个输出均可以借鉴的隐向量。此步骤对应下图的标号2。
- 与编码器不同,解码器会循环执行,直到输出所有结果。解码器以 标记和编码器的输出作为起点,生成输出序列的下一个 token。就像我们对编码器的输入所做的那样,我们会生成嵌入并添加位置编码来传给那些解码器。此步骤对应下图的标号3。
- 解码器栈内部通过掩码自注意力机制完成了对目标序列特征的提取,得到了目标序列内部元素之间的彼此相关性,保留了高维度潜藏的逻辑信息。或者说,掩码自注意力负责基于解码器的输入向量来建模每个解码器的输出向量。
- 自注意力机制只能提炼解构本序列的关联性特征,因此编码器栈和解码器栈之间通过交叉注意力在编码器和解码器之间传递信息,完成彼此的联系,确保了对特征进行非对称的压缩和还原。或者说,交叉注意力层则负责基于编码器的所有输出隐向量来进一步建模每个解码器的输出向量。
- 使用一个线性层来生成 logits。此步骤对应下图的标号4。
- 应用一个 softmax 层来生成概率,最终依据某种策略输出一个最可能的单词。此步骤对应下图的标号5。
- 解码器使用解码器的新输出token和先前生成的 token 作为输入序列来生成输出序列的下一个 token。此步骤对应下图的标号6。
- 重复步骤 3-6循环(对于步骤3,每次输入是变化的)来对下一个时刻的输出进行解码预测,直到生成 EOS 标记表示解码结束或者达到指定长度后停止。
这个解码过程其实就是标准的seq2seq流程。因此,注意力机制是Transformer 的"灵魂",Transformer 实际上是通过三重注意力机制建立起了序列内部以及序列之间的全局联系。

本章依然用机器翻译来分析说明。
0x01 编码器
编码器的输入是word embedding序列,这是一个低阶语义向量序列。编码器的目标就是对这个低阶语义向量序列进行特征提取、转换,并且最终映射到一个新的语义空间,从而得到一个高阶语义向量序列。因为编码器使用了注意力机制,所以这个高阶语义向量序列具有更加丰富和完整的语义,也是上下文感知的。这个高阶语义向量序列将被后续的解码器使用并生成最终输出序列。而且,编码器是为每一个待预测词都生成一个上下文向量。为何要在每一步针对每一个待预测词都生成一个新的上下文向量?我们可以通过例子来解答。
- 中文:我吃了一个苹果,然后吃了一个香蕉。
- 英文:I ate an apple and then a banana。
如果逐字翻译,翻译到"我吃了一个"时候,得到的英文应该是"I ate a"?还是"I ate an"?这就需要依据后面的"苹果"来判断。所以,翻译"苹果"之后,需要依据"苹果"才能确定是"a"还是"an",进而更新之前"一个"这个词对于的上下文向量。
1.1 结构
Transformer的编码器模块结构如下图紫色方框所示。编码器是由多个相同的EncoderLayer(编码器层,即下图的黄色部分)堆叠而成的。Transformer论文原图中只画了一个EncoderLayer,然后旁边写了个 Nx,这个Nx就表示编码器是由几个EncoderLayer堆叠而成。论文中设置N = 6。

每个EncoderLayer由以下模块组成:
- 多头自注意力机制(Multi-Head Self-Attention/MHA),其特点如下:
- MHA是对输入序列自身进行的注意力计算,用于获取输入序列中不同单词之间的相关性。
- 编码器的输入被转换(即输入经过嵌入层和位置编码后,分别与Query、Key 和 Value 的参数矩阵相乘)后,作为Query、Key 和 Value这三个参数传递给MHA,即QKV均来自一个序列。
- MHA可以使网络在进行预测时对输入句子的不同位置的分配不同的注意力。多头注意力意味着模型有多组不同的注意力参数,每组都会输出一个依据注意力权重来加权求和的向量,这些向量会被合并成最终的向量进行输出。
- 第一个残差连接(Residual Connection)。残差连接使注意力机制中产生的新数据和最开始输入的原始数据合并在一起,这个合并其实就是简单的加法,这样可以避免深度神经网络中的梯度消失问题。残差连接对应上图中的"Add"。
- 第一个Layer Normalization(层归一化)。该模块可以让数据更稳定,便于后续处理。具体对应图上的"Norm",其会对层的输入进行归一化处理,使得其均值为0,方差为1。
- FFN(Feed-Forward Networks/前馈神经网络)。这个模块由两个线性变换组成,中间夹有一个ReLU激活函数。它对每个位置的词向量独立地进行变换。这个层对经过注意力处理后的向量进一步进行处理和优化,产生一个新的表示。FFN输出是一个更具抽象性、更丰富的上下文表示,可以增加模型的非线性表示能力。
- 第二个残差连接,作用同第一个残差连接。
- 第二个Layer Normalization,作用同第一个Layer Normalization。
或者简化来看,编码器模块由一系列相同层构成,每个层分为两个重要子模块:MHA和FFN。每个重要子模块周围有一个残差连接,并且每个重要子模块的输出都会经过Layer Normalization。
1.2 输入和输出
因为编码器是层叠的栈结构,因此不同EncoderLayer的输入输出不尽相同。
编码器栈第一个EncoderLayer的输入是单词的Embedding加上位置编码,即图上的Input Embedding和Positional Encoding相加之后的结果,我们称之为Word Embedding(词向量)。加上位置编码的原因是由于Transformer模型没有循环或卷积操作,为了让模型能够利用词的顺序信息,需要在输入嵌入层中加入位置编码。因为多个EncoderLayer是串联在一起,所以栈的其它EncoderLayer的输入是上一个EncoderLayer的输出。
经过多层计算之后,最后一个EncoderLayer的输出就是编码器的输出(编码器和解码器之间的隐状态)。该输出会送入解码器堆栈中的每一个DecoderLayer中。通常在代码实现中把这个输出叫做memory。编码器的输出就是对原始输入的高阶抽象表达,是在更高维的向量空间中的表示。
输入的维度一般是[batch_size, seq_len, embedding_dim]。为了方便残差连接,每一个EncoderLayer输出的矩阵维度与输入完全一致。其形状也是[batch_size, seq_len, embedding_dim]。这样的设计也确保了模型在多个编码器层之间能够有效地传递和处理信息,同时也为更复杂的计算和解码阶段做好了准备。
1.3 流程
我们继续细化编码流程。一个Transformer编码块做的事情如下图所示。图中分为两部分,既包括编码器,也包括其输入(为了更好的说明,把处理输入部分也涵盖进来)。
- 上面部分( #2)就是Encoder模块里的一个独立的EncoderLayer。这个模块想要做的事情就是想把输入X转换为另外一个向量R,这两个向量的维度是一样的。然后向量R作为上一层的输入,会一层层往上传。
- 下面部分(#1)的是两个单词的embedding处理部分,即EncoderLayer的输入处理部分。

我们对上图流程分析如下。
- 第一步会用token embedding和位置编码来生成word embedding,对应图上圆形标号1。
- 第二步是自注意力机制,对应图上圆形标号2,具体操作是\(softmax(QK^T)V\)。所有的输入向量共同参与了这个过程,也就是说,X1和X2通过某种信息交换和杂糅,分别得到了中间变量Z1和Z2。自注意力机制就是句子中每个单词看看其它单词对自己的影响力有多大,本单词更应该关注在哪些单词上。在输入状态下,X1和X2互相不知道对方的信息,但因为在自注意力机制中发生了信息交换,所以Z1和Z2各自都有从X1和X2得来的信息。
- 第三步是残差连接和层归一化,对应图上圆形标号3。其具体操作是\(Norm(x+Sublayer(x))\)。
- 第四步是FFN层,对应图上圆形标号4。对应操作是\(max(0, xW_1 + b_1)W_2 + b_2\),即两层线性映射并且中间使用激活函数来激活。因为FFN是割裂开的,所以Z1和Z2各自独立通过全连接神经网络,得到了R1和R2。
- 第五步是第二个残差连接和层归一化,对应图上圆形标号5。其具体操作是\(Norm(x+Sublayer(x))\)。
至此,一个EncoderLayer就执行完毕,其输出可以作为下一个EncoderLayer的输入,然后重复2~5步骤,直至N个EncoderLayer都处理完毕。我们也可以看到,每个输出项的计算和其他项的计算是独立的,即每一层的EncoderLayer都对输入序列的所有位置同时进行操作,而不是像RNN那样逐个位置处理,这是Transformer模型高效并行处理的关键。
1.4 张量形状变化
我们来看看编码过程中的张量形状变化。编码器的输入是待推理的句子序列X: [batch_size, seq_len, d_model]。
注意:如果考虑到限制最大长度,则每次应该是[batch_size, max_seq_len, d_model],此处进行了简化。
编码器内部数据转换时候的张量形状变化如下表所示,对于输入X的Input Embedding张量来说,其形状在编码器内部始终保持不变,具体如下。
- 输入层中,在做embedding操作时,张量形状发生变化;在和位置编码相加时,张量形状保持不变。
- 在编码器层内部的流转过程中,张量形状保持不变。
- 在编码器内部,即多个编码器层之间交互的过程中,张量形状保持不变。
下表给出了详细的操作和张量形状。
视角 | 操作 | 操作结果张量的形状 |
---|---|---|
输入层 | X(token index) | [batch_size, seq_len] |
输入层 | X = embedding(X) | [batch_size, seq_len, d_model] |
输入层 | X = X + PE | [batch_size, seq_len, d_model] |
编码器层内部 | X = MHA(X) | [batch_size, seq_len, d_model] |
编码器层内部 | X = X + MHA(X) | [batch_size, seq_len, d_model] |
编码器层内部 | X = LayerNorm(X) | [batch_size, seq_len, d_model] |
编码器层内部 | X = FFN(X) | [batch_size, seq_len, d_model] |
编码器层 | X = EncoderLayer(X) | [batch_size, seq_len, d_model] |
编码器 | X = Encoder(X) = 6 x EncoderLayer(X) | [batch_size, seq_len, d_model] |
1.6 实现
我们接下来看看哈佛源码中编码器的实现。
Encoder
Encoder类是编码器的实现,它的forward()函数返回的就是编码之后的向量。
python
# 使用Encoder类来实现编码器,它继承了nn.Module类
class Encoder(nn.Module):
"Core encoder is a stack of N layers"
# Encoder的核心部分是N个EncoderLayer堆叠而成的栈
def __init__(self, layer, N):
"""
初始化方法接受两个参数,分别是:
layer: 要堆叠的编码器层,对应下面的EncoderLayer类
N: 堆叠多少次,即EncoderLayer的数量
"""
super(Encoder, self).__init__() # 调用父类nn.Module的初始化方法
# 使用clone()函数将layer克隆N份,并将这些层放在self.layers中
self.layers = clones(layer, N)
# 创建一个LayerNorm层,并赋值给self.norm,这是"Add & Norm"中的"Norm"部分
self.norm = LayerNorm(layer.size)
def forward(self, x, mask):
"Pass the input (and mask) through each layer in turn."
"""
前向传播函数接受两个参数,分别是:
x: 输入数据,即经过Embedding处理和添加位置编码后的输入。形状为(batch_size, seq_len,embedding_dim)
mask:掩码
"""
# 使用EncoderLayer对输入x进行逐层处理,每次都会得到一个新的x,然后将x作为下一层的输入
# 此循环的过程相当于输出的x经过了N个编码器层的逐步处理
for layer in self.layers: # 遍历self.layers中的每一个编码器层
x = layer(x, mask) # 将x和mask传递给当前编码器层,编码器层进行运算,并将输出结果赋值给x
return self.norm(x) # 对最终的输出x应用层归一化,并将结果返回
其中的clone函数的代码为
python
def clones(module, N):
"Produce N identical layers."
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
EncoderLayer
EncoderLayer类是编码器层的实现,作为编码器的组成单元, 每个EncoderLayer完成一次对输入的特征提取过程, 即编码过程。
python
class EncoderLayer(nn.Module):
"Encoder is made up of self-attn and feed forward (defined below)"
def __init__(self, size, self_attn, feed_forward, dropout):
"""
初始化函数接受如下参数:
size: 对应d_model,即word embedding维度的大小,也是编码层的大小
self_attn: 多头自注意力模块的实例化对象
feed_forward: FFN层的实例化对象
dropout: 置0比率
"""
super(EncoderLayer, self).__init__() # 调用父类nn.Module的构造函数
self.self_attn = self_attn # 设置成员变量
self.feed_forward = feed_forward # 设置成员变量
# 创建两个具有相同参数的SublayerConnection实例,一个用于自注意力,一个用于FFN
self.sublayer = clones(SublayerConnection(size, dropout), 2)
self.size = size
def forward(self, x, mask):
"Follow Figure 1 (left) for connections."
"""
对应论文中图1左侧Encoder的部分
前向函数的参数如下:
x: 源语句的嵌入向量或者前一个编码器的输出
mask: 掩码
"""
# 顺序运行两个函数:self_attn(),self.sublayer[0]()
# 1. 对输入x进行自注意力操作
# 2. 将自注意力结果传递给第一个SublayerConnection实例,SublayerConnection实例内部会做残差连接和层归一化
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
# 用上面计算结果来顺序运行两个函数:self.feed_forward()和self.sublayer[1]
# 1. FFN进行运算
# 2. 将FFN计算结果传递给第一个SublayerConnection实例,SublayerConnection实例内部会做残差连接和层归一化
return self.sublayer[1](x, self.feed_forward)
我们对代码中提到的SublayerConnection做下简要说明,后续文章会进行详述。从论文图上看,不管是自注意力模块还是FFN,它们都会先做自己的业务逻辑,然后做残差连接和层归一化,也会加入Dropout。因为有些逻辑可以复用,因此哈佛代码把他们封装在SublayerConnection类中。SublayerConnection类在其内部会构造 LayerNorm 和 Dropout的实例,自注意力或FFN还是放在EncoderLayer 中构造,然后在前向传播时候由EncoderLayer 传给SublayerConnection。解码器也是按照类似方式调用SublayerConnection。
但是SublayerConnection的实现和论文略有不同。
- 原始论文的实现机制是:\(LayerNorm(x+ Sublayer(x))\)。
- SublayerConnection则是:\(x+LayerNorm(Sublayer(x))\)。
研究人员把Transformer论文中的实现叫做Post LN,因为是最后做LayerNorm,把SublayerConnection的实现方式叫做Pre LN。两种方式各有优劣,我们会在后续文章中进行分析。
0x02 解码器
首先要提前说明下,解码器的输入有两个:编码器产生的隐状态和解码器之前预测的输出结果。解码器会基于这两个输入来预测下一个输出token。网上对编码器和解码器的关系有一个比较恰当的通俗比喻,作者依据自己的思考对该比喻做进一步调节:
- 输入序列是一个需要组装的玩具。
- 编码器是售货员,售货员对该玩具的各个组件进行研究,编码器的输出结果(隐状态)就是玩具的组装说明书,里面说明了玩具每个组件的用法(需要怎么和其它组件相配合)。
- 解码器就是购买者。如果想把玩具组装好,购买者就需要在组装说明书中查询每个零件的说明,然后依据说明书的描述找到最相似的零件(注意力匹配)进行组装。在组装过程中的玩具就是解码器在之前步骤中的预测输出结果。因为组装需要一面查询组装说明书,一面查看在组装过程中的玩具。所以解码器有两个输入:组装说明书和组装过程中的玩具。
- 购买者最终输出一个组装好的玩具。
2.1 结构
解码器的结构如下图所示,也是由多个解码器层组成。在解码器中,子层堆叠的目的是逐层细化和优化生成词汇的表示,使得模型能够生成更准确、更符合上下文的目标词。每个子层都有不同的功能和作用。

每个解码器层包括三个重要子模块:掩码多头注意力(Masked Multi-Head Attention),交叉注意力和FFN。每个子模块的输出会传递到下一个子模块,进一步丰富和优化生成序列的表示。与编码器类似,每个重要子模块周围都有一个残差连接,并且每个重要子模块的输出都会经过层归一化。这三个子模块的作用如下:
- 掩码多头注意力。这是输入序列对自身的注意力计算,每个解码步骤中,掩码自注意力层会通过自注意力机制计算输出序列中,当前词与已生成词的关系。其细节如下。
- 解码器的输入之一(解码器之前预测输出结果的拼接)被传递给所有三个参数,Query、Key和 Value,即QKV均来自一个序列。
- 掩码多头注意力模块会对"解码器之前预测的输出结果"这个序列进行编码,此编码执行类似编码器中全局自注意力层的工作。
- 掩码多头注意力与全局自注意力的不同之处在于在对序列中位置的处理上(掩码操作),我们马上会进行分析。
- 该层输出一个新的表示,是结合了已生成部分序列的信息的一个上下文向量,它包含当前生成的词与已生成词之间的上下文依赖关系。
- 交叉注意力。该模块将源序列和目标序列进行对齐,是解码器和编码器之间的桥梁,这个子层的目的是让解码器结合编码器的输出,也就是在解码期间参考源句子的上下文信息。通过这一层,解码器可以对源句子的每个 token 进行注意力计算,确保解码时能够参考源句子的结构和语义。交叉注意力是编码器和解码器的第二个不同之处。其细节如下。
- 不带掩码。
- 交叉注意力的输入来源有两处:编码器的输出和掩码多头注意力的输出。K、V矩阵来自编码器的输出,Q来自掩码多头注意力的输出。作者之所以这样设计,是在模仿传统编码器-解码器模型的解码过程,可以综合考虑目标序列与源序列的内容。
- 这个层的输出结合了编码器输出的上下文信息以及解码器当前步骤生成的目标词的表示,进一步优化了目标词的表示。输出是一个包含源句子信息的上下文表示(融合了源句子的结构和目标序列的部分结构)。
- FFN,作用同编码器的FFN。
解码器的掩码多头自注意力与编码器的多头自注意力不同之处的终极原因在于训练和推理的不同,即训练和推理在每个时间步的输入和操作的区别。
- 训练过程中每个时间步的输入是全部目标序列,在Encoder的多头自注意力中,每个位置都可以自由地注意序列中的所有其他位置。这意味着计算注意力分数时,并没有位置上的限制。这种设置是因为在编码阶段,我们假定有完整的输入序列,并且每个词都可以依赖于上下文中的任何其他词来获得其表示。
- 推理过程中每个时间步的输入是直到当前时间步所产生的整个输出序列,推理的本质也是串行自回归的。或者说,解码器的本质就是自回归的。
为了并行操作,人们使用了teacher forcing机制(需要结合掩码机制),这样可以让解码器同时对同一序列中的多个token进行解码和预测。因为解码器现在的输入是整个目标句子,而当预测某个位置的输出时,我们希望单词只能看到它以及它之前的的单词,不希望注意力在预测某个词时候就能关注到其后面的单词,那样模型有可能利用已经存在的未来词来辅助当前词的生成,就是"作弊"了。为了让前面的token不能观察到后面token的信息,所以使用了掩码技术。掩码的作用是确保解码器只能关注到它之前已经生成的词,而不能看到未来的词。掩码逻辑是为了训练来特殊打造。
另外,虽然推理时候所有输入都是已知输入,没有真正的"偷看未来词"的可能性,不需要掩码,但是为了保持与训练时的计算一致,推理时也保留了此处代码和模型结构。这样使得推理时的行为与训练时完全匹配,避免了训练与推理之间的行为差异。而且,虽然未来词还没有生成,掩码自注意力机制依然会起到限制作用,确保解码器在每个步骤只关注已经生成的上下文,而不会假设未来的信息存在。后续章节会对掩码做详细阐释。
2.2 输入和输出
解码器也是层叠的栈结构,因此不同解码器层的输入输出不尽相同。
- 第一个解码器层的输入有两个:
- 解码器之前预测输出结果的拼接,即前一时刻解码器的输入 + 前一时刻解码器的输出(预测结果)。另外还要加上Shfited Right操作。
- 编码器的输出,即第一个解码器层中交叉注意力的K、V均来自编码器的输出(编码器堆栈中最后一个编码器的输出)。
- 后续解码器层的输入有两个:
- 前一个解码器层的输出。
- 编码器的输出,即后续每一个解码器层中交叉注意力的K、V均来自编码器的同一个输出(编码器堆栈中最后一个编码器的输出)。
解码器最终会输出一个实数向量,传给架构图中最上方的线性层,由最终的线性变换和softmax层最终转换为概率分布,借此预测下一个单词。前面提到,编码器的输出就是对原始输入在更高维的向量空间中的表示。解码器就是在高维空间内对向量进行操作,找到按照注意力分数匹配的高阶向量。后续会经过generator等模块把高维向量转换回人类可以理解的低维向量。

2.3 流程
解码器的流程需要区分训练阶段和推理阶段,因为这两个阶段的流程不尽相同。
训练
注:解码器在训练时采用Teacher Forcing模式,会向解码器输入整个目标序列,可以并行预测目标序列的所有单词。我们会在后续章节对Teacher Forcing进行详细分析。
假如我们的训练任务得到了如下文本对:
- 中文:我爱你。
- 英文:I love you。
模型采用如下方式调用,其中batch.src是"我爱你",batch.tgt是"I love you"。
python
out = model.forward(batch.src, batch.tgt, batch.src_mask, batch.tgt_mask)
编码器接受"我爱你"作为输入,"我爱你"首先被tokenizer处理成token,然后转换成一组向量进行后续处理,得到输出memory。
python
memory = encode(self, src, src_mask)
解码器有两种输入:编码器的输出(对应下面代码的memory)和英文句子"I love you"(对应下面代码的tgt)。"I love you"经过tokenizer处理成token,然后转换成一组向量。解码器会把该组向量和编码器的输出结合起来,生成最终的翻译输出。
decode(self, memory, src_mask, tgt, tgt_mask)
我们要对解码器的输入再做一下说明。
- tgt:英文句子"I love you"是训练集的真值标签,需要结合掩码并且整体右移(Shifted Right)一位,最终得到是 shifted and masked ground truth。右移的原因解释如下:解码器在T-1时刻会预测T时刻的输出,所以需要在输入句子的最开始添加起始符 ,从而整个句子将整体右移一位,这样就把每个token的标签设置为它之后的token,方便预测第一个Token(在Teacher forcing模式下,也同时方便预测后续的token)。比如原始输入是"I love you",右移之后变成" I love you",这样我们就可以通过起始符 预测"I",也就是通过起始符 l来预测实际的第一个输出。否则无法预测第一个输出。

- src_mask和tgt_mask是源序列和目标序列的掩码。前面已经介绍过,加入掩码的原因是要隐藏未来信息。因为解码器的输入是整个目标句子,而当预测某个位置的输出时,我们希望单词只能它以及它之前的的单词,不希望注意力在预测某个词时候就能关注到其后面的单词,那样就是"作弊"了。因此需要借助掩码把后面单词的信息隐藏掉,这样才能在训练时候模拟实际推理的效果。
具体训练时候的输入输出如下图,因为是并行训练,所以解码器的输入之一是"I love you",在假设全部预测正确的情况下,输出也是"I love you",此输出是一次性全部输出。

推理
解码器在推理时的工作流程相对简单多了,所以不再需要掩码。此时,解码器采用的是自回归模式,也就是这次的输出会加到上次的输入后面,作为下一次的输入,这样每次解码都会利用前面已经解码输出的所有单词嵌入信息。因此,推理任务不用真实目标序列来指导生成过程,只使用了中文句子"我爱你"。即,Encoder的输入不变,而Decoder的输入会是之前Decoder输出的组合(也包括Encoder的输出)。
具体如下图所示,模型将从特殊的起始序列标记 开始依次生成输出序列。在生成下一个token时,模型会把刚预测的 token拼接在之前的输入序列上,以此作为新序列再输入给解码器,从而进行序列的自我生成。因此第一次推理输出"I",然后将 和"I"拼接起来一起输入到解码器,解码器第二次推理输出"love",然后再将" I love"输入给解码器,以此类推。当解码器生成 标记时,它将停止生成。

具体代码流程如下。
python
def inference_test():
test_model = make_model(11, 11, 2)
test_model.eval()
src = torch.LongTensor([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]])
src_mask = torch.ones(1, 1, 10)
memory = test_model.encode(src, src_mask)
ys = torch.zeros(1, 1).type_as(src)
for i in range(9):
out = test_model.decode(
memory, src_mask, ys, subsequent_mask(ys.size(1)).type_as(src.data)
)
prob = test_model.generator(out[:, -1])
_, next_word = torch.max(prob, dim=1)
next_word = next_word.data[0]
ys = torch.cat(
[ys, torch.empty(1, 1).type_as(src.data).fill_(next_word)], dim=1
)
print("Example Untrained Model Prediction:", ys)
test_model.decode()对应如下代码。
python
class EncoderDecoder(nn.Module):
def decode(self, memory, src_mask, tgt, tgt_mask):
return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask)
编码器会逐步调用编码器层的逻辑。
python
class Decoder(nn.Module):
def forward(self, x, memory, src_mask, tgt_mask):
for layer in self.layers:
x = layer(x, memory, src_mask, tgt_mask)
return self.norm(x)
最终编码器层会调用业务逻辑,即注意力计算、残差连接等。
python
class DecoderLayer(nn.Module):
def forward(self, x, memory, src_mask, tgt_mask):
"Follow Figure 1 (right) for connections."
m = memory
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))
return self.sublayer[2](x, self.feed_forward)
2.4 张量形状变化
我们来看看解码过程中的张量形状变化(为了更好的说明,我们把解码器的输入和输出都一起纳入)。解码器的输入是一个长度变化的张量Y:[batch_size, seq_len, d_model],初始时,这个张量中,每个矩阵只有1行,即开始字符的编码。
注意:如果考虑到限制最大长度,则每次应该是[batch_size, max_seq_len, d_model]。
对于输入Y的Input Embedding张量来说,其形状在解码器内部始终保持不变,具体如下。
- 输入层中,在做embedding操作时,张量形状发生变化;在和位置编码相加时,张量形状保持不变。
- 在解码器层内部的流转过程中,张量形状保持不变。
- 在解码器内部,即多个解码器层交互过程中,张量形状保持不变。
- 进入到输出层之后,张量形状开始变化。
注意,在单次推理过程中,张量不变,但是每次推理之后,矩阵增加一行,seq_len加1。
视角 | 操作 | 操作结果张量的形状 |
---|---|---|
输入层 | Y(token index) | [batch_size, seq_len] |
输入层 | Y = embedding(Y) | [batch_size, seq_len, d_model] |
输入层 | Y = Y + PE | [batch_size, seq_len, d_model] |
解码器层内部 | Y = Masked-MHA(Y) | [batch_size, seq_len, d_model] |
解码器层内部 | Y = LayerNorm(Y + Masked-MHA(Y)) | [batch_size, seq_len, d_model] |
解码器层内部 | Y = Cross-MHA(Y, M, M) | [batch_size, seq_len, d_model] |
解码器层内部 | Y = LayerNorm(Y + Cross-MHA(Y, M, M)) | [batch_size, seq_len, d_model] |
解码器层内部 | Y = FFN(Y) | [batch_size, seq_len, d_model] |
解码器层内部 | Y = LayerNorm(Y + FFN(Y)) | [batch_size, seq_len, d_model] |
解码器层 | Y = DecoderLayer(Y) | [batch_size, seq_len, d_model] |
解码器 | Y = Decoder(Y) = N x DecoderLayer(Y) | [batch_size, seq_len, d_model] |
输出层 | logits = Linear(Y) | [batch_size, seq_len, d_voc] |
输出层 | prob = softmax(logits) | [batch_size, seq_len, d_voc] |
2.5 实现
Decoder
Decoder类是解码器的实现,是 N 个解码层堆叠的栈。编码器会将自己输出的隐向量编码矩阵C传递给解码器,这些隐向量可以帮助解码器知道它应该更加关注输入序列哪些位置。解码器的每个解码层都会使用同一个隐向量编码矩阵C,这些隐向量将被每个解码层用于自身的Encoder-Decoder交叉注意力模块,
Decoder类依次会根据当前翻译过的第i个单词,翻译下一个单词(i+1)。在解码过程中,翻译到第i+1单词时候需要通过Mask操作遮盖住(i+1)之后的单词。Decoder类的代码具体如下。
python
class Decoder(nn.Module):
"Generic N layer decoder with masking."
def __init__(self, layer, N):
"""
初始化函数有两个参数。layer对应下面的DecoderLayer,是要堆叠的解码器层;N是解码器层的个数
"""
super(Decoder, self).__init__()
self.layers = clones(layer, N) # 使用clones()函数克隆了N个DecoderLayer,然后保存在layers这个列表中
self.norm = LayerNorm(layer.size) # 层归一化的实例
def forward(self, x, memory, src_mask, tgt_mask):
"""
前向传播函数有四个参数:
x: 目标数据的嵌入表示,x的形状是(batch_size, seq_len, d_model),在预测时,x的词数会不断增加,比如第一次是(1,1,512),第二次是(1,2,512),以此类推
memory: 编码器的输出
src_mask: 源序列的掩码
tgt_mask: 目标序列的掩码
"""
# 实现多个编码层堆叠起来的效果,并完成整个前向传播过程
for layer in self.layers: # 让x逐次在每个解码器层流通,进行处理
x = layer(x, memory, src_mask, tgt_mask)
# 对多个编码层的输出结果进行层归一化并返回最终的结果
return self.norm(x)
DecoderLayer
DecoderLayer类是解码器层的实现。作为解码器的组成单元,每个解码器层根据给定的输入向目标方向进行特征提取操作,即实施解码过程。DecoderLayer和EncoderLayer的内部非常相似,区别EncoderLayer只有一个多头自注意力模块,而DecoderLayer有两个多头自注意力模块,从代码上看,就是比EncoderLayer多了一个src_attn成员变量。self_attn和src_attn的实现完全一样,只不过使用的Query,Key 和 Value 的输入不同。
DecoderLayer主要成员变量如下:
- size:词嵌入的维度大小,即解码器层的尺寸。
- self_attn: 掩码多头自注意力模块,负责对解码器之前的输出(即当前的输入)做自注意力计算。该模块需要Q=K=V。Self-Attention 的 Query,Key 和 Value 都是来自下层输入或者是原始输入。
- src_attn:交叉注意力模块,负责对编码器的输出和解码器之前的输出做交叉注意力计算,但是Q!=K=V。Query 来自 self-attn 的输出,Key 和 Value则是编码器最后一层的输出(代码中是memory变量)。
- feed_forward:FFN模块。
- sublayer:应用了残差连接和层归一化。
- drop:置零比率。
上述这些成员变量通过参数传给初始化函数。
python
class DecoderLayer(nn.Module): # 继承自PyTorch的nn.Module类
"Decoder is made of self-attn, src-attn, and feed forward (defined below)"
def __init__(self, size, self_attn, src_attn, feed_forward, dropout):
super(DecoderLayer, self).__init__()
self.size = size
self.self_attn = self_attn
self.src_attn = src_attn
self.feed_forward = feed_forward
# 创建三个SublayerConnection类实例,分别对应self_attn,src_attn和feed_forward
self.sublayer = clones(SublayerConnection(size, dropout), 3)
def forward(self, x, memory, src_mask, tgt_mask):
"Follow Figure 1 (right) for connections."
"""
前向传播函数有四个参数:
x: 目标数据的嵌入表示,x的形状是(batch_size, seq_len, d_model),在预测时,x的词数会不断增加,比如第一次是(1,1,512),第二次是(1,2,512),以此类推。x可能是上一层的输出或者是整个解码器的输出
memory: 编码器的输出
src_mask: 源序列的掩码
tgt_mask: 目标序列的掩码
"""
m = memory # 将memory表示成m方便之后使用
# 第一个子层执行掩码多头自注意力计算。相当于顺序运行了两个函数:self_attn()和self.sublayer[0]()。这里的Q、K、V都是x。tgt_mask的作用是防止预测时看到未来的单词。
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask))
# 第二个子层执行交叉注意力操作,此时Q是输入x,K和V是编码器输出m。src_mask在此处的作用是遮挡填充符号,避免无意义的计算,提升模型效果和训练速度。此刻需要注意的是,两个注意力计算的mask参数不同,上一个是tgt_mask,此处是src_mask
x = self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask))
# 第三个子层是FFN,经过它的处理后就可返回结果
return self.sublayer[2](x, self.feed_forward)
0x03 交叉注意力深入
前面已经简单介绍过注意力的分类,此处结合代码对交叉注意力再进行深入分析。
3.1 分类
Transformer 中,注意力被用在三个地方,Encoder中的Multi-Head Attention;Decoder中的Masked Multi-Head Attention;Encoder和Decoder交互部分的Multi-Head Attention。注意力层(Self-attention 层及 Encoder-Decoder-attention 层)以三个参数的形式接受其输入:查询(Query)、键(Key)和值(Value)。我们接下来就分析下,看看Transformer中各个注意力模块的Q、K、V到底是怎么来的。
名称 | 位置 | Q | K/V |
---|---|---|---|
多头自注意力 | 编码器 | QKV均来自同一个序列 | QKV均来自同一个序列 |
掩码多头自注意力 | 解码器 | QKV均来自同一个序列 | QKV均来自同一个序列 |
交叉注意力 | 解码器 | 掩码多头自注意力的输出 | 编码器输出被用做V和K。 |
三个注意力模块的作用如下:
- 多头自注意力:是输入序列对自身的注意力计算,允许每个位置自由地注意到整个序列,这样可以获取输入句子中不同单词之间的相关性。此处可以说是Transformer最大的创新。
- 掩码多头自注意力:是输入序列对自身的注意力计算,用于获取输出句子(已经翻译好的部分)中不同单词之间的相关性。同时通过序列掩码来限制注意范围,以保持自回归属性,确保生成过程的正确性。这种设计是Transformer模型能够并行训练的关键所在。
- 交叉注意力:是目标序列对输入序列的注意力计算。这种设计是Transformer模型能够有效处理类似文本翻译任务的关键所在。
或者说, 注意力机制是寻找sequence内部(self-attention)或者sequence之间(cross-attention)不同位置上的相似性或者相关性。
3.2 业务逻辑
我们接下来看看交叉注意力的业务逻辑。交叉注意力计算的是每个源序列单词与每个目标序列单词之间的相互作用或者相似度,是在输入序列和输出序列之间进行对齐。
seq2seq场景存在一种因果关系,该因果性体现在上下文(即包括输入序列,也包括输出序列)上,因为显然源文本中每个位置的字符应该和目标翻译文本各位置字符存在一定的对照关系,因此源文本每个位置的token对于当下要预测的token应该有不一样的影响(权重分配)。我们以机器翻译任务为例,解码器的目标是在给定某源语言序列时产生正确的目标语言输出序列。为了实现这一点,解码器需要:
- 学习到历史译文的所有信息。只有知道历史生成的内容,才有了正确输出下一个token的基础。
- 学习到于源文本中与当前输出的 token 相关的部分。这其实蕴含了解码器需要对编码器输出的所有信息都有所了解。只有解码器的每个位置都能够获取输入序列中的所有位置的信息,才能通过学习生成正确的输出 token。
但是,掩码自注意力只能保证解码器学习到历史译文的内容。还需要一个方式来学习到源文本信息,以及把源文本和历史译文融合起来。因此,人们提出了交叉注意力来完成这个功能,这也是因果关系的一种体现。"编码器-解码器交叉注意力"从两个来源获得输入,这两个来源分别来自不同的范畴,因此交叉注意力可以理解为是自注意力的双塔实践。
- Q是译文,来自于解码器的输出。因为Q是来自解码器的掩码自注意力,所以此时天然已经获取了历史译文的内容。
- K和V是原文,来自于编码器的输出,已经持有了输入序列(比如:"我喜欢苹果")的信息。
交叉注意力机制可以让解码器和编码器进行交互,确保了解码器可以"询问"编码器有关输入序列的信息,可以聚焦于源语言句子中的不同部分。或者说,编码器输出的隐向量本质是聚合了输入序列信息的一个数据库(V),而解码器的每一个输入token本质是一条查询语句(Q),负责查询数据库中与之最相似的(最需要注意的)token。最终 \(QK^T\) 这个矩阵的每一行都表示decoder的一个输入token对隐向量中所有token的注意力。例如,在进行"我喜欢苹果"到"I love apple"的翻译时,解码器可能会询问编码器:"根据你对'我'的理解,接下来我应该输出什么?"。通过这种方式,解码器可以更准确地预测目标序列的下一个词。这种"一问一答"的描述是形象的,它说明了解码器是如何利用编码器的信息来确定最合适的输出的。
3.3 业务流程
然后我们再看交叉注意力是如何在编码器和解码器之间起作用的。
解码器在对当前时刻进行解码输出时,都会将当前时刻之前所有的预测结果作为输入来对下一个时刻的输出进行预测。假设现在需要将"我吃了一个苹果翻译成英语"I ate an apple",目前解码器已经输出了"I ate"两个单词,接下来需要对下一时刻的输出"an"进行预测,那么整个过程就可以通过下图来进行表示,图上蓝色部分是解码器,左边的小蓝框是解码器中的掩码多头自注意力模块,右面大蓝框是编码器的交叉注意力模块,左下方红色虚线框是编码器。

具体流程说明如下:
- 左上角的矩阵(对应序号1)是解码器中掩码自注意力机制对输入
" I ate"
这3个词编码后的结果。 - 左下角(对应序号2)是编码器对输入`"我吃了一个苹果"编码后的结果,这就是编码器最终输出的从多角度集自身与其他各个字关系的矩阵,记为memory。
- 因为上述两个矩阵分别都做了自注意力转换,所以每个矩阵中的每一个向量都包含了本序列其它位置上的编码信息。
- 把序号1看作是Q(对应序号3),把序号2看作是K和V(对应序号4和5)。
- 接下来,Q与K计算得到了一个注意力分数矩阵(对应序号6),矩阵的每一行就表示在对memory(图中的V)中的每一位置进行解码时,应该如何对注意力进行分配。
- 进行掩码操作(对应序号7)。
- 进行softmax操作(对应序号8)得到注意力权重A。此权重可以看做是Q(待解码向量)在K(本质上也就是memory)中查询memory中各个位置与Q有关的信息。
- 再将注意力权重A与V进行线性组合便得到了交叉注意力模块的输出向量(对应序号9)。此时这个输出向量可以看作是考虑了memory中各个位置编码信息的输出向量,也就是说它包含了在解码当前时刻时应该将注意力放在memory中哪些位置上的信息。
3.4 代码逻辑
我们再从代码逻辑进行梳理。

具体流程解读如下。
- run_epoch()函数调用模型的前向传播函数,即EncoderDecoder类的forward()函数(对应图上序号1),此时Batch类会提供数据和掩码(batch.src, batch.tgt, batch.src_mask, batch.tgt_mas)给模型进行前向传播。
- EncoderDecoder类会通过encode()函数执行编码功能。
- encode()函数利用src, src_mask调用Encoder类的forward()函数(对应图上序号2)。src里面是单词的索引,encode()函数中会调用self.src_embed(src)来生成src的word embedding。
- Encoder.forward()函数又会调用EncoderLayer.forward()函数(对应图上序号3),此处是编码器的编码功能。
- EncoderLayer.forward()函数最终调用到MultiHeadedAttention.forward()函数(对应图上序号4),此处是编码器层的编码功能。
- MultiHeadedAttention.forward()函数最终调用到attention()函数完成注意力计算功能(对应图上序号5)。
- encode()函数会返回memory,memory会被用作参数传递给decode()函数。
- EncoderDecoder类会通过decode()函数执行解码功能。
- decode()函数利用memory,src_mask, tgt, tgt_mask来调用到Decoder.forward()函数(对应图上序号6)。tgt里面是单词的索引,decode()函数会调用self.tgt_embed(tgt)来生成tgt的word embeddng。
- Decoder.forward()函数会调用DecoderLayer.forward()函数(对应图上序号7),此处是解码器的解码功能的实现。
- DecoderLayer.forward()函数首先会调用MultiHeadedAttention.forward()函数。因为是使用self.self_attn(x, x, x, tgt_mask)来调用(对应图上序号8),x是tgt,所以这是掩码多头自注意力。
- DecoderLayer.forward()函数其次会调用MultiHeadedAttention.forward()函数。因为是使用self.src_attn(x, m, m, src_mask)来调用(对应图上序号10),x是tgt,所以这是交叉注意力。
- 掩码多头自注意力的计算会使用tgt,tgt_mask来调用attenion()函数(对应图上序号9)。
- 交叉注意力的计算会使用tgt,memoy, memory, src_mask来调用attention()函数(对应图上序号11)。
其实这块与上文 Encoder 中 的 Multi-Head Attention 具体实现细节上完全相同。
我们总结下,交叉注意力机制使模型能够在生成输出序列的每一步都考虑到输入序列的全部信息,从而捕捉输入和输出之间的复杂依赖关系,在各种seq2seq任务中实现卓越的性能。
0x04 Decoder Only
Transformer的架构并非一成不变,它可以表现为仅编码器(Encoder Only)、仅解码器(Decoder Only)或经典的编码器-解码器模型。每种架构变体都针对特定的学习目标和任务进行了定制。
4.1 分类
Transformer架构最初作为机器翻译任务的编码器-解码器模型引入。在此架构中,编码器将整个源语言句子作为输入,并将其通过多个Transformer编码器块,提取输入句子的高级特征。然后,这些提取的特征被一个接一个地馈送到解码器中,解码器基于来自编码器的源语言特征以及解码器之前生成的tokens来生成目标语言中的tokens。在随后的工作中,研究人员也引入仅编码器和仅解码器的架构,分别从原始编码器-解码器架构中仅取编码器和解码器组件,如图所示。
- (a)是仅包含编码器的模型,并行执行所有token的推理。
- (b)是仅包含解码器的模型,以自回归方式进行推理。
- (c)是包含编码器-解码器的模型,,它使用编码序列的输出作为交叉注意力模块的输入。

下图给出了现代LLM的进化树,追溯了近年来语言模型的发展,并突出了一些最著名的模型。同一分支上的模型关系更密切。基于Transformer的模型以非灰色显示:
-
蓝色分支 代表Decoder-Only 模型。随着时间的推移,越来越多的 Decoder-Only 模型被推出。
-
粉色分支 代表Encoder-Only 模型。这些模型主要用于编码和表示输入序列。
-
绿色分支 代表 Encoder-Decoder 模型。结合了前两者的特点,既能够编码输入序列,又能生成输出序列。
模型在时间线上的垂直位置表示它们的发布日期。开源模型用实心方块表示,而闭源模型用空心方块表示。右下角的堆叠条形图显示了来自不同公司和机构的模型数量。

从进化树中,我们得出与本篇相关的以下观察结果:
- 仅解码器模型逐渐主导了LLM的发展。在LLM开发的早期阶段,仅解码器模型不如仅编码器和编码器-解码器模型流行。然而,在2021年之后,随着改变游戏规则的LLM-GPT-3的引入,仅解码器模型经历了显著的繁荣。与此同时,在BERT带来的最初爆炸性增长之后,仅编码器的模型逐渐开始逐渐消失。
- 编码器-解码器模型仍然很有前景,因为这种类型的架构仍在积极探索中,其中大多数都是开源的。谷歌对开源编码器-解码器架构做出了重大贡献。然而,仅解码器模型的灵活性和多功能性似乎使谷歌对这一方向的坚持不那么有希望。
4.2 Decoder Only
Decoder-Only 模型只使用标准 Transformer 的 Decoder 部分,但稍作改动,典型差异是少了编码器解码器注意层,即在 Decoder-Only 模型不需要接收编码器的信息输入。Decoder-Only 模型没有显式的编码器模块,不显式区分"理解"和"生成"阶段。模型在自注意力机制中隐式完成对用户输入的分析、理解和建模,同时为生成任务提供基础。
前文提到,在BERT带来的最初爆炸性增长之后,仅编码器的模型逐渐开始逐渐消失。因此目前只剩Encoder-Decoder模型和Decoder only模型。然而, Decoder Only模型也可以细分为Causal Decoder架构和Prefix Decoder架构。因此,现有LLM的主流架构在事实上大致可分为三种主要类型,即编码器-解码器、因果解码器和前缀解码器。下图给出了三种主流架构中注意力模式的比较。在这里,蓝色、绿色、黄色和灰色圆角矩形分别表示前缀token之间的注意力、前缀和目标token之间的注意力、目标token间的注意力和掩码注意力。

我们接下来分析两种Decoder Only架构。
- Causal Decoder(因果解码器)架构。因果解码器架构结合了单向注意力掩码,以确保每个输入token只能关注过去的token和自身。输入和输出token通过解码器以相同的方式进行处理。作为该架构的代表性语言模型,GPT系列模型是基于因果解码器架构开发的。
- Prefix Decoder(前缀解码器)架构。前缀解码器架构(也称为非因果解码器])修改了因果解码器的掩蔽机制,以实现对前缀token的双向关注和仅对生成的token的单向关注。这样,与编码器-解码器架构一样,前缀解码器可以对前缀序列进行双向编码,并逐一自回归预测输出token,其在编码和解码过程中共享相同的参数。与其从头开始进行预训练,一个实用的建议是不断训练因果解码器,然后将它们转换为前缀解码器以加速收敛,基于前缀解码器的现有代表性LLM包括GLM130B和U-PaLM。
4.3 架构选择
许多研究都对仅解码器架构和编码器-解码器架构的性能进行了研究,但在有足够的训练和模型规模的情况下,确实没有确凿证据证明一种架构在最终性能上优于另一种架构。在知乎上有一个知名的帖子:[为什么现在的LLM都是Decoder only的架构?](为什么现在的LLM都是Decoder only的架构?)。各路大神都有很精彩的见解。不完全总结如下:
注:下面的decoder only主要指Causal Decoder(因果解码器)架构。
- 适合生成任务。
- Decoder-Only 模型的任务适配性更好。"纯生成"任务(如对话、续写)没有明确的"输入"和"输出"分界,引入Encoder会显得多余。
- Decoder-Only 模型更适合生成任务。很多实际应用更关注生成的连贯性和语义丰富性,而不是对输入的复杂理解。
- 泛化性能更好。
- 苏神提出了"注意力满秩问题"。即Decoder-only架构的Attention矩阵一定是满秩的,这代表更强的表达能力,而双向注意力反而会变得不足。
- 在纯解码器Decoder-Only架构中,由于因果掩码(防止模型看到未来的标记Token),注意力矩阵被限制为下三角形式,理论上可以保持其全秩状态:对角线上的每个元素(代表自注意力)都有助于使行列式为正(只有 Softmax 才能得到正结果)。全秩意味着理论上更强的表达能力。
- 另外两种生成式架构都引入了双向注意力,因此无法保证其注意力矩阵的全秩状态。直觉上这是有道理的。双向注意力是一把双刃剑:它能加快学习过程,但也会破坏模型学习生成所必需的更深层预测模式。你可以把它想象成学习如何写作:填空比逐字逐句地写出整篇文章更容易,但这是一种不太有效的练习方式。不过,经过大量训练后,这两种方法都能达到学习如何写作的目的。
- Encoder-Decoder模型因为可以看到双向,虽然预测时候有优势,但是训练时候降低了学习难度,导致上限不高。而Decoder-Only 模型在模型足够大、数据足够多时,其学习通用表征的上限更高。
- 论文"What Language Model Architecture and Pretraining Objective Work Best for Zero-Shot Generalization" 比较了各种架构和预训练方法的组合。他们发现:
- Decoder-Only 模型在没有任何tuning数据的情况下、zero-shot表现最好。我们的实验表明,在纯粹的自监督预训练后,根据自回归语言建模目标训练的纯因果解码器模型表现出最强的零样本泛化能力。
- 而encoder-decoder则需要在一定量的标注数据上做multitask finetuning才能激发最佳性能。然而,在实验中,对具有非因果可见性的输入来说,先使用基于掩码语言建模目标训练,然后进行多任务微调的模型性能最好。
- Decoder-Only 模型是Casual attention,具备隐式的位置编码功能,可以打破Transformer的位置不变性。而双向attention的模型如果不带位置编码,则对语序区分能力较弱。
- 从提示词中进行上下文学习。在使用 LLM 时,我们可以采用提示词工程方法,例如提供少量实例来帮助 LLM 理解上下文或任务。在论文"Why Can GPT Learn In-Context? Language Models Secretly Perform Gradient Descent as Meta-Optimizers"中,研究人员用数学方法证明,这种上下文信息可以被视为具有与梯度下降类似的效果,可以更新零样本的注意力权重。如果我们把提示词看作是给注意力权重引入梯度,那么我们或许可以期待它对Decoder-Only模型产生更直接的效果,因为它在用于生成任务之前不需要先转化为中间语境的特征表示。从逻辑上讲,它应该仍然适用于Encoder-Decoder架构,但这需要对编码器进行仔细调整,使其达到最佳性能,而这可能比较困难。
- 苏神提出了"注意力满秩问题"。即Decoder-only架构的Attention矩阵一定是满秩的,这代表更强的表达能力,而双向注意力反而会变得不足。
- 高效性。
- Decoder-Only 模型在同一个模块中处理输入序列与输出序列,避免了模型结构的复杂化。根据奥卡姆剃刀原理:如果你有两个相互竞争的观点来解释同一现象,你应该选择更简单的观点。我们应该更倾向于只使用解码器的模型结构。
- Decoder-Only 模型在推理过程只需一次向前传播,而不是 Encoder 和 Decoder 分别进行向前传播,推理效率更高。
- Decoder-Only 模型支持一直复用KV Cache,对多轮对话更友好。而其它两种架构难以做到。在纯解码器模型(Decoder-Only)中,先前Token的键(K)和值(V)矩阵可以在解码过程中重复用于后面的标记Token。由于每个位置只关注之前的Token(由于因果注意力机制),因此这些标记Token的 K 和 V 矩阵保持不变。这种缓存机制避免了为已经处理过的标记Token重新计算 K 和 V 矩阵,从而提高了效率,有利于在自回归模型(如 GPT)的推理过程中加快生成速度并降低计算成本。
- 利用Scale Up。Encoder-Decoder 架构网络不是均匀对称的(不是线性而是有大量的分叉),导致数据依赖关系复杂,难以并行优化。而Decoder-Only 架构没有此问题。
- 训练数据效率高,训练成本低。
- Decoder-Only 模型的训练目标是预测下一个 Token(Next Token Prediction),这是大规模预训练任务的核心目标。这种目标与网络架构直接对齐,能高效利用海量的非结构化文本数据。
- Causal Decoder 模型因其强大的零样本泛化能力而表现出色,这与当前的惯例--在大规模语料库上进行自我监督学习十分契合。
- 而Encoder-Decoder 模型需要额外设计输入输出配对的数据。要实现 Encoder-Decoder结构的最大潜力,我们需要对标注数据进行多任务微调(基本上就是指令微调),这可能会非常昂贵,尤其是对于大型模型而言。
0xFF 参考
解剖Transformer 第二部分:你会用注意力机制组装出一个Transformer吗? 大方
A Learning Algorithm for Continually Running Fully Recurrent Neural Networks
Scheduled Sampling for Sequence Prediction with Recurrent Neural Networks
Professor Forcing: A New Algorithm for Training Recurrent Networks, 2016. Section 10.2.1, Teacher Forcing and Networks with Output Recurrence, Deep Learning, Ian Goodfellow, Yoshua Bengio, Aaron Courville, 2016.
为什么现在的LLM都是Decoder-only的架构? 苏剑林
Transformer 三大变体之Decoder-Only模型详解 浪子 [牛山AI公园]
Transformer系列:图文详解Decoder解码器原理 xiaogp
Transformer中的解码器详解 浪子 牛山AI公园
Transformer中的编码器详解 浪子 牛山AI公园
Decoder-only的LLM为什么需要位置编码? 苏剑林
为什么大多数LLM只使用Decoder-Only结构? AI算法之道
Transformers基本原理---Decoder如何进行解码? Python伊甸园
Why Can GPT Learn In-Context? Language Models Secretly Perform Gradient Descent as Meta-Optimizers. Retrieved from https://arxiv.org/abs/2212.10559 Dai, D., Sun, Y., Dong, L., Hao, Y., Sui, Z., & Wei, F. (2022).