
简介基于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, encodingutf-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, encodingutf-8) as f: json.dump(samples, f, ensure_asciiFalse, indent2) 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_freq3): 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_freq3是为了过滤低频字符降低词表噪声。如果你的数据里包含大量人名、特殊符号低频字符会占掉很多词表位置而且模型学不好它们过滤掉让它们统一走[UNK]是更稳的选择。字符级词表的好处是训练和推理时永远不会遇到 OOV词表外单词代价是序列长度变长训练速度变慢所以后面模型max_len设计要充分考虑这一点。3. 选型与落地的核心decoder-only 小模型怎么搭、参数怎么定3.1 为什么单轮场景选 decoder-only 而不是 encoder-decoderTransformer 用在对话生成上有两条主流路线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_maskNone): 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, devicex.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_model256, n_heads4, n_layers3, max_len128): 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, biasFalse) self.max_len max_len def forward(self, idx, attn_maskNone): 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, deviceidx.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共享权重是常见做法可以减少参数量设置biasFalse是因为共享后形状完全一致。max_len128意味着单条样本拼成序列后不能超过 128 个 token超出部分在数据阶段就要截断。3.3 关键参数怎么定一份可以直接抄的初始配置表训练一个单轮对话机器人参数不是越大越好而是要和你的数据量匹配。下面是我验证过很多次的一组初始配置适合 5 万到 20 万条单轮问答对的中文场景参数推荐值调整建议d_model256数据量大或显存充足可升到 512n_heads4必须能整除 d_modeln_layers310 万条以上数据可试 4 到 6 层max_len128中文按字切分时问题加回答一般不超过 128 字vocab_size由词表决定字符级通常在 2000 到 8000 之间batch_size32 到 64显存不够就降到 16配合梯度累积学习率峰值5e-4用小学习率 1e-4 起步更稳warmup_steps1000 到 2000防止训练初期 loss 剧烈震荡训练轮数15 到 30以验证集 loss 不再下降为准这个配置在 4GB 显存的 GPU 上就能跑CPU 上也能训练只是慢不少。新手建议先用 1 万条数据、d_model128、n_layers2把完整流程跑通确认代码没有 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, reductionnone) loss loss[mask_flat].mean() # 只统计回答部分 return loss逻辑说明cross_entropy加上reductionnone后返回每个位置的 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, epochs20): optimizer torch.optim.AdamW(model.parameters(), lr5e-4, weight_decay0.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(fepoch {epoch}, val_loss {val_loss / len(val_loader):.4f}) torch.save(model.state_dict(), fcheckpoint_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_tokens40, temperature0.8, top_p0.9): model.eval() input_ids tokenizer.encode(question, add_bosTrue) input_tensor torch.tensor([input_ids], dtypetorch.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, descendingTrue) 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_samples1).item() input_tensor torch.cat([input_tensor, torch.tensor([[next_id]])], dim1) 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 就停止生成。参数参考偏客服、偏事实回答的场景temperature0.6、top_p0.85效果比较稳偏闲聊、希望回答更丰富的场景可以调到temperature0.9、top_p0.95。注意 temperature 太大比如大于 1.5时回答会明显开始语无伦次。4.4 训练时怎么判断模型好了没不要只看 LossLoss 下降不完全等于回答质量好尤其是生成任务。我的习惯是每个 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_tokens40作为硬性上限防止个别坏样本无限生成。5.4 同一个问题每次生成的回答都不一样时好时坏现象输入完全相同的一句话第一次生成「图书馆在行政楼一楼」第二次生成「图书馆开门时间是早上八点」内容一会儿对一会儿错。原因这个场景下大多数情况不是模型坏了而是model.train()没有被切回model.eval()。训练模式下 Dropout 和 LayerNorm 的行为和推理模式不同导致输出分布不稳定。另外温度参数设置过高、top_p设置过大也会让每次采样结果漂移明显。解决推理脚本里必须在torch.no_grad()之外显式调用model.eval()这和no_grad()是两回事——前者关 Dropout后者关梯度计算缺一不可。如果切换后还有波动把temperature从 0.9 降到 0.7top_p从 0.95 降到 0.9效果会稳定很多。做自动化评测时把所有生成参数固定住包括随机种子否则每次跑都不一样没法对比两版模型的好坏。5.5 训练时 OOMbatch_size 已经很小了还是爆显存现象batch_size降到 8 还是会 OOM日志里报CUDA out of memory但模型参数量明明不大。原因Transformer 的显存占用不仅取决于参数量更取决于序列长度和 batch 的乘积。max_len128、batch_size32时中间激活值的大小是参数量好几倍。还有一个隐蔽原因数据加载时没有做 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_steps4每次小 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_modelChatResponse) def chat(req: ChatRequest): answer generate(model, tokenizer, req.question) return ChatResponse(answeranswer)逻辑说明模型和 tokenizer 在服务启动时加载一次不要在每个请求里重新初始化否则模型加载几秒钟、回答生成几十毫秒体验全毁在初始化上了。CPU 上跑 3 层小模型单条回答一般在 100 毫秒到 300 毫秒之间如果业务能接受这个延迟纯 CPU 部署完全可行。6.3 交付时别忘了验证「能跑通」这件事项目包里通常包含了源码、数据集、模型权重和项目使用说明。交付或自用之前我最后的习惯动作是换一台干净环境按使用说明从头到尾走一遍从安装依赖到训练脚本再到推理脚本中间不做任何「我记得这个环境有这个包」的假设。跑不通就回头改使用说明直到换环境也能一遍过。这个习惯救过我很多次——模型权重文件路径写错、数据集路径写死成绝对路径、依赖库版本锁太死导致新版装不上这些问题都在「换干净环境跑一遍」时暴露出来。路径建议全部用相对路径依赖统一写在requirements.txt里说明里写清楚 Python 版本和 PyTorch 版本范围而不是只贴一行pip install torch。单轮对话机器人这个方向把数据质量、mask 正确性、采样参数这三件事做好一个小模型已经能给出不错的回答效果。如果后续想升级可以往两个方向走一是把数据扩展成多轮对话格式让模型看到历史上下文二是用这个小模型做数据筛选把生成的错误样本挑出来喂回训练集做迭代。先把手头这一版做稳定再谈进阶希望帮到你。本文还有配套的精品资源点击获取