1. 为什么在2025年还要手写一个Seq2Seq对话模型
现在满屏都是“百亿参数”“万亿token”“多模态”,可我一上来却让你手写一个只用几十万参数就能跑通的Seq2Seq对话模型。不是走复古路线,而是因为大模型再花哨,骨子里的生成逻辑仍然逃不开这一个核心动作:根据已经看到的内容,预测下一个字。Seq2Seq恰恰把这个动作拆得明明白白。
很多人对大模型的第一认知是“它很复杂”,复杂到只能通过调用API来使用。但实际上,从GPT到Qwen这类生成式大模型,核心训练目标依然是“给定前文,预测下一个token”,也就是自回归语言建模。我们日常所谓的“对话能力”,不过是把用户输入的文本当作前文,模型逐步预测出后续的回复内容。而Seq2Seq的Decoder做的正是这件事——先接收一个开始信号,然后一个词一个词地把回复“吐”出来,每吐一个词,又把它当作下一步的输入,接着预测下一个词。
这个项目适合谁?两句话可以概括:第一,想真正搞懂大模型底层原理、而不是只停留在调用接口层面的人;第二,想把序列到序列、Encoder-Decoder、自回归生成、Teacher Forcing这些术语从“听说过”变成“自己写过”的人。看完这篇文章,你会得到一个用PyTorch实现的、可以在普通笔记本上跑起来的中文简单对话模型,训练脚本和推理脚本都能直接运行。
这里有个很实在的观点:大模型微调、部署、Prompt工程这些热词,本质上都建立在同一个底层能力上——理解模型是怎么从输入到输出的。把Seq2Seq这个最小闭环跑通,再看那些大模型的源码、文档、微调教程,视角会完全不同。
2. 动手之前,先把对话功能拆清楚
2.1 对话不是“查数据库”,而是“条件生成”
早期的聊天机器人,比如小黄鸡时代的很多产品,走的是“规则匹配”路线。用户说“你好”,系统就去知识库里找包含“你好”的回复模板,然后填一下变量输出。这种系统的优点是可控,缺点是遇到没见过的说法就直接傻眼。同样问“你好”和“您好在吗”,规则系统要写两条规则,生成式模型却能学会它们本质上是一回事。
我做的这个对话功能,走的是生成式路线。输入一句话,模型把这句话编码成一个紧凑的语义表示,然后在这个语义表示的引导下,逐字生成回复。这个“逐字生成”的过程,专业名叫自回归生成。它不依赖预设模板,理论上同一句话可以生成无限多种回复形式,这也是为什么大家说生成式对话更“聪明”。
理解这一点很重要,因为后面的代码几乎都围绕这个思想展开。Encoder负责“读懂”输入,Decoder负责“写出”回复。读得懂,才能写得出。
2.2 Seq2Seq的核心思想:Encoder读一句,Decoder写一句
Seq2Seq的架构可以类比成“接力跑”。第一棒是Encoder,它从头到尾读一遍输入句子,每读一个字,就更新一次自己的内部状态。整个句子读完,它手里握着一个浓缩了全句信息的向量——专业上叫上下文向量,或者简单理解成“这句话的语义摘要”。然后这个摘要被交给第二棒Decoder。
Decoder接过摘要后,从“开始符号”出发,每一步预测下一个输出字符。预测出“你”,就把“你”作为下一步的输入;接着预测“好”,再把“好”加进去……直到预测出“结束符号”,生成过程才停止。这个逐字循环,就是前面说的自回归生成。
如果拿现在的GPT系列大模型来对照,你会发现GPT本质上就是一个没有Encoder的Decoder。用户输入的Prompt直接作为初始上下文,模型不断预测下一个token,再把新token拼到上下文中继续预测。理解了Seq2Seq的Decoder,你就理解了GPT类模型生成答案的全部基本逻辑。
2.3 为什么选GRU,而不是LSTM或者Transformer
这版实现里我用了GRU作为循环单元。同样能完成这个任务的还有LSTM,甚至纯Transformer,但GRU在这个场景下有三个非常实际的优势。
GRU的参数量大约是LSTM的三分之二。它把LSTM的输入门、遗忘门、输出门精简成了更新门和重置门。对小数据集、小模型来说,这个精简不仅没有明显掉点,反而让训练更稳定、更快,CPU上也能一会儿就跑完。
那为什么不用Transformer?不是不行,而是对刚入门的同学来说,Transformer的Self-Attention、Multi-Head、Positional Encoding等概念堆在一起,会掩盖核心的序列建模逻辑。先用GRU把“消息传递”的直觉建立起来,后面升级到Transformer时,自然知道每一步在做什么。我在文章最后也会给出升级路径。
3. 数据、字符表与Embedding:模型记忆的最小单元
3.1 造一个小而全的闲聊数据集
这个项目不依赖外部下载,我构造了一个小规模的中文问答对数据集。格式很简单,就是一个一个“输入—回复”的二元组:
PAIRS = [ ("你好", "你好!有什么可以帮你的吗?"), ("你是谁", "我是一个简单的对话机器人。"), ("你叫什么名字", "我叫小助手。"), ("谢谢", "不客气,很高兴能帮到你。"), ("再见", "再见,欢迎下次再来!"), ("今天天气怎么样", "查询天气需要联网,我这里暂时无法获取。"), ("你会做什么", "我可以陪你聊天,回答一些简单的问题。"), ("你多大了", "我的年龄取决于训练时间,准确说我还很年轻。"), ("你喜欢什么", "我喜欢和你聊天。"), ("我想学编程", "编程是个好技能,建议从Python开始学习。"), ("我很无聊", "可以试试看看书、听听音乐,或者和我聊聊天。"), ("给我讲个笑话", "为什么程序员总是分不清万圣节和圣诞节?因为Oct 31 == Dec 25。"), ("你吃饭了吗", "我不需要吃饭,只需要充电和训练数据。"), ("你很聪明", "谢谢夸奖,我还有很多需要学习的地方。"), ("什么是人工智能", "人工智能是让机器模拟人类智能的技术,我就是一个简单的例子。"), ]数据规模很小,一共15条,每条对话长度也很短。这决定了后面模型的定位:它是一个完整但简陋的最小可运行系统,用来把流程和原理跑通,而不是一个能陪你聊一整天的机器人。
这里有个实际建议:如果你自己玩,想看到更好的效果,可以把数据扩到几百条,比如找一些开源的闲聊语料,或者把自己常聊的高频话术整理成问答对。数据量每扩大一个数量级,生成结果的合理度都会有肉眼可见的提升。但作为教学演示,这个规模够了,训练速度快,调试起来也直观。
3.2 中文按字符切词,为什么不先做分词
对中文来说,常见的处理方式是先用jieba分词,把句子切成“你好”这样的词组。但在这个项目里,我选择直接按字符切分,也就是“你好吗”会变成三个字:你、好、吗。
这么做的理由有三点。第一,字级别的词表天然小巧,中文常用字也就几千个,而不分词的话词组数量会爆炸;第二,不需要额外引入分词库,项目依赖更少,复现门槛低;第三,对一个小型对话系统来说,让模型自己从字里学出语义边界,本身就是一次很好的实践。
切分完之后,我们需要把字映射成模型能处理的数字编号。这个映射表叫词表,通常用两个字典维护:word2index负责把字变成编号,index2word负责把编号变回字。构建词表的代码很直接:
PAD_TOKEN = 0 SOS_TOKEN = 1 EOS_TOKEN = 2 UNK_TOKEN = 3 def build_vocab(pairs): word2index = {"<PAD>": PAD_TOKEN, "<SOS>": SOS_TOKEN, "<EOS>": EOS_TOKEN, "<UNK>": UNK_TOKEN} index2word = ["<PAD>", "<SOS>", "<EOS>", "<UNK>"] for src, trg in pairs: for w in list(src) + list(trg): if w not in word2index: word2index[w] = len(index2word) index2word.append(w) return word2index, index2word特殊Token是这套系统里的“仪式感”。为什么要特意安排PAD、SOS、EOS、UNK四个?因为深度学习训练要求一个batch内的所有样本长度一致,短句子需要用PAD填充到相同长度。SOS告诉Decoder“回复从这里开始生成”,EOS告诉模型“回复到此结束”。UNK则兜底处理所有没见过的字,保证模型不会因为遇到生僻字而崩溃。这四个Token虽然不产生实际语义,但缺少任何一个,模型都会在训练或推理中出问题。
3.3 Embedding层到底在做什么
把字变成数字编号之后,模型还不能直接用这些数字。编号“5”和编号“6”之间没有天然的大小关系,如果直接把编号喂给GRU,模型会把编号的数值当成语义强度,这显然是错的。
所以每个编号要先经过Embedding层,换算成一个稠密向量。这个向量才是模型真正用来计算的“语义单位”。Embedding层本质上是一个查找表,每一行对应词表里的一个字,初始值是随机的,在训练过程中会不断更新。
用生活里的场景来类比:词表里的每个字相当于一个人,编号是它的身份证号,Embedding向量则是它的“性格画像”。刚初始化的时候,所有人的画像都是随机乱码,没任何信息量。随着训练数据不断冲刷,意思相近的字,比如“你”和“您”,画像会逐渐变得相似;语义无关的字则互不相干。这个“画像”就是模型对语言的全部理解原材料。
训练代码里,编码器和解码器各自持有自己的Embedding层,但是词表是共享的。这也是一个值得注意的设计选择:共享词表保证生成出来的字一定是输入侧见过的字,不引入额外的词表对齐问题。
4. Encoder-Decoder核心代码实现
4.1 Encoder:把一句话读进GRU
Encoder的核心任务,是把一个变长的输入序列压缩成一个固定维度的语义向量。实现上,它由一个Embedding层和一个GRU层组成。Embedding先对每个字的编号做查表,GRU再按顺序把每个字的向量读进去。
class Encoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, n_layers=1): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size) self.gru = nn.GRU(embed_size, hidden_size, n_layers, batch_first=True) def forward(self, x): embedded = self.embedding(x) outputs, hidden = self.gru(embedded) return outputs, hidden这里batch_first=True是PyTorch里一个容易踩坑的小细节。设置了它,输入张量的形状就是(batch, seq_len, embed_size),更接近人的直觉,否则默认形状是(seq_len, batch, embed_size),初学者经常会弄混维度。
GRU返回两个值:outputs是所有时间步的隐藏状态,形状是(batch, seq_len, hidden_size);hidden是最后一步的隐藏状态,也就是整个句子的语义摘要,形状是(num_layers, batch, hidden_size)。这个hidden就是我们要传给Decoder的“接力棒”。
有个现象值得说说:输入句子结尾通常有PAD填充字符。虽然模型读到了PAD,但GRU会把这些位置也当作普通字处理一遍。严格来说,这种做法不够严谨,因为PAD本身不携带语义。更专业的做法是用PyTorch的pack_padded_sequence把真实长度告诉GRU,让PAD位置不参与计算。但对短文本和小模型来说,影响非常有限,所以这个demo里不做也不会出问题。如果你去写生产级代码,我建议把这个优化加上。
4.2 Decoder:一个字一个字地吐出来
Decoder和Encoder的结构几乎对称,只是多了一个输出层。它的任务是在Encoder给出的hidden基础上,从SOS开始,逐步生成回复。每次输入一个字,GRU更新一次状态,线性层把隐藏状态映射到整个词表上,得到一个“哪个字最可能是下一个字”的分数分布。
class Decoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, n_layers=1): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size) self.gru = nn.GRU(embed_size, hidden_size, n_layers, batch_first=True) self.fc_out = nn.Linear(hidden_size, vocab_size) def forward(self, token, hidden): embedded = self.embedding(token) outputs, hidden = self.gru(embedded, hidden) logits = self.fc_out(outputs[:, -1, :]) return logits, hiddenself.fc_out就是前面说的输出层,它把GRU最新状态的hidden_size维向量,映射成vocab_size个分数。哪个字的分数最高,模型就倾向于“选”它作为下一个输出。
Decoder的“逐字生成”特性决定了它在训练和推理时行为不完全相同,这就引入了下面要讲的Teacher Forcing。
4.3 训练时要Teacher Forcing,推理时却不行
Teacher Forcing这个概念,初学的时候很容易懵。我举个例子你就明白了。
假设真实回复是“你好”,Decoder第一步需要预测目标“你”。训练时有两种做法:一种是让Decoder用自己的预测结果作为下一步输入,但在训练早期,模型几乎全是瞎猜,猜错了就会导致错误不断累积,越错越离谱。另一种做法是直接告诉它“上一步的标准答案就是‘你’”,把真实字符喂给下一步。第二种做法就叫Teacher Forcing,翻译过来就是“老师逼着你在正确轨道上走”。
代码里的体现,就是Seq2Seq的前向函数:
class Seq2Seq(nn.Module): def __init__(self, encoder, decoder, device): super().__init__() self.encoder = encoder self.decoder = decoder self.device = device def forward(self, src, trg, teacher_forcing_ratio=0.5): batch_size = src.size(0) trg_len = trg.size(1) vocab_size = self.decoder.fc_out.out_features outputs = torch.zeros(batch_size, trg_len, vocab_size).to(self.device) _, hidden = self.encoder(src) input_token = trg[:, 0].unsqueeze(1) for t in range(1, trg_len): logits, hidden = self.decoder(input_token, hidden) outputs[:, t, :] = logits teacher_forcing = torch.rand(1).item() < teacher_forcing_ratio if teacher_forcing: input_token = trg[:, t].unsqueeze(1) else: input_token = logits.argmax(1).unsqueeze(1) return outputs注意trg[:, 0]就是SOS,是所有样本的“起跑信号”。循环从t=1开始,每一步要么用真实目标字(Teacher Forcing),要么用模型自己刚生成的argmax结果(自回归)。输出的logits保存在outputs里,后面计算损失时会拿它和真实的trg做比对。
经验上,Teacher Forcing比例设成0.5是常见的起步值。0.5的意思是有一半时间用真实值喂下一步,另一半时间用模型自己的预测,既保证训练早期不致崩溃,也给模型逐步学会独立预测的机会。也可以从1.0慢慢衰减到0.1,效果通常会更好。
4.4 损失函数的坑:忽略PAD位置
对话回复里PAD被用来补齐长度。如果损失函数把PAD也当成普通目标来算错误率,模型会花大量精力去“学习”输出无意义的PAD,本质上是在做无用功,而且会让训练过程变得不稳定。
解决办法是用CrossEntropyLoss的ignore_index参数,直接让PAD位置的损失归零:
criterion = nn.CrossEntropyLoss(ignore_index=PAD_TOKEN)计算损失时还要注意,outputs第0个时间步是空的,我们实际上是从第1步开始预测。所以标准做法是拿outputs[:, 1:]去对齐trg[:, 1:]:
loss = criterion(outputs[:, 1:].reshape(-1, vocab_size), trg[:, 1:].reshape(-1))这个细节如果没处理好,模型会学得莫名其妙。我在早期写Seq2Seq时就掉进过这个坑,当时loss怎么都降不下去,排查半天才发现是把SOS位置也当成目标算了。
5. 训练与推理:从损失值下降到能“聊天”
5.1 超参数设置与训练脚本
整体代码框架已经齐全,现在把训练流程串起来。超参数我贴在下面,并简单解释每个值的来历:
EMBED_SIZE = 128 HIDDEN_SIZE = 256 N_LAYERS = 1 BATCH_SIZE = 8 EPOCHS = 300 LR = 0.001 TEACHER_FORCING_RATIO = 0.5 MAX_LEN = 20Embedding维度和隐藏状态维度设成128和256,对这个小任务来说足够有余。学习率用0.001,这是Adam优化器在文本任务上最常用的起始点。BATCH_SIZE设8,因为数据集只有15条,太小会浪费计算,太大会让每个epoch只更新一两次。
训练前,要把原始问答对转成模型能吃的编号张量。encode_pair函数负责做这件事:给输入句子在末尾补PAD,给目标句子在开头加SOS、末尾加EOS,然后截断到MAX_LEN:
def encode_pair(pair, word2index): src, trg = pair src_ids = [word2index.get(w, UNK_TOKEN) for w in list(src)][:MAX_LEN] trg_ids = [SOS_TOKEN] + [word2index.get(w, UNK_TOKEN) for w in list(trg)][:MAX_LEN - 2] + [EOS_TOKEN] src_ids = src_ids + [PAD_TOKEN] * (MAX_LEN - len(src_ids)) trg_ids = trg_ids + [PAD_TOKEN] * (MAX_LEN - len(trg_ids)) return torch.tensor(src_ids), torch.tensor(trg_ids)训练循环本身没什么神秘的地方,无非是标准的“算损失、清梯度、反向传播、更新参数”。这里额外做了一件事:每训练10个epoch,打印一次平均损失,并调用推理函数,看“你好”能回复出什么。这个简单验证能让你直观看到模型有没有在学。
实际跑下来,字符级词表大概六七十个字,初始损失大约在log(70)≈4.2附近。随着训练推进,损失会稳步下降到1.x甚至更低。如果训练200到300轮,一个典型的输出是:问“你好”,模型回复“你好!有什么可以帮你的吗?”,基本能复现训练数据里的标准回答。
但不讳言地说,数据量只有15条,模型很大程度上是在背答案而不是理解语言。你问一句“你是谁”,它大概率能给出标准回复“我是一个简单的对话机器人”,但你换一种问法,比如“你是什么”,它可能就答非所问了。这完全是正常现象,不是代码写错了,而是语料本身没有提供足够的语言规律。
5.2 推理时的生成控制:贪心搜索和终止条件
训练完成后,模型以eval模式进入推理状态。推理和训练的差异主要体现在上一步说的Teacher Forcing上:推理时没有任何真实回复可以引导,只能完全依赖模型自己的预测。
def predict(model, text, word2index, index2word, device): model.eval() src_ids = [word2index.get(w, UNK_TOKEN) for w in list(text)][:MAX_LEN] src = torch.tensor([src_ids]).to(device) with torch.no_grad(): _, hidden = model.encoder(src) input_token = torch.tensor([[SOS_TOKEN]]).to(device) result = [] for _ in range(MAX_LEN): logits, hidden = model.decoder(input_token, hidden) next_token = logits.argmax(1).item() if next_token == EOS_TOKEN: break if next_token not in (PAD_TOKEN, UNK_TOKEN): result.append(index2word[next_token]) input_token = torch.tensor([[next_token]]).to(device) return "".join(result)生成控制的核心策略是贪心搜索:每一