Seq2seq+LSTM+Attention情绪检测聊天机器人实现指南

发布时间:2026/10/8 11:04:29
Seq2seq+LSTM+Attention情绪检测聊天机器人实现指南 简介这是一套毕业设计级聊天机器人结合情绪检测项目面向自然语言处理方向学生与开发者。项目基于Seq2seq框架、LSTM和Attention机制使用TensorFlow2.0与Keras完成模型构建及训练前端采用HTML、Vue和Ajax实现网页交互。除实时对话外项目利用带标签数据训练了抑郁检测分类模型在对话过程中动态调用初步判断用户情绪状况。压缩包共45个文件大小66.81MB包含Python训练与推理脚本、Jupyter调试笔记、H5模型权重、pkl词向量数据、前端页面模板等覆盖文本预处理、模型训练、模型部署到前端展示的完整流程。词典和向量文件可直接复用多种留存模型便于对比效果另有语料、字体与简繁转换辅助模块方便二次开发。该资源已有191人学习适合参考数据整理、注意力改进、前后端联调等细节能显著降低毕业设计起步门槛。1. 这个项目到底要交付什么不只是聊天而是能感知用户情绪的回复系统把 Seq2seq LSTM Attention 这套框架拿来做聊天机器人本身不算新鲜事但在这个标题里加上“情绪检测”项目性质就变了。真正要交付的不是“能回话的玩具”而是一个能感知用户当前情绪、并把这种感知用进回复策略的完整系统。对本科或硕士毕设来说这个点选得准模型规模可控每个模块都能自己讲清楚又能把 NLP 里的文本生成和文本分类两个典型任务同时展示。适合正在选毕设方向的学生也适合想快速搭一套 LSTM 基线对话服务的开发者。这篇文章从数据准备、模型实现、训练避坑到验证流程给一条可以直接照着走的路。2. 架构先立住Seq2seq LSTM Attention 的分工以及情绪检测两种接入方式2.1 为什么选 LSTM 而不是 Transformer毕业设计答辩里的解释成本Seq2seq 是整体框架编码器把用户句子编码成语义向量解码器逐步生成回复LSTM 是编码器和解码器内部的神经网络单元负责把“记忆”变成可训练的隐状态Attention 机制出现在解码端解决 LSTM 的单点瓶颈——把所有信息压缩进最后一个向量会丢失太多局部细节而 attention 让解码器每一步都回头查看编码器全部时间步的输出。毕业设计场景下为什么不用 Transformer 或预训练大模型第一个原因是解释成本。LSTM 的门控结构一张图能讲明白答辩时“为什么它能记住长距离信息”“为什么双向比单向好”都有标准答案换成多头注意力加位置编码要额外花很多篇幅而且一旦引入预训练权重“你自己实现的部分”就很难界定。第二个原因是数据规模和算力。闲聊语料如果有几十万句LSTM 足够拟合Transformer 在小语料上反而容易欠拟合最后多半还要靠预训练模型兜底毕设就变成了调包调参。第三个原因是情绪检测和 LSTM 天然同构情绪分类本质是句子级序列建模完全可以把编码器输出的最后一步拉出来接一个全连接分类器两套任务共用同一套文本表示逻辑代码复用度高。这里提醒一句如果你检索 Attention 的优化方法会看到 flash attention 这类为大规模训练设计的加速技巧它解决的是长序列和高吞吐场景下的显存带宽问题对毕设规模的 LSTM 模型没有明显收益不必主动引入。对比维度从头训练 Seq2seqLSTMAttention预训练大模型微调训练成本单卡 CPU / 入门 GPU 几小时需要加载数 GB 权重自行实现程度编码器、注意力、解码器全部可手写低主要是 prompt 与微调答辩解释难度低每个模块拆开都能讲高核心模型是别人的主要风险长句生成质量一般小数据下泛化能力依赖外部权重2.2 情绪检测与对话生成的两种接入姿势前置感知与多任务训练情绪检测模块与对话生成模块怎么接我见过两类做法。方案 A 是前置感知用户输入先进情绪分类器得到情绪向量或标签再送入解码器。情绪向量可以在初始化解码器状态时拼一次也可以拼到解码器每步输入里。user_text preprocess(今天上班好累) emotion_vec emotion_classifier(user_text) # shape: [B, emotion_dim] reply chat_decoder.generate(user_text, emotion_vec) # 每步输入都拼上 emotion_vec这么做的最大优点是两个模块可以独立训练先训练对话主模型产出正常回复再训练情绪分类器最后只微调解码器输入层。哪一环出问题能立刻定位。我一般把这个方案作为毕设推荐路线。方案 B 是多任务联合训练共享 encoder同时接一个生成 head 和一个分类 headloss 是两个交叉熵相加。共享表征看起来优雅但实际训练经常出现 loss 尺度不一致生成 loss 和分类 loss 的数值范围差很多一个任务容易把另一个压过去需要反复调两个 loss 的权重训练周期拉得很长。如果时间不是特别充裕不建议碰。所以我的建议是三步走先训对话主模型再单独训情绪分类器最后冻结情绪模型把 emotion_vec 接入 decoder微调几个 epoch。整个系统解耦答辩时也能说清楚每个模型的输入输出。2.3 工程目录一份可照抄的项目布局代码组织直接影响后期联调效率。我习惯把表情分类和对话生成拆成两个模块避免循环依赖。chatbot_emo/ ├── configs/ │ └── config.yaml # batch_size, lr, max_len 等超参数 ├── data/ # 原始语料与预处理中间文件 ├── src/ │ ├── vocab.py # 词表构建与加载 │ ├── dataset.py # Dataset / DataLoader / collate_fn │ ├── encoder.py # 双向 LSTM 编码器 │ ├── attention.py # 通用 Attention 模块 │ ├── decoder.py # 带 Attention 和情绪向量的解码器 │ ├── emotion.py # 情绪分类模型与训练入口 │ └── train.py # 对话模型训练循环 └── checkpoints/ # 模型权重保存目录这样拆分后emotion.py 不依赖 train.py对话模型也不依赖情绪模型到联调阶段只需要在 train.py 或推理脚本里 import 情绪模型把 emotion_vec 送到 decoder 的 forward 里。如果一开始就把两个模型写进同一个文件后面调参和加模型版本都会很痛苦。3. 数据准备对话语料清洗、情绪标签来源与 DataLoader 三个关键参数3.1 对话语料格式从连续对话到 src/trg 训练对常见做法是用 Cornell Movie Dialogs 这类英文公开语料或者中文闲聊语料。以 Cornell 为例原始结构是 movie_lines每行一个句子带 id和 movie_conversations记录哪些句子组成一组对话。处理分两步先把一组对话变成多个相邻句子对再做清洗。def build_pairs(lines_file, conversations_file): lines {} with open(lines_file, encodinglatin-1) as f: for line in f: parts line.split( $ ) lines[parts[0]] parts[-1].strip() pairs [] with open(conversations_file, encodinglatin-1) as f: for line in f: parts line.split( $ ) utterance_ids parts[-1].strip()[1:-1].replace(, ).split(, ) for i in range(len(utterance_ids) - 1): src, trg lines[utterance_ids[i]], lines[utterance_ids[i 1]] pairs.append((src, trg)) return pairslines 字典存句子 id 到文本pairs 按相邻语句制作训练对比随机配对更接近真实对话。中文语料不要照搬这个编码直接用 utf-8 打开文件。清洗时要做的是去 HTML 标签、全半角标点统一、把连续空格压缩成一个中文还需要分词可以用 jieba 分词后用空格连接也可以走字符级。字符级的好处是词表稳定不受分词错误影响缺点是序列明显变长训练更慢。对话量在几万句以内时我一般直接字符级省掉分词这一层调试成本。3.2 情绪标签从哪来公开情感数据集与弱监督方案情绪检测的训练数据不能和对话语料混在一起否则标签会非常脏。常见做法是英文语料去情感分类公开数据集比如带正负标签的句子级语料中文语料去电商评论或酒店评论这类带打分标签的公开数据。如果实在拿不到对话场景的情绪数据可以用表情符号做弱监督收集大量带明确情绪的短文本把表情符号映射成情绪标签。EMOJI_LABEL { anger: anger, cry: sad, laugh: joy, fear: fear, shock: surprise, neutral: neutral } def weak_label(text: str): for symbol, label in EMOJI_LABEL.items(): if symbol in text: return label return None上面这段用符号名代替了实际字符原理不变遍历一个表情到标签的映射表命中即可作为弱标签。这种弱监督有噪声比如有些人发“笑哭”可能只是在自嘲不代表真的开心。缓解方法是只保留文本中情绪词和表情一致的样本或者在损失函数里降低置信度低样本的权重。另一个要点是标签体系不要做太细。八分类在数据不足时很难收敛三分类正向、负向、中性就足够完成“检测用户情绪状况”的目标也方便做人工评估。我见过不少项目一上来就定八分类最后结果经常是两三类完全混淆又回过头来合并标签白白浪费时间。3.3 词表与 DataLoadermin_freq、max_len、pad 对齐词表构建和 DataLoader 有三个参数是新手最容易忽略的min_freq、max_len、pad 对齐方向。min_freq 决定低频词的去留常见做法是取 3太小词表膨胀太大 OOV 词增多max_len 按语料长度分布的 95% 分位取英文取 20 到 30 个 token中文字符级取 50 左右比较稳pad 统一在句子右侧补mask 必须和 pad 对应。import torch from torch.utils.data import Dataset, DataLoader class DialogDataset(Dataset): def __init__(self, pairs, vocab, max_len30): self.data [(vocab.encode(s), vocab.encode(t)) for s, t in pairs] self.max_len max_len def __len__(self): return len(self.data) def __getitem__(self, idx): src, trg self.data[idx] return torch.tensor(src[: self.max_len]), torch.tensor(trg[: self.max_len]) def collate_fn(batch, pad_idx0): srcs, trgs zip(*batch) src_lens torch.tensor([len(s) for s in srcs]) trg_lens torch.tensor([len(t) for t in trgs]) src_pad torch.zeros(len(srcs), max(src_lens), dtypetorch.long).fill_(pad_idx) trg_pad torch.zeros(len(trgs), max(trg_lens), dtypetorch.long).fill_(pad_idx) for i, s in enumerate(srcs): src_pad[i, : len(s)] s for i, t in enumerate(trgs): trg_pad[i, : len(t)] t return src_pad, trg_pad, src_lens, trg_lensvocab.encode 方法负责把 token 序列映射成 id并在句子首尾加上起始符和结束符collate_fn 返回 padding 后的矩阵和真实长度。真实长度后面要给 pack_padded_sequence 用也给 attention mask 用。pad_idx 建议直接从词表对象里读取不要硬编码成 0因为一旦词表重建索引可能变化mask 就会静默失效这是后面注意力错位的一大来源。4. 手写编码器、Attention 解码器与情绪检测头PyTorch 实现和参数说明4.1 双向 LSTM 编码器把输入压成 attention 能查询的序列编码器使用双向 LSTM输出每个时间步的隐状态供 attention 查询同时返回最后一个时刻的 hidden 和 cell作为解码器初始状态。import torch import torch.nn as nn class EncoderLSTM(nn.Module): def __init__(self, vocab_size, emb_dim, hidden_dim, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, emb_dim, padding_idx0) self.lstm nn.LSTM(emb_dim, hidden_dim, num_layers1, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(dropout) def forward(self, src, src_lens): emb self.dropout(self.embedding(src)) # [B, T, emb_dim] packed nn.utils.rnn.pack_padded_sequence( emb, src_lens.cpu(), batch_firstTrue, enforce_sortedFalse) packed_out, (hidden, cell) self.lstm(packed) output, _ nn.utils.rnn.pad_packed_sequence(packed_out, batch_firstTrue) return output, hidden, cell # output: [B, T, 2*hidden_dim]双向 LSTM 的 output 维度是 hidden_dim 乘 2前向和后向隐状态拼在一起。hidden 和 cell 的形状是 [2, B, hidden_dim]2 代表两个方向。这里 embedding 的 padding_idx 必须和词表的 pad 位置一致后续 mask 才不会泄漏。句子的真实长度通过 pack_padded_sequence 打包可以跳过 padding 位置的计算。enforce_sortedFalse让函数在内部自己排序这样 collate_fn 不必提前按长度降序排列。常见报错是 lengths must be sorted in decreasing order很多新手在这个地方翻车其实设置成 False 就行。4.2 一个通用的 Attention 模块decoder 每步的软对齐实现Attention 是这段代码的灵魂。下面这个模块同时支持常见的点乘注意力和加性注意力属于可插拔的实现。import torch.nn.functional as F class Attention(nn.Module): def __init__(self, hidden_dim, methodgeneral): super().__init__() self.method method if method general: self.W nn.Linear(2 * hidden_dim, hidden_dim, biasFalse) elif method concat: self.W nn.Linear(2 * hidden_dim hidden_dim, hidden_dim, biasFalse) self.v nn.Parameter(torch.Tensor(hidden_dim)) def forward(self, decoder_hidden, encoder_outputs, src_mask): # decoder_hidden: [B, hidden_dim] # encoder_outputs: [B, T, 2*hidden_dim] if self.method general: energy self.W(encoder_outputs) # [B, T, hidden_dim] energy torch.bmm(energy, decoder_hidden.unsqueeze(2)).squeeze(2) else: dec decoder_hidden.unsqueeze(1).expand_as(encoder_outputs) concat torch.cat([encoder_outputs, dec], dim-1) energy torch.tanh(self.W(concat)) energy torch.matmul(energy, self.v) energy energy.masked_fill(src_mask 0, -1e9) # 关键mask padding attn F.softmax(energy, dim-1) context torch.bmm(attn.unsqueeze(1), encoder_outputs).squeeze(1) return context, attngeneral 是点乘注意力concat 是加性注意力。后者参数更多在小数据上更容易过拟合前者是更常见的默认选择。mask 处理是这里的核心src_mask 为 0 的位置在 softmax 前被填成很大的负数权重无限接近 0padding 不会污染 context。如果不做这一步attention 的权重经常飘到句子末尾的 pad 上生成质量会明显下降。4.3 情绪检测头与解码器融合让情绪向量参与每一步生成情绪检测模型本身不复杂常见做法是 BiLSTM 加一个全连接分类头用交叉熵训练。关键在推理时把分类器倒数第二层输出的情绪向量取出来而不是只用预测标签。class EmotionClassifier(nn.Module): def __init__(self, emb_dim, hidden_dim, num_labels3): super().__init__() self.embedding nn.Embedding(vocab_size, emb_dim, padding_idx0) self.lstm nn.LSTM(emb_dim, hidden_dim, batch_firstTrue, bidirectionalTrue) self.classifier nn.Linear(2 * hidden_dim, num_labels) def forward(self, x): emb self.embedding(x) _, (hidden, _) self.lstm(emb) h torch.cat([hidden[-2], hidden[-1]], dim1) return self.classifier(h) # 分类 logits def get_emotion_vec(self, x): emb self.embedding(x) _, (hidden, _) self.lstm(emb) h torch.cat([hidden[-2], hidden[-1]], dim1) return h # 作为情绪向量送入 decodermerge 到解码器时我用的是把情绪向量拼到每一步输入的做法。好处是情绪信息持续存在而不是只在开头出现一次。class DecoderLSTM(nn.Module): def __init__(self, vocab_size, emb_dim, hidden_dim, emotion_dim, attention): super().__init__() self.embedding nn.Embedding(vocab_size, emb_dim, padding_idx0) self.emotion_proj nn.Linear(emotion_dim, emb_dim) self.lstm_cell nn.LSTMCell(emb_dim emb_dim, hidden_dim) self.attention attention self.output_proj nn.Linear(hidden_dim 2 * hidden_dim, vocab_size) def forward_step(self, token, prev_hidden, prev_cell, emotion_vec, encoder_outputs, src_mask): emb self.embedding(token) emo self.emotion_proj(emotion_vec) lstm_input torch.cat([emb, emo], dim-1) # 情绪拼到每一步输入 prev_hidden, prev_cell self.lstm_cell(lstm_input, (prev_hidden, prev_cell)) context, _ self.attention(prev_hidden, encoder_outputs, src_mask) logits self.output_proj(torch.cat([prev_hidden, context], dim-1)) return logits, prev_hidden, prev_cell这里没有直接用 nn.LSTM 而是用 LSTMCell 手动循环因为要在每一步插入 emotion_vec 和 attention context。emotion_proj 把情绪向量投影到和词向量相同的维度后拼接LSTMCell 输入维度是 emb_dim 加 emb_dim。output_proj 把隐状态和 context 拼起来映射到词表这是常用的 concat 解码器风格。4.4 训练循环teacher forcing、损失函数与梯度裁剪训练循环里需要格外注意三件事teacher forcing 比例、padding 位置的 loss 屏蔽、梯度裁剪。optimizer torch.optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss(ignore_index0) teacher_forcing_ratio 0.7 for epoch in range(epochs): for src_pad, trg_pad, src_lens, trg_lens in dataloader: optimizer.zero_grad() encoder_outputs, hidden, cell encoder(src_pad, src_lens) # 双向 hidden 转单向初始状态 dec_hidden torch.cat([hidden[-2], hidden[-1]], dim-1) dec_hidden nn.Linear(2 * hidden_dim, hidden_dim)(dec_hidden) dec_cell torch.cat([cell[-2], cell[-1]], dim-1) dec_cell nn.Linear(2 * hidden_dim, hidden_dim)(dec_cell) # 冻结情绪模型只取向量 with torch.no_grad(): emotion_vec emotion_model.get_emotion_vec(src_pad) input_tokens trg_pad[:, 0] # 起始符 loss 0 for t in range(trg_pad.size(1) - 1): logits, dec_hidden, dec_cell decoder.forward_step( input_tokens, dec_hidden, dec_cell, emotion_vec, encoder_outputs, src_mask) loss criterion(logits.view(-1, vocab_size), trg_pad[:, t 1].view(-1)) if torch.rand(1).item() teacher_forcing_ratio: input_tokens trg_pad[:, t 1] else: input_tokens logits.argmax(-1) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()dec_hidden 要从双向转单向因为双向 LSTM 返回 hidden 是 [2, B, hidden_dim]把两个方向拼成 2*hidden_dim 后再线性投影到 hidden_dim。如果直接取其中一层信息损失很大。teacher_forcing_ratio 为 0.7意思是七成步数用真实 token 作为下一步输入三成用模型自己的预测。训练初期可以设 1.0第三个 epoch 开始线性降到 0.5 附近。loss 里 ignore_index0 让 padding 位置不参与计算否则不同长度句子的 loss 会被 pad 数量明显稀释。梯度裁剪 5.0 在 LSTM 是必须项因为反向传播经过的时间步长很容易让梯度范数爆炸。5. 训练与推理避坑从 loss 震荡到注意力错位的四条排查记录5.1 loss 不下降或震荡学习率、teacher forcing 与梯度裁剪的配合现象训练十几个 epochloss 始终在 7 到 8 附近波动偶发下降又立刻弹回去。原因最常见的是学习率过大LSTM 对学习率比 CNN 敏感得多其次是梯度裁剪缺失长序列反传时梯度范数爆炸teacher forcing 比例长期固定在 1.0模型学不会在推理时自我纠错。解决学习率从 1e-3 起步如果 batch 比较小或词表比较大降到 5e-4。优化器用 Adam不要一上来就用 AdamW 或 SGD 并自行调动量。优化器初始化后立刻接梯度裁剪nn.utils.clip_grad_norm_(model.parameters(), 5.0)teacher forcing 按余弦或线性衰减。最简单做法是前两个 epoch 固定 1.0之后每 epoch 降 0.1最低到 0.4。如果 loss 变成 NAN先检查学习率是否超过 1e-2再看 embedding 初始化有没有过大的数值。5.2 解码输出重复或提前终止temperature 与 top-k 的调整方向现象推理时回复出现“好好好好好”或者 20 步内一直不输出终止符最后被截断。原因贪心解码在概率分布比较平滑时容易陷入重复吸引子temperature 过低会加剧过高又会随机到烂词。LSTM 生成长序列时上下文衰减注意力能缓解但不会完全消除。解决不要只用贪心解码用 temperature 加 top-k 采样。def decode_step(logits, temperature0.8, top_k10): logits logits / temperature if top_k 0: top_k min(top_k, logits.size(-1)) values, _ logits.topk(top_k) logits[logits values[:, -1].unsqueeze(-1)] -1e9 probs torch.softmax(logits, dim-1) return torch.multinomial(probs, 1)temperature 等于 1 是原始分布小于 1 会让分布变尖大于 1 会让分布变平。重复频繁出现时先检查是不是 temperature 小于 0.6如果输出很散再调回 0.8 或 0.9。top_k 限制候选词范围避免低概率的烂词被采样到。这个函数在推理脚本里直接替换 argmax 即可训练循环里不要用。5.3 注意力权重飘到 pad 上mask 不一致导致的隐性错误现象把 attention 权重画成热力图发现大量权重落在句子右侧的 pad 区域或者生成的回复明明很短却引用到了句尾无关内容。原因attention 模块里用了 src_mask但 mask 和 pad_idx 对不上。最常见的是词表重建后 pad_idx 变了collate_fn 里仍然写死 0另一种是训练时没用 mask推理时却补了 mask模型行为前后不一致生成结果自然漂移。解决让 pad_idx 从词表对象读取mask 在 collate_fn 里用同一个 pad_idx 生成。训练和推理共用一个 attention 类不单独写推理版本。每训练几个 epoch保存一张 attention 热力图检查pad 位置权重接近 0 才是正常状态。这个检查比你盯 loss 曲线管用得多。5.4 pack_padded_sequence 的 hidden 错位长度排序与状态提取现象训练时 loss 正常但推理时第一次生成的 token 分布明显不对或者编码器返回的 final hidden 表现为某个短句的状态。原因pack_padded_sequence 会在内部重排 batch如果直接使用 output 的最后一个时间步作为句子表示拿到的是 padding 之后的位置不是真实句尾。另一个常见错误是手动排序后没有保存原始索引导致 hidden 和小批样本错位。解决不要手动排序传入enforce_sortedFalse让函数内部处理取真实句尾状态时优先使用 LSTM 返回的 hidden 作为解码器初始状态。如果一定要从 output 取按真实长度 gatheridx (src_lens - 1).unsqueeze(1).unsqueeze(2).expand(-1, -1, 2 * hidden_dim) last_hidden torch.gather(output, 1, idx).squeeze(1)这样取出的才是每个样本最后一个有效词位置的隐状态。记住一个原则pack 之后的 output 是给 attention 用的全序列输出而 final hidden 是给解码器用的句子摘要两者职责不同。6. 把情绪检测真正用起来评测指标、采样技巧和一套可演示的验证流程对话质量不能只看 BLEU。闲聊场景里同一个输入有无数合理答案BLEU 只能作为参考。常见做法是三个指标配合困惑度看生成概率是否整体下降attention 热力图看语义对齐是否合理人工打分看连贯性、相关性和情绪匹配度。情绪检测部分用 precision、recall、F1 就够了再在测试脚本里加一个实时情绪显示把每一轮用户输入的情绪预测结果打到控制台。推理脚本里我习惯把情绪预测和回复生成放在同一个函数里方便一次性演示。def chat_with_emotion(user_text, temperature0.8, top_k10): processed preprocess(user_text) emotion_vec emotion_model.get_emotion_vec(processed) emotion_label emotion_model.predict_label(processed) reply decode_greedy_with_emotion(processed, emotion_vec) # 保底回复 reply_variant decode_sampling_with_emotion(processed, emotion_vec, temperature, top_k) return emotion_label, reply, reply_variant贪心解码生成一个保底回复temperature 采样再生成一个候选人工对比后选择效果更好的那一个。这套流程现场演示非常稳定不会因为采样随机性出现尴尬输出。第一次做这类项目时我把大多数时间花在让对话更流畅上情绪检测只挂在最后当标签输出。后来才明白把情绪向量送进解码器、让情绪影响每一步生成才是这个题目和普通聊天机器人拉开差距的地方。最值得花时间的点在这里而不是在模型层数或词表大小上折腾。希望帮到你。本文还有配套的精品资源点击获取