基于GPT2的古诗生成器:SFT 篇

发布于:2026-05-04 | 分类:machine learning


预训练模型只会"接龙",如何让它听懂"写一首思乡诗"这样的指令?本篇详解监督微调(SFT)的完整流程:从数据集构造、Completion-Only损失掩码,到7.7分钟完成训练,让模型从"续写机器"进化为"指令诗人"。

本文可以配合此处 源码 阅读和理解。

0. 从"续写"到"创作":为什么需要SFT

在上一篇中,我们训练了一个72M参数的GPT2模型,它在古诗续写上表现不错——给它"白日依山尽",它能往下接(虽然大概率不是"黄河入海流")。但如果你对它说"写一首思乡诗",它会怎么做?

答案是:它根本不知道你在说什么,而是接着这句话续写。

写一首思乡诗,写千里送情。
看江山满目,古今陈迹,无限凄凉。
回首关河万里,关塞隔云长。
多少伤心事,都付寒螀。
我亦平生心事,算年年客里,都是思量。
只当时明月,依旧照离肠。

这个六字的输入让它切换到词的频道,然后关键字“思乡”触发了相关的意向。模型有响应有输出,但不是遵循你的指令的输出,例如这个例子中连“古诗”的形式都不具备。这就是预训练模型的根本局限:它只会接龙,不会按需创作/对话。它不理解"指令"这个概念,不知道"写一首XX诗"是一个需要执行的任务。

SFT的目标:指令遵循能力

监督微调(Supervised Fine-Tuning, SFT)的核心目标,就是让模型获得**指令遵循**(Instruction Following)能力。具体来说:

  • 预训练阶段:模型学习 P(token_n | token_1, token_2, ..., token_{n-1})——给定上文,预测下文
  • SFT阶段:模型学习 P(answer | instruction)——给定指令,生成回答

这个转变看似简单,实则深刻。预训练让模型掌握了语言的"语法"(格律、韵律、词汇搭配),SFT则让模型学会了语言的"应用"(理解意图、执行任务)。

指令微调的核心思想

SFT的训练方式非常直观:用 "问题-答案"对 训练模型。每条训练数据包含:

  • 指令(instruction):用户想要什么,比如"创作一首思乡诗"
  • 回答(answer):期望的输出,比如一首完整的思乡诗

监督微调训练时,训练数据不再是简单的文本/诗词本身,而是把指令和回答拼接成一段文本,让模型学习:看到指令后,应该生成什么样的回答

监督微调的训练流程与预训练是一样的,只不过分词器和模型直接加载预训练的成果即可,不再赘述;接下来重点关注数据集的构建与处理,以及训练配置。

1. SFT 数据集设计

为了让模型具备指令遵循能力,我们设计了三种指令类型,覆盖不同的创作场景:

(1)体裁创作

指令格式:"以白日依山尽为出句,续写绝句/律诗" / "续写律诗,古戍依重险"

这类指令训练模型理解 诗歌体裁 的概念。绝句四句、律诗八句,模型需要学会根据体裁要求控制输出长度和格式。

(2)主题创作

指令格式:"以思乡为主题,写一首诗" / "创作一首愁绪诗"

这类指令训练模型理解 主题/情感 的概念。模型需要学会围绕特定主题组织意象和情感表达,例如思乡诗多用"月"、"雁"、"故园",愁绪诗多用"泪"、"残"、"孤"。

(3)诗人仿写

指令格式:"请仿写李白的诗" / "请仿写白居易的诗"

这类指令训练模型理解 风格 的概念。李白飘逸豪放,白居易平易近人,模型需要学会模仿不同诗人的语言风格。

数据来源与采样策略

数据集共约 49,256条 指令-回答对,从原始诗歌语料中按以下策略采样构造:

指令类型 数量 来源
体裁创作 20,000 从 5.4万+ 唐诗中随机选取绝句和律诗
主题创作 20,000 排列组合带有主题、标签的诗词数据集
诗人仿写 9256 从 5.4万+ 唐诗中选取13位著名诗人作品

数据格式

每条数据在CSV中的存储格式为:

instruction,answer
创作一首思乡诗,客里春光又可怜,客怀乡思总凄然。风尘未扫燕南地,雨雪偏深蓟北天。...
仿写李白的诗,头陀悬万仞,远眺望华峰。聊借金沙水,洗开九芙蓉。...
续写律诗,古戍依重险,高楼见五梁。山根盘驿道,河水浸城墙。...

训练时,在数据加载与处理环节,将指令和回答拼接为统一格式:

{instruction}\n输出:{answer}

例如:

创作一首思乡诗
输出:客里春光又可怜,客怀乡思总凄然。风尘未扫燕南地,雨雪偏深蓟北天。...

这里 \n输出: 是一个**分隔标记**,它告诉模型:前面的部分是指令,后面的部分是你需要生成的回答。这个标记在后续的Completion-Only损失掩码中起到关键作用。

关于体裁创作中出句续写的特殊处理

出句续写有一个微妙的细节,以"续写律诗,古戍依重险"为例:

  • 指令续写律诗,古戍依重险
  • 回答高楼见五梁。山根盘驿道,河水浸城墙。庭树巢鹦鹉,园花隐麝香。忽然江浦上,忆作捕鱼郎。

注意,训练数据中回答部分 不包含出句,只包含后续的诗句。推理时,用户输入出句,模型自动续写后面的内容;如果为了显示完整诗,可以在推理时额外拼接出句和模型生成的内容。

如果将全诗作为回答内容进行训练,因为重复出句为第一句的预期和模型预训练阶段学会的诗句接龙能力不符,最终效果为生成诗作无法保证第一句和出句保持一致(尽管相关)。

例如可能得到符合预期的输出:

输入:以白日依山尽为出句,续写律诗

输出:白日依山近,青山入寺微。僧归云半出,蝉噪雨初归。地静人稀到,天空鸟自飞。禅翁心不动,独坐掩柴扉。

但大多数情况下模型输出的第一句不会和出句保持一致:

输入:以白日依山尽为出句,续写绝句

输出:白昼连山尽,清风拂槛斜。主人方闭户,稚子正烹茶。

2. 数据加载与处理

数据加载与处理的基本流程和预训练阶段是一样的,但 SFT 最关键的技术细节是不能让模型学习"重复指令"。

考虑一条训练数据:

创作一首思乡诗
输出:秦燕千树地,富贵出山庄。...

如果我们不做任何处理,直接把整段文本喂给模型做语言建模训练,模型会学到什么?

它会学到:看到 创作一首思乡诗\n输出: 时,预测 ;看到 创作一首思乡诗\n输出:秦 时,预测 ……这没问题。

但它同时也会学到:看到 创作一首 时,预测 思乡诗;看到 创作 时,预测 一首……

模型在浪费容量学习"重复指令"。我们真正关心的是模型能否根据指令生成好的回答,而不是它能否一字不差地复述指令文本。更严重的是,指令文本(如"创作一首思乡诗")在数据集中高频重复出现,如果不加掩码,模型会过度拟合这些指令文本,导致: - 浪费模型容量在无意义的"指令复述"上 - 可能削弱模型对回答部分的关注 - 推理时如果指令稍有变化,模型可能表现不佳

DataCollatorForCompletionOnlyLM 的完整实现解析

解决方案是自定义一个 Data Collator,在构建批次数据时,将指令部分的 label 设为 -100(PyTorch CrossEntropyLoss 的特殊忽略标记)。于是,在 SFT 训练中 只对模型的"输出/响应"部分计算损失,而忽略"指令/输入"部分。

完整实现如下:

class DataCollatorForCompletionOnlyLM:
    def __init__(self, tokenizer, response_template="输出:"):
        self.tokenizer = tokenizer
        self.response_template_ids = tokenizer.encode(
            response_template, add_special_tokens=False
        )

    def __call__(self, features):
        # 1. 对批次中的序列进行 padding
        batch = self.tokenizer.pad(features, return_tensors="pt")
        # 2. 克隆 input_ids 作为 labels
        labels = batch["input_ids"].clone()

        # 3. 逐条处理:找到"输出:"标记,将其之前的部分设为 -100
        for i in range(len(labels)):
            input_ids = labels[i].tolist()
            template_len = len(self.response_template_ids)
            for j in range(len(input_ids) - template_len + 1):
                if input_ids[j:j+template_len] == self.response_template_ids:
                    labels[i, :j+template_len] = -100
                    break

        batch["labels"] = labels
        return batch

核心逻辑简单直白:定位 输出: 标记,将指令部分的 label 设为 -100,返回包含 labels 的批次字典。Trainer 会自动将 labels 传入模型的 forward 方法,模型内部会计算 CrossEntropyLoss,自动忽略 label 为 -100 的位置。

以"创作一首思乡诗\n输出:秦燕千树地"为例:

token:   创  作  一  首  思  乡  诗  \n  输  出  :  秦  燕  千  树  地
input:   1   2   3   4   5   6   7   8   9  10  11  12  13  14  15  16
label:  -100 -100 -100 -100 -100 -100 -100 -100 -100 -100 -100 12  13  14  15  16
        ↑_____________________指令部分,label=-100________________↑  ↑_回答部分,正常计算loss_↑

模型在位置11()处,需要预测位置12();在位置12处,需要预测位置13()……依此类推;而位置1到11的预测任务被完全忽略。

data_collator 与 train_dataset 的配合机制

train_datasetdata_collator 在 Trainer 中构成**数据准备管道**的两个关键环节:

┌─────────────────────────────────────────────────────────────────┐
│                      Trainer 训练循环                           │
├─────────────────────────────────────────────────────────────────┤
│  1. 从 train_dataset 中随机抽取 batch_size 条数据               │
│           ↓                                                    │
│  2. 将抽取的数据组成一个列表(features)                         │
│           ↓                                                    │
│  3. 调用 data_collator(features) 进行批次处理                   │
│           ↓                                                    │
│  4. 返回完整的批次字典(包含 input_ids, attention_mask, labels)│
│           ↓                                                    │
│  5. 送入模型进行前向传播和损失计算                               │
└─────────────────────────────────────────────────────────────────┘

假设 batch_size=2,数据集包含以下两条数据:

# train_dataset[0]
{
    "input_ids": [101, 2003, 3000, 102],      # "指令:问题?输出:答案"
    "attention_mask": [1, 1, 1, 1]
}

# train_dataset[1]  
{
    "input_ids": [101, 4000, 5000, 6000, 102],  # "指令:更长的问题?输出:更长的答案"
    "attention_mask": [1, 1, 1, 1, 1]
}

步骤 1:Trainer 抽取数据

indices = [0, 1]  # 随机抽取的索引
features = [train_dataset[i] for i in indices]

步骤 2:调用 data_collator

batch = data_collator(features)

# batch["input_ids"](padding 后)
[[101, 2003, 3000, 102, 0],   # 第 0 条:padding 1 个 0
 [101, 4000, 5000, 6000, 102]] # 第 1 条:无需 padding

# batch["labels"](处理后)  
[[-100, -100, 3000, 102, -100],  # 前两个 token 设为 -100(指令部分)
 [-100, -100, -100, 6000, 102]]  # 前三个 token 设为 -100

步骤 3:送入模型

outputs = model(
    input_ids=batch["input_ids"],
    attention_mask=batch["attention_mask"],
    labels=batch["labels"]  # 用于计算损失
)
loss = outputs.loss  # 只计算 labels != -100 位置的损失

完整数据流如下:

CSV 文件
   ↓
load_dataset() 加载原始数据
   ↓
map(tokenize_function) 进行 tokenize(格式:f"{instruction}\n输出:{answer}")
   ↓
train_dataset(每条数据是一个字典)
   ↓
Trainer 按 batch_size 抽取数据
   ↓
DataCollatorForCompletionOnlyLM(features) 组装批次并构建 labels
   ↓
model(input_ids, attention_mask, labels)
   ↓
计算损失(只在 labels != -100 的位置)

与 DataCollatorForLanguageModeling 的对比

预训练阶段使用的是 HuggingFace 官方的 DataCollatorForLanguageModeling(mlm=False)

维度 DataCollatorForLanguageModeling DataCollatorForCompletionOnlyLM
用途 预训练(因果语言建模) SFT(指令微调)
labels 构造 labels = input_ids.clone() 同上,但指令部分设为 -100
忽略部分 仅忽略 padding token 忽略指令 + padding
损失计算范围 整个序列(除padding) 仅回答部分

核心区别在于:预训练阶段,整个序列都是"学习目标"——每个位置都在预测下一个token;SFT阶段,只有回答部分是学习目标——指令只是"条件",不需要被预测。

3. 训练配置及结果

SFT的训练参数与预训练的主要不同在于:更小的学习率,更少的训练轮数

training_args = TrainingArguments(
    output_dir="checkpoints",
    num_train_epochs=3,                    # 仅3轮,避免过拟合
    per_device_train_batch_size=32,        # 单卡batch size
    gradient_accumulation_steps=4,         # 梯度累积,等效batch=128
    save_steps=200,
    logging_steps=100,
    learning_rate=3e-5,                    # 预训练的1/10
    weight_decay=0.01,
    fp16=False,
)

预训练阶段学习率为 3e-4,SFT阶段降至 3e-5,仅为预训练的 1/10。原因在于:

  • 预训练是从随机权重开始,需要大步长快速收敛
  • SFT是从已训练好的权重开始,只需要**微调**,大步长会破坏预训练学到的知识
  • 过大的学习率会导致**灾难性遗忘**——模型忘记了预训练学到的格律、韵律等基础能力

预训练跑了10个epoch,SFT只需要3个epoch。因为:

  • SFT数据集(4.9万条)远小于预训练数据集(85万首)
  • 指令遵循是一个相对"简单"的任务——模型已经会写诗,只需要学会"听懂指令"
  • 更多的epoch容易导致过拟合——模型可能记住特定的指令-回答对,而非学会泛化的指令遵循能力

得益于GPT2仅72M参数的小体量,SFT训练非常高效:

  • 总步数:1,155步(3 epochs × 49,256 samples / 128 effective batch size)
  • 训练耗时:约460秒(7.7分钟
  • 训练速度:约2.5步/秒

Loss从初始的约4.5快速下降到1.5左右,仅用3个epoch就达到了稳定状态。这说明:

  1. 预训练模型的基础能力扎实——它已经会写诗,SFT只是"教会它听懂指令"
  2. 学习率设置合理——没有出现loss震荡或发散
  3. 数据质量和数量充足——4.9万条数据足以覆盖主要的指令类型

训练完成后,我们测试了模型在不同指令类型上的表现。

体裁及出句续写示例

输入:续写律诗,白日依山尽
输出:青山绕涧斜。草迷藏虎迹,松老避僧家。径僻惟栽药,门闲未种花。更逢樵父说,来往问桑麻。

模型在给定出句"白日依山尽"后,续写了完整的五言律诗。续写内容延续出句的写景画面,并体现律诗的押韵、对仗和起承转合等格律要求。

主题创作示例

输入:创作一首思乡诗
输出:久负林泉志,频为江海游。故园归未得,归梦绕沧洲。

模型成功生成了一首五言绝句,首句“久负林泉志”以“负”字暗藏遗憾,隐逸山水之愿长年被辜负;“频为江海游”以“频”字道出世事羁縻、身不由己的漂泊感。两句形成理想与现实的张力,为后文思归埋下伏笔。。

诗人仿写示例

输入:请仿写白居易的诗
输出:昨日已寒食,今朝又清明。出门何所适,出门无所行。新旧半衰谢,旧来多变更。唯有一旬日,相随还入城。

这首诗语言平易、情感自然,感时伤逝而不雕琢,颇具白居易质朴含蓄的风格。虽然与真正的白居易诗相比还有差距,但风格方向是接近的。

4. 推理实现

推理脚本 chat.py 实现了交互式对话功能:

def poem_chat():
    while True:
        user_input = input("\n输入:")
        if user_input.lower() == 'quit':
            break

        # 拼接指令格式
        prompt = f"{user_input}\n输出:"
        inputs = tokenizer(prompt, return_tensors="pt").to(model.device)

        with torch.no_grad():
            outputs = model.generate(
                **inputs,
                max_new_tokens=100,
                do_sample=True,
                top_p=0.9,
                temperature=0.7,
                repetition_penalty=1.2,
                eos_token_id=tokenizer.eos_token_id,
                pad_token_id=tokenizer.eos_token_id
            )

        full_result = tokenizer.decode(outputs[0], skip_special_tokens=True)

        # 提取"输出:"之后的内容作为回答
        if "输出:" in full_result:
            answer = full_result.split("输出:")[-1].strip()
        else:
            answer = full_result

        print(f"\n古诗生成器:{answer}")

推理流程与训练时的数据格式完全一致:

  • 用户输入指令(如"创作一首思乡诗")
  • 脚本自动拼接 \n输出: 形成完整prompt
  • 模型生成文本
  • 从生成结果中提取 输出: 之后的部分作为回答展示

这种设计保证了 训练和推理的一致性 —— 模型在训练时学到的模式(看到指令+输出:后生成回答)与推理时的输入格式完全匹配。

5. 小结:SFT的得与失

SFT是从"语言模型"到"AI助手"的关键一步。通过仅3个epoch的训练,我们让一个只会续写的预训练模型学会了听懂指令,能够根据不同的体裁、主题和风格要求生成符合预期的古诗。

SFT虽然有效,但**全量微调**(更新全部72M参数)存在几个固有问题:

  • 灾难性遗忘:更新全部参数可能覆盖预训练阶段学到的通用语言能力。虽然本项目通过低学习率(3e-5)和少epoch(3轮)缓解了这个问题,但风险始终存在。

  • 过拟合风险:4.9万条数据对于72M参数来说并不算多。全量微调时,模型可能记住训练数据中的特定模式,而非学会泛化的指令遵循能力。

  • 存储成本:每个微调任务都需要保存一份完整的模型副本(约300MB)。如果要对同一个基座模型做多个下游任务的微调,存储开销会线性增长。

有没有一种方法,既能获得SFT的指令遵循能力,又能避免全量微调的上述问题?下一篇我们将用LoRA(Low-Rank Adaptation)重新训练同一个SFT任务,看看"四两拨千斤"的高效微调是如何实现的。