# Transformer架构实战:如何用Encoder和Decoder分别搭建NLP模型(附代码对比)
如果你已经对Transformer架构有了基本的了解,知道它由编码器和解码器组成,但在实际项目中,面对一个具体的NLP任务时,可能还是会感到一丝犹豫:我是该用BERT那样的纯编码器模型,还是用GPT那样的纯解码器模型?或者,非得用上完整的编码器-解码器结构吗?这种困惑非常普遍,尤其是在资源有限、需要快速验证想法的开发场景中。
这篇文章就是为你准备的。我们不打算重复教科书上的理论,而是直接从代码和工程实践的角度出发,手把手地带你搭建两个核心模型:一个仅使用编码器,一个仅使用解码器。通过并排的代码对比、性能基准测试以及真实的适用场景分析,你会清晰地看到它们各自的“脾气秉性”。最终,你将获得一套可操作的决策框架,知道在下一个项目中,何时该选择哪种架构,以及如何快速上手实现。
## 1. 核心架构拆解:从理论到代码骨架
在深入代码之前,我们先快速回顾一下Transformer中编码器和解码器最本质的设计差异,这直接决定了它们的代码实现和适用场景。
编码器的核心任务是**理解与编码**。它接收一个完整的输入序列(比如一句话),通过多层自注意力(Self-Attention)和前馈网络(Feed-Forward Network)进行处理。关键在于,它的自注意力是“双向”的,每个词都能看到序列中所有其他词的信息,从而生成一个富含上下文信息的表示。这个表示可以是一个序列(每个输入词对应一个向量),也可以被聚合成一个单一的向量(如[CLS]标记的输出)。
解码器的核心任务是**自回归生成**。它通常用于一步步地生成输出序列(比如翻译结果或续写的文本)。它的自注意力层是“掩码(Masked)”的,这意味着在生成第`t`个词时,它只能“看到”第`1`到`t-1`个词,未来的词是看不到的。这种设计保证了生成过程的因果性。在完整的编码器-解码器架构中,解码器还会额外有一层“交叉注意力(Cross-Attention)”,用于关注编码器输出的信息。
下面,我们用PyTorch来勾勒出这两个架构最简化的代码骨架。注意,这里为了突出结构差异,省略了层归一化(LayerNorm)、残差连接(Residual Connection)等细节,但它们在实际模型中至关重要。
```python
import torch
import torch.nn as nn
import math
class TransformerEncoderLayer(nn.Module):
"""简化版Transformer编码器层"""
def __init__(self, d_model, nhead, dim_feedforward=2048):
super().__init__()
self.self_attn = nn.MultiheadAttention(d_model, nhead)
self.linear1 = nn.Linear(d_model, dim_feedforward)
self.linear2 = nn.Linear(dim_feedforward, d_model)
self.activation = nn.ReLU()
def forward(self, src):
# 双向自注意力:src能看见自身序列的全部
attn_output, _ = self.self_attn(src, src, src)
# 前馈网络
ff_output = self.linear2(self.activation(self.linear1(attn_output)))
return ff_output
class TransformerDecoderLayer(nn.Module):
"""简化版Transformer解码器层(仅自注意力,无交叉注意力)"""
def __init__(self, d_model, nhead, dim_feedforward=2048):
super().__init__()
# 掩码自注意力,防止信息泄露
self.self_attn = nn.MultiheadAttention(d_model, nhead)
self.linear1 = nn.Linear(d_model, dim_feedforward)
self.linear2 = nn.Linear(dim_feedforward, d_model)
self.activation = nn.ReLU()
def forward(self, tgt):
# 生成序列长度
seq_len = tgt.size(0)
# 创建因果掩码:下三角矩阵,True表示被掩盖的位置
causal_mask = torch.triu(torch.ones(seq_len, seq_len) * float('-inf'), diagonal=1).bool()
# 掩码自注意力:tgt在生成当前位置时,看不到未来的信息
attn_output, _ = self.self_attn(tgt, tgt, tgt, attn_mask=causal_mask)
# 前馈网络
ff_output = self.linear2(self.activation(self.linear1(attn_output)))
return ff_output
```
> 提示:在实际的`nn.TransformerDecoderLayer`中,还包含一个用于关注编码器输出的交叉注意力层。我们这里构建的是类似GPT的“仅解码器”模型,因此暂不包含该层。
从代码骨架可以直观感受到:
* **编码器层**的注意力是全局的、无掩码的,适合对已有完整信息的深度理解。
* **解码器层**的注意力是因果的、带掩码的,适合从左到右的顺序生成。
这个根本区别,像DNA一样,决定了它们后续在模型构建、训练和部署上的所有不同。
## 2. 纯编码器模型实战:构建一个文本分类器
纯编码器模型在自然语言理解(NLU)任务上大放异彩,BERT是其中最著名的代表。这类模型擅长提取文本的深层语义特征。我们以文本情感分类(二分类:正面/负面)为例,搭建一个简易版的BERT。
我们的模型结构很简单:一个嵌入层(词嵌入+位置编码),堆叠N个编码器层,最后用一个池化层(取[CLS]标记的输出)接一个分类头。
```python
class EncoderOnlyClassifier(nn.Module):
"""基于纯编码器的文本分类模型"""
def __init__(self, vocab_size, d_model=512, nhead=8, num_layers=6, num_classes=2):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoder = PositionalEncoding(d_model) # 需自定义位置编码
encoder_layers = nn.TransformerEncoderLayer(d_model, nhead)
self.transformer_encoder = nn.TransformerEncoder(encoder_layers, num_layers)
# 分类头
self.pooler = nn.Linear(d_model, d_model) # 类似BERT的池化层
self.classifier = nn.Linear(d_model, num_classes)
self.d_model = d_model
def forward(self, src, src_key_padding_mask=None):
# src shape: (seq_len, batch_size)
src = self.embedding(src) * math.sqrt(self.d_model)
src = self.pos_encoder(src)
# 编码器处理整个序列
memory = self.transformer_encoder(src, src_key_padding_mask=src_key_padding_mask)
# 取第一个位置([CLS])的输出用于分类
cls_output = memory[0, :, :] # shape: (batch_size, d_model)
pooled_output = torch.tanh(self.pooler(cls_output))
logits = self.classifier(pooled_output)
return logits
# 假设我们有一个简单的数据批次
batch_size = 4
seq_len = 20
vocab_size = 10000
d_model = 512
model = EncoderOnlyClassifier(vocab_size, d_model=d_model)
input_ids = torch.randint(0, vocab_size, (seq_len, batch_size)) # 模拟输入
logits = model(input_ids)
print(f"模型输出logits形状: {logits.shape}") # 应为 (batch_size, 2)
```
**关键操作解析**:
1. **输入处理**:模型一次性接收整个输入序列。`src_key_padding_mask`用于告诉模型哪些位置是填充的(PAD),需要被忽略。
2. **特征提取**:多个编码器层对序列进行深层编码,每个词的输出向量都融合了全局上下文信息。
3. **池化与分类**:我们取序列开头特殊标记`[CLS]`对应的输出向量,认为它聚合了整个句子的信息,然后通过一个线性层进行分类。
**适用场景与优势**:
* **文本分类**:情感分析、新闻分类、意图识别。
* **序列标注**:命名实体识别(NER)、词性标注。此时不使用`[CLS]`,而是对序列中每个词的输出向量进行标注。
* **句子对任务**:自然语言推理(NLI)、语义相似度。需要将两个句子拼接输入,让编码器理解其间关系。
* **优势**:由于双向注意力,对上下文的理解非常透彻,在需要深度理解输入的任务上通常表现更优。推理时,只需前向传播一次,速度相对较快。
## 3. 纯解码器模型实战:构建一个文本生成器
纯解码器模型是生成式任务的王者,GPT系列是典型。它通过自回归的方式,逐个生成下一个词。我们以文本续写为例,搭建一个简易的生成模型。
这个模型的结构与编码器模型类似,但使用的是带掩码的解码器层。它的训练目标是标准的语言模型任务:给定前文,预测下一个词。
```python
class DecoderOnlyLM(nn.Module):
"""基于纯解码器的语言模型(用于文本生成)"""
def __init__(self, vocab_size, d_model=512, nhead=8, num_layers=6):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoder = PositionalEncoding(d_model)
# 使用TransformerDecoderLayer,但注意我们暂时不提供encoder的输出
decoder_layer = nn.TransformerDecoderLayer(d_model, nhead)
self.transformer_decoder = nn.TransformerDecoder(decoder_layer, num_layers)
self.output_layer = nn.Linear(d_model, vocab_size)
self.d_model = d_model
def forward(self, tgt, tgt_key_padding_mask=None, tgt_mask=None):
# tgt shape: (seq_len, batch_size)
tgt = self.embedding(tgt) * math.sqrt(self.d_model)
tgt = self.pos_encoder(tgt)
# 解码器处理,使用因果掩码确保自回归性
# 这里memory参数为None,因为我们没有编码器输出
output = self.transformer_decoder(tgt, memory=None,
tgt_key_padding_mask=tgt_key_padding_mask,
tgt_mask=tgt_mask)
logits = self.output_layer(output)
return logits # shape: (seq_len, batch_size, vocab_size)
# 生成过程中的因果掩码创建函数
def generate_square_subsequent_mask(sz):
"""生成一个下三角布尔掩码矩阵,用于防止解码器看到未来信息。"""
mask = torch.triu(torch.ones(sz, sz), diagonal=1).bool()
return mask
# 训练时前向传播示例
model = DecoderOnlyLM(vocab_size, d_model=d_model)
seq_len = 20
tgt = torch.randint(0, vocab_size, (seq_len, batch_size))
# 创建因果掩码
tgt_mask = generate_square_subsequent_mask(seq_len)
logits = model(tgt, tgt_mask=tgt_mask)
print(f"语言模型输出logits形状: {logits.shape}") # (20, 4, 10000)
```
**关键操作解析**:
1. **训练(Teacher Forcing)**:输入是完整的目标序列,但通过`tgt_mask`(因果掩码)确保在预测位置`i`时,模型只能看到位置`0`到`i-1`的输入。损失函数计算每个位置预测下一个词的交叉熵。
2. **推理(生成)**:这是一个迭代过程。给定一个起始标记(如`<BOS>`),模型预测下一个词的概率分布,通过采样(如Top-p采样)选择一个词,将其追加到输入序列后,再次输入模型预测下一个词,如此循环直到生成结束标记或达到最大长度。
```python
# 简化的自回归生成函数(贪婪解码)
def generate_text(model, start_token, max_len=50, temperature=1.0):
model.eval()
generated = [start_token]
with torch.no_grad():
for _ in range(max_len):
input_seq = torch.tensor(generated).unsqueeze(1) # (seq_len, 1)
# 为当前序列生成掩码
tgt_mask = generate_square_subsequent_mask(len(generated))
logits = model(input_seq, tgt_mask=tgt_mask) # (seq_len, 1, vocab_size)
# 取最后一个位置的logits
next_token_logits = logits[-1, 0, :] / temperature
probabilities = torch.softmax(next_token_logits, dim=-1)
next_token = torch.multinomial(probabilities, 1).item()
generated.append(next_token)
if next_token == eos_token_id: # 假设定义了结束符ID
break
return generated
```
**适用场景与优势**:
* **文本生成**:故事创作、邮件/文章续写、代码补全。
* **对话系统**:给定对话历史,生成下一轮回复。
* **无监督特征提取**:类似GPT,其深层表示也可用于下游任务,但通常效果弱于同等规模的编码器模型。
* **优势**:生成文本流畅、连贯,具有很强的创造性和延续性。在零样本(Zero-shot)或少样本(Few-shot)学习场景下,通过巧妙的提示(Prompt)设计,能完成多种任务,展现出强大的通用性。
## 4. 性能对比与项目选型指南
了解了如何构建两者之后,我们更需要知道在真实项目中如何选择。下面从多个维度进行对比。
**计算效率与资源消耗**
| 维度 | 纯编码器模型 (如BERT) | 纯解码器模型 (如GPT) |
| :--- | :--- | :--- |
| **训练效率** | 通常更高。可利用双向上下文并行计算,GPU利用率高。预训练常采用MLM等非自回归目标。 | 相对较低。自回归生成必须串行计算损失,无法完全并行。但使用KV缓存等技术可加速。 |
| **推理速度 (理解任务)** | **快**。一次前向传播即可得到整个序列的表示,适合实时分类/标注。 | 慢。需要模拟生成过程,即使不做生成,其因果注意力也限制了并行度。 |
| **推理速度 (生成任务)** | 不直接适用于生成。若用于生成(如BERT+LM头),需迭代且效果通常不佳。 | **专为生成优化**。虽然自回归串行,但通过高效的缓存机制,每个生成步骤很快。 |
| **内存占用** | 相对较低。注意力矩阵计算可针对全序列优化。 | 相对较高。推理时需缓存之前所有时间步的键值对(KV Cache),随生成长度线性增长。 |
**任务适配性与效果表现**
> 注意:这里的“优势”是相对而言,并非绝对。随着模型演进(如Encoder-Decoder架构的T5、编解码混合的UL2),界限正在模糊。
* **文本分类、NER、句子相似度**:
* **编码器模型是首选**。其双向上下文建模能力能更精准地把握词语在特定语境下的含义和句子间的细微关系。例如,判断“苹果”指的是水果还是公司,编码器能同时看到前后文。
* **开放域文本生成、创意写作、代码生成**:
* **解码器模型是首选**。其自回归特性天然契合逐词生成的过程,能产生更长、更连贯、更具逻辑性的文本。GPT系列在故事生成、对话上的表现有目共睹。
* **摘要、翻译、问答(需生成答案)**:
* **传统上属于编码器-解码器架构的领域**。编码器理解源文本,解码器生成目标文本。
* **现状**:如今,**大语言模型(LLM,多为解码器架构)通过指令微调(Instruction Tuning)**,在这些任务上展现了惊人的能力,实现了“一个模型解决所有问题”。而纯编码器模型在这些生成任务上则力不从心。
**项目选型决策树**
面对一个新项目,你可以问自己以下几个问题:
1. **我的任务核心是“理解”还是“生成”?**
* **理解**(分类、提取、比较):优先考虑**纯编码器模型**。如果追求极致效果且资源充足,可以尝试微调BERT、RoBERTa等。如果希望快速部署且轻量,可以考虑蒸馏后的小型编码器模型(如DistilBERT、TinyBERT)。
* **生成**(创作、翻译、摘要、对话):优先考虑**纯解码器模型(大语言模型)**。如果拥有足够的计算资源和数据,可以微调LLaMA、ChatGLM等开源大模型。如果资源有限,可以考虑参数更小的生成模型,或使用大模型的API服务。
2. **我是否有足够的标注数据和计算资源?**
* **数据少,资源少**:考虑使用**预训练好的编码器模型进行特征提取**,然后接一个简单的分类器(如SVM、逻辑回归)。或者,使用**提示工程(Prompt Engineering)** 激发大语言模型的少样本学习能力。
* **数据充足,资源一般**:**微调一个中等规模的编码器或解码器模型**通常是性价比最高的选择。
* **数据海量,资源丰富**:可以尝试从头预训练,或继续在大规模预训练模型上进行领域自适应预训练。
3. **我对推理延迟和吞吐量的要求是什么?**
* **高并发、低延迟的在线服务**(如实时情感分析):**纯编码器模型**是更稳妥的选择,其一次性计算的特点易于优化和部署。
* **对延迟不敏感的后台任务**(如批量生成报告):**纯解码器模型**可以胜任,但需要仔细设计生成策略(如束搜索宽度、长度惩罚)来平衡质量和速度。
在我最近参与的一个智能客服项目中,我们同时用到了两种架构。对于用户**意图识别**(分类问题),我们微调了一个轻量化的编码器模型(ALBERT),它在准确率和响应速度上达到了完美平衡。而对于**自动生成回复话术**(生成问题),我们则接入了一个经过指令微调的中等规模解码器模型(如ChatGLM-6B),它能够根据识别的意图和上下文,生成流畅、专业的多种回复选项供客服人员选择。这种“编码器处理理解,解码器负责生成”的混合模式,在实际系统中非常有效。
架构本身没有绝对的优劣,只有是否适合。编码器像一位深思熟虑的分析师,善于解构和洞察;解码器像一位才华横溢的作家,善于构建和创造。理解它们的内在机制和代码实现,能让你在技术选型时不再迷茫,而是根据项目需求,精准地拿起最合适的那把工具。