简介:基于Transformer模型训练的单轮对话聊天机器人Python源码包,定位为NLP入门者、毕设/课设开发者提供了一个从数据准备、模型训练到对话推理的完整基线方案。资源共22个文件、约83.68MB,主要类型包括Python训练与推理脚本、yaml/yml格式的训练预测配置、src/trg双语语料对、已训练好的词表与模型文件,以及项目说明文档,目录中代码、数据、配置分离,便于按需查阅和替换。已有517人学习/下载。可直接加载演示目录中的预训练词表和模型,运行对话脚本快速开启中文单轮聊天;若希望获得更好回复质量,参考文档安装SentencePiece、NeurST和LightSeq,将词表扩至32k并训练Transformer-big模型。资源附带完整数据、模型示例与配置脚本,既支持开箱即用,也方便自行调参、二次训练和扩展,对课程设计、毕业设计或Transformer入门实践均有较高参考价值。
1. 单轮对话机器人:为什么要用 Transformer 训练而不是查表
拿到「基于Transformer模型训练的单轮对话聊天机器人」这类项目时,不少人的第一反应是:单轮对话不就是用户问一句、系统答一句吗?直接建个问答库查表不就行了。真做过一版就会发现,查表方案在固定答案的FAQ场景里能用,但稍微放开一点问题表述就全碎了——换个说法、换个语序、带点口语,检索就匹配不上,而且每条新问法都要人工补规则。用Transformer训练一个条件生成模型,解决的是「见过类似表达就能学会自己答」的问题:给一句query,模型直接生成回答文本,不依赖命中预设答案。
这套方案适合课程设计、内部客服原型、以及想快速验证NLP生成流程的从业者,项目包里通常已经配好了源码、数据集、训练好的模型权重和使用说明。下面我从数据准备讲到模型选型、训练、推理、避坑,最后落到可交付的验证方法,都是可以直接照着复现的路径。
2. 把对话变成训练数据:单轮任务的数据格式、清洗与切分
2.1 单轮对话为什么是条件生成任务:先明确学习目标
单轮对话在形式上是「Q → A」的一对一映射,但它不是一个分类问题,而是一个条件文本生成问题。模型要学的是:在给定用户输入文本的条件下,生成一段通顺、相关、有信息量的回答文本。和机器翻译本质上是同一类任务,只是源端从「外语」换成了「用户问题」,目标端从「中文译文」换成了「机器人回复」。
这个定义直接影响数据准备策略。你不需要堆几百万条泛泛的闲聊数据,更需要的是和业务场景对齐的高质量问答对。比如你做的是校园助手Demo,那数据里就应该大量出现「图书馆几点开门」「补考怎么申请」这类问题,而不是天南地北的闲聊。模型的能力上限是由数据分布决定的,Transformer只是把数据里的模式学出来,数据本身不对齐,模型再强也答非所问。
另外要清楚「单轮」的含义:每一条训练样本都是独立的(question, answer),不依赖历史对话上下文。如果你想做多轮机器人,那数据组织方式要改成(context, response),把前几轮拼成一段上下文输入。标题限定在单轮,我们就按(question, answer)来做。
2.2 单轮对话数据集长什么样:从原始记录拆成 Q-A 对
公开的聊天数据集、客服日志、多轮对话语料,都要先加工成单轮 Q-A 对。常见的数据源格式是 JSON 或 CSV,一条原始记录里包含sender、text、timestamp等字段。把多轮对话拆成单轮样本时,我一般只取「上一句用户发言 + 当前机器人回复」作为一条样本,不把更早的对话拼进来;如果上一句太短或只是「嗯嗯」「好的」这类接续词,直接丢弃。
下面是一个从多轮记录拆单轮的示例脚本:
import json def extract_single_turn(data_path, out_path): samples = [] with open(data_path, "r", encoding="utf-8") as f: sessions = json.load(f) # 每个 session 是一段多轮对话 for session in sessions: prev_user_text = None for turn in session["turns"]: if turn["sender"] == "user": prev_user_text = turn["text"].strip() elif turn["sender"] == "bot" and prev_user_text is not None: # 只保留问题长度合理、回答非空的样本 if 3 <= len(prev_user_text) <= 200 and turn["text"].strip(): samples.append({ "question": prev_user_text, "answer": turn["text"].strip() }) prev_user_text = None # 每条样本只依赖紧邻的上一句 with open(out_path, "w", encoding="utf-8") as f: json.dump(samples, f, ensure_ascii=False, indent=2) print(f"共提取 {len(samples)} 条单轮问答对") extract_single_turn("raw_chat.json", "single_turn.json")逻辑说明:这个脚本的核心是维护一个prev_user_text变量,只有遇到bot回复并且前面有用户问题时才生成一条样本。取完一条样本后立即把prev_user_text置为None,避免把同一句用户发言和多个回复重复配对。长度过滤我用的是 3 到 200 个字,太短的问题信息量不足,太长的问题在单轮场景里往往带了过多噪声。
参数说明:3 <= len(prev_user_text) <= 200里的上下限要根据你的数据分布调整。客服场景问题普遍短,下限设 6 更稳;开放域闲聊问题可能长,上限可以放宽到 300,但注意后面模型max_len要能覆盖。
2.3 数据清洗、切分与分词:别让脏数据拖垮 Transformer
数据清洗是决定训练能否收敛的隐性因素。我整理数据时会依次处理这些点:
- 删除完全重复的
(question, answer)对,防止模型把某条常见回复背下来; - 去除 HTML 标签、URL、多余空格、不可见字符;
- 中英文标点统一,全角转半角(中文场景保留中文标点);
- 删除回答长度过短(少于 4 个字)或明显是无意义填充的样本;
- 删除带着「客服」「小编」等角色标签的文本,只保留纯回答内容。
清洗完成后要切分训练集、验证集、测试集。这里有个容易被忽略的坑:不要直接随机切分。如果原始数据里同一个主题的多轮对话拆成了多条样本,随机切分会把主题相同、文本高度相似的样本同时分到训练集和验证集,导致验证集 Loss 虚低,模型实际泛化能力远差于预期。最好按原始 session 分组后,再整组切分。
分词方面,如果你是纯中文场景,最简单可靠的做法是按字切分,把每个汉字当一个 token,词表就是所有出现过的字符加上[PAD]、[BOS]、[EOS]、[UNK]四个特殊标记。字级模型在小数据量下反而比词级模型更稳,因为不会遇到没见过的词,也不会被分词错误带偏。英文或中英混合场景,建议直接用 SentencePiece 或 Hugging Face 的 tokenizers 库训练一个 BPE 词表,数据量在 10 万条以内时词表大小设 30000 左右就够。
# 中文按字切词,构建字符级词表 def build_char_vocab(samples, min_freq=3): from collections import Counter counter = Counter() for item in samples: counter.update(item["question"]) counter.update(item["answer"]) vocab = {"[PAD]": 0, "[BOS]": 1, "[EOS]": 2, "[UNK]": 3} for ch, freq in counter.items(): if freq >= min_freq: vocab[ch] = len(vocab) return vocab vocab = build_char_vocab(train_samples)逻辑说明:min_freq=3是为了过滤低频字符,降低词表噪声。如果你的数据里包含大量人名、特殊符号,低频字符会占掉很多词表位置,而且模型学不好它们,过滤掉让它们统一走[UNK]是更稳的选择。字符级词表的好处是训练和推理时永远不会遇到 OOV(词表外单词),代价是序列长度变长,训练速度变慢,所以后面模型max_len设计要充分考虑这一点。
3. 选型与落地的核心:decoder-only 小模型怎么搭、参数怎么定
3.1 为什么单轮场景选 decoder-only 而不是 encoder-decoder
Transformer 用在对话生成上有两条主流路线:encoder-decoder 结构和 decoder-only 结构。做单轮聊天机器人,我推荐优先选decoder-only,理由有三个:
第一,decoder-only 把「理解问题」和「生成回答」统一成一个自回归过程,用户问题和机器人回答拼成一段序列,模型只学一种语言建模任务,训练目标单一,容易收敛;encoder-decoder 是两个模块分别优化,小数据量下反而更容易出现编码器学得好、解码器没跟上,或者反过来。
第二,decoder-only 的推理链路简单,不用维护 encoder 和 decoder 两套状态,内存占用低,部署方便,对课程设计和内部 Demo 来说这一点很重要。
第三,现在的对话大模型主流都是 decoder-only 路线,你在这个小项目里积累的训练、采样、mask 经验,后面迁移到更大的预训练模型时不用推翻重来。
但这不意味着 encoder-decoder 不能用。如果你的任务更接近「从一段文本中抽取信息并生成答案」,比如知识型问答、摘要式回复,encoder-decoder 结构往往更能利用好输入信息的双向上下文。单轮闲聊场景,decoder-only 是最省事且效果稳定的选择。
3.2 搭建一个可复现的小型 Transformer:从 embedding 到自回归生成
下面是一份精简但完整的 decoder-only Transformer 实现,核心模块包括 token embedding、位置编码、带因果 mask 的多头自注意力、前馈网络和层归一化。这份代码不依赖 Hugging Face 的transformers库,只用 PyTorch,方便你看清每个细节:
import torch import torch.nn as nn import math class CausalSelfAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() assert d_model % n_heads == 0 self.d_model = d_model self.n_heads = n_heads self.head_dim = d_model // n_heads self.qkv = nn.Linear(d_model, 3 * d_model) self.proj = nn.Linear(d_model, d_model) def forward(self, x, attn_mask=None): B, T, C = x.size() qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim) q, k, v = qkv.unbind(2) # 每个形状都是 (B, T, n_heads, head_dim) q = q.transpose(1, 2) # (B, n_heads, T, head_dim) k = k.transpose(1, 2) v = v.transpose(1, 2) attn = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim) # 因果 mask:让第 i 个 token 只能看到前 i 个 token causal_mask = torch.tril(torch.ones(T, T, device=x.device)).bool() attn = attn.masked_fill(~causal_mask, float("-inf")) if attn_mask is not None: attn = attn.masked_fill(~attn_mask[:, None, None, :], float("-inf")) attn = torch.softmax(attn, dim=-1) out = attn @ v # (B, n_heads, T, head_dim) out = out.transpose(1, 2).reshape(B, T, C) return self.proj(out)逻辑说明:qkv一次线性变换把输入映射成 query、key、value 三份,unbind(2)按维度拆分。torch.tril生成下三角矩阵,把未来 token 的注意力分数置为-inf,经过 softmax 后权重变成 0,实现自回归约束。attn_mask是可选的 padding mask,用于把无效位置遮掉。注意head_dim要参与缩放,否则点积数值过大,softmax 会进入饱和区,梯度变小,训练变慢。
class MiniTransformer(nn.Module): def __init__(self, vocab_size, d_model=256, n_heads=4, n_layers=3, max_len=128): super().__init__() self.token_embed = nn.Embedding(vocab_size, d_model) self.pos_embed = nn.Embedding(max_len, d_model) self.layers = nn.ModuleList( [TransformerBlock(d_model, n_heads) for _ in range(n_layers)] ) self.ln_f = nn.LayerNorm(d_model) self.lm_head = nn.Linear(d_model, vocab_size, bias=False) self.max_len = max_len def forward(self, idx, attn_mask=None): B, T = idx.size() assert T <= self.max_len, f"序列长度 {T} 超过 max_len {self.max_len}" token_emb = self.token_embed(idx) positions = torch.arange(T, device=idx.device).unsqueeze(0) pos_emb = self.pos_embed(positions) x = token_emb + pos_emb for layer in self.layers: x = layer(x, attn_mask) x = self.ln_f(x) logits = self.lm_head(x) # (B, T, vocab_size) return logits逻辑说明:位置编码这里用了可学习的pos_embed,比三角函数固定编码在小模型上更灵活。lm_head和token_embed共享权重是常见做法,可以减少参数量(设置bias=False是因为共享后形状完全一致)。max_len=128意味着单条样本拼成序列后不能超过 128 个 token,超出部分在数据阶段就要截断。
3.3 关键参数怎么定:一份可以直接抄的初始配置表
训练一个单轮对话机器人,参数不是越大越好,而是要和你的数据量匹配。下面是我验证过很多次的一组初始配置,适合 5 万到 20 万条单轮问答对的中文场景:
| 参数 | 推荐值 | 调整建议 |
|---|---|---|
| d_model | 256 | 数据量大或显存充足可升到 512 |
| n_heads | 4 | 必须能整除 d_model |
| n_layers | 3 | 10 万条以上数据可试 4 到 6 层 |
| max_len | 128 | 中文按字切分时问题加回答一般不超过 128 字 |
| vocab_size | 由词表决定 | 字符级通常在 2000 到 8000 之间 |
| batch_size | 32 到 64 | 显存不够就降到 16,配合梯度累积 |
| 学习率峰值 | 5e-4 | 用小学习率 1e-4 起步更稳 |
| warmup_steps | 1000 到 2000 | 防止训练初期 loss 剧烈震荡 |
| 训练轮数 | 15 到 30 | 以验证集 loss 不再下降为准 |
这个配置在 4GB 显存的 GPU 上就能跑,CPU 上也能训练,只是慢不少。新手建议先用 1 万条数据、d_model=128、n_layers=2把完整流程跑通,确认代码没有 bug 之后再加大规模训练,否则一个 mask 写错,等训练到一半才发现,浪费的时间够重写三遍。
4. 从训练到推理:loss 设计、生成采样与最小可用聊天脚本
4.1 训练时要屏蔽问题部分的 loss:只学怎么回答
decoder-only 结构把问题和回答拼成一个序列后,如果直接对整个序列计算交叉熵 loss,模型会花大量精力学习「预测用户问题里的下一个字」。这本身不是坏事,但会稀释模型对回答部分的拟合能力。更标准的做法是:只对回答部分的 token 计算 loss,问题部分的预测误差不参与反向传播。
实现方法也不复杂。训练时给每条样本标记哪些位置属于回答部分,生成一个label_mask,在算 loss 前把问题位置的 logits 和 label 都遮掉:
def compute_loss(logits, labels, label_mask): # logits: (B, T, V), labels: (B, T), label_mask: (B, T) 且值为 0 或 1 B, T, V = logits.size() logits_flat = logits.reshape(B * T, V) labels_flat = labels.reshape(B * T) mask_flat = label_mask.reshape(B * T).bool() loss = nn.functional.cross_entropy(logits_flat, labels_flat, reduction="none") loss = loss[mask_flat].mean() # 只统计回答部分 return loss逻辑说明:cross_entropy加上reduction="none"后返回每个位置的 loss,再用label_mask取出回答部分的 loss 求平均。注意label_mask的形状要和labels完全一致,一个常见的翻车点是 mask 算错了一位——因为序列拼接时问题在前、回答在后,mask 的下标偏移一位就会导致模型在学错误的位置。
数据拼序列的伪代码如下:
def build_sample(question_ids, answer_ids, max_len): # 输入是分词后的 id 列表 seq = [BOS_ID] + question_ids + [EOS_ID] + answer_ids + [EOS_ID] seq = seq[:max_len] label_mask = [0] * (1 + len(question_ids) + 1) + [1] * len(answer_ids) + [1] # 回答部分算 loss,问题部分不算;最后的 EOS 也算回答的一部分 label_mask = label_mask[:max_len] + [0] * (max_len - len(seq)) # 超长部分截断后,padding 位置的 label_mask 置 0 return seq, label_mask逻辑说明:BOS_ID和EOS_ID分别是序列起始和结束标记。这里把回答末尾的EOS_ID也计入 loss,这样模型才能学会在回答结束时主动输出 EOS,而不是永远生成下去。label_mask初始化时问题部分置 0、回答部分置 1,后面再用 0 补齐 padding 位置。
4.2 训练主循环:优化器、warmup、梯度裁剪和 eval
一个小型 Transformer 训练循环并不复杂,但有几个细节直接决定能不能收敛:学习率要配合 warmup、梯度要裁剪、每个 epoch 结束要在验证集上看 loss 和实际生成效果。下面是一个可运行骨架:
import torch import torch.nn as nn def train_loop(model, train_loader, val_loader, vocab_size, epochs=20): optimizer = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.01) # warmup + 线性衰减调度器 def lr_lambda(step): warmup_steps = 1500 if step < warmup_steps: return step / warmup_steps return max(0.0, 1.0 - (step - warmup_steps) / (epochs * len(train_loader))) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) for epoch in range(epochs): model.train() for batch in train_loader: input_ids = batch["input_ids"] # (B, T) labels = batch["labels"] # (B, T) label_mask = batch["label_mask"] # (B, T) logits = model(input_ids) loss = compute_loss(logits, labels, label_mask) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() # 验证集评估 model.eval() val_loss = 0.0 with torch.no_grad(): for batch in val_loader: logits = model(batch["input_ids"]) val_loss += compute_loss(logits, batch["labels"], batch["label_mask"]).item() print(f"epoch {epoch}, val_loss = {val_loss / len(val_loader):.4f}") torch.save(model.state_dict(), f"checkpoint_epoch{epoch}.pt")逻辑说明:LambdaLR调度器实现了前 1500 步学习率从 0 线性升到峰值、之后线性衰减到 0 的策略。warmup 的意义在于:训练初期模型参数离最优解很远,梯度方向噪声大,如果直接用大学习率,容易把参数推到不好的区域。clip_grad_norm_把梯度的全局范数限制在 1.0,防止个别样本的极端梯度把参数冲崩。每个 epoch 保存一次 checkpoint,是为了后面可以回滚到某个 loss 较低的中间状态,不用每次从头训练。
4.3 生成回答时的采样策略:temperature、top-k、top-p 怎么配合
训练完成后,推理阶段不是简单地取概率最大的那个 token,那样会得到重复、呆板的回答。我一般用带 temperature 的 top-p 采样:
def generate(model, tokenizer, question, max_new_tokens=40, temperature=0.8, top_p=0.9): model.eval() input_ids = tokenizer.encode(question, add_bos=True) input_tensor = torch.tensor([input_ids], dtype=torch.long) with torch.no_grad(): for _ in range(max_new_tokens): if input_tensor.size(1) >= model.max_len: break logits = model(input_tensor)[0, -1, :] # 只取最后一个位置的 logits logits = logits / temperature # temperature 控制分布平滑度 # top-p 过滤:只保留累积概率超过 p 的最小 token 集合 probs = torch.softmax(logits, dim=-1) sorted_probs, sorted_indices = torch.sort(probs, descending=True) cumprobs = torch.cumsum(sorted_probs, dim=-1) keep_mask = cumprobs <= top_p # 至少保留一个 token if not keep_mask.any(): keep_mask[0] = True filtered_probs = torch.zeros_like(probs) filtered_probs[sorted_indices[keep_mask]] = sorted_probs[keep_mask] filtered_probs = filtered_probs / filtered_probs.sum() next_id = torch.multinomial(filtered_probs, num_samples=1).item() input_tensor = torch.cat([input_tensor, torch.tensor([[next_id]])], dim=1) if next_id == tokenizer.eos_id(): break return tokenizer.decode(input_tensor[0, len(input_ids):])逻辑说明:temperature越小,分布越尖锐,输出越保守确定;越大越随机,但容易跑题。top_p是做核采样:只从累积概率达到top_p的最小 token 集合里采样,把长尾的低概率 token 直接剪掉,避免生成莫名其妙的内容。multinomial是真正做随机采样的函数,取到 EOS 就停止生成。
参数参考:偏客服、偏事实回答的场景,temperature=0.6、top_p=0.85效果比较稳;偏闲聊、希望回答更丰富的场景,可以调到temperature=0.9、top_p=0.95。注意 temperature 太大(比如大于 1.5)时,回答会明显开始语无伦次。
4.4 训练时怎么判断模型好了没:不要只看 Loss
Loss 下降不完全等于回答质量好,尤其是生成任务。我的习惯是每个 epoch 结束后,固定准备 10 个测试问题,用当前 checkpoint 走一遍生成,肉眼扫一眼回答是否通顺、是否切题。这比盯着验证集 loss 数字直观得多。
一个小技巧:把这个问题集写死在验证脚本里,每个 epoch 的输出都打印出来对比,你能直观看到模型从「胡言乱语」到「勉强通顺」再到「基本能答」的变化过程。如果训练到第 10 个 epoch 生成结果和 5 个 epoch 没什么区别,说明模型容量已经饱和,再训练下去大概率只是过拟合训练集,该停了。
5. 训练与部署避坑:5 个让单轮机器人翻车的典型问题
5.1 训练 Loss 降得很低,但生成的全是复读机
现象:验证集 loss 一路降到 0.5 以下,看起来收敛得不错,但实际输入任何问题,模型都只会回答同一句话,比如「我不知道」或者「好的呢」。
原因:最常见的有两个。一是数据里重复问题太多,模型发现「大部分问题对应的回答都是同一句」,直接学成了捷径;二是label_mask写错了,导致模型只在学[BOS]、[EOS]这些固定 token 的预测,真正该学的回答内容没参与 loss 计算。
解决:先用一条命令统计数据集里重复问题的数量,把重复度高的问题丢掉或做近义改写;再检查label_mask的长度是否和序列长度一致——我一般会在训练脚本里加一行断言,assert label_mask.shape == labels.shape,不相等立刻报错。最后,把训练集里回答文本过长或过短的样本清洗掉,避免模型学到「不管什么问题都套固定模板」。
5.2 中文对话效果差,英文反而还行
现象:同一个模型配置,英文数据生成出来的回答还算通顺,中文数据生成结果字与字之间不连贯,经常出现「我 觉 得 你 说 的 很」这种断裂。
原因:中文分词方案和序列长度不匹配。如果用了词级分词但词表覆盖不够,很多常见词被切成单字,模型学到的语言模式碎片化;又或者max_len设得太小,中文的一句话本来需要的 token 就比英文多,被大量截断后,模型根本没见过完整的句子结构。
解决:中文场景无脑用字级切分,或者自己用 SentencePiece 训练一个中文 BPE 词表,不要直接套英文 BPE 词表。max_len建议设 128 以上,做完数据预处理后统计一下样本长度的分布,保证 95% 以上的训练样本不会被截断。另外检查一下数据清洗时是否把中文标点全删了——句号、逗号、问号这些是模型学习句子节奏的重要信号,删掉后生成质量会明显下降。
5.3 推理时生成停不下来,输出一段超长重复文本
现象:输入问题后模型开始生成,但一直不输出 EOS,不断重复「你好你好你好……」直到达到max_new_tokens上限。
原因:训练数据没有正确让模型学会 EOS 的语义。如果数据里每条回答的末尾EOS_ID缺失,或者label_mask把 EOS 位置设成了 0,模型在推理时根本不知道什么时候该停止。另一个可能是训练轮数太少,EOS 对应的参数还没充分更新。
解决:检查build_sample里回答末尾是否正确加上了EOS_ID,并且label_mask中 EOS 位置的值为 1。训练里把 EOS 的预测单独拎出来观察——在验证阶段统计 EOS 被正确预测的比例,如果这个比例低于 80%,说明模型还没学会收尾,需要继续训练或检查数据。推理时设置max_new_tokens=40作为硬性上限,防止个别坏样本无限生成。
5.4 同一个问题每次生成的回答都不一样,时好时坏
现象:输入完全相同的一句话,第一次生成「图书馆在行政楼一楼」,第二次生成「图书馆开门时间是早上八点」,内容一会儿对一会儿错。
原因:这个场景下大多数情况不是模型坏了,而是model.train()没有被切回model.eval()。训练模式下 Dropout 和 LayerNorm 的行为和推理模式不同,导致输出分布不稳定。另外温度参数设置过高、top_p设置过大也会让每次采样结果漂移明显。
解决:推理脚本里必须在torch.no_grad()之外显式调用model.eval(),这和no_grad()是两回事——前者关 Dropout,后者关梯度计算,缺一不可。如果切换后还有波动,把temperature从 0.9 降到 0.7,top_p从 0.95 降到 0.9,效果会稳定很多。做自动化评测时,把所有生成参数固定住,包括随机种子,否则每次跑都不一样,没法对比两版模型的好坏。
5.5 训练时 OOM,batch_size 已经很小了还是爆显存
现象:batch_size降到 8 还是会 OOM,日志里报CUDA out of memory,但模型参数量明明不大。
原因:Transformer 的显存占用不仅取决于参数量,更取决于序列长度和 batch 的乘积。max_len=128、batch_size=32时,中间激活值的大小是参数量好几倍。还有一个隐蔽原因:数据加载时没有做 padding 前的长度分组,一个 batch 里只要有一条超长样本,整个 batch 都会被 pad 到max_len,白白浪费大量显存。
解决:做长度分桶——把样本按长度分成几组,比如 0-32、32-64、64-128 三桶,同一桶内的样本才放进同一个 batch,这样 pad 比例大幅下降。代码层面用torch.utils.data.BatchSampler配合collate_fn实现,或者在生成 batch 时动态计算该 batch 的max_len并只 pad 到本 batch 最长样本。另外可以用gradient_accumulation_steps=4,每次小 batch 算梯度,累积 4 次再更新参数,等效于大 batch 的训练效果,显存占用不变。
6. 效果验证与最终交付:把模型从「能跑」升级到「能聊」
6.1 先看生成样例,再算自动指标
模型训练完,第一件事不是跑 BLEU,而是把 20 到 30 个覆盖典型场景的测试问题输入进去,逐个看生成结果。我看三个维度:通顺度(句子是否完整)、相关性(是否切题)、信息量(是否给出有效信息还是空话)。这三个维度过了,再跑 BLEU 或 ROUGE 才有点参考意义——对自由生成的对话,BLEU 分数本身不可靠,因为一个意思可以有很多种表达方式,参考答案只覆盖了一部分。
验证脚本的核心逻辑很简单:加载 checkpoint,构造问题列表,循环生成,打印。我一般会把模型在 CPU 上跑一遍,确认部署环境没有 GPU 也能正常推理。这一步很重要,因为很多项目的训练跑在 GPU 服务器上,交付给别人的时候只在普通电脑上运行,CPU 推理速度也是交付质量的一部分。
6.2 最小可用的本地聊天服务
如果要把模型交付成可用的脚本形态,我会写一个最小封装。最终效果是:启动一个本地命令或一个 HTTP 服务,输入问题,返回回答。用 FastAPI 做封装时,核心逻辑和训练脚本几乎没有差别,只是外面套了一层接口:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class ChatRequest(BaseModel): question: str class ChatResponse(BaseModel): answer: str @app.post("/chat", response_model=ChatResponse) def chat(req: ChatRequest): answer = generate(model, tokenizer, req.question) return ChatResponse(answer=answer)逻辑说明:模型和 tokenizer 在服务启动时加载一次,不要在每个请求里重新初始化,否则模型加载几秒钟、回答生成几十毫秒,体验全毁在初始化上了。CPU 上跑 3 层小模型,单条回答一般在 100 毫秒到 300 毫秒之间,如果业务能接受这个延迟,纯 CPU 部署完全可行。
6.3 交付时别忘了验证「能跑通」这件事
项目包里通常包含了源码、数据集、模型权重和项目使用说明。交付或自用之前,我最后的习惯动作是:换一台干净环境,按使用说明从头到尾走一遍,从安装依赖到训练脚本再到推理脚本,中间不做任何「我记得这个环境有这个包」的假设。跑不通就回头改使用说明,直到换环境也能一遍过。
这个习惯救过我很多次——模型权重文件路径写错、数据集路径写死成绝对路径、依赖库版本锁太死导致新版装不上,这些问题都在「换干净环境跑一遍」时暴露出来。路径建议全部用相对路径,依赖统一写在requirements.txt里,说明里写清楚 Python 版本和 PyTorch 版本范围,而不是只贴一行pip install torch。
单轮对话机器人这个方向,把数据质量、mask 正确性、采样参数这三件事做好,一个小模型已经能给出不错的回答效果。如果后续想升级,可以往两个方向走:一是把数据扩展成多轮对话格式,让模型看到历史上下文;二是用这个小模型做数据筛选,把生成的错误样本挑出来喂回训练集做迭代。先把手头这一版做稳定,再谈进阶,希望帮到你。
本文还有配套的精品资源,点击获取