前言:快递员与翻译官的故事

为什么所有大模型都逃不开“先理解,再生成”?一文讲透 Seq2Seq 架构的原理、代码与演进

想象你要寄一个包裹从北京到纽约。
你不能直接把包裹扔进太平洋——你需要:

  1. 打包(编码):把物品装箱、贴标签、生成运单号;
  2. 运输+中转(隐藏状态传递);
  3. 拆包(解码):对方根据运单号取出物品、还原内容。

编码器-解码器(Encoder-Decoder)模型,干的就是这件事——只不过它“寄”的不是包裹,而是语义信息

比如:

  • 中文 → 英文(机器翻译)
  • 语音波形 → 文字(语音识别)
  • 图像 → 描述句子(图像 captioning)
  • 用户问题 → AI 回答(对话系统)

💡 一句话定义
编码器-解码器是一种将输入序列映射为输出序列的神经网络架构,由两部分组成:

  • 编码器:读取输入,压缩成一个“语义向量”(context vector)
  • 解码器:基于该向量,逐步生成输出序列

1. 经典 RNN 编码器-解码器(Seq2Seq)

1.1 架构图

在这里插入图片描述


[Input: 我 爱 中国] 
      ↓
[Encoder RNN] → h_t (固定长度向量)
      ↓
[Decoder RNN] → I love China.

1.2 核心思想(大白话)

编码器像一个“速记员”,听完你说完一整句话后,记下一个摘要笔记(h_T)。
解码器像一个“翻译官”,拿着这个笔记,一句一句往外翻。


1.3 数学表达(专业性)

设输入序列为 X = (x1, x2, …, xT) ,输出为 Y = (y1, y2, …, yT’)

  • 编码器(通常是 LSTM/GRU):
    [
h_t = \text{RNN}_{\text{enc}}(x_t, h_{t-1})
]

    最终得到上下文向量 ( c = h_T )

  • 解码器
    [
s_t = \text{RNN}_{\text{dec}}(y_{t-1}, s_{t-1}, c)
]
[
P(y_t | y_{<t}, X) = \text{softmax}(W s_t + b)
]

⚠️ 致命缺陷
所有输入信息被压缩到一个固定长度的向量 c 中。
当句子很长时(比如一篇论文),这个“小本本”根本记不下!
→ 这就是 信息瓶颈问题


2. 注意力机制:给解码器一个“放大镜”

此处可参考我之前的文章:【人工智能】【深度学习】 ⑤ 注意力机制:从原理到代码实现,看懂模型如何“聚焦”关键信息


2.1 改进思路(大白话):

不再让解码器只看“摘要笔记”,而是允许它随时回头查阅原文
就像你在写作文时,可以不断翻看参考资料。


2.2 架构图

在这里插入图片描述

Encoder: [我]→h_1, [爱]→h_2, [中国]→h_3
                     ↗
Decoder: 生成 "I" 时,重点关注 h_1(“我”)
         生成 "love" 时,重点关注 h_2(“爱”)

2.3 核心公式

  1. 计算注意力权重(对每个解码步 t):
    在这里插入图片描述

  2. 加权求和得到上下文向量
    在这里插入图片描述

  3. 解码器输入变为在这里插入图片描述

效果:长句翻译质量大幅提升!Google Neural Machine Translation (GNMT) 就靠它称霸一时。


2.4 核心公式深度解析:注意力机制如何“动态聚焦”?

在经典编码器-解码器模型中,解码器只能看到一个固定长度的语义向量(比如把整句话压缩成一句话摘要)。
但人类翻译时不会这样——我们会边翻边回头看原文
注意力机制就是给 AI 装上“回头查阅”的能力

下面我们一步步拆解它的数学原理。


2.4.1 第一步:解码器当前状态 vs 编码器所有位置 → 打分!

💡 大白话:

解码器正在生成第 t 个词(比如英文的 “love”),它想知道:
“中文原文里,哪个词最相关?是‘我’?‘爱’?还是‘中国’?”
于是它对每个中文词打一个“相关分”。

📐 数学表达(加性注意力,Bahdanau et al., 2015):

在这里插入图片描述

🔎 符号解释(逐个击破):
符号 含义 生活类比
在这里插入图片描述 解码器在生成第 t 个词之前的隐藏状态(记忆) 翻译官当前脑子里想的:“我要表达‘喜欢’这个意思”
在这里插入图片描述 编码器对输入第 i 个词(如“爱”)的表示 原文笔记里关于“爱”这个词的详细记录
在这里插入图片描述 把两个向量拼接起来 把“我想表达的意思”和“原文某个词的记录”放在一起对比
在这里插入图片描述 可学习的权重矩阵(把拼接向量映射到中间空间) 一个“打分规则模板”,AI 自己学出来的
在这里插入图片描述 激活函数,把数值压缩到 [-1, 1] 防止分数爆炸,像给打分加个“冷静滤镜”
在这里插入图片描述 另一个可学习向量(把中间表示压成一个标量) 最终的“打分笔”,输出一个具体分数

为什么叫“加性”注意力?
因为它通过加法+非线性变换计算相似度(区别于 Transformer 的“点积”)。
优势:即使 s 和 h 维度不同,也能打分!


2.4.2 第二步:把所有分数变成“关注度比例” → Softmax 归一化

💡 大白话:

现在有三个分数:

  • “我”:0.8
  • “爱”:3.2
  • “中国”:0.5
    但这些数字不好直接用。我们需要知道:每个词占总注意力的百分比
    就像投票:不是看绝对票数,而是看占比。
📐 数学表达:

在这里插入图片描述

🔎 关键点解析:
  • 指数函数 exp(·):把负分变小、正分变大,放大差异(“强者更强”)
  • 分母求和:确保所有 α_{t,i} 加起来等于 1(概率分布)
  • 结果 α_{t,i} ∈ (0,1):表示“生成第 t 个词时,应分配给输入第 i 个词的注意力比例”

举个栗子
如果 e = [0.8, 3.2, 0.5],则
exp(e) ≈ [2.23, 24.53, 1.65]
总和 ≈ 28.41
所以 α ≈ [0.078, 0.863, 0.058]
86.3% 的注意力给了“爱”,完美!


2.4.3 第三步:用注意力权重“加权平均”编码器信息 → 上下文向量

💡 大白话:

现在我知道该重点看“爱”了,但不能只看“爱”一个词(万一上下文重要呢?)。
所以我按比例混合所有词的信息,得到一个“定制化上下文包”。

📐 数学表达:

在这里插入图片描述

🔎 直观理解:
  • 这是一个加权求和(weighted sum)
  • 权重 α_{t,i} 越大,对应的 h_i 对最终结果影响越大
  • 结果 c_t 是一个动态上下文向量,专为生成第 t 个词而生

对比传统 Seq2Seq
旧方法:c = h_T(固定,全句压缩)
新方法:c_t(动态,每步不同)→ 信息无损,灵活聚焦


2.4.4 第四步:把上下文向量喂给解码器 → 生成更准的词

💡 大白话:

翻译官现在手里有两个东西:

  1. 自己的记忆(s_{t-1})
  2. 刚查到的重点资料包(c_t)
    他把这两样东西合起来,决定下一个词说什么。
📐 解码器输入更新:

在这里插入图片描述

其中 y_{t-1} 是上一个已生成的词(或其嵌入)。

这样,解码器每一步都既记得历史,又看得原文,翻译自然更准!


🔄 完整流程图(文字版)

[输入序列] → Encoder → [h₁, h₂, ..., h_T]
                              ↓
[解码步 t] → s_{t-1} + {h_i} → 计算 e_{t,i} → Softmax → α_{t,i} → 加权求和 → c_t
                              ↓
                  [y_{t-1}; c_t] → Decoder → y_t

📌 为什么这个设计如此重要?

问题 传统 Seq2Seq 带注意力的 Seq2Seq
长句信息丢失 ❌ 压缩成固定向量 ✅ 每步动态查阅原文
可解释性 ❌ 黑箱 ✅ 可视化 α_{t,i} 看对齐
性能上限 低(BLEU 分不高) 高(Google 翻译 2016 年靠它大幅提升)

💬 终极比喻
传统模型像闭卷考试——考前把整本书背成一句话;
注意力模型像开卷考试——答题时可以随时翻书,只看关键页。


📘 公式出处(权威来源)

  • 原始论文:Bahdanau, D., Cho, K., & Bengio, Y. (2015). Neural Machine Translation by Jointly Learning to Align and Translate. ICLR.
    👉 https://arxiv.org/abs/1409.0473
  • 命名由来:“Attention” 一词首次在此文中用于描述这种动态聚焦机制。

3. PyTorch 代码示例(可运行)

# ==============================
# 1. 定义带注意力的编码器-解码器
# ==============================
# =============================================================
# 文件说明:基于注意力机制的编码器-解码器模型(Encoder-Decoder with Attention)
# 用途:适用于机器翻译、文本摘要等序列到序列任务
# 特点:使用双向LSTM编码器 + 单向LSTM解码器 + 加性注意力(Bahdanau-style)
# =============================================================

import torch
import torch.nn as nn
import torch.nn.functional as F


# ==============================
# 1. 编码器(Encoder)模块
# 功能:将输入序列(如中文句子)编码为一系列隐藏状态和最终上下文表示
# ==============================
class Encoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_dim):
        """
        初始化编码器
        :param vocab_size: 词表大小(例如 10000)
        :param embed_dim: 词嵌入维度(例如 256)
        :param hidden_dim: RNN 隐藏层维度(注意:双向后实际输出为 hidden_dim * 2)
        """
        super(Encoder, self).__init__()
        # 词嵌入层:将单词索引转换为稠密向量
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        # 双向LSTM:从前向后和从后向前同时读取序列,捕获更丰富的上下文
        # batch_first=True 表示输入形状为 (batch, seq_len, features)
        self.rnn = nn.LSTM(embed_dim, hidden_dim, batch_first=True, bidirectional=True)

    def forward(self, x):
        """
        前向传播
        :param x: 输入张量,形状 [batch_size, src_seq_len],元素为词索引
        :return:
            outputs: 所有时间步的隐藏状态,形状 [batch_size, src_seq_len, hidden_dim * 2]
            hidden: 最后一个时间步的正向+反向隐藏状态拼接,形状 [batch_size, hidden_dim * 2]
        """
        # 将输入词索引转换为词向量(嵌入)
        # 输入 x: [batch, src_len] → 输出 embedded: [batch, src_len, embed_dim]
        embedded = self.embedding(x)

        # 通过双向LSTM处理嵌入序列
        # outputs: 每个时间步的输出(拼接正向+反向),形状 [batch, src_len, hidden_dim*2]
        # (hidden, cell): 最后时刻的隐藏状态和细胞状态,各为 [2, batch, hidden_dim]
        outputs, (hidden, cell) = self.rnn(embedded)

        # 将正向(forward)和反向(backward)的最后一个隐藏状态拼接
        # hidden[0] 是正向LSTM的最后隐藏状态,hidden[1] 是反向LSTM的最后隐藏状态
        # 拼接后:[batch, hidden_dim * 2]
        hidden = torch.cat((hidden[0], hidden[1]), dim=1)

        # 返回所有时间步的输出(用于注意力)和最终隐藏状态(可选初始解码状态)
        return outputs, hidden


# ==============================
# 2. 注意力机制(Attention)模块
# 功能:计算解码器当前步应“关注”输入序列的哪些位置
# 使用加性注意力(Bahdanau Attention)
# ==============================
class Attention(nn.Module):
    def __init__(self, enc_hidden_dim, dec_hidden_dim):
        """
        初始化注意力模块
        :param enc_hidden_dim: 编码器单向隐藏维度(实际输入是 enc_hidden_dim * 2)
        :param dec_hidden_dim: 解码器隐藏维度
        """
        super(Attention, self).__init__()
        # 将拼接后的 [decoder_hidden, encoder_output] 映射到中间维度
        # 输入维度 = dec_hidden_dim + enc_hidden_dim*2
        self.W = nn.Linear(enc_hidden_dim * 2 + dec_hidden_dim, dec_hidden_dim)
        # 将中间表示压缩为标量(能量分数)
        self.v = nn.Linear(dec_hidden_dim, 1, bias=False)

    def forward(self, dec_hidden, enc_outputs):
        """
        计算注意力权重
        :param dec_hidden: 解码器当前隐藏状态,形状 [batch_size, dec_hidden_dim]
        :param enc_outputs: 编码器所有时间步输出,形状 [batch_size, src_len, enc_hidden_dim*2]
        :return: attention_weights,形状 [batch_size, src_len],表示每个输入词的重要性
        """
        # 获取源序列长度(用于后续重复操作)
        src_len = enc_outputs.shape[1]

        # 将解码器隐藏状态扩展为与源序列长度一致的维度
        # 原始 dec_hidden: [batch, dec_hidden_dim]
        # 扩展后: [batch, src_len, dec_hidden_dim]
        dec_hidden_expanded = dec_hidden.unsqueeze(1).repeat(1, src_len, 1)

        # 拼接解码器状态和每个编码器输出
        # [batch, src_len, dec_hidden_dim + enc_hidden_dim*2]
        concatenated = torch.cat((dec_hidden_expanded, enc_outputs), dim=2)

        # 通过线性层 + tanh 激活得到能量向量
        # energy: [batch, src_len, dec_hidden_dim]
        energy = torch.tanh(self.W(concatenated))

        # 将能量向量压缩为标量(每个位置一个分数)
        # attention_scores: [batch, src_len, 1] → squeeze 后 [batch, src_len]
        attention_scores = self.v(energy).squeeze(2)

        # 对注意力分数做 Softmax,得到归一化的权重(和为1)
        # attention_weights: [batch, src_len]
        attention_weights = F.softmax(attention_scores, dim=1)

        return attention_weights


# ==============================
# 3. 解码器(Decoder)模块
# 功能:基于编码器输出和注意力权重,逐步生成目标序列
# ==============================
class Decoder(nn.Module):
    def __init__(self, vocab_size, embed_dim, enc_hidden_dim, dec_hidden_dim):
        """
        初始化解码器
        :param vocab_size: 目标词表大小
        :param embed_dim: 词嵌入维度
        :param enc_hidden_dim: 编码器单向隐藏维度(注意:实际接收的是 *2)
        :param dec_hidden_dim: 解码器隐藏维度
        """
        super(Decoder, self).__init__()
        # 目标语言的词嵌入层
        self.embedding = nn.Embedding(vocab_size, embed_dim)
        # 注意力模块
        self.attention = Attention(enc_hidden_dim, dec_hidden_dim)
        # LSTM解码器:输入 = 词嵌入 + 上下文向量(来自注意力)
        # 因此输入维度 = embed_dim + enc_hidden_dim*2
        self.rnn = nn.LSTM(embed_dim + enc_hidden_dim * 2, dec_hidden_dim, batch_first=True)
        # 输出层:将解码器输出映射回词表
        # 输入 = 解码器隐藏状态 + 上下文向量
        self.fc = nn.Linear(dec_hidden_dim + enc_hidden_dim * 2, vocab_size)

    def forward(self, input_token, dec_hidden, enc_outputs):
        """
        解码器单步前向传播(一次生成一个词)
        :param input_token: 当前输入词索引,形状 [batch_size, 1]
        :param dec_hidden: 解码器上一时刻隐藏状态,形状 [batch_size, dec_hidden_dim]
        :param enc_outputs: 编码器所有输出,形状 [batch_size, src_len, enc_hidden_dim*2]
        :return:
            prediction: 当前步的词表概率分布,形状 [batch_size, vocab_size]
            new_hidden: 当前步的隐藏状态,形状 [batch_size, dec_hidden_dim]
            attention_weights: 当前步的注意力权重,形状 [batch_size, src_len]
        """
        # 将当前输入词转换为嵌入向量
        # input_token: [batch, 1] → embedded: [batch, 1, embed_dim]
        embedded = self.embedding(input_token)

        # 计算当前步对编码器各位置的注意力权重
        # weights: [batch, src_len]
        attention_weights = self.attention(dec_hidden, enc_outputs)

        # 利用注意力权重对编码器输出进行加权求和,得到上下文向量
        # attention_weights: [batch, src_len] → unsqueeze: [batch, 1, src_len]
        # enc_outputs: [batch, src_len, enc_hidden_dim*2]
        # 矩阵乘法后 context: [batch, 1, enc_hidden_dim*2]
        context = torch.bmm(attention_weights.unsqueeze(1), enc_outputs)

        # 将词嵌入和上下文向量拼接,作为LSTM的输入
        # rnn_input: [batch, 1, embed_dim + enc_hidden_dim*2]
        rnn_input = torch.cat((embedded, context), dim=2)

        # 通过LSTM处理拼接后的输入
        # output: [batch, 1, dec_hidden_dim]
        # (hidden, cell): 隐藏状态 [1, batch, dec_hidden_dim]
        output, (hidden, cell) = self.rnn(rnn_input)

        # 为了后续使用,去掉LSTM输出的第一个维度(seq_len=1)
        output = output.squeeze(1)      # [batch, dec_hidden_dim]
        context = context.squeeze(1)    # [batch, enc_hidden_dim*2]

        # 将LSTM输出和上下文向量再次拼接,送入全连接层预测下一个词
        # combined: [batch, dec_hidden_dim + enc_hidden_dim*2]
        combined = torch.cat((output, context), dim=1)

        # 全连接层输出未归一化的 logits(词表大小)
        prediction = self.fc(combined)  # [batch, vocab_size]

        # 返回预测结果、新的隐藏状态(用于下一步)、注意力权重(可用于可视化)
        return prediction, hidden.squeeze(0), attention_weights


# ==============================
# 4. 完整模型封装(可选)
# ==============================
class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder):
        super(Seq2Seq, self).__init__()
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, src, trg, teacher_forcing_ratio=0.5):
        """
        训练时的完整前向过程(支持教师强制)
        :param src: 源序列 [batch, src_len]
        :param trg: 目标序列 [batch, trg_len]
        :param teacher_forcing_ratio: 使用真实标签作为输入的概率(0~1)
        :return: 所有时间步的预测 logits,形状 [trg_len, batch, vocab_size]
        """
        batch_size = trg.shape[0]
        trg_len = trg.shape[1]
        vocab_size = self.decoder.fc.out_features

        # 初始化输出张量
        outputs = torch.zeros(trg_len, batch_size, vocab_size)

        # 编码器处理源序列
        enc_outputs, hidden = self.encoder(src)

        # 第一个解码输入是 <sos>(假设 trg[:, 0] 是起始符)
        input_token = trg[:, 0].unsqueeze(1)  # [batch, 1]

        # 逐时间步解码
        for t in range(1, trg_len):
            # 解码一步
            output, hidden, _ = self.decoder(input_token, hidden, enc_outputs)
            outputs[t] = output

            # 决定是否使用教师强制
            use_teacher_forcing = torch.rand(1).item() < teacher_forcing_ratio
            if use_teacher_forcing:
                # 使用真实目标词作为下一步输入
                input_token = trg[:, t].unsqueeze(1)
            else:
                # 使用模型预测的最高概率词作为下一步输入
                top1 = output.argmax(1).unsqueeze(1)  # [batch, 1]
                input_token = top1

        return outputs

使用说明
此代码可直接用于训练简单的机器翻译模型(需配合数据预处理和损失函数)。
Seq2Seq 类中的 teacher_forcing_ratio 是训练技巧,防止误差累积。

📌 提示:此代码为简化版,实际训练需配合教师强制(teacher forcing)和损失函数。
推理时(无 trg),需手动循环调用 decoder,每次传入上一步预测的 token。


4. 从 Seq2Seq 到 Transformer:架构的进化

虽然注意力机制解决了长距离依赖,但 RNN 本身无法并行计算,训练慢如蜗牛。

于是 2017 年,Google 提出 Transformer ——
彻底抛弃 RNN,只用注意力机制,实现:

  • 完全并行化
  • 更强的长程建模能力
  • 成为 BERT、GPT、LLaMA 等大模型的基石

🔜 预告:下一篇文章《【人工智能】【深度学习】⑩ Transformer 再解析:为什么它是大模型的“心脏”?》将深入剖析其如何用“多头自注意力 + 前馈网络”重构整个 AI 世界。


5. 行业应用:不止于翻译

领域 应用 编码器-解码器变体
智能客服 用户问 → AI答 Transformer 解码器(如 GPT)
语音助手 语音 → 文字 → 意图 → 回复 ASR (编码) + NLU + NLG (解码)
代码生成 自然语言 → Python 代码 CodeT5、StarCoder
AI 外呼 客户语音 → 意图识别 → 话术生成 我之前写的 AI外呼系统

6. 延伸阅读 & 经典书籍

  • 《Deep Learning》(花书):第 10 章 Sequence Modeling 详细讲解 RNN 与 Seq2Seq
  • 《Speech and Language Processing》(Jurafsky & Martin):第 10 章 Neural Machine Translation
  • 《动手学深度学习》:第 9 章“现代循环神经网络”含注意力与 Seq2Seq 实战(PyTorch/MXNet)
  • ** Seq2Seq 模型详解 **

🔗 源码推荐学习

  • HuggingFace Transformers:EncoderDecoderModel
  • OpenNMT-py:工业级 Seq2Seq 框架
  • TensorFlow NMT Tutorial

🔗 7. 引导回你的系列

如果你觉得本文有帮助,欢迎继续阅读我的 AI 系列:

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐