资讯动态

编码器-解码器架构实战:从RNN到Transformer的序列建模全解析

发布时间:2026/10/2 12:46:44 来源:尧图企业网站定制
编码器-解码器架构这几个字在深度学习项目里出现的频率实在太高了。我最初接触它是在机器翻译任务上后来做文本摘要、对话生成、语音识别特征序列建模绕来绕去都绕不开这个框架。可以说只要你做的是“输入一个序列、输出另一个序列”的任务编码器-解码器就是绕不过去的主干道。这篇文章我打算把自己的实际经验掏出来把架构的设计动机、组件选型、训练中的坑一次性讲透适合准备入手深度学习序列建模项目的同学也适合已经在做NLP或者多模态模型、想回头系统梳理架构原理的工程师。我会尽量用项目里真实遇到的问题来说明而不是单纯堆理论。1. 编码器-解码器架构的整体设计与应用场景1.1 它到底在解决什么问题在深度学习火起来之前很多序列建模任务用的是传统的统计机器学习方法比如HMM或者CRF。这些方法能处理一些简单的序列标注但一旦涉及“源序列和目标序列长度都不固定”的问题比如机器翻译里中文句子有8个字英文翻译出来可能是5个词也可能是11个词传统模型的建模能力就很吃力了。编码器-解码器架构最核心的价值就是打破了“输入和输出必须等长”的限制。编码器先把不定长的源序列读进来压缩成一个或者一组向量表示这个表示里包含了源序列的关键信息然后解码器再根据这个表示一步一步生成目标序列。不管是文本、语音特征还是图像特征只要你能把原始数据表示成序列形式就能套进这个框架里。我做过一个实际的新闻标题生成项目输入是一篇600字左右的长文输出是一句话标题长度大概20到40个字。输入和输出的长度比例严重失衡而且目标句子里的词并不是从原文里简单复制出来的。这种任务用传统的分类模型完全没法做但用编码器-解码器模型先把全文编码成上下文向量再由解码器带着注意力机制去逐词生成标题效果就很直观。1.2 为什么编码和解码要拆成两个模块很多人第一次看到这个架构会问为什么不直接把整个模型做成一个大的神经网络输入一段文本直接输出另一段文本非要分成编码器和解码器两个模块这里的关键在于“表示”和“生成”这两种任务的性质完全不一样。编码器要做的是理解输入把高维、冗余、结构复杂的原始输入压缩成紧凑的语义表示解码器要做的是生成输出在给定语义表示的前提下一个一个地预测目标序列的每个位置。如果把这两件事混在一个模块里模型既要理解输入又要生成输出参数会非常难优化而且对于不同长度的输入输出计算效率和效果都会打折扣。在实际工程项目里拆成两个模块还有一个很大的好处可以分别替换和升级。我维护过的一个系统早期用的是LSTM编码器和LSTM解码器后来效果卡住了我就只把编码器换成了Transformer解码器先不动结果BLEU值直接涨了1.5个点左右。这种“松耦合”的设计对调优和迭代非常友好。当然如果你做的是语音识别这类任务编码器处理的是声学特征解码器处理的是文本两个模块对应的数据形态本身就不同拆开的必要性就更加明显了。2. 编码器-解码器的核心组件与关键选择2.1 输入表示Embedding、位置编码和Mask不要把输入表示想得太简单我在初期项目里吃过不少亏。文本进入编码器之前首先要做的是Tokenization把句子切成词或者子词然后查表得到一个向量这个过程叫Embedding。Embedding可以是随机初始化的也可以是预训练好的比如用Word2Vec、GloVe或者BERT的embedding层。我的经验是如果训练数据量不大用预训练好的embedding做初始化效果会稳很多如果数据量很大随机初始化加充分的训练也能达到很好的效果。除了embedding序列位置信息也很关键。RNN类模型因为是逐步读入序列位置信息天然隐含在时间步里但是Transformer这类模型是并行计算的如果不加位置编码模型根本分不清“我打你”和“你打我”的区别。常见的位置编码方式有绝对位置编码和相对位置编码Transformer原始论文用的是正弦余弦函数后来很多项目直接用可学习的位置编码效果也差不多。Mask是另一个容易忽视的点。padding mask用来告诉模型哪些位置是填充的无意义token计算attention的时候要屏蔽掉causal mask则用在解码器里确保生成当前词的时候看不到未来的词。我一开始训练对话模型时忘了在解码器加causal mask结果模型训练时loss降得很漂亮一到推理就完全乱套后来排查了两天才发现是这个问题。这个坑非常经典值得每个做序列生成的人都记住。2.2 编码器选型RNN、LSTM、Transformer怎么选编码器到底选哪种网络结构我的建议是先看任务类型和数据量。如果是短序列、小数据量、或者对延迟要求极高的场景比如实时语音指令识别LSTM编码器依然是个不错的选择。LSTM通过门控机制缓解了梯度消失对中短序列的记忆能力很强而且推理时是逐步计算不需要一次性把整条序列加载进显存。但LSTM的缺点是训练无法并行序列一长训练时间就非常夸张。如果是长序列、大数据量、并且你有足够的GPU资源Transformer编码器是更主流的选择。Transformer通过自注意力机制让每个位置都能直接看到序列里的所有位置长距离依赖建模能力远强于RNN。我之前做长文档摘要时用LSTM编码器处理超过500个token的输入已经非常吃力换成Transformer之后输入长度扩展到2000个token都很正常。还有一个折中方案就是BERT这类预训练模型直接充当编码器。在实际项目里我经常直接用预训练好的BERT做编码器只微调后面接的解码器这种迁移学习的策略在小数据集上特别管用能省下大量训练时间和标注成本。2.3 注意力机制是解码器的点睛之笔最早的编码器-解码器架构有个明显的毛病解码器生成每个词时看的都是同一个上下文向量这就像让你闭着眼睛翻译一整段话每翻译一个词都只能靠记忆里的那点信息。长句一多信息早就丢了效果自然差。Bahdanau等人提出注意力机制的核心思想就是让解码器在生成每个词的时候可以动态地去查询编码器输出的不同部分把注意力集中到跟当前生成词最相关的输入片段上。用大白话解释注意力机制就是给解码器配了一个“手电筒”生成第几个词就往输入序列的哪个区域打光。比如翻译“I love China”到中文时生成“爱”这个字的时候模型应该把注意力主要放在“love”上而不是“I”或者“China”。我强烈建议做序列生成的初学者至少要手动推一遍attention的计算公式。查询向量来自解码器隐状态键和值来自编码器输出通过点积或者加性方式计算相似度再经过softmax变成权重最后加权求和得到上下文向量。理解了这一步后面看Transformer的多头注意力就会轻松很多。2.4 训练阶段和推理阶段的差异这个问题是很多新手最容易忽略的。训练的时候解码器通常采用教师强制teacher forcing策略就是不管模型上一时刻预测得对不对都直接把真实的目标词作为下一时刻的输入。这样做的好处是训练快、收敛稳模型能快速学会依赖真实历史信息进行预测。但推理的时候不可能拿到真实的目标序列模型只能用自己上一时刻预测出来的词作为下一时刻的输入。这就是所谓的“暴露偏差”问题训练和推理时的输入分布不一致导致误差会随着生成长度不断累积。我的实践经验是先用教师强制训练到模型基本收敛然后可以尝试计划采样scheduled sampling也就是在训练过程中以一定概率让模型用自己的预测结果作为下一时刻输入概率从0慢慢增大到某个阈值。这个方法在我做对话生成时提升了大概8%的BLEU值代价是训练时间变长了一些。如果你是做短序列生成暴露偏差的影响可能不大但长序列任务一定要重视。3. 从零实现一个最小可用的编码器-解码器项目3.1 环境与数据准备我不喜欢一上来就上大规模预训练模型那样很多东西都被框架封装好了反而不容易理解原理。这里我用PyTorch实现一个最小可用的Seq2Seq模型任务选择最简单的中文数字翻译成阿拉伯数字比如“一百二十三”要输出“123”。这个任务数据量小、规则明确、方便调试非常适合用来理解编码器-解码器的运行流程。环境方面PyTorch 2.x以上版本都可以CPU或者GPU都能跑因为数据集很小CPU跑几百个epoch也没问题。数据准备阶段需要把中文数字序列映射成索引目标数字序列也映射成索引然后在每个序列开头加上起始符BOS结尾加上结束符EOS。这个BOS和EOS非常重要解码器就是靠BOS触发开始生成靠EOS判断何时停止。我生成数据时用了随机组合的方式覆盖了从0到999的各种数字表达训练集和验证集分开。如果只做源码级别的demo直接在内存里生成数据就行不需要落盘。这里要提醒一句哪怕是最简单的demo也别省掉验证集不然你根本不知道模型是记住了训练数据还是真正学会了规律。3.2 模型代码与参数说明下面是核心的模型定义代码我把注释写得比较详细方便直接对照理解。import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.gru nn.GRU(embed_size, hidden_size, batch_firstTrue) def forward(self, x): # x: [batch, src_len] embedded self.embedding(x) # [batch, src_len, embed_size] output, hidden self.gru(embedded) # output: [batch, src_len, hidden_size] return output, hidden class Decoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.gru nn.GRU(embed_size hidden_size, hidden_size, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, encoder_output, hidden): # x: [batch, 1] 当前时刻输入词 embedded self.embedding(x) # [batch, 1, embed_size] # 注意力机制计算当前隐状态与编码器输出的相似度 attn_scores torch.bmm(encoder_output, hidden[-1].unsqueeze(2)) # [batch, src_len, 1] squeeze到[batch, src_len] attn_weights F.softmax(attn_scores.squeeze(2), dim1) # 用注意力权重对编码器输出做加权求和 context torch.bmm(attn_weights.unsqueeze(1), encoder_output) # [batch, 1, hidden_size] rnn_input torch.cat([embedded, context], dim2) output, hidden self.gru(rnn_input, hidden) logits self.fc(output.squeeze(1)) return logits, hidden class Seq2Seq(nn.Module): def __init__(self, encoder, decoder, device): super().__init__() self.encoder encoder self.decoder decoder self.device device def forward(self, src, trg, teacher_forcing_ratio0.5): # src: [batch, src_len] # trg: [batch, trg_len] batch_size src.size(0) trg_len trg.size(1) trg_vocab_size self.decoder.fc.out_features outputs torch.zeros(batch_size, trg_len - 1, trg_vocab_size).to(self.device) encoder_output, hidden self.encoder(src) input_token trg[:, 0] # BOS for t in range(1, trg_len): logits, hidden self.decoder(input_token.unsqueeze(1), encoder_output, hidden) outputs[:, t - 1, :] logits top1 logits.argmax(1) teacher_force torch.rand(1).item() teacher_forcing_ratio input_token trg[:, t] if teacher_force else top1 return outputs这里的核心参数有三个embed_size控制词向量的维度hidden_size控制隐状态维度teacher_forcing_ratio控制训练时使用真实目标词作为输入的概率。我建议调试时hidden_size先用128embed_size用64跑通之后再逐步增大。注意解码器的GRU输入拼了context向量这是实现注意力机制的关键可以让解码器在每一步都感知到源序列中与当前生成词最相关的信息。3.3 训练、评估与推理的完整流程训练流程和普通分类模型类似输入编码器得到输出和隐状态然后循环让解码器生成每一步的prob分布用交叉熵损失和真实目标词计算loss。关键点在于mask掉padding部分的loss不然后面的填充token会干扰模型学习。我的做法是把target中PAD_IDX对应的位置设置为ignore_index这样PyTorch的CrossEntropyLoss会自动忽略这些位置的loss。评估阶段我建议用Beam Search而不是贪心搜索。贪心搜索每一步取概率最大的词容易陷入局部最优生成的句子常常有重复词。Beam Search会每一步保留top-k个候选序列最终选择整体概率最高的序列。我在demo里只用beam size为3翻译效果就明显比贪心好。要注意的是Beam Search会显著增加计算量所以线上推理时需要结合业务需求调beam size一般是4到8之间比较常用。推理时最重要的一个细节是解码停止条件。模型需要持续生成直到输出EOS token或者达到最大生成长度两者必须同时做限制否则可能陷入无限生成。我见过不少线上事故就是只判断EOS导致模型在长文本生成时迟迟不输出EOS最后返回超时。一定要设置max_len给模型一个硬性天花板。4. 实际项目中常见的坑和排查技巧4.1 长句表现差的常见原因编码器-解码器模型在长句上效果差原因通常不是单一的。首先是信息瓶颈问题如果你的架构采用的是把所有源序列信息压缩成一个固定维度的上下文向量那么句子越长压缩损失越大。解决方案就是上注意力机制或者使用多层编码器来增强表达能力。其次是训练数据里长句样本太少模型根本没有见过足够多的长句模式。我的经验是数据增强时要有意识地控制句长分布别让长句只占5%以下。还有一个很容易忽视的细节是长度归一化。使用Beam Search时序列越长累积概率越小模型天然偏好短句。实际项目里可以在计算序列得分时除以长度的若干次方比如常见的length penalty。我之前做摘要生成时不加length penalty模型动不动就输出两三个词的精简摘要加上这个惩罚项之后输出长度才回到正常范围。4.2 梯度问题和训练不稳定的处理RNN系编码器-解码器最常见的训练问题是梯度消失和梯度爆炸。梯度消失会导致模型很难学到长距离依赖梯度爆炸则会导致loss突然变成NaN。我的处理方案很朴素先加梯度裁剪把梯度范数限制在5以内如果还有问题再考虑换用LSTM或者GRU这些门控结构对梯度流动更友好。换成Transformer之后训练不稳定的另一个高频原因是学习率设置不合理。Transformer对学习率非常敏感官方推荐用warmup策略也就是先让学习率从0线性增长到一个峰值再按某种衰减曲线减小。我习惯用峰值学习率2e-4配合warmup steps为4000在大多数中规模任务上都能稳定收敛。如果你用的优化器是AdamWweight decay从0.01开始调比较稳。另外要注意数值稳定性。计算attention分数之后要做softmax如果分数数值很大softmax会进入饱和区梯度很容易消失。一个常规操作是把q和k的点积除以sqrt(d_k)也就是缩放点积注意力。这个细节我在Transformer项目里被坑过一次当时没做缩放模型训练到第3个epoch就开始震荡后来加上缩放问题立刻解决。4.3 训练与推理不一致导致的“雪崩”训练和推理不一致是序列生成模型最防不胜防的问题。除了前面说的教师强制和暴露偏差还有一个我踩过很多次的坑是embedding矩阵不统一。有时候你训练时用的词表是A推理时加载的模型词表却少了一个预训练embedding层导致推理结果全是乱码。所以模型保存和加载时一定要把vocab_size、pad_idx、bos_idx、eos_idx这些元信息一起保存而不是只保存state_dict。此外训练时的数据预处理流程和推理时也要严格一致。我记得有个项目里训练时文本做了繁简转换推理时忘了做结果模型效果直接从天上掉到地上。这种问题不像模型结构问题那么容易定位最好的办法是搭一条离线的pipeline测试把训练数据里的样本重新走一遍推理流程看是否复现训练时的输出。如果复现不了那就是预处理不一致。4.4 实用排查速查表我把常见的故障现象和排查方向整理成一张表项目里遇到问题可以对照着查能省不少时间。现象可能原因排查方向loss不下降学习率过大或过小、数据预处理错误检查输入输出索引映射打印一段训练数据观察loss为NaN梯度爆炸、attention分数过大加梯度裁剪检查是否做缩放点积训练正常但推理乱码预处理不一致、词表不一致用训练集样本走离线推理链路对比生成重复词beam search参数问题、训练不充分调大length penalty检查模型是否欠拟合生成内容过短模型偏向高概率短句使用length penalty检查EOS是否过早触发长句效果差注意力机制没生效、长句训练样本少可视化attention权重增加长句数据增强解码时爆显存序列太长、候选过多限制max_len减小beam size使用梯度检查点这七类问题几乎覆盖了我做过的大多数序列生成项目任何时候有奇怪的性能问题我都建议先对着表快速排查一遍再看网络结构。5. 架构的现代演进从Seq2Seq到Transformer5.1 Transformer彻底抛弃循环的编码器-解码器Vaswani等人在2017年提出的Transformer是对编码器-解码器架构的一次大重构。它把编码器里的RNN/LSTM全部换成了自注意力网络解码器里也换成了带mask的自注意力加交叉注意力。这样做的结果是训练时所有位置可以并行计算训练速度大幅提升同时模型对长距离依赖的建模能力也变强了。我自己用Transformer做翻译项目时最直观的感受是“训练速度上去了显存占用也上去了”。并行计算意味着需要在显存里保存更多的中间结果所以大模型训练时经常要配合梯度累积、混合精度等技术。如果你是从LSTM迁移到Transformer显存不够是第一个要面对的现实问题。Transformer的另一个关键设计是位置编码因为自注意力本身看不出顺序必须额外注入位置信息。后续的改进版本里有用相对位置编码的也有用旋转位置编码的比如现在很多大模型用的RoPE。这些变体的目的都是让模型更好地感知token之间的相对位置关系。5.2 只留半边Encoder-only和Decoder-only随着BERT和GPT相继出现编码器-解码器这个经典框架开始衍生出“单边”变体。Encoder-only模型比如BERT只保留编码器部分适合做理解类任务比如文本分类、命名实体识别、句对匹配Decoder-only模型比如GPT系列只保留解码器部分适合做生成类任务比如对话、写作、代码生成。我个人的体会是不要被“编码器-解码器”这个名词框死。在实际项目里如果你做的是判别式任务直接用Encoder-only模型往往更简单高效如果你做的是开放式生成任务Decoder-only的自回归模型已经成了主流。但如果你做的是“输入理解目标生成”这种复合任务比如机器翻译、文本摘要、语音识别那完整的编码器-解码器结构依然是更自然的选择。BART和T5这类模型则保留了完整的编码器-解码器结构同时利用了预训练的优势。它们在文本生成任务上的表现通常比单边模型更均衡这也是为什么很多摘要、翻译项目的baseline可以直接用T5。我建议团队在做技术选型时先判断任务本质是理解、生成还是“理解生成”不要盲目追新也不要固守旧框架。5.3 怎么把这些思路用到非NLP任务编码器-解码器不只属于NLP。图像描述任务里编码器可以用CNN或者ViT处理图像解码器生成文本描述语音识别里编码器处理音频特征解码器生成文字视频理解里编码器处理帧序列解码器生成事件描述。甚至在一些时间序列预测项目里我见过用编码器处理历史窗口、解码器逐步预测未来值的做法。我在实际接触过的一个多模态检索项目里就把文本编码器和图像编码器分别产出各自的语义向量再通过一个解码器模块统一映射到共享语义空间。这种思路本质上还是“编码成表示解码成目标”的哲学只不过中间的“解码”不再是生成自然语言而是生成一个匹配空间里的向量。所以编码器-解码器并不是某个算法的名字而是一种信息处理范式先把复杂输入压缩成有结构的表示再从表示出发构建你需要的输出。理解了这个层面你在面对新任务时就能很快找到适合的落地方案。6. 最后分享一点我的实际体会做序列生成这几年我最大的感触是模型结构的好用程度很多时候取决于你对数据细节的把控而不只是选哪个架构。编码器-解码器这个东西原理说起来非常顺滑但真正训练起来十个坑里有八个都出在数据处理、mask、预处理一致性这些不起眼的地方。我建议新入坑的朋友不要急着上大规模预训练模型先拿小任务把整个pipeline跑通把每一步的输入输出都打印出来检查一遍这个过程积累的经验值会非常高。另外关于架构选型我个人的偏好是“够用就好”。如果LSTM加注意力能解决你的问题没必要非上大Transformer如果大模型的推理成本你能接受且效果确实更好那就果断升级。模型是服务于业务的千万不要为了炫技而堆复杂度。最后再分享一个我常用的调试技巧训练完一个小模型之后把encoder输出的attention权重可视化出来看看模型在生成每个词的时候到底在关注输入的哪些部分。如果权重视觉化结果完全跟你的直觉违逆那很可能是数据预处理错了而不是模型的问题。这个技巧帮我定位过至少三次data bug强烈推荐你尝试一下。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑