首页 / PyTorch 入门教程 / 聊天机器人原理

PyTorch 入门教程

聊天机器人原理

本教程共 60 篇 · 第 44 篇 · 更新于 2026-08-17 · 约 4 分钟阅读

PyTorch聊天机器人seq2seqLuong注意力对话生成掩码损失GRU

本节目标:搞懂检索式与生成式聊天机器人的区别,理解对话数据怎么整理成训练样本,并看懂 seq2seq + 注意力如何套用到「你一句、我一句」的对话生成上。

聊天机器人(Chatbot)分两大流派。

检索式(Retrieval-Based):从预置答案库里挑最合适的一句。客服机器人常用,局限也明显——答案库外的问法就抓瞎。

生成式(Generative):像翻译一样现想现说。输入你的话,输出模型自己组织的句子。上一章的 seq2seq 稍加改造,就是最经典的生成式对话模型:把「法语→英语」换成「你的话→机器人的回复」。

数据:从电影台词里挖对话

官方教程用康奈尔电影对话数据集(Cornell Movie-Dialogs Corpus):617 部电影里 9 千多个角色、22 万多次对话、30 多万句台词。

原始文件是角色一句句的台词,要整理成「上句-下句」的问答对:同一场对话里相邻两句配对,前一句当输入,后一句当目标。

Can we make this quick?   → Well, I thought we'd start with pronunciation.
Well, I thought...        → Not the hacking and gagging part. Please.

相邻两句一配对,30 万句台词就变成 22 万个训练样本,这个量级 CPU 也能训。

原始台词还要清洗:转 ASCII、小写、去多余标点。教程里还有两条过滤规则:句子超过 10 个词就丢掉,词频低于 3 的生僻词剪掉。22 万对筛完剩 6.4 万对,剪词后又剩 5.3 万对。

import re

def normalize_string(s):
    s = s.lower().strip()
    s = re.sub(r"([.!?])", r" \1", s)      # 标点前加空格
    s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)  # 其余符号换成空格
    return s

print(normalize_string("I'm kidding!?"))  # i m kidding ! ?

词表与特殊标记

对话数据量不小,词表管理要用点心思。教程里的 Voc 类维护词到索引的双向映射,还预置三个特殊标记:

  • PAD(0):填充符,把短句补到和 batch 里最长句一样长。
  • SOS(1):句子开始。
  • EOS(2):句子结束。
class Voc:
    def __init__(self, name):
        self.name = name
        self.word2index = {}
        self.index2word = {0: "PAD", 1: "SOS", 2: "EOS"}
        self.num_words = 3

    def add_sentence(self, sentence):
        for word in sentence.split(" "):
            if word not in self.word2index:
                self.word2index[word] = self.num_words
                self.index2word[self.num_words] = word
                self.num_words += 1

voc = Voc("movie")
voc.add_sentence("hello there")
print(voc.word2index)  # {'hello': 3, 'there': 4}

索引按词首次出现的顺序分配,先来的索引小。

出现次数少于 3 次的生僻词直接剪掉(Trim),包含生僻词的句子也一并丢弃。词表小了,训练更稳。

小批量与掩码

一个 batch 里句子长短不一,全部补零到最长句。张量形状转成 (max_length, batch_size),第一维是时间步,方便按步取整批数据。

补出来的 PAD 位置不该算损失。办法是生成一个掩码(Mask):真实词位置为 1,PAD 位置为 0,损失只统计掩码为 1 的部分。解码器输出是 softmax 概率,取目标词对应位置的概率,求负对数,再按掩码平均。掩码不只在算损失时有用,统计准确率、做分析都要排除 PAD 位。

def mask_nll_loss(inp, target, mask):
    n_total = mask.sum()
    cross_entropy = -torch.log(
        torch.gather(inp, 1, target.view(-1, 1)).squeeze(1))
    loss = cross_entropy.masked_select(mask).mean()
    return loss, n_total.item()

模型:双向编码器 + Luong 注意力

编码器用双向 GRU(Bidirectional GRU):一个方向正着读,一个方向倒着读,每个时间步的输出相加。这样每个词都能看到完整上下文,而不只是前面的词。

batch 里有填充,直接喂 GRU 会浪费计算。用 pack_padded_sequence 打包,跑完再 pad_packed_sequence 解开,RNN 就只处理真实长度。

import torch.nn as nn

class EncoderRNN(nn.Module):
    def __init__(self, hidden_size, n_layers=2, dropout=0.1):
        super().__init__()
        self.hidden_size = hidden_size
        self.gru = nn.GRU(hidden_size, hidden_size, n_layers,
                          dropout=dropout, bidirectional=True)

    def forward(self, embedded, lengths):
        packed = nn.utils.rnn.pack_padded_sequence(embedded, lengths)
        outputs, hidden = self.gru(packed)
        outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs)
        # 双向输出相加
        outputs = (outputs[:, :, :self.hidden_size]
                   + outputs[:, :, self.hidden_size:])
        return outputs, hidden
Note

pack_padded_sequence 默认要求 batch 里的句子按长度降序排好。教程的 batch2TrainData 会先排序再打包,别漏了这一步。

解码器用的是 Luong 注意力(第 43 章那种是 Bahdanau 风格,两者是同一思想的两个实现):每步拿解码器当前隐状态和所有编码器输出算相关度,softmax 成权重,加权求和得到上下文向量,和当前输出拼接后预测下一个词。相关度有三种算法:dot(点积)、general(过一层线性)、concat(拼接后过线性),小模型用 dot 就够。编码器和解码器共用同一个 embedding 层,词表一致,参数省一半。

Note

官方聊天机器人教程的早期版本用 torch.jitscript/trace 脚本化模型方便部署。这两个接口从 PyTorch 2.10 起已弃用,现在导出模型请用 torch.export(第 50 章会讲),本章代码不涉及。

训练技巧

对话模型训练有两个关键技巧,第 43 章讲过一半:

  1. 教师强制(Teacher Forcing):部分时间步喂真实答案当输入,加速收敛。比例调太高,推理时模型会「不会自己走路」。
  2. 梯度裁剪(Gradient Clipping):对话序列长,梯度容易爆炸,把梯度范数限制在阈值内(教程用 50),训练瞬间稳下来。
nn.utils.clip_grad_norm_(encoder.parameters(), 50.0)
nn.utils.clip_grad_norm_(decoder.parameters(), 50.0)

教程的配置参考:隐层 500、编码器解码器各 2 层 GRU、dropout 0.1、batch 64、学习率 0.0001,4000 次迭代损失能从 9 降到 2.5 左右。CPU 上要跑一阵,想快点就把 hidden_size 调小。

训练动辄几小时,中途得存档。教程把编码器、解码器、优化器和词表的状态字典打成一个字典存成 .tar 文件,断点续训或直接推理都方便(第 25 章讲过 state_dict)。

让机器人开口说话

训练好之后,交互流程叫贪心搜索解码(Greedy Search Decoding):用户的输入标准化后转成索引,编码器读一遍,解码器从 SOS 开始逐词生成,每步取概率最大的词,碰到 EOS 停止,最后把索引还原成句子,过滤掉 EOS/PAD 就是回复。

官方教程训出来的效果像这样:

> hello?
Bot: hello .
> where am I?
Bot: you re in a hospital .
> who are you?
Bot: i m a lawyer .

效果来自 22 万句电影台词和几小时训练,谈不上聪明,但已经能接住简单对话。交互入口通常是一个死循环:等你输入,输入 q 或 quit 退出,碰到词表外的词就捕获 KeyError 提示换个说法。

生成式对话的今天

用电影台词训出的 seq2seq 聊天机器人,是生成式对话的启蒙课。中间还出现过角色一致性、情感控制等改进方向,但骨架没变。今天的对话模型已经换成 Transformer + 海量语料 + 人类反馈对齐(RLHF),第 60 章的路线图会展开。但「输入编码 → 注意力取上下文 → 逐词生成」这条骨架,从 2016 年到现在没变过。

本章一句话:把翻译任务的「法语→英语」换成「用户→机器人」,数据换成电影台词问答对,加上掩码损失和梯度裁剪,一个能聊天的生成式机器人就立起来了。