Seq2Seq:从零实现一个简单的对话生成模型
虽说比不上一线大神,不过做 AI 时间久了以后,或多或少也有了一些心得体会。至于你说我为什么敢腆着脸,写这么一套算不上教材的教材,无他,唯手熟耳。
最近这段时间,突然很想聊一下如何从零搭建一个语言模型。考虑到大多数普通开发者的经费和算力有限,这个系列会从基础结构讲起,逐步实现、训练一个小型语言模型,再向 GPT-2 这类 Transformer 语言模型的结构靠近。
这里需要先说清楚:实现相似的网络结构、训练相近参数规模的模型,以及达到相近的语言能力,是三件不同的事。模型效果还取决于数据量、数据质量、训练预算和训练方法。
就好像你暂时造不出先进制程的芯片,也可以在《我的世界》里搭出逻辑电路,或者用分立元件做一个能完成简单运算的处理器。规模可以小一些,原理却值得亲手走一遍。
那么,作为这个系列的第一篇,我们先来做一个简单的对话生成模型。
比如你问它:
今天你好吗?
我们希望它能够回答:
我很好,你怎么样呀?
要实现这种“读入一句话,再生成另一句话”的功能,可以从Seq2Seq(Sequence-to-Sequence,序列到序列)入手。本文使用两个 LSTM,分别负责读取输入和生成回答,先实现一个没有注意力机制的基础版本。
本文的目标是跑通数据处理、模型定义、训练和生成流程。后面的两条示例数据只用于演示,不能让模型获得通用聊天能力。
一、什么是 Seq2Seq?
Seq2Seq 的基本任务,是根据一个输入序列,生成另一个输出序列。两者的长度可以不同,内容也不必一一对应。
常见的应用包括:
- 机器翻译:根据中文句子生成英文句子。
- 语音识别:根据音频特征序列生成文字序列。
- 文本摘要:根据一篇文章生成较短的摘要。
- 对话生成:根据用户输入生成回复。
Seq2Seq 不等同于 RNN。RNN、LSTM 和 GRU 可以用于构建 Seq2Seq 模型,Transformer 也可以完成序列到序列的任务。
本文采用的是经典的编码器—解码器结构:
| 部分 | 本文中的实现 | 作用 |
|---|---|---|
| 编码器 Encoder | Embedding + LSTM | 读取输入,得到最后的隐藏状态和细胞状态 |
| 解码器 Decoder | Embedding + LSTM + Linear | 根据编码结果和已有输出,逐步预测回答 |
理解这个基础版本,有助于理解编码、条件生成,以及为什么后来会引入注意力机制。但不能把它理解成现代大语言模型中必然存在的内部模块。例如,GPT-2 使用仅解码器的 Transformer 结构,并不包含本文这样的 LSTM 编码器和解码器。
二、先理解输入和输出是怎样分开的
1. RNN 并不要求输入一句话就同步输出一句话
处理文本时,我们通常先把文本转换成 token 序列。token 是分词器划分出的基本单位,可能是字、词、子词或标点,不一定对应一个汉字。
RNN 每读取一个 token,就结合当前输入和上一个时间步的隐藏状态,更新自己的状态。是否在这个时间步输出预测结果,取决于任务设计。
所以,“输入一个字,就必须立即输出一个字”并不是 RNN 的限制。单个 RNN 也可以先读取提示文本,再继续生成内容。
编码器—解码器结构提供了一种清晰的组织方式:编码器先读取输入,解码器再生成回答,两个阶段分别使用自己的时间步。
比如输入:
唐先生,您外边走走去?快中午啦!
我们希望生成:
掌柜的,捧捧场,收我两文钱行不?
输入有多少个 token,不决定回答必须有多少个 token。回答何时结束,可以通过模型生成的结束标记来判断。
2. 编码器保留的是学到的表示
在本文的基础模型中,编码器逐个读取输入 token,最后得到两个状态:
hidden:LSTM 的隐藏状态。cell:LSTM 的细胞状态。
我们把它们传给解码器,作为解码器的初始状态。
可以把这个过程理解成:编码器读完一句话,形成一份供解码器使用的“笔记”。但这份笔记是模型学到的数值表示,不保证完整记录原文的全部内容。
无论输入长短,本文传给解码器的状态维度都保持不变。这也会带来信息压缩的瓶颈:句子越长、细节越多,模型越难把需要的信息都保留下来。后面学习注意力机制时,我们还会回到这个问题。
3. 开始标记和结束标记
为了控制生成过程,我们需要两个特殊标记:
BOS:Beginning of Sequence,序列开始。EOS:End of Sequence,序列结束。
本文借用日语 BERT 分词器已有的特殊 token:用[CLS]充当解码开始标记,用[SEP]充当结束标记。这是本例的约定,不代表它们在所有模型里都承担这个角色。
目标回答在预处理后可以表示为:
[CLS] 回答的第一个 token 回答的第二个 token …… [SEP]开始生成时,我们人为给解码器输入[CLS],同时用编码器得到的hidden和cell初始化它。解码器随后预测回答的第一个 token,再逐步生成后续 token。
注意,[CLS]是我们提供的起始输入,不是模型需要预测的第一个 token。模型需要学会预测的是回答内容,以及最后的[SEP]。
三、动手之前需要准备什么?
本文使用:
- Python 3.12。
- PyTorch。
- Transformers,以及日语分词需要的
fugashi、unidic_lite。 - 输入—输出成对的数据。
可以使用 Linux,也可以使用其他能正常运行这些依赖的系统。RTX 4080 属于可选的训练设备;本文的小样本演示也可以使用 CPU,只是运行速度会受硬件和环境影响。
先按照 PyTorch 官方安装说明安装适合自己系统和计算设备的 PyTorch,再在终端安装其他依赖:
python-mpipinstalltransformers fugashi unidic_lite如果使用 Jupyter Notebook,可以在单元格中运行:
%pip install transformers fugashi unidic_lite%pip是 Notebook/IPython 命令,不要把这一行放进普通 Python 脚本。首次加载分词器时,需要能够下载对应的配置和词表;已缓存时可以复用本地文件。
为方便复现实验,确认环境能正常运行后,应记录并固定实际使用的依赖版本。下面会显式指定 MeCab 使用unidic_lite,避免依赖不同 Transformers 版本中的默认字典设置。
四、先把整个流程理顺
我手上正好有一些日语对话数据,这里就用日语举例。换成其他语言时,除了更换数据,还需要选择适合该语言的分词器。
训练数据是输入—输出对,并不一定要是字面上的“一问一答”。对话中的发言和回复、翻译中的原文和译文,都可以采用这种形式。
整个实现分为四步:
- 数据准备:把输入文本和目标文本分别转换为 token ID 序列。
- 编码:通过 Embedding 将输入 ID 映射成向量,再用 LSTM 读取整个输入。
- 训练解码:根据编码器状态和前面的 token,预测回答中的下一个 token,并计算损失。
- 推理生成:从开始标记出发,反复使用模型自己预测的 token,直到遇到结束标记或达到长度上限。
这里尤其要区分两件事:分词器得到的是整数 ID;Embedding 层才把这些 ID 映射成模型使用的稠密向量。
另外,我们只借用 BERT 的分词器,不加载 BERT 模型权重。下面的 Embedding、LSTM 和输出层,都是从随机初始化开始训练的。
五、实现模型
下面的 Python 代码块按顺序组成完整示例。可以依次在 Notebook 中运行,也可以依次放入同一个 Python 脚本;前面的安装命令除外。
1. 引入依赖,设置设备和分词器
importrandomimporttorchimporttorch.nnasnnimporttorch.optimasoptimfromtransformersimportAutoTokenizer# 固定常用随机种子,便于比较实验;跨硬件、跨版本不保证逐位一致。SEED=42random.seed(SEED)torch.manual_seed(SEED)iftorch.cuda.is_available():torch.cuda.manual_seed_all(SEED)device=torch.device("cuda"iftorch.cuda.is_available()else"cpu")# 显式指定字典,与上面的安装命令保持一致。tokenizer=AutoTokenizer.from_pretrained("cl-tohoku/bert-base-japanese",mecab_kwargs={"mecab_dic":"unidic_lite"},)BOS_ID=tokenizer.cls_token_id EOS_ID=tokenizer.sep_token_idifBOS_IDisNoneorEOS_IDisNone:raiseValueError("当前示例需要分词器提供 [CLS] 和 [SEP]。")# 包括特殊 token 在内的词表大小。INPUT_DIM=len(tokenizer)OUTPUT_DIM=len(tokenizer)ENC_EMB_DIM=256DEC_EMB_DIM=256HID_DIM=512MAX_LEN=128print("运行设备:",device)print("PyTorch 版本:",torch.__version__)这里使用同一个日语分词器处理输入和输出,所以两个词表大小相同。更一般的 Seq2Seq 模型也可以分别使用输入词表和输出词表。
2. 定义 Encoder
classEncoder(nn.Module):def__init__(self,input_dim,emb_dim,hid_dim):super().__init__()self.embedding=nn.Embedding(input_dim,emb_dim)# 默认 batch_first=False,输入形状为 (序列长度, 批大小, 特征维度)。self.rnn=nn.LSTM(emb_dim,hid_dim)defforward(self,src):# src: (src_len, batch_size),内容为 token ID。embedded=self.embedding(src)# embedded: (src_len, batch_size, emb_dim)_,(hidden,cell)=self.rnn(embedded)# 本文是单层、单向 LSTM:# hidden、cell: (1, batch_size, hid_dim)returnhidden,cell编码器没有直接输出一串文字。它最后返回hidden和cell,供解码器初始化状态。
本文没有使用注意力机制,因此暂时不保留编码器每个时间步的输出。以后加入注意力机制时,这些逐步输出会派上用场。
3. 定义 Decoder
classDecoder(nn.Module):def__init__(self,output_dim,emb_dim,hid_dim):super().__init__()self.embedding=nn.Embedding(output_dim,emb_dim)self.rnn=nn.LSTM(emb_dim,hid_dim)self.fc_out=nn.Linear(hid_dim,output_dim)defforward(self,input_token,hidden,cell):# input_token: (batch_size,)embedded=self.embedding(input_token.unsqueeze(0))# embedded: (1, batch_size, emb_dim)output,(hidden,cell)=self.rnn(embedded,(hidden,cell))logits=self.fc_out(output.squeeze(0))# logits: (batch_size, output_dim)returnlogits,hidden,cell解码器每次处理一个时间步:接收一个 token 和当前状态,输出更新后的状态,以及对下一个 token 的预测分数。
fc_out把隐藏状态映射到目标词表大小。输出的logits是未经 softmax 归一化的分数,不是概率。训练时直接把 logits 交给CrossEntropyLoss;贪心解码时直接取最大分数的位置即可,因为 softmax 不会改变最大值对应的位置。
4. 理解 teacher forcing
训练解码器时,我们已经知道正确回答,因此不必总把模型刚刚预测的 token 作为下一步输入。
假设模型在某一步预测错了,接下来可以有两种选择:
- 把预测错的 token 继续喂回模型。
- 把这一位置的真实 token 喂回模型,让它在正确的前文条件下学习下一步。
第二种方式叫作teacher forcing,教师强制。
例如,完整目标序列是[CLS], A, B, [SEP],完全使用 teacher forcing 时,对齐关系如下:
| 时间步 | 解码器输入 | 该步预测目标 |
|---|---|---|
| 第 1 步 | [CLS] | A |
| 第 2 步 | A | B |
| 第 3 步 | B | [SEP] |
代码中的teacher_forcing_ratio控制使用真实 token 的概率。设为1.0时,训练始终使用真实前文;设为0.0时,开始标记之后都使用模型自己的预测。
本文先用1.0演示标准的监督训练。需要注意,推理时没有真实回答可用,只能依靠模型已经生成的内容。这种训练与推理条件的差异,也是序列生成中需要考虑的问题。混合使用真实 token 和预测 token 是一种可供实验的策略,不保证一定改善效果。
5. 组合为 Seq2Seq
classSeq2Seq(nn.Module):def__init__(self,encoder,decoder):super().__init__()self.encoder=encoder self.decoder=decoderdefforward(self,src,trg,teacher_forcing_ratio=1.0):# src: (src_len, batch_size)# trg: (trg_len, batch_size),首个 token 为 BOS。iftrg.size(0)<2:raiseValueError("目标序列至少应包含开始和结束标记。")ifnot0.0<=teacher_forcing_ratio<=1.0:raiseValueError("teacher_forcing_ratio 必须位于 [0, 1]。")hidden,cell=self.encoder(src)input_token=trg[0]predictions=[]fortinrange(1,trg.size(0)):logits,hidden,cell=self.decoder(input_token,hidden,cell)predictions.append(logits)predicted_token=logits.argmax(dim=1)use_teacher_forcing=random.random()<teacher_forcing_ratio input_token=trg[t]ifuse_teacher_forcingelsepredicted_token# 不预测开头的 BOS,因此输出比完整目标序列短一个位置。# outputs: (trg_len - 1, batch_size, output_dim)returntorch.stack(predictions,dim=0)这个类负责把编码和解码串起来。需要注意,本实现返回的第一个预测已经对应trg[1],因此计算损失时,预测结果直接与trg[1:]对齐,不要再把预测结果的第一步删掉。
六、准备数据并开始训练
1. 预处理输入—输出对
defencode_text(text):# 显式添加起止符,预留两个位置,使总长度不超过 MAX_LEN。content_ids=tokenizer.encode(text,add_special_tokens=False,max_length=MAX_LEN-2,truncation=True,)return[BOS_ID]+content_ids+[EOS_ID]defpreprocess_data(dialogues):data=[]forinput_text,target_textindialogues:src_ids=encode_text(input_text)trg_ids=encode_text(target_text)# 当前每次处理一条样本,所以 batch_size=1。src=torch.tensor(src_ids,dtype=torch.long).unsqueeze(1)trg=torch.tensor(trg_ids,dtype=torch.long).unsqueeze(1)data.append((src,trg))returndata训练和推理都使用同一个encode_text,确保分词、特殊标记和长度限制一致。这里的截断规则只用于简化演示;处理真实数据时,应检查截断是否损害了样本含义。
这份实现逐条训练,不把不同长度的样本拼成一个批次,因此暂时不需要 padding。
2. 初始化模型、优化器和损失函数
encoder=Encoder(INPUT_DIM,ENC_EMB_DIM,HID_DIM)decoder=Decoder(OUTPUT_DIM,DEC_EMB_DIM,HID_DIM)model=Seq2Seq(encoder,decoder).to(device)optimizer=optim.Adam(model.parameters(),lr=1e-3)criterion=nn.CrossEntropyLoss()编码器和解码器在这里使用相同的隐藏维度,并且都是单层、单向 LSTM,因此状态可以直接传递。若以后改变层数、方向或隐藏维度,需要相应地调整状态连接方式。
当前没有补齐用的[PAD],所以损失函数不需要设置ignore_index。以后实现变长批处理时,应让损失忽略目标中的 padding,并妥善处理编码器输入的有效长度;仅仅忽略目标 padding 并不能解决编码器末状态被补齐部分影响的问题。
3. 定义训练函数
deftrain_epoch(model,data,optimizer,criterion,clip=1.0):ifnotdata:raiseValueError("训练数据不能为空。")model.train()model_device=next(model.parameters()).device total_loss=0.0total_tokens=0# 使用打乱后的副本,不修改原数据列表。shuffled_data=random.sample(data,k=len(data))forsrc,trginshuffled_data:src=src.to(model_device)trg=trg.to(model_device)optimizer.zero_grad()logits=model(src,trg,teacher_forcing_ratio=1.0)output_dim=logits.size(-1)targets=trg[1:].reshape(-1)loss=criterion(logits.reshape(-1,output_dim),targets)loss.backward()torch.nn.utils.clip_grad_norm_(model.parameters(),clip)optimizer.step()# 按预测 token 数加权,统计本轮训练中的平均损失。token_count=targets.numel()total_loss+=loss.item()*token_count total_tokens+=token_countreturntotal_loss/total_tokensloss.backward()计算梯度,optimizer.step()更新参数。中间的梯度裁剪用于限制梯度范数,帮助缓解循环网络训练中可能出现的梯度爆炸。
这里统计的是本轮各次更新时的训练损失,主要用来观察学习过程。训练损失下降,不等于模型已经能够回答没见过的问题。
4. 运行训练
dialogues=[("おはようございます。","今日はとても良いお天気ですね。"),("お昼ご飯に行きましょうか?","ぜひ行きましょう。"),]train_data=preprocess_data(dialogues)# 这里只演示流程;10 轮不保证模型已经记住样例。N_EPOCHS=10forepochinrange(N_EPOCHS):train_loss=train_epoch(model,train_data,optimizer,criterion)print(f"Epoch{epoch+1:02d}| Train loss:{train_loss:.4f}")这两条数据、十轮训练总共只有二十次参数更新。运行结束后,模型可能仍然输出不完整、重复或不相关的内容。
如果只是检查模型能否记住样例,可以在观察损失和生成结果的同时增加训练轮数。但即使它最终记住了这两条回答,也只是完成了一个小样本拟合实验,不能据此判断它拥有聊天能力。
七、让模型生成回复
训练时可以使用真实回答;实际生成时,模型只能根据输入句子和自己已经生成的 token 继续往下预测。
本文使用贪心解码:每一步都选择分数最高的 token。它实现简单,但不保证得到整个序列中最好的回答。束搜索(beam search)可以保留多个候选序列,不过本文先不展开。
@torch.no_grad()defgenerate_response(model,sentence,max_new_tokens=50):ifmax_new_tokens<1:raiseValueError("max_new_tokens 必须大于 0。")model.eval()model_device=next(model.parameters()).device src_ids=encode_text(sentence)src=torch.tensor(src_ids,dtype=torch.long,device=model_device).unsqueeze(1)hidden,cell=model.encoder(src)input_token=torch.tensor([BOS_ID],dtype=torch.long,device=model_device)generated_ids=[]for_inrange(max_new_tokens):logits,hidden,cell=model.decoder(input_token,hidden,cell)input_token=logits.argmax(dim=1)next_id=input_token.item()ifnext_id==EOS_ID:breakgenerated_ids.append(next_id)returntokenizer.decode(generated_ids,skip_special_tokens=True)model.eval()将模型设为评估模式,torch.no_grad()关闭梯度记录。二者作用不同:仅仅调用eval(),不会自动关闭梯度计算。
本例中的 LSTM 没有启用 dropout,也没有批归一化层,但仍然保留这一写法,方便以后扩展模型。
下面先检查训练样例上的生成结果:
forquestion,targetindialogues:response=generate_response(model,question)print("输入:",question)print("训练目标:",target)print("模型生成:",repr(response))print()这里使用repr,方便看清空字符串和空格。如果模型第一步就生成结束标记,返回的回答会是空字符串;日语分词器解码后也可能在 token 之间保留空格。这些展示现象需要和模型是否学会回答分开判断。
再试一个没有出现在这两条训练数据中的输入:
new_question="こんにちは。"print("新输入:",new_question)print("生成回复:",repr(generate_response(model,new_question)))这只是观察模型行为,不是正式的泛化能力评测。不要期待模型仅凭两条样本就理解这个新输入。正式实验应准备独立的验证集和测试集,避免把训练集上的记忆效果当成泛化能力。
八、这个模型已经做到了什么,还缺什么?
到这里,我们已经实现了一个基础的 LSTM Seq2Seq 模型:它能读取输入序列,通过监督训练学习目标序列,并在推理时逐个生成 token。
它还存在几个明显限制:
- 信息压缩瓶颈。解码器只获得编码器最后的两个状态,无法直接访问输入的逐个位置。
- 数据不足。两条数据只能演示代码流程,无法覆盖真实对话中的表达和语义。
- 没有多轮上下文。当前只把这一句输入交给模型,尚未加入对话历史。
- 训练和生成条件不同。训练时使用真实前文,生成时使用自己的预测,错误可能逐步累积。
- 尚未实现高效批处理。当前一次训练一条样本,扩大数据规模后需要完善数据加载、padding 和有效长度处理。
下一步可以先扩大并清理数据,建立独立的评估集合,再给解码器加入注意力机制,让它在生成每个 token 时,能够参考输入序列中不同位置的信息。
理解这些限制以后,再去看 Transformer,你会更容易分清:哪些问题来自序列信息的表示,哪些来自训练方式,哪些又是数据和算力造成的。
参考资料
- Sutskever、Vinyals、Le:Sequence to Sequence Learning with Neural Networks。
- PyTorch:NLP From Scratch: Translation with a Sequence to Sequence Network and Attention。
- PyTorch:Chatbot Tutorial。
- Hugging Face:GPT-2 文档。
- Hugging Face:日语 BERT 分词器实现。