简介面向毕业设计场景的完整项目包基于Seq2seq框架、LSTM与Attention机制实现聊天机器人的实时对话并利用带标签语料训练抑郁分类模型在对话过程中同步检测用户情绪状态。项目覆盖文本预处理、模型构建与训练、网页前端设计HtmlVueAjax等关键环节。压缩包共45个文件类型以Python脚本、模型权重、训练数据、Jupyter Notebook及网页模板为主整体大小约66.81MB可直接用于学习或二次开发已有191人学习下载。特别适合需要完成NLP方向毕业设计或入门Seq2seq聊天机器人的学习者可从中掌握从数据清洗、词向量处理到模型训练、网页部署的完整链路并参考抑郁检测模型的构建思路拓展到其他文本分类任务。1. 毕业设计选这个题关键不在聊天在情绪检测毕业设计选这个题的人不少是被“聊天机器人”四个字吸引来的最后卡在情绪检测上。如果不做情绪检测这个项目的核心其实就是 Seq2seq 框架加 LSTM 加 Attention 机一套标准的老牌机器翻译结构。但把“检测用户情绪”叠上去之后整个项目的工程量、算法深度和答辩可讲性都上了不止一个台阶。它能解决的问题很具体让聊天机器人不仅能回复还能判断对方现在是高兴、生气还是沮丧并且根据情绪调整回复方式。适合谁呢手头算力有限、不想被 Transformer 新框架拖进预训练泥潭同时又想稳过一个算法型毕业设计的人。别觉得这些技术过时了很多实际业务里LSTM 加 Attention 的推理成本远低于大模型落地价值反而更容易讲清楚。2. Seq2seq LSTM Attention为什么这个老组合还能打2.1 不用 Transformer 的理由资源、数据量、可解释性很多同学一上来就问2025 年了怎么还在用 Seq2seq 加 LSTM是不是该直接上 Transformer答案取决于你手里有什么资源。论文写作阶段Transformer 的预训练权重确实好拿但毕业设计要求的是“自己实现一个完整的算法链路”而不是“调一个开源模型再包装”。Seq2seq 框架从编码器到解码器的每一步都能拆开讲LSTM 网络的结构也远比 Self-Attention 好解释答辩老师问一句“你的梯度流是怎么走的”你能从头推到尾这就是优势。另一个现实问题是数据量。对话语料本身就不大几十万条的中文闲聊数据已经是中等规模在这种量级下LSTM 的收敛速度和稳定性并不比 Transformer 差太多。而 Transformer 在数据不足时很容易过拟合反而需要额外的预训练或数据增强技巧这对毕设来说是把简单问题复杂化。可解释性是这个组合最被低估的价值。Attention 机制天然能输出一张“模型在看输入哪些词”的权重图这在答辩现场展示效果极佳。老师问你模型为什么回复这句话直接把 attention map 画出来比任何公式推导都直观。这些点组合起来我认为对毕设场景来说LSTM 加 Attention 依然是最稳的选择。2.2 数据准备中文对话语料、分词与词表构建聊天的数据格式通常是“问句”和“答句”的配对一行一对话。公开渠道能拿到的中文闲聊语料大多长这样问句和答句用 Tab 分隔。但原始语料里混着大量超长句、重复句和符号垃圾第一步是清洗和过滤。我一般会按长度过滤一次超过 50 个字的句子直接丢弃因为对话场景里正常人不会连续说 50 个字保留这些句子只会拖慢训练速度还容易诱导解码器生成冗长的废话。分词这里有个经验别用最细的字符级切分也别上新词发现算法就用 jieba 的标准模式够用了。代码大致是这样import jieba from collections import Counter def build_vocab(corpus_path, max_vocab_size30000): word_counter Counter() pairs [] with open(corpus_path, r, encodingutf-8) as f: for line in f: parts line.strip().split(\t) if len(parts) ! 2: continue q, a parts[0], parts[1] if not q or not a or len(q) 50 or len(a) 50: continue pairs.append((q, a)) word_counter.update(jieba.lcut(q)) word_counter.update(jieba.lcut(a)) vocab [pad, bos, eos, unk] [ w for w, _ in word_counter.most_common(max_vocab_size - 4) ] word2idx {w: i for i, w in enumerate(vocab)} return pairs, word2idx这段逻辑很直白先按行读语料过滤掉格式不对的过滤超长句。然后对所有问句和答句做 jieba 切词计数后保留高频词前四个位置固定留给特殊符号。词表大小取 30000这个数在中文场景下够覆盖绝大多数日常词汇。你在 Windows 环境下跑这段代码时如果语料文件是 GBK 编码记得把 encoding 换成 gbk不然第一条就报 UnicodeDecodeError。我再顺手做一个小提醒词表构建完成后建议把unk的比例统计出来。如果训练集里有超过 3% 的词被映射成unk说明你的词表太小或者分词粒度不对会让模型在预测时频繁输出未知词聊天质量会很难看。这个比例越低越好。2.3 模型骨架Encoder、Decoder、Attention 怎么拼Seq2seq 的核心思想是用一个循环网络把输入序列读成一个语义向量再用另一个循环网络从这个向量里逐步生成输出。LSTM 相比普通 RNN 多了一条细胞状态通道能控制梯度流的信息保留和遗忘因此长句的上下文记忆能力明显更强。但光靠细胞状态还不够句子超过 20 个词之后编码器最后一步的隐藏状态已经很难承载全部信息这就是 Attention 机出现的原因解码器在每个时间步回看编码器的所有隐藏状态按相关性加权取上下文向量。编码器部分很简单就是 Embedding 加 LSTMimport torch import torch.nn as nn class EncoderLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue) def forward(self, x): emb self.embedding(x) outputs, (h, c) self.lstm(emb) return outputs, h, cembed_dim 取 128hidden_dim 取 256这是我比较顺手的组合。批大小 64 时单卡训练速度足够快而且在 grad clip 之后很少出现梯度爆炸。padding_idx0 是让pad位置不参与 embedding 更新这在后处理里能省不少麻烦。注意这里没有设置 num_layers 大于 1因为 PyTorch 的 LSTM 在 num_layers1 时dropout 参数实际上不生效这是文档里写了但很多人踩过的坑。解码器是核心它把上一步的 token embedding 和 Attention 计算出的 context 拼接在一起送入 LSTMclass Attention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn nn.Linear(hidden_dim * 2, hidden_dim) self.v nn.Linear(hidden_dim, 1, biasFalse) def forward(self, decoder_hidden, encoder_outputs): seq_len encoder_outputs.size(1) decoder_hidden decoder_hidden.unsqueeze(1).expand(-1, seq_len, -1) energy torch.tanh(self.attn(torch.cat([decoder_hidden, encoder_outputs], dim-1))) score self.v(energy).squeeze(-1) weights torch.softmax(score, dim-1) context torch.bmm(weights.unsqueeze(1), encoder_outputs).squeeze(1) return context, weights这里用的是 Bahdanau 加性 Attention它先拼接解码器隐藏状态和编码器输出过一个线性层和 tanh 激活再经过一个向量 v 压缩成标量分数。softmax 后得到权重再和 encoder_outputs 做加权平均。要说明的是decoder_hidden 取的是最后一层的隐藏状态prev_h[-1]它形状是[batch, hidden_dim]需要先 expand 到和目标序列每个时间步对齐才能做拼接。很多新手在这里把维度搞错导致直接报广播错误调试时先打印decoder_hidden.shape和encoder_outputs.shape比对一下再跑。解码器主体class DecoderLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim256): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim hidden_dim, hidden_dim, batch_firstTrue) self.attention Attention(hidden_dim) self.fc nn.Linear(hidden_dim, vocab_size) def forward(self, x, prev_h, prev_c, encoder_outputs): emb self.embedding(x.unsqueeze(1)) context, _ self.attention(prev_h[-1], encoder_outputs) lstm_input torch.cat([emb, context.unsqueeze(1)], dim-1) output, (h, c) self.lstm(lstm_input, (prev_h, prev_c)) logits self.fc(output.squeeze(1)) return logits, h, c注意 LSTM 的输入维度是 embed_dim hidden_dim因为 embedding 输出和 context 拼在一起。这个细节决定了整个模型能不能端到端跑通也是最容易翻车的地方。训练时每个时间步的解码器输入是上一步的 token先喂bos往后逐步预测。细节上还有一个常见的做法是把上一时间步的 token embedding 作为输入不接其它额外特征这套结构就这么简洁。你对这套结构有把握之后后面训练调参才能不慌。3. 情绪检测模块和对话模型同吃一个词表的设计思路3.1 独立 LSTM 分类器还是和对话模型共享编码器情绪检测有两种接入方式。第一种是把情绪识别直接塞进对话模型的解码器里学一个多任务输出头第二种是单独训练一个情绪分类器聊天机器人和情绪分类器两个模型独立部署。我推荐第二种。多任务学习听起来优雅实际做的时候问题很多。对话生成的损失温度高、波动大情绪分类的损失在训练前期收敛很快两者梯度相加后小的情绪分类梯度很容易被对话生成任务淹没。你最后得到的是对话质量和情绪准确率都不理想的结果而且调试时没法定位是哪个任务拖后腿。独立分类器最大的好处是解耦对话数据不够我就单独为情绪检测准备标注数据对话模型翻车不影响情绪模块的指标答辩时也可以分开讲两条技术路线。实践上我习惯让两个模型共用同一个词表但各自训练各自的 embedding。共用词表能省内存情绪分类中出现的新词直接查word2idx就能拿到索引。共享 embedding 就不必了对话语料的词频分布和情绪语料不完全一致强绑在一起反而会互相带偏。3.2 情绪标签怎么定六分类、标注策略与类别平衡情绪类别不是越多越好。最开始我试过八分类把“尴尬”“嫌弃”这种细粒度情绪都算进去结果标注一致性很差两个同学标同一条数据经常打架。最后折中成六分类高兴、生气、难过、害怕、惊讶、中性。这六类在对话场景里覆盖率很高而且类别间的区分度足够大标注者基本不会产生分歧。情绪类别典型用词训练目标高兴哈哈、开心、太好了识别积极情绪生气气死、烦、滚识别冲突与负面情绪难过哭、伤心、委屈识别低落状态害怕怕、慌、恐惧识别紧张情绪惊讶天哪、居然、不是吧识别意外反应中性嗯、好的、知道兜底类别标注策略分两步先用情绪词典做粗标再用人工抽样修正。情绪词典不能太复杂每个类别挑 15 到 20 个高频触发词就行emotion_dict { 高兴: [哈哈, 开心, 太好了, 爽, 笑死], 生气: [气死, 烦死了, 滚, 讨厌, 无语], 难过: [难过, 哭, 伤心, 委屈, 孤独], 害怕: [害怕, 慌, 恐惧, 吓死], 惊讶: [天哪, 居然, 不是吧, 真的假的], } def rough_label(text): for emotion, words in emotion_dict.items(): for w in words: if w in text: return emotion return 中性这个函数处理一轮后你会得到一批带标签的数据然后按类别采样每类抽 200 条人工看一遍。重点看“中性”类里是否混入了“气死我了”这种明显负面但还没被词典命中的文本。修正后的结果反馈到词典里再迭代一轮粗标。整个过程不用写复杂模型一个晚上就能把几万条数据的标注质量拉起来。3.3 一个能用的 BiLSTM 情绪分类器代码与训练要点情绪分类器我用的是 Embedding 加双向 LSTM 加全连接。双向的意义在于情绪信号往往分布在句子的两端比如“我真是开心极了”里“极了”在末尾单向 LSTM 容易丢失开头信息双向结构能把前后文都利用起来class SentimentBiLSTM(nn.Module): def __init__(self, vocab_size, embed_dim128, hidden_dim128, num_classes6): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue, bidirectionalTrue) self.dropout nn.Dropout(0.3) self.fc nn.Linear(hidden_dim * 2, num_classes) def forward(self, x): emb self.embedding(x) _, (h, _) self.lstm(emb) h_fwd h[-2, :, :] h_bwd h[-1, :, :] h_combined torch.cat([h_fwd, h_bwd], dim-1) logits self.fc(self.dropout(h_combined)) return logits这里有两个特别容易出错的细节。第一双向 LSTM 的隐藏状态h形状是[num_layers * 2, batch, hidden_dim]最后一层的前向状态在h[-2]后向状态在h[-1]取反了就全乱了。第二c我们没用上但它在 LSTM forward 里是必返回项必须用一个占位变量接住否则解包会报错。训练时每条样本的长度不一样batch 内要做 padding损失函数要用CrossEntropyLoss(ignore_index0)把pad位置的预测踢出损失计算否则模型会花大量精力去预测一堆没意义的 padding 位置。训练超参和对话模型基本一致Adam 优化器学习率 0.001batch size 64epochs 设 10 到 15。情绪数据量不大通常 5 个 epoch 之后准确率就稳定在 85% 上下继续训练反而开始过拟合。要防止 demo 现场翻车建议在每个 epoch 结束后跑一次验证集保存验证集准确率最高的那个 checkpoint而不是最后一个 epoch 的模型。4. 训练、调参与跑通 demo从损失下降到 Flask 接口4.1 损失函数、Teacher Forcing 与梯度裁剪训练 Seq2seq 的标准损失是交叉熵。每个解码时间步都会输出一个词表大小的 logits和真实 token 算交叉熵然后对所有时间步求平均。这里不需要自己手写循环求和PyTorch 的CrossEntropyLoss直接支持批量计算。但维度要对logits 形状是[batch, vocab_size]目标 token 形状是[batch]对不上就报错。Teacher forcing 是这个训练过程里最关键的机制。它的意思是解码器的输入有多大概率使用真实的上一个 token而不是模型自己预测的 token。训练初期全部用真实 token模型收敛快后期逐步降低 teacher forcing ratio让模型适应自己的预测误差。我一般从 1.0 开始每 10 个 epoch 降到 0.8最后稳定在 0.5 左右teacher_forcing_ratio max(0.5, 1.0 - epoch * 0.05)梯度裁剪是第二个必开项。LSTM 虽然有细胞状态缓解梯度消失但解码器在长序列上依然容易在反向传播时累积梯度爆炸。clip_grad_norm_把梯度的 L2 范数限制在 5.0 以内训练稳定性立刻上一个台阶。如果你是新手建议先固定这个值不要开太大也不要关掉。4.2 需要盯住的五组超参一张参数表训练过程中值得反复调整的其实就五组参数其它的按默认值跑就行参数推荐值观测指标说明学习率0.001训练 loss 曲线高于 0.003 大概率发散batch size64GPU 显存占用16G 显存跑不动就降到 32hidden_dim256语义向量维度不是越大越好显存翻倍teacher_forcing_ratio0.8 起步回复质量新手别一上来就设 0.5beam_size3 到 5回复多样性beam 太大回复反而死板这里特别说一下 hidden_dim。有些同学觉得隐藏层维度越大模型容量越高效果一定更好。实际上对话生成任务里 hidden_dim 超过 512 之后收益很小显存占用和训练时间倒是一直涨。毕设场景 256 够了注意力矩阵的维度也是这个数配合起来正好。4.3 把模型包进 Flask最小可用的聊天接口训练完模型后需要把预测逻辑封装成接口。我的做法是用 Flask 起一个 HTTP 服务输入用户的聊天文本返回两样东西机器人的回复文本、情绪检测结果。流程不复杂但有一个细节要提前想好输入的文本要经过和训练完全相同的分词流程否则词索引对不上。具体来说调用 jieba.lcut 之后再通过word2idx把 token 映射成索引最后补一个eos结尾标记。from flask import Flask, request, jsonify import torch import jieba app Flask(__name__) def predict_reply(text): tokens jieba.lcut(text) input_ids [word2idx.get(w, 3) for w in tokens] [2] input_tensor torch.tensor([input_ids], devicedevice) encoder_outputs, h, c encoder(input_tensor) decoder_input torch.tensor([[1]], devicedevice) reply_ids [] with torch.no_grad(): for _ in range(50): logits, h, c, _ decoder(decoder_input, h, c, encoder_outputs) next_token logits.argmax(dim-1).item() if next_token 2: break reply_ids.append(next_token) decoder_input torch.tensor([[next_token]], devicedevice) return .join(idx2word[i] for i in reply_ids) app.route(/chat, methods[POST]) def chat(): data request.get_json() user_text data.get(message, ) reply predict_reply(user_text) emotion, prob predict_emotion(user_text) return jsonify({reply: reply, emotion: emotion, probability: prob}) if __name__ __main__: app.run(host0.0.0.0, port5000)这里word2idx.get(w, 3)里的 3 是unk的索引补在序列末尾的 2 是eos。解码时遇到eos就停止生成最大长度限制在 50 个 token 以内避免模型陷入死循环。这套接口跑起来后可以用 postman 或者 requests 发一条消息测试我建议测试时带上中文标点因为很多分词结果好不好看标点边界就能知道个大概。5. 避坑清单情绪检测与模型联调时的五条血泪经验5.1 训练 loss 不降且居高不下现象怎么调学习率loss 都停在 8 以上几乎不变化。排查几次后发现问题出在损失函数没有忽略 padding 位置。原来CrossEntropyLoss默认会对所有位置计算损失而 batch 内不同句子的长度差异很大短句被 pad 成和长句一样长模型在大量pad位置上做了无意义预测梯度被这些位置污染了。解决办法在CrossEntropyLoss中显式指定ignore_index0让 pad 位置彻底退出损失计算。5.2 attention 权重图几乎全是一个颜色现象画 attention map 时发现权重均匀分布所有输入词的得分差不多没有明显的聚焦点。原因通常是编码器的 outputs 和解码器的 hidden 在拼接时维度没有对齐导致 energy 计算退化成了恒等映射。我在调试时先打印了三个关键 tensor 的 shapeencoder_outputs应该是[batch, seq_len, hidden_dim]decoder_hidden展开后应该是[batch, seq_len, hidden_dim]。只要这里是对的attention 权重的区分度很快就能出来。5.3 情绪分类器被“我不高兴”骗了现象用户输入“我不高兴”情绪分类器判断成高兴准确率虚高但实际语义反了。原因是训练数据里全是“哈哈”“开心”这种字面情绪词缺少否定句和反讽样本。解决办法是在标注阶段主动构造负样本把“我不开心”“没有不高兴”“气死我算了”这种句式加进去同时训练数据的类别权重增加负面类的权重从损失函数层面让模型更重视错的离谱的样本。5.4 解码器输出不断重复“你呢你呢你呢”现象生成的回复在几个词之间循环打转无法正常结束直到触发最大长度限制才停。这是 LSTM 对话生成最常见的翻车现场。原因有两个第一是训练语料里短回复占多数模型学到的最优策略就是输出高频词第二是解码时没有加重复惩罚。我一般在 beam search 里引入重复三元组惩罚出现过的 n-gram 在后续打分时直接扣分重复率能降一半以上。5.5 显存占用异常batch size 只能设到 16现象hidden_dim 调到 512 之后显存直接爆掉被迫把 batch size 降下来结果训练速度反而更慢。原因是我在一开始就调大了隐藏层维度忽略了双向 LSTM 在反向传播时需要额外存储两层中间状态。后来我把 hidden_dim 调回 256batch size 恢复到 64训练速度反而更快指标也没掉。调参顺序应该是先确定 batch size再根据显存余量决定 hidden_dim而不是反过来。6. 答辩加分的一个技巧把情绪得分接进 Beam Search最后一章我给一个能让答辩眼前一亮的小技巧在 beam search 解码时把情绪分类器的预测得分直接融入到候选回复的评分公式里让机器人可以带明确情绪倾向地回复。标准 beam search 在每一步只按语言模型得分选前 k 个候选结果往往是安全但无趣的“嗯嗯”“好的”。如果我在评分时加一项情绪得分就能让模型生成“哈哈那就好”“别难过我在呢”这种真正接得住用户状态的回复。具体做法是每生成一个候选 token 序列就调用情绪分类器预测一次完整候选句的情绪概率和目标情绪计算 KL 散度最后加权到总分里。这里的目标情绪来自对话开始前对用户输入的预测比如用户说“今天项目又挂了”情绪分类器判为难过那么解码时就往难过方向拉一拉。这个本身也是常见的“情绪调节对话生成”思路用在毕设里属于加分的亮点def beam_score_with_emotion(log_probs, token_ids): base_score log_probs.sum() full_text .join(idx2word[i] for i in token_ids) emotion_prob sentiment_model.predict(full_text) # emotion_target 是用户当前情绪越接近得分越高 emotion_score emotion_prob[emotion_target] return base_score 0.3 * emotion_score我自己的实践里系数 0.3 比较稳妥。设得太大模型会过度倾向情绪词生成的句子语法混乱设得太小情绪调节几乎没有效果。演示的时候先让测试用户输入一句很丧的话再用脚本把普通 beam search 结果和情绪增强版结果并列展示直观对比下的心理冲击力比任何指标表格都管用。做这个项目最大的收获不是我用了多少冷门技术而是明白了一个道理模型跑起来只是开始让模型输出贴合真实场景才是难点。情绪检测的价值不在于分类准确率高了几个点而在于让机器人拥有了“看人下菜碟”的能力。希望你做的时候能少走我走过的那些弯路做出来的 demo 能在答辩现场让老师愿意多问两句。希望帮到你。本文还有配套的精品资源点击获取