第2章:核心架构:Encoder-Decoder结构总览,输入输出流程

好,咱们直接进入正题。

Transformer 最核心的设计,就是 Encoder-Decoder 结构。说白了,它就是一个「编码-解码」的管道。你扔进去一句话,它先编码成机器能懂的「中间表示」,再解码成你想要的另一句话。

我刚开始接触这个结构时,总觉得它像个黑盒子。后来亲手搭了一遍,才明白里面的门道。今天我就带你把这个盒子拆开看看。

2.1 整体架构长什么样?

先看一张抽象图(你可以在脑子里想象一下):

  • 左边是 Encoder(编码器):负责把输入序列「读进去」,提取特征。
  • 右边是 Decoder(解码器):负责根据编码器的输出,一步步「生成」目标序列。
  • 中间有个连接层:Decoder 的每一层都会「偷看」Encoder 的输出,这就是 Cross-Attention。

嗯,这里要注意:Encoder 和 Decoder 都不是单层,而是由 N 个相同的层堆叠起来的。原论文里 N=6,我习惯叫它「6层堆叠」。你想想看,层数越多,模型容量越大,但训练也越难。

核心记忆点:

  • Encoder 是双向的(可以看整个句子)
  • Decoder 是单向的(只能看已经生成的部分)
  • 两者通过 Cross-Attention 交互

2.2 输入流程:从文本到向量

输入流程,说白了就是「把文字变成数字」的过程。我把它拆成三步:

  1. Tokenization(分词):把句子切成一个个 token。比如 "I love NLP" 切成 ["I", "love", "NLP"]。
  2. Embedding(嵌入):每个 token 映射成一个向量。维度一般是 512 或 768。
  3. Positional Encoding(位置编码):给每个向量加上位置信息。因为 Transformer 没有循环结构,得靠这个告诉模型「词与词的顺序」。

我在项目中遇到过一个问题:如果输入句子长度超过模型预设的最大长度(比如 512),就得截断或做滑窗。这个坑我踩过,后面会细说。

小技巧: 位置编码我建议用正弦余弦函数(原论文方案),不要用可学习的。原因?正弦余弦能外推到更长的序列,可学习的就不行。我试过,效果差不少。

2.3 输出流程:从向量到文本

输出流程是输入流程的逆过程,但有个关键区别:它是自回归的

什么叫自回归?就是一次只生成一个 token,然后把生成的 token 拼回去,再生成下一个。就像你写文章,写一个字,看一遍,再写下一个字。

具体流程:

  1. Decoder 接收「已生成的部分」作为输入
  2. 经过 Masked Self-Attention(防止看到未来的词)
  3. 再经过 Cross-Attention(参考 Encoder 的输出)
  4. 最后通过一个线性层 + Softmax,输出下一个 token 的概率分布
  5. 选择概率最高的 token(或者用采样策略)作为输出

我曾经犯过一个低级错误:在推理时忘了加 <start><end> 标记。结果模型永远不知道什么时候开始,什么时候结束。嗯,这个教训挺深刻的。

2.4 输入输出的维度变化

咱们用一张表来理清维度变化。假设 batch_size=2,序列长度=10,embedding_dim=512:

阶段 输入形状 输出形状 说明
Embedding (2, 10) (2, 10, 512) token ID 转向量
Positional Encoding (2, 10, 512) (2, 10, 512) 加上位置信息
Encoder 输出 (2, 10, 512) (2, 10, 512) 经过 N 层编码
Decoder 输入 (2, 1, 512) (2, 1, 512) 初始只有 <start>
Decoder 输出 (2, 1, 512) (2, vocab_size) 预测下一个 token

注意: 训练时 Decoder 的输入是整个目标序列(右移一位),推理时是逐步生成的。这两者的实现方式完全不同,千万别搞混。我见过有人把训练代码直接拿来推理,结果内存爆炸。

2.5 一个简单的代码骨架

为了让你更直观地理解,我写个伪代码级别的骨架。别纠结细节,看流程就好:

class Transformer(nn.Module):
    def __init__(self):
        self.encoder = Encoder(num_layers=6)
        self.decoder = Decoder(num_layers=6)
        self.linear = nn.Linear(512, vocab_size)
    
    def forward(self, src, tgt):
        # src: 源语言句子 (batch, src_len)
        # tgt: 目标语言句子 (batch, tgt_len)
        
        memory = self.encoder(src)  # 编码器输出
        output = self.decoder(tgt, memory)  # 解码器输出
        logits = self.linear(output)  # 映射到词表大小
        return logits

你看,核心就三行:编码、解码、映射。但每一行背后都有复杂的注意力机制在支撑。

2.6 我的一点经验总结

最后,分享几个我踩过的坑:

  • 输入输出长度不一致很正常:Encoder 输入 10 个词,Decoder 可能输出 15 个词。别慌,这是正常的。
  • Padding 要小心:batch 内句子长度不同,需要 padding。但 padding 的位置不能参与注意力计算,记得用 mask。
  • 推理速度慢是通病:自回归生成一次只出一个词,长句子会慢。我后来用 KV Cache 优化,速度提升了好几倍。

好了,这一章就到这里。Encoder-Decoder 结构是整个 Transformer 的骨架,后面的章节会往上面加肌肉和血管。你先把这张图刻在脑子里,后面学起来会轻松很多。