
大模型对话系统这两年火得一塌糊涂但很多人一上来就想微调ChatGLM或者LLaMA结果连最基本的序列到序列映射都没搞明白。其实对话系统的底层骨架就是Seq2Seq——编码器把一句话压成一个语义向量解码器再把这个向量展开成另一句话。这个结构不复杂但真正动手写的时候从数据构造、词表建立、模型维度对齐到训练时的teacher forcing每一步都有坑。我这次用PyTorch从零搭了一个简易的中文对话模型不依赖任何预训练权重纯手工实现编码器、解码器和注意力机制跑通之后对Transformer和LLaMA的理解反而更透彻了。下面把整个实现过程、关键决策理由和踩过的坑完整记录下来适合有Python基础、想搞懂对话模型底层原理的读者。1. 为什么选Seq2Seq作为对话系统的入门骨架1.1 对话任务的本质是序列映射对话系统看起来复杂但剥掉意图识别、槽位填充、知识检索这些外围模块之后最核心的部分就是一个序列到序列的映射问题。用户说今天天气怎么样系统回答今天晴天气温25度输入是一个变长序列输出也是一个变长序列而且输入和输出的长度往往不相等。这种任务用普通的全连接网络或者固定窗口的RNN都做不好因为全连接网络要求输入输出维度固定而对话的句子长度天然是变化的。Seq2Seq架构恰好解决了这个问题。它的编码器逐步读入输入序列每读一个词就更新一次隐藏状态读完之后隐藏状态就包含了整句话的语义信息。解码器则从这个隐藏状态出发一步一步生成输出序列每生成一个词就把这个词作为下一步的输入直到生成结束标记。整个过程不要求输入输出等长也不需要固定窗口天然适配对话场景。我选择从Seq2Seq入手而不是直接上Transformer理由很简单Transformer的核心是自注意力机制而自注意力本质上是一种更高效的编码方式如果连基础的RNN编码都没亲手写过直接看多头注意力的代码很容易变成调包侠。先把Seq2Seq跑通再去看Transformer的Encoder-Decoder结构会发现很多设计思路是一脉相承的。1.2 LSTM相比朴素RNN解决了什么问题朴素RNN的隐藏状态更新公式是h_t tanh(W_h * h_{t-1} W_x * x_t b)理论上可以捕捉任意长度的依赖关系但实际训练时梯度会沿着时间步不断相乘要么指数衰减到接近零要么指数爆炸。这就是经典的梯度消失和梯度爆炸问题。对话场景中用户前面说的关键信息可能在十几个词之后才需要被用到朴素RNN根本记不住。LSTM通过引入门控机制来解决这个问题。它有三个门遗忘门决定丢弃多少旧记忆输入门决定写入多少新信息输出门决定当前时刻暴露多少记忆。还有一个细胞状态专门用来做长距离的信息高速公路梯度可以沿着细胞状态几乎无衰减地传播。我用LSTM替换朴素RNN之后训练loss下降明显更稳定生成回复的连贯性也好了很多。具体到代码层面PyTorch的nn.LSTM已经封装好了这些门控逻辑我只需要设置input_size、hidden_size和num_layers就行。但要注意nn.LSTM默认的初始隐藏状态是全零如果num_layers大于1每一层都有自己的隐藏状态和细胞状态维度是(num_layers, batch_size, hidden_size)这个维度顺序在拼接和切片时特别容易搞错后面会详细说。1.3 注意力机制为什么是必需品只用编码器最后一个时间步的隐藏状态作为解码器的初始状态会有一个严重问题如果输入句子很长比如我想问一下明天下午三点从北京到上海的高铁还有没有票编码器读到最后的时候前面明天下午三点这些关键信息可能已经被覆盖得差不多了。这就是信息瓶颈问题。注意力机制的做法是解码器每生成一个词都回头看一眼编码器所有时间步的隐藏状态计算一个加权和作为当前步的上下文向量。这样无论句子多长解码器都能直接访问到编码器的每一个位置不需要把所有信息都压缩到一个固定维度的向量里。我实现的是Bahdanau注意力也叫加性注意力。计算方式是把解码器当前隐藏状态和编码器每个时间步的隐藏状态拼接后过一个线性层再经过tanh激活最后通过一个线性层映射成标量分数softmax归一化后得到注意力权重。这个计算过程在PyTorch里用广播机制可以很简洁地实现但要注意维度对齐否则会报形状不匹配的错误。2. 数据准备与词表构建中的关键决策2.1 对话语料的构造方式我没有用公开的大规模对话数据集而是自己构造了一个小规模的问答对大概两百多条覆盖天气、时间、问候、简单咨询等场景。每条数据是一个输入输出对比如输入你好输出你好有什么可以帮你的输入今天天气怎么样输出今天晴天气温二十五度。构造数据时有一个细节要注意输入和输出都要加上起始标记和结束标记。起始标记告诉解码器从哪里开始生成结束标记告诉解码器什么时候停止。我用的标记是sos和eos这两个标记也会进入词表参与训练。为什么不用大规模数据集因为我的目的是理解原理不是追求效果。小数据集训练快几分钟就能跑完一轮方便快速验证代码正确性。等模型跑通之后再换大数据集只需要改数据加载部分模型代码不用动。2.2 词表的建立与特殊标记的处理词表构建的流程是遍历所有输入和输出句子用jieba分词或者按字切分统计词频保留频率最高的N个词其余映射为unk。我选择按字切分而不是按词切分原因是中文分词会引入分词错误而按字切分虽然序列变长了但词表更小不会出现未登录词的问题。对于小规模对话数据按字切分的效果反而更稳定。词表里必须包含四个特殊标记pad用于填充到相同长度sos和eos用于标记序列起止unk用于处理未登录词。这四个标记的索引要固定下来我一般把pad设为0因为PyTorch的nn.Embedding默认padding_idxNone如果手动设为0填充位置的嵌入向量在训练中不会更新也不会影响梯度计算。词表大小我控制在500左右因为按字切分之后常用汉字也就三千多个但我的对话数据只覆盖了很小一部分实际出现的不同字符大概四百多个。词表太大会导致嵌入层参数过多小数据下容易过拟合词表太小则unk太多模型学不到东西。我的经验是词表大小设为实际不同字符数的1.2倍左右比较合适。2.3 批次填充与掩码的细节训练时一个batch里的句子长度不一样需要填充到相同长度。填充用pad标记填充位置在计算loss时要忽略掉否则模型会学会预测填充符影响正常生成。PyTorch的nn.CrossEntropyLoss有一个ignore_index参数把它设为pad的索引计算loss时就会自动忽略这些位置。但要注意解码器的输出维度是(batch_size, seq_len, vocab_size)而target的维度是(batch_size, seq_len)直接传给CrossEntropyLoss需要把输出转置成(batch_size, vocab_size, seq_len)或者用view(-1, vocab_size)展平。我习惯用后者把输出reshape成(batch_size * seq_len, vocab_size)target reshape成(batch_size * seq_len)这样维度对得上ignore_index也能正常工作。还有一个容易忽略的点编码器的输入也需要填充但编码器是LSTM填充位置如果参与计算会影响最后一个时间步的隐藏状态。正确的做法是用pack_padded_sequence把填充位置压缩掉让LSTM只处理真实长度。不过对于小规模数据句子长度差异不大我实测下来不压缩也能跑但生成效果会略差。如果追求严谨建议加上pack_padded_sequence和pad_packed_sequence这一对操作。3. 编码器与解码器的PyTorch实现细节3.1 编码器的结构设计与维度对齐编码器就是一个嵌入层加一个LSTM。嵌入层把输入的词索引映射成稠密向量LSTM逐步读入这些向量输出每个时间步的隐藏状态和最后一个时间步的隐藏状态与细胞状态。class Encoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_layers1): super(Encoder, self).__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue) self.hidden_size hidden_size self.num_layers num_layers def forward(self, x): embedded self.embedding(x) outputs, (hidden, cell) self.lstm(embedded) return outputs, hidden, cell这里有几个维度问题需要说清楚。batch_firstTrue表示输入维度是(batch_size, seq_len, embed_size)如果不设这个参数默认是(seq_len, batch_size, embed_size)后面所有维度操作都要跟着变。我强烈建议统一用batch_firstTrue因为这样更符合直觉调试时不容易搞混。hidden和cell的维度是(num_layers, batch_size, hidden_size)。如果num_layers1可以直接用hidden.squeeze(0)去掉第一维变成(batch_size, hidden_size)。但如果num_layers1解码器的初始隐藏状态需要和编码器的层数匹配不能随便squeeze。3.2 解码器的逐步生成逻辑解码器和编码器结构类似但多了一个关键步骤每一步的输入是上一个时间步生成的词而不是完整的目标序列。训练时用teacher forcing把真实的目标词作为下一步输入推理时用模型自己生成的词作为下一步输入。class Decoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_layers1): super(Decoder, self).__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hidden, cell): embedded self.embedding(x) output, (hidden, cell) self.lstm(embedded, (hidden, cell)) prediction self.fc(output) return prediction, hidden, cell训练时我把目标序列整体输入解码器一次前向传播就能得到所有时间步的输出。这是因为teacher forcing下每一步的输入都是已知的真实词不需要逐步循环。但推理时必须逐步生成因为每一步的输入依赖上一步的输出。这里有一个常见的错误训练时把目标序列整体输入但忘了在目标序列前面加上sos导致解码器第一步看到的是目标序列的第一个真实词而不是起始标记。正确的做法是解码器输入为sos target[:-1]target为target这样每一步预测的是下一个词。3.3 注意力机制的接入位置与计算方式注意力机制接在解码器的LSTM之后、全连接层之前。解码器每一步的LSTM输出会和编码器所有时间步的输出计算注意力权重加权求和得到上下文向量然后和LSTM输出拼接后一起送入全连接层。class Attention(nn.Module): def __init__(self, hidden_size): super(Attention, self).__init__() self.attn nn.Linear(hidden_size * 2, hidden_size) self.v nn.Linear(hidden_size, 1, biasFalse) def forward(self, decoder_hidden, encoder_outputs): seq_len encoder_outputs.size(1) decoder_hidden decoder_hidden.unsqueeze(1).repeat(1, seq_len, 1) energy torch.tanh(self.attn(torch.cat((decoder_hidden, encoder_outputs), dim2))) attention self.v(energy).squeeze(2) return F.softmax(attention, dim1)这段代码里decoder_hidden的维度是(batch_size, hidden_size)需要先unsqueeze(1)变成(batch_size, 1, hidden_size)再repeat成(batch_size, seq_len, hidden_size)才能和encoder_outputs的(batch_size, seq_len, hidden_size)拼接。拼接后维度是(batch_size, seq_len, hidden_size * 2)过线性层变成(batch_size, seq_len, hidden_size)再过v线性层变成(batch_size, seq_len, 1)squeeze后得到(batch_size, seq_len)的注意力分数。softmax的维度是dim1表示对编码器的时间步做归一化每个时间步的权重加起来等于1。这个维度如果搞错注意力就失效了生成结果会变得很奇怪。4. 训练过程中的teacher forcing与梯度裁剪4.1 teacher forcing的比例调度teacher forcing就是在训练时用真实的目标词作为解码器的下一步输入而不是用模型自己生成的词。这样做的好处是训练收敛快因为每一步的输入都是正确的模型只需要学习从正确输入到正确输出的映射。但缺点是推理时模型看到的是自己生成的词如果训练时从来没见过自己的错误输出推理时一旦生成错一个词后面就会雪崩式地出错。我的做法是设置一个teacher forcing比例初始为1.0每训练若干轮衰减一次最终降到0.5左右。这样模型在训练后期会逐渐接触到自己的生成结果学会从错误中恢复。实现方式是在每个时间步随机决定用真实词还是模型生成的词作为下一步输入。对于小规模数据我实测下来teacher forcing比例降到0.6左右效果最好。降得太低模型收敛慢降得不够推理时容易崩。这个比例没有绝对标准需要根据数据量和任务难度调整。4.2 梯度裁剪的必要性与阈值选择LSTM虽然缓解了梯度消失但梯度爆炸的问题依然存在尤其是当序列较长或者学习率较大的时候。梯度裁剪的做法是计算所有参数的梯度范数如果超过阈值就按比例缩放保证范数不超过阈值。PyTorch里用torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)一行就能搞定。max_norm我一般设为1.0到5.0之间。设得太小会限制模型学习设得太大等于没裁剪。我试过1.0、3.0、5.0三个值在对话任务上3.0的效果最稳定loss曲线最平滑。还有一个细节梯度裁剪要在loss.backward()之后、optimizer.step()之前调用。如果顺序搞反了裁剪的是上一步的梯度等于没裁。4.3 学习率与优化器的搭配我用的优化器是Adam学习率设为0.001。Adam对学习率不敏感0.001是一个比较安全的默认值。如果loss下降太慢可以调到0.003如果loss震荡严重可以降到0.0005。训练轮数我设了200轮每轮打印一次loss。小数据下大概50轮左右loss就能降到比较低的水平但生成效果还需要继续训练才能稳定。我一般会保存每20轮的模型最后手动测试几个输入选生成效果最好的那个。还有一个经验如果loss降到很低但生成结果还是重复或者不通顺大概率是过拟合了。这时候可以减小模型维度比如把hidden_size从256降到128或者增加dropout。我在嵌入层和LSTM输出后都加了dropout比例0.3效果比不加好很多。5. 推理阶段的生成策略与常见问题5.1 贪心解码与束搜索的取舍推理时最简单的策略是贪心解码每一步选概率最大的词作为输出直到生成eos或者达到最大长度。贪心解码速度快但容易生成重复或者平淡的回复因为它只看当前步的最优不考虑全局最优。束搜索维护k个候选序列每一步扩展所有候选并保留概率最高的k个。k一般取3到10。束搜索生成的回复质量更高但计算量是贪心解码的k倍。对于小规模对话模型我实测k3时效果提升明显k再大提升就有限了。不过束搜索有一个坑它倾向于生成较短的序列因为每一步都在累乘概率序列越长概率越低。解决办法是加长度惩罚把概率除以生成长度的某个幂次。我一般用长度惩罚系数0.7效果比较平衡。5.2 生成重复问题的三种缓解方案重复生成是Seq2Seq模型的经典问题模型会陷入循环一直输出同一个词或者同一段话。原因有几个训练数据中重复模式太多、注意力机制失效、解码器隐藏状态陷入局部循环。我试过三种缓解方案。第一种是加重复惩罚在计算下一个词的概率分布时对已经生成过的词降低其logit值。第二种是限制连续重复如果连续生成了同一个词超过三次就强制选择概率次高的词。第三种是改进注意力用覆盖机制记录哪些编码器位置已经被关注过避免反复关注同一个位置。三种方案里重复惩罚实现最简单效果也最直接。我在softmax之前对已生成词的logit减去一个惩罚值惩罚值设为1.0到2.0之间。实测下来惩罚值1.5时重复问题基本消失同时不会影响正常生成。5.3 未登录词与长句处理的边界情况推理时如果输入包含词表里没有的字会被映射为unk模型看到unk之后生成的内容往往不可控。我的处理方式是在预处理阶段尽量覆盖常见字同时设置一个兜底回复如果输入中unk的比例超过30%直接返回抱歉我还没学会回答这个问题。长句处理是另一个边界情况。编码器LSTM的隐藏状态维度固定输入太长时信息会被压缩得厉害。我的做法是设置最大输入长度超过部分截断。截断时优先保留句尾因为对话中关键信息往往在后面。同时解码器设置最大生成长度防止生成无限长的回复。还有一个容易忽略的点推理时要调用model.eval()切换到评估模式关闭dropout和batch normalization的随机性。如果忘了这一步每次生成的结果都不一样调试时会很困惑。6. 从Seq2Seq到注意力机制的进阶思考6.1 加性注意力与点积注意力的对比我实现的是加性注意力计算方式是v^T * tanh(W * [h_dec; h_enc])。还有一种更常见的点积注意力计算方式是h_dec^T * h_enc直接做点积。点积注意力计算更快因为可以用矩阵乘法一次性算完所有时间步但要求解码器和编码器的隐藏维度相同。加性注意力没有这个限制但计算量稍大。Transformer用的是缩放点积注意力在点积基础上除以sqrt(d_k)防止维度太大时点积结果过大导致softmax梯度消失。这个缩放因子在Seq2Seq里其实也可以加我试过之后发现对训练稳定性有一定帮助尤其是hidden_size大于256的时候。6.2 双向编码器对对话理解的影响我上面实现的编码器是单向LSTM只能看到当前词之前的上下文。如果改成双向LSTM每个时间步的隐藏状态会同时包含前向和后向的信息对输入句子的理解更完整。但双向编码器有一个限制解码器需要的是编码器的最后一个隐藏状态作为初始状态而双向LSTM有两个方向的最后状态需要拼接或者相加。拼接的话维度翻倍解码器的隐藏维度也要跟着翻倍相加的话维度不变但信息会有损失。我一般用拼接然后加一个线性层把维度映射回解码器的隐藏维度。实测下来双向编码器对短句对话的提升不明显但对长句理解有改善。如果数据里长句较多建议用双向如果都是短句单向就够了还省计算量。6.3 这个简易框架还能往哪些方向扩展这个Seq2Seq框架虽然简单但扩展性很好。往小了说可以换GRU替换LSTM参数量更少训练更快可以加多层LSTM提升模型容量可以换不同的注意力变体比如Luong注意力。往大了说可以把LSTM换成Transformer的编码器和解码器就变成了标准的Transformer对话模型。再往上可以加载预训练的词向量或者直接用预训练模型做初始化。我下一步打算把这个框架改成Transformer版本对比一下在相同数据上的生成效果。还有一个有意思的方向是加入对话历史。现在的模型只考虑当前输入如果能把前几轮对话拼接起来作为输入模型就能处理多轮对话。实现上只需要修改数据构造部分把历史对话和当前输入拼成一个长序列模型结构不用变。7. 实操中踩过的坑与排查思路7.1 维度不匹配报错的定位方法PyTorch的维度报错信息往往很长但关键信息就几个期望的维度和实际的维度。我遇到最多的是LSTM的隐藏状态维度不对。比如编码器是num_layers2解码器是num_layers1直接把编码器的hidden传给解码器就会报错。排查方法是打印每个张量的shape从输入开始一步步往后看找到第一个维度不对的地方。我一般在forward函数里加print语句确认输入、嵌入后、LSTM输出、全连接输出的维度都符合预期。调试完之后再把print删掉。还有一个隐蔽的坑nn.LSTM的batch_first参数如果编码器和解码器设置不一致拼接时维度会对不上。我建议所有LSTM统一设batch_firstTrue避免混乱。7.2 loss不下降的常见原因loss不下降的原因有很多我按排查优先级列一下。第一检查数据有没有问题比如输入输出搞反了或者特殊标记加错了位置。第二检查学习率是不是太大或太小太大loss会震荡太小loss下降极慢。第三检查梯度有没有被裁剪掉如果max_norm设得太小梯度全被缩放成很小的值模型学不动。第四检查模型有没有进入死亡ReLU状态虽然LSTM用的tanh和sigmoid不容易死但全连接层如果用了ReLU还是有可能的。我遇到过一次loss一直停在4.6左右不降排查了半天发现是词表构建时把pad的索引设成了1而不是0而ignore_index设的是0导致填充位置参与了loss计算模型一直在学预测填充符。把pad索引改成0之后loss正常下降。7.3 生成结果不通顺的调优方向生成结果不通顺可能的原因和对应的调优方向如果生成结果重复加重复惩罚或者改进注意力如果生成结果太短检查eos标记是不是加错了位置或者降低结束标记的权重如果生成结果和输入不相关检查注意力机制是不是失效了可以打印注意力权重看看模型关注了哪些位置如果生成结果语法混乱可能是训练数据太少或者模型容量不够增加数据或者增大hidden_size。我一般会先用几个固定输入测试比如你好、今天天气、几点看生成结果是否合理。如果这几个简单输入都生成不好说明模型本身有问题如果简单输入可以但复杂输入不行说明模型容量或者数据覆盖不够。7.4 模型保存与加载的注意事项保存模型时我习惯保存state_dict而不是整个模型对象因为state_dict只包含参数加载时不受代码结构变化的影响。保存的时候要同时保存词表否则加载时词表对不上生成的词索引全是乱的。加载时先实例化模型结构再调用load_state_dict。如果模型结构有变化比如增加了层数load_state_dict会报key不匹配的错误。这时候可以用strictFalse忽略不匹配的key但要注意忽略的层会保持随机初始化可能影响效果。还有一个细节如果训练时用了GPU保存的state_dict里的张量在GPU上加载到CPU环境时需要先调用.cpu()或者用map_location参数指定设备。我一般保存时就转成CPU避免环境不一致的问题。8. 关于这个项目的个人体会这个Seq2Seq对话模型我从头到尾写了三遍第一遍照着教程抄跑通了但不知道每行代码为什么这么写第二遍自己默写卡在注意力机制的维度变换上调了两个小时才跑通第三遍加了teacher forcing调度和重复惩罚生成效果明显提升。三遍下来最大的体会是看代码和写代码是两回事看的时候觉得都懂写的时候才发现每个维度、每个索引都有讲究。另外一点小数据上调参的经验和大数据完全不一样。小数据下模型很容易过拟合hidden_size设大了反而效果差dropout和权重衰减比大数据场景下更重要。我最后用的hidden_size是128embed_size是64比很多教程里的256和128都小但在我的数据上效果最好。如果你也在学Seq2Seq我的建议是先把这份代码跑通然后试着改改参数看看loss曲线和生成结果怎么变。改参数的过程比看教程更能加深理解。等这个跑顺了再去看Transformer的代码会发现很多概念是相通的上手会快很多。