监督微调 SFT

阶段 4 · 让模型从"会说话"进化到"会听话"

灵魂拷问

预训练完成后,模型能续写文章、补全句子。但如果你输入 "请帮我写一首关于春天的诗",它可能接着写 "请帮我写一首关于夏天的诗、秋天的诗……"——它在续写,而不是在回答。

模型已经"会说话"了,但还不会"听话"——不懂指令,不懂角色,不懂对话。怎么让它变成一个能听懂你、能靠谱回答的 AI 助手?这就是 SFT(Supervised Fine-Tuning)要解决的问题。

当模型不会听话

图:预训练模型与 SFT 后模型的行为对比

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两组对比。左侧"问题"组(红色边框):用户输入"请写一首关于春天的诗"→ 预训练模型输出"请写一首关于夏天的诗、关于秋天的诗……"(续写行为)。右侧"目标"组(绿色边框):同样用户输入"请写一首关于春天的诗"→ SFT后模型输出"春风拂面花满枝,燕子归来筑新巢……"(回答行为) - 各元素关系:左侧"问题"组通过大箭头标注"SFT"连接到右侧"目标"组,表示 SFT 是从续写到回答的转换过程 - 关键标注:左侧标注"续写而非回答"(红色),右侧标注"理解意图并回答"(绿色) - 配色建议:浅灰背景,问题组红色边框,目标组绿色边框,SFT箭头用橙色加粗 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

预训练学会了语言的"形",SFT 教会模型语言的"用"——什么场合说什么话。

Part 1: 为什么需要 SFT

先搞清楚动机——为什么不直接用预训练模型?SFT 到底改变了什么?

预训练后的模型虽然"博览群书",但它只会续写——你问它问题,它可能接着你的话往下编。SFT 就是那个把"书呆子"变成"好助手"的关键步骤。理解这一步的动机和本质差异,是后面所有内容的地基。

  • 从类比到本质——用"图书馆 vs 家教"的比喻建立直觉,再回到技术细节
  • 能力跃迁对比——直观展示预训练后与 SFT 后模型输出的差异
  • 核心差异一览表——从目标、数据、学习率、标签策略等维度全面对比预训练和 SFT
  • 学习率的秘密——为什么 SFT 的学习率比预训练小 50 倍?不这么做会怎样?

从图书馆到家教

预训练 = 读完图书馆

模型读了几 GB 的文本,学会了语法、词汇、常识。但它只学会了一件事:接着写。

给它任何输入,它只会往后"续"。

SFT = 请一个家教

家教手把手教模型:别人问问题时,你要这样回答;给你指令时,你要这样执行。

从"复读机"变成"对话者"。

比喻卸甲:"图书馆"和"家教"只是为了建立直觉。技术上的区别是训练目标和数据格式——预训练用纯文本做"预测下一个 token",SFT 用"指令-回答"对且只在回答部分计算 loss(Label Masking)。模型并没有真的"读书"或"被教",它始终在做同一件事:根据上文预测下一个 token,只是 SFT 调整了它预测的内容偏好。

能力跃迁

预训练后能做什么

续写文章:"今天天气很好,所以..." → "我决定出门散步。"

补全句子:"中国的首都是" → "北京。"

但它不理解"这是用户在问我问题"这件事。

SFT 后能做什么

识别"用户在提问"这个意图本身。

以"助手"身份给出有条理的回答。

遵循格式指令:"请列出三点"就真的列三点。

Pretrain 学会"说人类语言",SFT 学会"做人类助手"。这是两个完全不同的能力层级。

核心差异一览

维度PretrainSFT
目标学会语言规律学会按指令对话
数据格式纯文本,每行一段话多轮对话 JSON
数据集类PretrainDatasetSFTDataset
学习率5e-41e-5(小 50 倍)
序列长度340768(更长)
训练轮数较多默认 2 轮
标签策略所有 token 都学只学助手回答

学习率为什么差50倍

定义:学习率控制每次参数更新的步长大小。

白话:预训练就像教一个不会说话的婴儿——大胆学、多犯错没关系。而 SFT 就像教一个已经会弹钢琴的人新的指法——只能微调,改太猛就把原来会的全忘了。

改太猛的后果有个专业名字:灾难性遗忘。模型把预训练学到的语言知识全丢了,SFT 也就白费了。

这个比喻只是为了帮你建立直观感受。在数学上,学习率决定的是梯度下降的步长——参数空间里每一步走多远。预训练从随机参数起步,需要大步探索;SFT 在一个已经很好的起点上,只需要小心翼翼地微调方向。

Part 2: 对话数据格式

先看一眼真实数据长什么样,再去理解代码就轻松多了。

写代码之前先认识数据格式,这是工程实践中的好习惯。SFT 的训练数据不是随便的文本,而是结构化的多轮对话——每一轮都有明确的角色分工。搞懂数据长什么样,后面的 Label Masking、generate_labels 才能看得明白。

  • JSONL 格式解析——每行一个 JSON 对象,包含完整的多轮对话
  • 四种对话类型——普通问答、带系统提示、带推理过程、工具调用,各有各的用途
  • 多轮对话结构——一轮 question 配一轮 answer,数据可以任意长度

JSONL 多轮对话

定义:SFT 数据使用 JSONL 格式(每行一个 JSON 对象),每条包含一段对话。

白话:就像微信聊天记录的导出——每条消息有发送者(角色)和内容。

比喻卸甲:"聊天记录"只是帮助你建立直觉。实质上 JSONL 是一种逐行存储 JSON 对象的文本格式,每行是一个独立的训练样本,没有数据库、没有消息协议,就是纯文本文件。

{
  "conversations": [
    {"role": "user", "content": "请介绍一下中国的四大发明"},
    {"role": "assistant", "content": "中国四大发明分别是造纸术、印刷术、火药和指南针...",
     "reasoning_content": "用户问四大发明,我要简要介绍每一项"}
  ]
}

对话类型一览

图:SFT 数据支持的四种对话类型

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:顶部"SFT 数据 JSONL"矩形,向下扇出4条箭头连接到4个子类型矩形:①"普通问答 user→assistant"(蓝色),②"带系统提示 system→user→assistant"(绿色),③"带推理过程 含 reasoning_content"(橙色),④"工具调用对话 含 tools 和 tool_calls"(紫色) - 各元素关系:从顶部数据源向下分支为4种对话类型,每种类型标注角色流转顺序 - 关键标注:每种类型用不同颜色区分,底部标注"角色有四种:system、user、assistant、tool" - 配色建议:浅灰背景,四种类型分别用蓝、绿、橙、紫区分 - 画面比例:16:9 - 避免:过度装饰、3D 效果

角色有四种:system(系统指令)、user(用户)、assistant(助手)、tool(工具)。reasoning_content 字段记录助手的思考过程,可以为空。

多轮对话示例

{
  "conversations": [
    {"role": "system", "content": "你是一个数学老师"},
    {"role": "user", "content": "勾股定理是什么?"},
    {"role": "assistant", "content": "勾股定理:直角三角形两直角边平方和等于斜边平方。即 a² + b² = c²。", "reasoning_content": "用户问基础数学概念"},
    {"role": "user", "content": "举个数字例子?"},
    {"role": "assistant", "content": "比如 3² + 4² = 9 + 16 = 25 = 5²,所以 3、4、5 就是一组勾股数。", "reasoning_content": ""}
  ]
}

一条数据可以包含任意多轮对话。模型会从每一轮助手的回答中学习。

Part 3: Label Masking

这是 SFT 与预训练最本质的区别。搞懂这个机制,就搞懂了 SFT 的一半。

预训练时每个 token 都参与学习,但 SFT 不一样——模型只需要学会"怎么回答",不需要学会"怎么提问"。Label Masking 就是告诉模型"哪些位置该学,哪些位置该忽略"的机制。这一节是理解 generate_labels 算法的前置知识。

  • 核心问题——为什么不能让模型学习用户提问的部分?
  • -100 的含义——PyTorch 中如何用特殊值标记"不参与 loss 计算的位置"
  • 可视化 Masking——用直观的方式看到序列中谁学谁不学
  • 多轮对话的 Masking——多轮场景下,每轮 assistant 的回答都要被标记为可学习

核心问题

预训练中每个 token 都参与训练,但 SFT 中只让模型学"自己该说的部分"——用户的问题、系统提示都不是模型写的,不应该学。

设想一个场景:对话数据包含"用户:1+1等于多少?助手:1+1等于2。"

如果让模型学习整个序列,它会学着去预测"1+1等于多少?"——但这是用户说的,不是助手该说的。

我们只想让它学"1+1等于2"这部分。

什么是 Label Masking

定义:Label Masking 是一种训练技巧——把序列中不需要学习的位置的 label 设为 -100,其余位置设为真实 token ID。

白话:就像老师批改试卷时,只批改答案部分,题目部分直接跳过。-100 就是"跳过"的标记。

比喻卸甲:"批改试卷"只是直觉。实际机制是 CrossEntropyLoss 的 ignore_index=-100——遇到 -100 的位置直接跳过损失计算,不产生梯度。模型并没有"判断对错"的能力,它只是在被告诉"这些位置不用学"。

为什么是 -100?因为 PyTorch 的 CrossEntropyLoss 默认 ignore_index=-100。遇到 label 为 -100 的位置,损失函数自动跳过,不计算梯度。还记得预训练中 PAD 位置的 labels 也设为 -100 吗?同一个原理,不同的目的——预训练忽略填充,SFT 忽略非助手回答。

可视化:谁学谁不学

图:Label Masking 张量对齐全景

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:两行横向排列的长条形"纸带",上方纸带标注"input_ids",下方纸带标注"labels"。每条纸带被竖线分割成一个个小格子,每个格子代表一个 token - 上方纸带的内容按顺序为:[浅灰色块] system标记 → [浅灰色块] "你是AI助手" → [浅灰色块] user标记 → [浅灰色块] "光合作用是什么?" → [浅蓝色块] assistant标记 → [绿色块] "光合作用是植物利用阳光..." → [绿色块] im_end标记 → [浅灰色块] pad - 下方纸带的内容与上方一一对应对齐:[红色块] "-100" → [红色块] "-100" → [红色块] "-100" → [红色块] "-100" → [红色块] "-100" → [绿色块] 实际token → [绿色块] 实际token → [红色块] "-100" - 关键标注:绿色区域上方画一个大括号标注"参与训练(loss 计算)",红色区域上方画一个大括号标注"不参与训练(被 -100 忽略)" - 配色建议:浅灰/浅蓝背景,绿色格子用 #51cf66,红色格子用 #ffa07a - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色

不参与训练(labels = -100)

系统提示部分

用户问题部分

填充(pad)部分

参与训练(labels = 实际 token)

助手回答的每一个 token

包括思考过程(<think>...</think>)

以及结束标记 <|im_end|>

多轮对话的 Masking

图:多轮对话 Label Masking

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个长条形 token 序列纸带,分为上下两行,上行标注"input_ids",下行标注"labels" - 区块从左到右依次为:[灰色] system 提示 → [灰色] user: 第一个问题 → [绿色] assistant: 第一个回答 → [灰色] user: 第二个问题 → [绿色] assistant: 第二个回答 - 下方 labels 对应:[红色] -100 → [红色] -100 → [绿色] 实际token → [红色] -100 → [绿色] 实际token - 关键标注:用虚线圆圈标注两次 assistant 的 bos_id 位置,用箭头标注"generate_labels 的 while 循环会找到所有 assistant 块" - 配色建议:白色背景,绿色 #51cf66,红色 #ffa07a - 画面比例:16:9 - 避免:过度装饰、3D 效果、格子太密

关键理解:generate_labels 中的 while 循环会从头到尾扫描整个序列,找到所有 assistant 回答的位置。无论是 1 轮还是 10 轮对话,每个 assistant 回答都会被正确标记。

Part 4:数据变身记

一段对话 JSON 怎么变成模型能吃的 (input_ids, labels) 张量对?关键在于找到 assistant 说话的起止位置,只在那里计算 loss。

这是 SFT 整个数据管线中最核心、也是最有技术含量的一节。从原始 JSON 到模型可用的训练样本,中间要经过模板渲染、tokenization、Label Masking、填充对齐等多道工序。我们一行一行看代码,把每个环节拆清楚。

  • SFTDataset 构造函数——加载数据、初始化 tokenizer、预计算定位标记
  • Chat Template——把对话渲染成模型认识的特殊格式
  • generate_labels 算法——SFT 最核心的函数,用 bos/eos 标记定位 assistant 回答区域
  • 四步拆解与流程图——把复杂算法拆成可消化的小步骤
  • __getitem__ 全流程——从单条数据到 (input_ids, labels) 的完整流水线

构造函数

构造函数完成三件事:加载数据、初始化 tokenizer、预计算定位标记。

class SFTDataset(Dataset):
    def __init__(self, jsonl_path, tokenizer, max_length=1024):
        super().__init__()
        self.tokenizer = tokenizer
        self.max_length = max_length
        features = Features({
            'conversations': [{
                'role': Value('string'),
                'content': Value('string'),
                'reasoning_content': Value('string'),
                'tools': Value('string'),
                'tool_calls': Value('string')
            }]
        })
        self.samples = load_dataset('json', data_files=jsonl_path,
                                     split='train', features=features)
        # 预计算 BOS 和 EOS 标记的 token 序列
        self.bos_id = tokenizer(f'{tokenizer.bos_token}assistant\n',
                                add_special_tokens=False).input_ids
        self.eos_id = tokenizer(f'{tokenizer.eos_token}\n',
                                add_special_tokens=False).input_ids

Features 定义了数据的字段类型,确保 reasoning_content、tools 等可选字段缺失时不会报错。

bos_id 与 eos_id

定义:bos_id 是 <|im_start|>assistant\n 的 token 序列,eos_id 是 <|im_end|>\n 的 token 序列。

白话:它们是"助手回答开始"和"助手回答结束"的定位标记——像书签一样,帮我们在长长的 token 序列中找到"助手到底说了什么"。

这个比喻只是为了帮你建立直观感受。在计算机内部,bos_id/eos_id 实际上是预先编码好的整数列表,在 generate_labels 中用来做子序列匹配,定位 assistant 回答的起止位置。

图:bos_id 与 eos_id 定位标记结构

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右三段结构。左侧"bos_id 开头标记"矩形内含三个小格:"<|im_start|>" → "assistant" → "\n"。右侧"eos_id 结尾标记"矩形内含两个小格:"<|im_end|>" → "\n"。中间用大括号区域标注"助手的回答内容夹在两者之间" - 各元素关系:bos_id 在左,中间是助手回答区域,eos_id 在右,三者线性排列 - 关键标注:bos_id 标注"定位助手回答起始",eos_id 标注"定位助手回答结束" - 配色建议:浅灰背景,bos_id 蓝色,eos_id 红色,中间回答区域绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果

为什么要预计算

预判解惑:为什么不每次实时 tokenize 来找标记?

因为 bos_id 和 eos_id 的值是固定的——模板格式不变,它们的编码就不变。预计算一次存下来,后面每条数据直接拿来比较,省下海量重复计算。

如果有 10000 条训练数据,每条要扫描一次 input_ids,那就省下了 10000 次 tokenize 调用。

Chat Template

定义:create_chat_prompt 调用 tokenizer 内置的 chat_template,把结构化对话数据转为带特殊标记的文本。

白话:就是翻译官——把"角色+内容"的对话列表翻译成模型能读懂的一整段文字。

比喻卸甲:"翻译官"只是直觉。实际是 chat_template 里一段 Jinja2 模板字符串,按固定规则把 role/content 拼接成带 <|im_start|> 等特殊标记的纯文本,没有任何"翻译"智能,纯粹是字符串拼接。

def create_chat_prompt(self, conversations):
    messages = []
    tools = None
    for message in conversations:
        message = dict(message)
        if message.get("role") == "system" and message.get("tools"):
            tools = json.loads(message["tools"])
        if message.get("tool_calls") and isinstance(message["tool_calls"], str):
            message["tool_calls"] = json.loads(message["tool_calls"])
        messages.append(message)
    return self.tokenizer.apply_chat_template(
        messages, tokenize=False,
        add_generation_prompt=False, tools=tools
    )

这个方法会解析 JSON 字符串格式的 tools 和 tool_calls,然后交给 apply_chat_template 统一渲染。

Template 渲染结果

经过 apply_chat_template 后,结构化对话变成了这样的文本:

<|im_start|>system
你是一个知识丰富的AI助手<|im_end|>
<|im_start|>user
什么是光合作用?<|im_end|>
<|im_start|>assistant
<think>
用户在问光合作用,我需要解释清楚
</think>

光合作用是植物利用阳光...<|im_end|>

关键:每段对话以 <|im_start|>角色名\n 开头,以 <|im_end|>\n 结尾。这就是 bos_id 和 eos_id 要找的标记——它们在文本中的样子就是 <|im_start|>assistant\n 和 <|im_end|>\n。

generate_labels

SFT 最核心的函数——决定了模型学什么、不学什么

def generate_labels(self, input_ids):
    # 第一步:全部初始化为 -100(全部忽略)
    labels = [-100] * len(input_ids)
    i = 0
    while i < len(input_ids):
        # 第二步:找到 '<|im_start|>assistant\n' 的位置
        if input_ids[i:i + len(self.bos_id)] == self.bos_id:
            start = i + len(self.bos_id)  # 跳过 bos 标记本身
            end = start
            # 第三步:找到 '<|im_end|>\n' 的位置
            while end < len(input_ids):
                if input_ids[end:end + len(self.eos_id)] == self.eos_id:
                    break
                end += 1
            # 第四步:将 bos 到 eos 之间的标签设为实际 token
            for j in range(start, min(end + len(self.eos_id), self.max_length)):
                labels[j] = input_ids[j]
            i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
        else:
            i += 1
    return labels

算法执行流程

图:generate_labels 算法执行流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:纵向流程图。顶部"开始:labels 全部设为 -100"→"i=0"→菱形"找到 bos_id?"(否→"i+=1"→菱形"i<len?"→是回到"找到 bos_id?",否→"返回 labels")。找到 bos_id 后→"start = i + len(bos_id)"→"向后搜索 eos_id"→菱形"找到 eos_id?"(否继续搜索)→找到后→"labels[start:end+eos] = 实际token"→"跳到 eos 后面继续"→回到"i<len?"判断 - 各元素关系:双重循环结构——外层循环扫描 bos_id,内层循环搜索 eos_id,找到后填充 labels - 关键标注:初始状态标注"全部-100",填充操作标注"替换为真实token" - 配色建议:浅蓝背景,处理步骤蓝绿色,菱形判断橙色,初始/结束绿色 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果、节点太密

四步拆解

第一步:全部忽略

labels = [-100] * len(input_ids)

先默认什么都不学。安全第一。

第二步:找开头

逐位扫描,用子序列匹配找 bos_id(即 <|im_start|>assistant\n)。

找到了就说明"从这里开始是助手的回答"。

第三步:找结尾

从开头标记之后继续扫描,找 eos_id(即 <|im_end|>\n)。

找到了就确定了助手回答的完整范围。

第四步:填充标签

把这个范围内的 labels 从 -100 替换为实际的 token ID。

只有这些位置会参与 loss 计算。

__getitem__ 全流程

每次 DataLoader 取数据时,会调用 __getitem__,串联所有处理步骤:

def __getitem__(self, index):
    # 1. 获取原始数据
    sample = self.samples[index]
    conversations = pre_processing_chat(sample['conversations'])
    # 2. 格式化:将对话转为 chat template 文本
    prompt = self.create_chat_prompt(conversations)
    # 3. 后处理:可能移除空的思考标签
    prompt = post_processing_chat(prompt)
    # 4. 分词 + 截断 + 填充
    input_ids = self.tokenizer(prompt).input_ids[:self.max_length]
    input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids))
    # 5. 生成标签(核心步骤!)
    labels = self.generate_labels(input_ids)
    return torch.tensor(input_ids, dtype=torch.long), \
           torch.tensor(labels, dtype=torch.long)

处理流水线全景

图:SFT 数据从 JSONL 到训练样本的处理流水线

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右8个圆角矩形依次连接:①"JSONL 原始数据"→②"pre_processing 随机系统提示"→③"create_chat_prompt Chat Template"→④"post_processing 空思考标签"→⑤"tokenizer 分词截断"→⑥"padding 填充 max_length"→⑦"generate_labels 标签掩码"→⑧"返回 input_ids, labels" - 各元素关系:从左到右依次处理,每步输出作为下一步输入,线性流水线 - 关键标注:步骤②标注"20%概率",步骤④标注"80%移除空思考",步骤⑦标注"核心步骤"并用加粗边框 - 配色建议:浅蓝背景,普通步骤浅蓝色,核心步骤⑦用橙色边框加粗 - 画面比例:16:9 - 避免:过度装饰、3D 效果、节点太密

注意填充(padding)在 generate_labels 之前执行。所以 pad 部分的 labels 也都是 -100——因为初始值就是 -100,不会被覆盖。

Part 5: 预处理与后处理

数据在送入 tokenizer 之前和之后,还有两道"清洗工序"。它们让模型更鲁棒。

光有干净的数据格式还不够,还有一些细节处理能显著提升训练效果。这些"小技巧"看起来不起眼,但在实际工程中往往是区分"能用"和"好用"的关键。

  • 随机系统提示——以 20% 概率为对话添加系统提示,让模型学会适应有无 system 的场景
  • 预处理决策流程——用流程图展示数据增强的判断逻辑
  • 空思考标签处理——80% 概率移除空的 think 标签,作为数据增强手段

随机系统提示

定义:pre_processing_chat 以 20% 概率为没有 system 消息的对话随机添加系统提示。

白话:不是每条对话都自带系统提示。我们随机"塞"一个进去,让模型学会在有/无系统提示时都能正常回答。

def pre_processing_chat(conversations, add_system_ratio=0.2):
    # 有工具定义的对话不做处理
    if any(conv.get('tools') for conv in conversations):
        return conversations
    SYSTEM_PROMPTS = [
        "你是一个知识丰富的AI,尽力为用户提供准确的信息。",
        "你是minimind,一个小巧但有用的语言模型。",
        "You are a helpful AI assistant.",
        # ... 共 10 条中英文提示
    ]
    if conversations[0].get('role') != 'system':
        if random.random() < add_system_ratio:
            return [{'role': 'system', 'content': random.choice(SYSTEM_PROMPTS)}] + conversations
    return conversations

预处理决策流程

图:pre_processing_chat 预处理决策流程

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:纵向决策树。顶部"收到对话数据"→菱形"有工具定义?"(是→"直接返回"),否→菱形"已有 system?"(是→"直接返回"),否→菱形"random < 0.2?"(否80%→"不加提示",是20%→"插入随机系统提示") - 各元素关系:三层菱形判断依次筛选,每层有"是/否"分支 - 关键标注:每条分支标注概率(20%/80%),"直接返回"标注"工具对话不做处理" - 配色建议:浅灰背景,判断菱形橙色,直接返回蓝色,插入提示绿色 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果

为什么是 20% 而不是 100%?如果每条数据都加系统提示,模型会"依赖"它。20% 的概率让模型既能在有系统提示时遵循指令,也能在没系统提示时自然回答。

空思考标签处理

定义:post_processing_chat 以 80% 概率移除空思考标签 <think>\n\n</think>\n\n。

白话:当助手没有推理过程时(reasoning_content 为空),模板会生成空的思考标签。80% 的情况下删掉它(更干净),20% 保留(让模型学会"有时不思考就直接回答")。

def post_processing_chat(prompt_content, empty_think_ratio=0.2):
    if '<think>\n\n</think>\n\n' in prompt_content and random.random() > empty_think_ratio:
        prompt_content = prompt_content.replace('<think>\n\n</think>\n\n', '')
    return prompt_content

这和系统提示的随机添加是同一个思路:数据增强。让模型见到各种变体,提高泛化能力。

Part 6: Thinking 模式

模型可以先"想"再"答"。这个能力来自 DeepSeek-R1 / OpenAI o1 的启发。

Think 模式让模型在给出最终回答之前,先在"草稿纸"上推理一番。这不仅是推理阶段的技巧,训练阶段也需要特殊处理——思考过程要不要参与 loss 计算?答案和你想的可能不一样。

  • 思考模式格式——用 <think>...</think> 标签包裹内部推理过程
  • 训练区域划分——思考内容和最终回答都算 assistant 的回答,全部参与 loss 计算

思考模式格式

定义:模型在回答之前先进行内部推理,推理过程包裹在 <think>...</think> 标签中。

白话:就像考试时先在草稿纸上算一遍,再往卷子上写答案。

这个比喻只是为了帮你建立直观感受。在计算机内部,Thinking 模式只是在最终回答前多生成了一段 <think> 包裹的文本。这些文本和回答一样,都在 assistant 回答区域内,都会参与训练。

<|im_start|>assistant
<think>
用户问的是勾股定理,我需要给出定义并举例子。
先说定义:a² + b² = c²。
再举个例子:3, 4, 5。
</think>

勾股定理:直角三角形中,两直角边的平方和等于斜边的平方。
例如:3² + 4² = 5²。
<|im_end|>

思考模式的训练

图:Thinking 模式下助手回答的完整训练区域

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:纵向流程展示助手回答内部结构。顶部"<|im_start|>assistant"→"<think>"→"推理过程 reasoning_content"→"</think>"→"正式回答 content"→"<|im_end|>"。整个区域用一个大的绿色虚线框包围,标注"全部参与训练" - 各元素关系:从上到下线性排列,think部分在上,正式回答在下,全部被绿色框包围表示参与训练 - 关键标注:think区域标注"先想后答",正式回答标注"最终输出",绿色框标注"全部参与 loss 计算" - 配色建议:浅灰背景,think标签橙色,正式回答蓝色,绿色虚线框包围全部 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果

提升推理能力

数学、逻辑、编程任务上,先"想"再"答"的效果明显更好。

提高可解释性

推理过程对开发者可见,便于调试和改进。

数据来源

JSONL 中的 reasoning_content 字段。为空则模板生成空思考标签,由后处理函数决定是否移除。

Part 7:训练如何运转

有了前面的知识储备,现在看训练流程就轻松多了。SFT 训练的核心和预训练几乎一样,只多了 Label Masking。

前面几节搞清楚了数据和 Label Masking,现在把它们装进训练循环里。好消息是 SFT 的训练代码和预训练高度相似,只有少数关键差异需要注意。这一节帮你把训练的每个环节都看明白。

  • 关键超参数——学习率 1e-5、序列长度 768、必须从预训练权重开始
  • 为什么序列更长——对话数据天然比单段文本更长的原因
  • 训练循环核心代码——逐行讲解与预训练的差异
  • 梯度累积与裁剪——在显存有限时模拟大 batch size 的技巧
  • 学习率调度——余弦退火策略,先快后慢

关键超参数

parser.add_argument("--learning_rate", type=float, default=1e-5)   # 比 Pretrain 小 50 倍
parser.add_argument("--epochs", type=int, default=2)               # SFT 通常 2 轮就够
parser.add_argument("--max_seq_len", default=768, type=int)        # 比 Pretrain 的 340 更长
parser.add_argument("--batch_size", type=int, default=16)
parser.add_argument("--accumulation_steps", type=int, default=1)   # 梯度累积
parser.add_argument("--grad_clip", type=float, default=1.0)        # 梯度裁剪
parser.add_argument("--dtype", type=str, default="bfloat16")       # 混合精度
parser.add_argument("--data_path", type=str,
                    default="../dataset/sft_t2t_mini.jsonl")
parser.add_argument("--from_weight", default='pretrain')           # 基于预训练权重!
from_weight 必须是 pretrain!用随机初始化的模型做 SFT,就像让一个不识字的人学写诗——根本学不会。模型必须先"会说话"(预训练),才能"学听话"(SFT)。

为什么序列更长

SFT 的 max_seq_len 是 768,而预训练只有 340。为什么?

预训练数据

每条就是一段纯文本,短文本就够学语言规律了。

SFT 数据

一条对话包含 system + user + assistant,可能还有多轮。对话天然更长。

如果截断太短,助手回答可能被截掉,那 labels 里就没有有效训练信号了。768 是一个平衡值——既覆盖大多数对话,又不浪费太多显存。

训练循环核心

for step, (input_ids, labels) in enumerate(loader):
    input_ids = input_ids.to(args.device)
    labels = labels.to(args.device)

    with autocast_ctx:
        res = model(input_ids, labels=labels)
        loss = res.loss + res.aux_loss
        loss = loss / args.accumulation_steps

    scaler.scale(loss).backward()

    if step % args.accumulation_steps == 0:
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
        scaler.step(optimizer)
        scaler.update()
        optimizer.zero_grad(set_to_none=True)

与预训练的差异

训练循环与 Pretrain 几乎一模一样。前向传播、反向传播、梯度裁剪、混合精度——全都一样。

唯一的实质性区别在数据:SFTDataset 返回的 labels 经过了 Label Masking 处理,只有助手回答参与损失计算。这个区别在 Dataset 层已经搞定了,训练循环不需要任何特殊处理。

这就是好的架构设计——训练逻辑不变,数据逻辑封装在 Dataset 里。

梯度累积与裁剪

梯度累积(accumulation_steps)

显存不够加大 batch size 怎么办?分成小步累加梯度,累积够了再更新参数。

等价 batch size = batch_size × accumulation_steps。

梯度裁剪(grad_clip=1.0)

当梯度的范数超过 1.0 时,等比例缩小。防止某一步更新太猛导致训练崩溃。

SFT 学习率已经很小了,但裁剪是双保险。

学习率调度

lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate)
for param_group in optimizer.param_groups:
    param_group['lr'] = lr

每一步都会根据当前进度重新计算学习率。通常使用余弦退火(cosine annealing)策略——开始时学习率最大,逐步衰减到接近 0。

这样模型在训练初期学得多、后期学得少,符合"先抓大方向、再打磨细节"的直觉。

Part 8: 续训机制

训练中断了怎么办?MiniMind 内置了断点续训功能。

训练大模型往往需要很长时间,中途断电、OOM、手动中断都是常事。如果每次都从头开始,那简直是噩梦。续训机制让你从断点无缝恢复,不浪费之前的计算成果。

  • 自动续训代码——如何用命令行参数控制是否从断点恢复
  • 恢复内容清单——不只是模型参数,优化器状态、训练步数、混合精度状态都要恢复
  • 检查点保存——轻量权重文件 vs 完整检查点,各有各的用途

自动续训

# 命令行参数
parser.add_argument('--from_resume', default=0, type=int,
                    choices=[0, 1], help="是否自动检测并续训")

# 脚本中的续训逻辑
ckp_data = lm_checkpoint(lm_config, weight=args.save_weight,
                          save_dir='../checkpoints') if args.from_resume == 1 else None

start_epoch, start_step = 0, 0
if ckp_data:
    model.load_state_dict(ckp_data['model'])
    optimizer.load_state_dict(ckp_data['optimizer'])
    scaler.load_state_dict(ckp_data['scaler'])
    start_epoch = ckp_data['epoch']
    start_step = ckp_data.get('step', 0)

设 --from_resume 1 后,脚本会自动检测最新的 checkpoint,恢复模型参数、优化器状态、学习率调度器,然后从断点处继续训练。不需要手动指定从哪个 epoch 开始。

续训恢复内容

模型参数

model.load_state_dict(ckp_data['model'])

恢复到中断前的模型状态。

优化器状态

optimizer.load_state_dict(ckp_data['optimizer'])

包括动量、二阶矩等 AdamW 内部状态。

训练进度

start_epoch 和 start_step

确保不重复训练已经学过的数据。

混合精度状态

scaler.load_state_dict(ckp_data['scaler'])

恢复 GradScaler 的缩放因子。

保存检查点

脚本每隔 save_interval 步(默认 1000)保存一次检查点:

if (step % args.save_interval == 0 or step == iters) and is_main_process():
    model.eval()
    # 保存模型权重(FP16 节省空间)
    state_dict = raw_model.state_dict()
    torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
    # 保存完整检查点(含优化器状态,用于续训)
    lm_checkpoint(lm_config, weight=args.save_weight, model=model,
                  optimizer=optimizer, epoch=epoch, step=step,
                  wandb=wandb, save_dir='../checkpoints', scaler=scaler)
    model.train()

注意:保存了两种文件——轻量权重文件(纯模型参数,用于推理)和完整检查点(含优化器状态,用于续训)。别搞混。

Part 9: 端到端全景

让我们把所有碎片拼起来,看一条数据从原始 JSON 到梯度更新的完整旅程。

前面我们把 SFT 拆成了一块块零件——数据格式、Label Masking、模板渲染、训练循环。现在是时候把它们串成一条完整的流水线,从全局视角重新审视整个训练过程。

  • 训练架构全景图——一张图看懂数据从 JSON 到梯度更新的完整链路
  • 端到端数据追踪表——跟踪一条具体数据在每个阶段的形态变化
  • 完整数据流——从文件读取到 loss 反传的全景回顾

训练架构全景

图:SFT 训练完整架构

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右三层架构图 - 第一层(数据层,浅蓝色):JSONL文件 → SFTDataset → DataLoader(返回 input_ids 和 labels) - 第二层(模型层,浅绿色):Transformer模型 → 输入input_ids → 前向传播 → logits - 第三层(优化层,浅橙色):logits与labels计算CrossEntropyLoss(labels中-100位置自动被忽略)→ loss.backward() → optimizer.step() - 数据层到模型层标注"input_ids 送入模型",模型层到优化层标注"logits + labels → loss" - 关键标注:在loss计算处展示"只有assistant部分(绿色)参与计算,其余被-100屏蔽(灰色)" - 配色建议:数据层浅蓝,模型层浅绿,优化层浅橙 - 画面比例:16:9 - 避免:过度装饰、3D 效果、组件太密

端到端数据追踪

{"conversations": [
  {"role": "user", "content": "1+1等于多少?"},
  {"role": "assistant", "content": "1+1等于2。", "reasoning_content": ""}
]}
步骤操作结果
1. 读取加载 JSON原始对话数据
2. 预处理pre_processing_chat20% 概率加了 system 消息
3. 格式化apply_chat_template带 <|im_start|> 标记的文本
4. 后处理post_processing_chat空思考标签可能被移除
5. 分词tokenizer[1, 284, 56, ...]
6. 填充pad 到 768[1, 284, ..., 0, 0]
7. 标签generate_labels助手回答处=实际 token,其余=-100
8. Tensortorch.tensor送入模型训练

完整数据流

图:SFT 端到端完整数据流

【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:纵向流程图。顶部"读取 JSONL"→"pre_processing 20%加系统提示"→"create_chat_prompt Chat Template"→"post_processing 80%移除空思考"→"tokenizer 分词"→"截断+padding 到 max_seq_len"→"generate_labels Label Masking"→"DataLoader 批处理"→"模型前向传播"→"CrossEntropyLoss -100自动忽略"→"反向传播+参数更新" - 各元素关系:从上到下线性流程,数据经过预处理、格式化、分词、标签生成、送入模型训练 - 关键标注:generate_labels 步骤用橙色边框加粗标注"核心",loss处标注"只计算助手回答" - 配色建议:浅灰背景,数据步骤蓝色系,模型计算绿色系,loss/优化橙色系 - 画面比例:9:16(纵向) - 避免:过度装饰、3D 效果、节点太密

Part 10: 动手实践

理论学完了,动手跑一跑才能真正理解。

读再多代码不如自己跑一遍。这一节给你完整的动手指南——从启动训练命令开始,到自定义数据、动手练习,再到遇到问题时的排错手册。把前面的知识转化为真正的工程能力。

  • 启动训练——一行命令跑通 SFT,附带关键参数说明
  • 准备自己的数据——如何构造符合格式要求的 JSONL 数据集
  • 动手练习——手工标注 labels、算法跟踪 generate_labels、调参决策
  • 错误诊断——loss 不降、loss 飙升、模型复读等常见问题的排查思路
  • FAQ——集中回答学习过程中最常见的疑问

启动训练

$ cd trainer
$ python train_full_sft.py

默认参数:数据 sft_t2t_mini.jsonl,基于预训练权重,学习率 1e-5,序列长度 768,训练 2 轮。

预期输出:

Epoch:[1/2](1/xxx), loss: 8.xxxx, logits_loss: 8.xxxx, aux_loss: 0.xxxx, lr: 0.00000xxx, epoch_time: xx.xmin

自定义参数

$ python train_full_sft.py \
    --data_path ../dataset/my_sft_data.jsonl \
    --learning_rate 5e-6 \
    --max_seq_len 1024 \
    --epochs 3 \
    --batch_size 8 \
    --accumulation_steps 4 \
    --from_weight pretrain \
    --save_dir ../my_sft_output

等价 batch size = batch_size(8) × accumulation_steps(4) = 32。显存不够时用累积来模拟大 batch。

准备自己的数据

{"conversations": [{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你好!有什么可以帮助你的吗?", "reasoning_content": ""}]}
{"conversations": [{"role": "system", "content": "你是数学老师"}, {"role": "user", "content": "2×3=?"}, {"role": "assistant", "content": "2×3=6。", "reasoning_content": "简单的乘法"}]}
数据质量比数量更重要!1000 条高质量对话的效果可能比 10000 条低质量对话更好。确保回答准确、格式一致、涵盖多样场景。

每行一个 JSON 对象,必须包含 conversations 字段。reasoning_content 可以为空字符串。

练习:手工标注

假设以下是一条经过 chat_template 格式化后的 token 序列(简化表示):

[<|im_start|>, system, \n, 你是AI助手, <|im_end|>, \n,
 <|im_start|>, user, \n, 什么是光合作用?, <|im_end|>, \n,
 <|im_start|>, assistant, \n, 光合作用是植物利用阳光..., <|im_end|>, \n,
 <pad>, <pad>, <pad>]

请写出对应的 labels 序列

提示:system 和 user 部分的 labels 是什么?assistant 部分呢?padding 部分呢?

查看答案
[-100, -100, -100, -100, -100, -100,
 -100, -100, -100, -100, -100, -100,
 -100, -100, -100, 光合作用是植物利用阳光..., <|im_end|>, \n,
 -100, -100, -100]

只有从 assistant\n 之后到 <|im_end|>\n(含)之间的 token 才参与训练。padding 部分保持 -100 不变。

练习:算法跟踪

给定以下 input_ids(简化,每个元素代表一个 token):

input_ids = [10, 20, 30, 1, 2, 3, 40, 50, 4, 5, 60]
# bos_id = [1, 2, 3]  (代表 <|im_start|>assistant\n)
# eos_id = [4, 5]      (代表 <|im_end|>\n)

请手动模拟 generate_labels 的执行过程

问题 1:i=0 时,input_ids[0:3] 即 [10,20,30] 等于 [1,2,3] 吗?

问题 2:i=3 时,input_ids[3:6] 即 [1,2,3] 等于 [1,2,3] 吗?此时 start 和 end 各是多少?

问题 3:最终的 labels 是什么?

查看答案

问题 1:不等。[10,20,30] != [1,2,3],所以 i += 1,继续搜索。

问题 2:相等!start = 3 + 3 = 6。end 从 6 开始搜索:input_ids[8:10] = [4,5] == eos_id,所以 end = 8。

问题 3:labels[6] 到 labels[9](含 eos_id)设为 input_ids 的对应值:

[-100, -100, -100, -100, -100, -100, 40, 50, 4, 5, -100]

练习:调参决策

场景:你的 SFT 数据有 5000 条长对话,平均长度 900 tokens

请思考以下问题并给出你的参数选择:

1. max_seq_len 应该设多少?为什么?

2. learning_rate 设 1e-5 还是 1e-3?为什么?

3. epochs 设 2 还是 20?为什么?

查看答案

1. max_seq_len 应设 1024 或更大。因为平均长度 900,默认 768 会截断大量数据,助手回答可能被截掉,labels 里就没有有效训练信号了。

2. 1e-5。SFT 学习率必须很小,1e-3 会导致灾难性遗忘,把预训练知识全抹掉。

3. 2 轮就够。5000 条数据训练太多轮容易过拟合——模型把训练集背下来了,但遇到新问题就懵了。

错误诊断:loss 不降

症状

训练几十步后 loss 还是在 10.x 左右波动,没有明显下降趋势。

可能原因

from_weight 没有设为 pretrain,模型从随机参数起步,连语言都还不懂,SFT 自然学不到东西。

快速检查

看启动日志,确认加载了预训练权重文件。检查权重文件大小是否合理。

修复

确保 --from_weight pretrain,并且 out/pretrain_*.pth 文件存在。

错误诊断:loss 飙升

症状

loss 突然变成 inf 或 NaN,训练彻底崩溃。

可能原因

学习率太大了。你可能用了预训练的学习率 5e-4 而不是 SFT 的 1e-5。

快速检查

在日志中找到 lr: 那一列,看是否在 1e-5 量级。

修复

设 --learning_rate 1e-5。如果已经崩溃了,需要从预训练权重重新开始。

错误诊断:模型复读

症状

模型输出总是重复用户的问题,而不是给出回答。

可能原因

labels 掩码没生效——generate_labels 没有正确找到 bos_id 和 eos_id。

快速检查

取消 SFTDataset.__getitem__ 中被注释掉的调试打印代码,看 labels 是否正确。

修复

检查 tokenizer 的 bos_token 和 eos_token 是否与 chat_template 的标记一致。

错误诊断:其他问题

数据格式报错

症状:KeyError: 'conversations'

修复:检查 JSONL 文件格式。每行必须是合法 JSON,且包含 conversations 字段。

过拟合严重

症状:训练 loss 很低,但模型对训练集外的问题回答很差。

修复:减少 epochs,增加数据多样性。2 轮通常足够。

显存不够 OOM

症状:CUDA out of memory

修复:减小 batch_size,增大 accumulation_steps,或减小 max_seq_len。

常见问题 FAQ

-100 怎么被忽略的

CrossEntropyLoss 内置 ignore_index=-100,遇到 -100 自动跳过,不计算梯度。

需要多少数据

MiniMind 通常几千到几万条高质量对话就够。关键是多样、格式正确、回答质量高。

训练多久算够

默认 2 个 epoch。观察 loss 曲线,不再明显下降就可以停了。过长会过拟合。

能用 CPU 训练吗

可以,但会很慢。脚本会自动检测:有 GPU 用 GPU,没有用 CPU。小模型在 CPU 上勉强能跑。

SFT 核心回顾

数据格式

JSONL 多轮对话,支持 system/user/assistant/tool 四种角色,支持 reasoning_content 思考过程

关键机制:Label Masking

只有助手回答参与训练,其余标签为 -100;Chat Template 提供统一的对话格式标记

训练参数

学习率 1e-5(比预训练小 50 倍),序列长度 768,训练轮数 2

与 Pretrain 的关系

必须先 Pretrain 再 SFT——Pretrain 教语言规律,SFT 教对话行为

你学到了什么

为什么需要 SFT

从"会说话"到"会听话"的进化。预训练教语言规律,SFT 教对话行为。

Label Masking

只有助手回答参与训练,其余位置 label 为 -100。这是 SFT 与预训练的核心区别。

Chat Template

统一格式化对话,用 <|im_start|> / <|im_end|> 标记分隔角色。bos_id 和 eos_id 用来定位助手回答。

Thinking Mode

模型先"想"再"答",推理过程包裹在 <think> 标签中。由 reasoning_content 字段控制。

阶段 4 完成

从"会说话"到"会听话"——SFT 让模型学会遵循指令、扮演助手。

下一章:阶段 5 · LoRA 微调——只训练 1.4% 的参数,实现同样的微调效果

1 / X