Encoder和Decoder在NLP中的实战应用:从BERT到GPT的底层原理详解
Encoder与Decoder:从BERT到GPT,现代NLP的工程化实践与深度解析
如果你在过去几年里接触过自然语言处理,那么“Transformer”这个词一定如雷贯耳。它彻底重塑了我们对序列建模的认知,而构成其核心骨架的,正是编码器(Encoder) 与解码器(Decoder) 这对孪生组件。从BERT在理解任务上的横扫千军,到GPT系列在生成任务上的惊艳表现,背后都是这两种架构思想在不同方向上的极致演绎。对于一线的NLP工程师而言,仅仅知道“Encoder用于编码,Decoder用于解码”是远远不够的。真正的挑战在于,如何理解它们在具体模型中的实现差异,如何用代码将那些精妙的数学公式落地,以及如何在你的项目中做出最合适的选择。这篇文章将抛开教科书式的定义,直接从PyTorch/TensorFlow的代码片段切入,结合注意力机制、位置编码等核心技术,为你拆解Encoder和Decoder在BERT与GPT等主流模型中的实战应用与底层原理。
1. 核心基石:重新审视Transformer的双子星
在深入具体模型之前,我们必须建立一个清晰的认知框架:Encoder和Decoder在Transformer中扮演着截然不同的角色,这种差异直接决定了它们所擅长任务的本质。
Encoder 的核心使命是“理解”与“压缩”。它接收一个输入序列(例如一个句子),通过多层自注意力(Self-Attention)和前馈网络(Feed-Forward Network)的堆叠,为序列中的每个位置(如每个词)生成一个富含全局上下文信息的向量表示。你可以把它想象成一个极度专注的读者,它会反复咀嚼句子的每一个部分,并建立词与词之间错综复杂的关联网络,最终输出一个深度理解的“读书笔记”。这个“笔记”是固定上下文长度的,但它浓缩了输入的全部语义精华。因此,Encoder架构天然适合需要深度理解输入的任务,如文本分类、情感分析、命名实体识别(NER)和问答系统中的阅读理解。
Decoder 的核心使命则是“生成”与“演绎”。在经典的Seq2Seq模型中,Decoder以Encoder的最终输出(或所有输出)为“上下文”,并基于此前已生成的部分,自回归地(Auto-regressively)预测下一个输出 token。Transformer Decoder在自注意力机制上增加了一个关键限制——掩码自注意力(Masked Self-Attention)。这确保了在生成第t个词时,模型只能“看到”位置1到t-1的词,而无法窥见未来的信息,从而保证了生成过程的因果性。因此,Decoder架构是序列生成任务的王者,如机器翻译、文本摘要、对话生成和代码补全。
注意:这里存在一个常见的混淆点。GPT虽然名称中有“生成式预训练”,但它本质上是一个仅包含Decoder堆叠的模型。它利用掩码自注意力,在无监督预训练时通过上文预测下一个词,从而学习到一个强大的语言模型。在微调时,它可以适应多种生成任务,甚至通过Prompt设计完成一些理解任务。这与经典的Encoder-Decoder架构(如原始Transformer和T5)有根本区别。
为了更直观地对比,我们来看一下它们在架构和注意力机制上的关键区别:
| 特性维度 | Transformer Encoder | Transformer Decoder (经典) | GPT-style Decoder |
|---|---|---|---|
| 核心组件 | 多头自注意力 + FFN | 掩码多头自注意力 + 编码器-解码器注意力 + FFN | 掩码多头自注意力 + FFN |
| 注意力范围 | 全上下文双向 | 自注意力:因果掩码(单向);编码器-解码器注意力:全上下文 | 因果掩码(单向) |
| 输入/输出 | 接收完整输入序列,输出同等长度的上下文表示 | 接收编码器输出和已生成序列,输出下一个词的概率分布 | 接收历史序列,输出下一个词的概率分布 |
| 典型代表 | BERT, RoBERTa | 原始Transformer (用于翻译), T5 | GPT-3, LLaMA, ChatGLM |
| 擅长任务 | 理解类(分类、标注、抽取) | 序列到序列生成(翻译、摘要) | 自回归生成、对话、续写 |
2. BERT:双向编码器的巅峰实践
BERT(Bidirectional Encoder Representations from Transformers)的成功,将Encoder架构的价值推向了顶峰。其核心创新在于“双向”的预训练目标,而这完全依赖于Encoder的全上下文自注意力能力。
2.1 预训练任务:掩码语言模型与下一句预测
BERT的预训练同时进行两个任务,这要求其Encoder必须能同时建模词级和句级关系。
掩码语言模型(MLM):随机遮盖输入句子中15%的token,然后让模型预测这些被遮盖的原始词。例如:
输入: [CLS] 人工 智能 [MASK] 改变 世界 [SEP]
目标: 预测 [MASK] 位置为 “将”
这个过程迫使Encoder必须利用被遮盖词左右两侧的上下文信息进行综合推断,从而实现真正的“双向”理解。
下一句预测(NSP):给定两个句子A和B,判断B是否是A的下一句。这帮助模型理解句子间关系,对问答、推理任务至关重要。
下面是一个简化的PyTorch代码片段,展示如何构建一个用于MLM任务的BERT风格Encoder前向传播:
import torch
import torch.nn as nn
import torch.nn.functional as F
class BertStyleEncoderLayer(nn.Module):
def __init__(self, d_model=768, nhead=12, dim_feedforward=3072):
super().__init__()
# 多头自注意力层
self.self_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)
# 前馈网络:两个线性层 + 激活函数
self.ffn = nn.Sequential(
nn.Linear(d_model, dim_feedforward),
nn.GELU(), # BERT使用GELU而非ReLU
nn.Linear(dim_feedforward, d_model)
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(0.1)
def forward(self, src, src_key_padding_mask=None):
# 自注意力子层
attn_output, _ = self.self_attn(src, src, src, key_padding_mask=src_key_padding_mask)
src = src + self.dropout(attn_output)
src = self.norm1(src) # Add & Norm
# 前馈网络子层
ffn_output = self.ffn(src)
src = src + self.dropout(ffn_output)
src = self.norm2(src) # Add & Norm
return src
# 假设输入: [batch_size, seq_len, d_model]
x = torch.randn(2, 128, 768)
encoder_layer = BertStyleEncoderLayer()
output = encoder_layer(x)
print(f"Encoder层输出形状: {output.shape}") # torch.Size([2, 128, 768])
2.2 位置编码:让序列拥有“顺序感”
Transformer本身不具备处理序列顺序的能力,因此必须显式地注入位置信息。BERT使用的是绝对位置编码(Absolute Positional Encoding),即通过正弦余弦函数生成一个固定的、与位置相关的向量,加到词嵌入上。
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=512):
super().__init__()
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term) # 偶数维度用sin
pe[:, 1::2] = torch.cos(position * div_term) # 奇数维度用cos
self.register_buffer('pe', pe.unsqueeze(0)) # [1, max_len, d_model]
def forward(self, x):
# x: [batch_size, seq_len, d_model]
return x + self.pe[:, :x.size(1)]
在实际工程中,对于超过预训练最大长度(如512)的文本,需要进行截断或分段处理。一些后续模型(如RoBERTa)去除了NSP任务,并采用动态掩码等技巧,进一步提升了性能。
3. GPT:解码器架构的生成革命
与BERT的“理解”之路不同,GPT系列选择了另一条道路:专注于自回归生成的Decoder-only架构。GPT-3的惊人能力证明了,一个足够大的、仅使用掩码自注意力的Decoder模型,可以通过海量数据和规模扩展,涌现出强大的通用能力。
3.1 因果掩码:生成任务的核心约束
GPT的核心是因果掩码(Causal Mask),它确保了模型在预测位置i时,只能关注到位置0到i-1的token,形成一个三角形的注意力矩阵。这是实现自回归生成的关键。
def generate_causal_mask(seq_len):
"""生成一个下三角布尔矩阵,对角线及以下为True(允许关注),以上为False(屏蔽)"""
mask = torch.tril(torch.ones(seq_len, seq_len)).bool()
return mask
# 示例:序列长度为5
mask = generate_causal_mask(5)
print(mask)
# tensor([[ True, False, False, False, False],
# [ True, True, False, False, False],
# [ True, True, True, False, False],
# [ True, True, True, True, False],
# [ True, True, True, True, True]])
在多头自注意力计算中,这个掩码会被加到注意力权重矩阵上,未来位置的权重在softmax前会被设置为一个极大的负值(如-1e9),从而使softmax后的概率接近零。
3.2 GPT风格解码器层实现
一个标准的GPT解码器层比Encoder层更简单,因为它只包含掩码多头自注意力和前馈网络。
class GPTDecoderLayer(nn.Module):
def __init__(self, d_model=768, nhead=12, dim_feedforward=3072):
super().__init__()
# 掩码多头自注意力
self.self_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True)
self.ffn = nn.Sequential(
nn.Linear(d_model, dim_feedforward),
nn.GELU(),
nn.Linear(dim_feedforward, d_model)
)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(0.1)
def forward(self, tgt, causal_mask=None):
# tgt: 目标序列,在训练时是完整的输出序列右移一位,在推理时是已生成的历史
# 掩码自注意力
attn_output, _ = self.self_attn(tgt, tgt, tgt, attn_mask=causal_mask)
tgt = tgt + self.dropout(attn_output)
tgt = self.norm1(tgt)
# 前馈网络
ffn_output = self.ffn(tgt)
tgt = tgt + self.dropout(ffn_output)
tgt = self.norm2(tgt)
return tgt
3.3 生成策略:从贪婪搜索到束搜索
GPT模型的魅力在于其生成能力。在推理阶段,我们使用自回归的方式逐个生成token。常见的生成策略有:
- 贪婪搜索(Greedy Search):每一步都选择概率最高的词。简单高效,但容易导致重复和缺乏创造性的输出。
- 束搜索(Beam Search):维护一个大小为
k的候选序列集合(束宽),每一步扩展所有候选序列,保留总体概率最高的k个。生成质量通常更高,是机器翻译等任务的标配。 - 采样(Sampling):根据输出的概率分布随机采样。可以引入温度参数
temperature来控制分布的平滑程度。温度高(>1)则分布更平,生成更多样;温度低(<1)则分布更尖锐,生成更确定。 - Top-k / Top-p (Nucleus)采样:更先进的采样方法。Top-k只从概率最高的k个token中采样;Top-p从累积概率超过p的最小token集合中采样。后者能动态调整候选集大小,效果通常更好。
def top_p_sampling(logits, top_p=0.9, temperature=1.0):
"""Top-p (nucleus) 采样"""
logits = logits / temperature
probs = F.softmax(logits, dim=-1)
sorted_probs, sorted_indices = torch.sort(probs, descending=True)
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
# 移除累积概率超过top_p的部分
sorted_indices_to_remove = cumulative_probs > top_p
# 确保至少保留一个token
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
sorted_indices_to_remove[..., 0] = 0
# 将需要移除的token概率置零
indices_to_remove = sorted_indices[sorted_indices_to_remove]
probs.scatter_(-1, indices_to_remove, 0.0)
# 重新归一化并采样
probs = probs / probs.sum(dim=-1, keepdim=True)
next_token = torch.multinomial(probs, num_samples=1)
return next_token
在实际项目中,直接使用Hugging Face transformers库的generate函数是更明智的选择,它集成了所有上述策略并做了大量优化。
4. 融合与演进:Encoder-Decoder架构的现代变体
纯粹的Encoder或Decoder架构各有侧重,而将两者结合的Encoder-Decoder模型则在序列到序列任务上展现出强大能力。除了经典的Transformer,T5和BART是其中的杰出代表。
4.1 T5:将一切任务视为文本到文本
T5(Text-To-Text Transfer Transformer)的核心思想是统一框架。无论是翻译、摘要、分类还是回归,所有任务都被格式化为文本到文本的转换。例如:
- 翻译:输入
translate English to German: That is good.,输出Das ist gut. - 情感分类:输入
sentiment: This movie is terrible!,输出negative
这种设计极大地简化了任务接口和模型架构。T5使用标准的Transformer Encoder-Decoder,并在大规模“Colossal Clean Crawled Corpus”上进行预训练,任务就是去噪:随机遮盖或丢弃文本片段,让模型重建原始文本。
# 使用Hugging Face Transformers库加载T5进行摘要任务示例
from transformers import T5ForConditionalGeneration, T5Tokenizer
model_name = "t5-small"
tokenizer = T5Tokenizer.from_pretrained(model_name)
model = T5ForConditionalGeneration.from_pretrained(model_name)
input_text = "summarize: The Transformer architecture has become the de-facto standard for NLP tasks. It relies entirely on attention mechanisms and has shown superior performance compared to previous RNN-based models."
input_ids = tokenizer.encode(input_text, return_tensors="pt")
# 生成摘要
summary_ids = model.generate(input_ids, max_length=50, num_beams=4)
summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
print(f"摘要: {summary}")
4.2 编码器-解码器注意力:信息传递的桥梁
在Encoder-Decoder架构中,Decoder除了自身的掩码自注意力,还有一个关键的编码器-解码器注意力层(Cross-Attention)。这一层允许Decoder在生成每一个目标词时,有选择地“回顾”Encoder对所有源词编码后的完整上下文信息。
其计算过程可以概括为:
- Query (Q) 来自Decoder上一层的输出。
- Key (K) 和 Value (V) 来自Encoder的最终输出。
- 计算Decoder当前位置的Query与Encoder所有位置的Key的相似度,得到注意力权重。
- 用该权重对Encoder的Value进行加权求和,得到上下文向量。
- 将该上下文向量与Decoder自注意力的输出融合,送入前馈网络。
这个过程使得翻译中的“对齐”成为可能,例如在生成德语“gut”时,模型可以高度关注英语源句中的“good”。
4.3 工程实践中的选择与权衡
面对具体项目,如何选择架构?这里有一些经验性的指导:
- 如果你的任务是理解/分类/标注:优先考虑纯Encoder模型,如BERT及其变体(RoBERTa, ALBERT, DeBERTa)。它们通常能提供最强大的上下文表示,微调成本相对较低。
- 如果你的任务是开放式生成:优先考虑纯Decoder模型,如GPT系列、LLaMA、ChatGLM。它们擅长续写、对话和创意写作。对于代码生成,Codex/CodeLlama也是基于此架构。
- 如果你的任务是严格的序列到序列转换:如机器翻译、文本摘要、语法纠错,Encoder-Decoder模型(如T5, BART, mT5)通常是更直接和强大的选择。它们明确区分了源序列的编码和目标序列的解码过程。
提示:模型选择并非绝对。通过巧妙的Prompt设计,Decoder-only模型(如ChatGPT)也能完成许多理解类任务。同样,Encoder-only模型也可以通过添加一个简单的生成头(如LM Head)用于生成,但效果通常不如专门的生成模型。资源限制(模型大小、推理速度)和领域适配(是否有领域内预训练模型)也是必须考虑的关键因素。
5. 超越基础:注意力机制的高级优化与实战技巧
理解了基础架构后,我们需要关注那些让模型真正高效、可扩展的高级技巧。
5.1 高效注意力:应对长序列挑战
标准自注意力计算复杂度为O(n²),这严重限制了模型处理长文本的能力。以下是一些主流解决方案:
- 稀疏注意力(Sparse Attention):如Longformer的滑动窗口注意力、BigBird的全局+局部+随机注意力。它们只计算特定位置对之间的注意力,将复杂度降低到O(n)或O(n log n)。
- 线性注意力(Linear Attention):通过核函数近似,将softmax注意力分解为两个线性运算,实现O(n)复杂度,如Performer、Linear Transformer。
- 分块/局部注意力(Local/Block Attention):将序列分块,主要在块内计算注意力,块间进行稀疏交互。
例如,Longformer结合了滑动窗口注意力(局部)和任务相关的全局注意力(如[CLS] token关注所有位置,所有位置关注某些特殊token),使其能处理长达4096的文档。
5.2 位置编码的演进:从绝对到相对
正弦余弦绝对位置编码在训练长度外泛化能力差。相对位置编码(Relative Positional Encoding)成为主流,它不关注词的绝对位置,而是关注词对之间的相对距离。
- Transformer-XL/DeBERTa使用的相对位置编码:在计算注意力分数时,注入一个与相对位置(i-j)相关的可学习偏置项。
- RoPE(旋转位置编码):被LLaMA、GPT-NeoX等模型采用。通过旋转矩阵将绝对位置信息融入token的向量表示中,在注意力计算时能自然地体现出相对位置信息,且具有良好的外推性。
# RoPE (旋转位置编码) 概念性简化示意
def apply_rope(q, k, pos):
"""
q, k: [batch, head, seq_len, dim]
pos: 位置索引
"""
# 将dim维度分成两半,分别视为复数的实部和虚部
dim = q.shape[-1]
half_dim = dim // 2
# 计算旋转角度 theta
theta = 1.0 / (10000 ** (torch.arange(0, half_dim, 2) / half_dim))
# 计算旋转矩阵并应用于q, k
# ... 具体实现涉及复数运算
return q_rotated, k_rotated
5.3 训练与推理加速技巧
- 梯度检查点(Gradient Checkpointing):用计算时间换内存。只保存部分层的激活值,其余的在反向传播时重新计算,能显著降低显存占用,训练更大模型。
- 混合精度训练(AMP):使用FP16进行前向和反向传播,用FP32维护主权重并更新。能大幅减少显存占用并加速训练。
- 模型并行(Model Parallelism):当单个GPU放不下整个模型时,将模型的不同层分布到多个GPU上。
- KV缓存(KV Cache):在自回归生成推理时,对于已经计算过的历史序列,其Key和Value向量可以被缓存起来,避免在生成新token时重复计算,这是提升推理速度的关键。
# KV缓存的概念性使用(伪代码风格)
class DecoderWithKVCache:
def step(self, input_token, past_key_values=None):
# input_token: 当前步输入的token id
# past_key_values: 之前所有步缓存的K和V,形状可能为 [layers, 2, batch, head, past_len, dim]
if past_key_values is None:
# 第一次调用,没有缓存
outputs = model(input_token)
else:
# 使用缓存,只计算当前token的Q,并与缓存的K, V计算注意力
outputs = model(input_token, past_key_values=past_key_values)
new_key_values = update_cache(past_key_values, outputs.key_values)
next_token_logits = outputs.logits[:, -1, :]
return next_token_logits, new_key_values
掌握这些底层原理和工程技巧,能让你在面对实际NLP挑战时,不仅知道用什么模型,更明白为什么用,以及如何根据具体场景进行定制和优化。从Encoder到Decoder,从BERT到GPT,这条技术脉络的每一次演进,都为我们提供了更强大的工具来理解和生成人类语言。
更多推荐




所有评论(0)