# 第 16 课:Full SFT 训练脚本总览 这一课只解决一个问题:MiniMind 如何从预训练权重出发,用对话数据做 Full SFT。 ## 目录 - [0. 本节主线](#l16-mainline) - [1. 原理讲解](#l16-principle) - [2. 源码阅读顺序图](#l16-reading-order) - [3. MiniMind 源码走读](#l16-source-walkthrough) - [4. 本节必须会写 / 暂时不要求](#l16-must-write) - [5. 手写模块](#l16-handwrite) - [6. 对齐测试](#l16-alignment-test) - [7. 阶段组装](#l16-stage-assembly) - [8. 本节检查](#l16-check) - [9. 下一课](#l16-next) ## 0. 本节主线 Full SFT 的训练链路是: ```text 读取 sft_t2t_mini.jsonl -> SFTDataset 把 conversations 渲染成 chat prompt -> tokenizer 编码成 input_ids -> generate_labels 只保留 assistant 区域,其他位置设为 -100 -> 从 pretrain 权重初始化模型 -> 复用 pretrain 的 train_epoch 训练循环 -> 保存 full_sft 权重和 resume checkpoint ``` 一句话: ```text Full SFT 和 Pretrain 的训练循环几乎一样,真正不同的是数据目标:SFT 只让 assistant 回复区域参与 loss。 ``` 本节要把两条线连起来: ```text 第 5 课:SFT 数据如何构造 input_ids / labels 第 14-15 课:训练循环和 checkpoint 如何工作 ``` ## 1. 原理讲解 ### 1.1 Full SFT 是什么 Full SFT 指的是“全参数监督微调”。 它和 LoRA 不同: ```text Full SFT: 更新模型全部可训练参数。 LoRA: 通常冻结原模型,只训练额外插入的小矩阵。 ``` 它和 Pretrain 也不同: ```text Pretrain: 用普通文本学习 next-token prediction。 SFT: 用对话数据学习 assistant 应该怎么回答。 ``` 训练目标仍然是 causal LM next-token loss,但 labels 的有效区域不同。 ### 1.2 SFT 为什么仍然是 causal LM loss SFT 并没有把模型改成分类器。模型输入仍然是: ```text input_ids: [B, S] ``` 模型输出仍然是: ```text logits: [B, S, V] ``` loss 仍然是 shifted next-token cross entropy: $$ L_{\text{lm}} = -\frac{1}{|\mathcal{A}|}\sum_{t \in \mathcal{A}} \log p_{\theta}(x_t \mid x_{ labels = -100 assistant token -> labels = token_id padding token -> labels = -100 ``` 这样模型能看到完整上下文,但只因为 assistant 回复区域产生梯度。 ### 1.4 SFTDataset 如何找到 assistant 区域 MiniMind 的 tokenizer chat template 会把 assistant 回复包在特殊标记附近。 SFTDataset 初始化时构造两个模式: ```text bos_id = tokenizer(f"{bos_token}assistant\n", add_special_tokens=False).input_ids eos_id = tokenizer(f"{eos_token}\n", add_special_tokens=False).input_ids ``` 然后扫描 `input_ids`: ```text 找到 bos_id -> bos_id 后面是 assistant 内容开始 -> 找到 eos_id -> assistant 内容 + eos_id 这段 labels 保留 token id -> 其它位置保持 -100 ``` 用公式表示 label mask: $$ y_t = \begin{cases} x_t, & t \in \mathcal{A} \\ -100, & t \notin \mathcal{A} \end{cases} $$ 其中 $\mathcal{A}$ 是 assistant 回复和结束标记所在位置。 ### 1.5 Full SFT 从哪个权重开始 MiniMind 的 Full SFT 默认: ```text from_weight = pretrain save_weight = full_sft ``` 意思是: ```text 读取 out/pretrain_768.pth -> 训练 SFT -> 保存 out/full_sft_768.pth ``` 这体现了后训练的基本顺序: ```text pretrain 权重 -> SFT 权重 -> DPO/RL 等偏好或强化学习阶段 ``` 如果 `from_weight='none'`,那就是从随机初始化直接 SFT。能跑,但通常不是正常路线。 ### 1.6 Full SFT 训练循环和 Pretrain 有什么不同 训练循环几乎一样: ```text get_lr autocast forward loss = res.loss + res.aux_loss loss / accumulation_steps backward clip optimizer.step checkpoint resume ``` 不同主要在配置和数据: | 项目 | Pretrain | Full SFT | |---|---|---| | dataset | `PretrainDataset` | `SFTDataset` | | 默认数据 | `pretrain_t2t_mini.jsonl` | `sft_t2t_mini.jsonl` | | 默认权重来源 | `none` | `pretrain` | | 默认保存名 | `pretrain` | `full_sft` | | 默认学习率 | `5e-4` | `1e-5` | | 默认 max_seq_len | `340` | `768` | | 默认 accumulation | `8` | `1` | SFT 学习率更小,是因为它是在已有预训练能力上微调,不希望剧烈破坏已有语言建模能力。 ## 2. 源码阅读顺序图 这节源码按这个顺序读: ```text train_full_sft.py 参数默认值 -> init_model(..., from_weight='pretrain') -> SFTDataset 初始化 -> SFTDataset.create_chat_prompt -> SFTDataset.generate_labels -> train_full_sft.py::train_epoch -> checkpoint / resume 流程 ``` 对应文件: ```text trainer/train_full_sft.py dataset/lm_dataset.py trainer/trainer_utils.py model/model_minimind.py ``` 先看训练脚本,是为了知道 SFT 阶段怎么启动。 再看 `SFTDataset`,是为了知道 SFT 和 Pretrain 的真正差异。 最后回到 `train_epoch`,确认它复用的是同一套 causal LM 训练机制。 ## 3. MiniMind 源码走读 ### 第 1 步:Full SFT 的默认训练参数 File: `trainer/train_full_sft.py:84-107` Read this to understand: SFT 阶段默认用什么数据、从什么权重开始、保存成什么名字。 Code/config/template excerpt: ```python parser.add_argument('--save_weight', default='full_sft', type=str, help="保存权重的前缀名") parser.add_argument("--epochs", type=int, default=2, help="训练轮数") parser.add_argument("--batch_size", type=int, default=16, help="batch size") parser.add_argument("--learning_rate", type=float, default=1e-5, help="初始学习率") parser.add_argument('--max_seq_len', default=768, type=int, help="训练的最大截断长度(中文1token≈1.5~1.7字符)") parser.add_argument("--data_path", type=str, default="../dataset/sft_t2t_mini.jsonl", help="训练数据路径") parser.add_argument('--from_weight', default='pretrain', type=str, help="基于哪个权重训练,为none则不基于任何权重训练") ``` This code shows: - SFT 默认保存为 `full_sft`。 - SFT 默认从 `pretrain` 权重开始。 - SFT 默认数据是 `sft_t2t_mini.jsonl`。 - SFT 学习率比 Pretrain 小。 ### 第 2 步:SFT 初始化模型和数据 File: `trainer/train_full_sft.py:115-139` Read this to understand: Full SFT 的模型来自 `from_weight`,数据来自 `SFTDataset`。 Code/config/template excerpt: ```python lm_config = MiniMindConfig(hidden_size=args.hidden_size, num_hidden_layers=args.num_hidden_layers, use_moe=bool(args.use_moe)) ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints') if args.from_resume==1 else None ... model, tokenizer = init_model(lm_config, args.from_weight, device=args.device) train_ds = SFTDataset(args.data_path, tokenizer, max_length=args.max_seq_len) train_sampler = DistributedSampler(train_ds) if dist.is_initialized() else None scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16')) optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate) ``` This code shows: - `init_model(..., args.from_weight)` 会加载 `pretrain` 权重。 - `SFTDataset` 替代了 `PretrainDataset`。 - optimizer、scaler、sampler 的结构和 pretrain 一样。 - 如果 `from_resume=1`,会优先查 `full_sft` 的 resume checkpoint。 ### 第 3 步:`init_model` 如何加载 pretrain 权重 File: `trainer/trainer_utils.py:119-131` Read this to understand: `from_weight='pretrain'` 到底加载哪个文件。 Code/config/template excerpt: ```python if from_weight!= 'none': moe_suffix = '_moe' if lm_config.use_moe else '' weight_path = f'{save_dir}/{from_weight}_{lm_config.hidden_size}{moe_suffix}.pth' weights = torch.load(weight_path, map_location=device) model.load_state_dict(weights, strict=False) ``` This code shows: - `from_weight='pretrain'` 会加载 `../out/pretrain_768.pth`。 - MoE 模型会加载带 `_moe` 后缀的权重。 - `strict=False` 允许有些 key 不完全匹配。 - 这里加载的是普通权重文件,不是 resume checkpoint。 ### 第 4 步:SFTDataset 声明样本格式 File: `dataset/lm_dataset.py:58-66` Read this to understand: SFT 数据以 `conversations` 为核心字段。 Code/config/template excerpt: ```python 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) 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 ``` This code shows: - 每条样本包含一个 `conversations` 列表。 - `bos_id` 用来识别 assistant 回复开始。 - `eos_id` 用来识别 assistant 回复结束。 - `generate_labels` 后面会用这两个 token 序列扫描 assistant 区域。 ### 第 5 步:conversations 渲染成 prompt File: `dataset/lm_dataset.py:71-86` Read this to understand: SFT 训练时不是直接拼字符串,而是使用 tokenizer 的 chat template。 Code/config/template excerpt: ```python 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 isinstance(message["tools"], str) else 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 ) ``` This code shows: - `add_generation_prompt=False`,因为 SFT 样本已经包含 assistant 回复。 - 普通对话只需要 `role/content`。 - 工具调用字段会在 template 前转成 JSON 对象。 - 输出是 prompt 字符串,还不是 token id。 ### 第 6 步:generate_labels 只保留 assistant 区域 File: `dataset/lm_dataset.py:88-104` Read this to understand: SFT labels mask 的核心实现。 Code/config/template excerpt: ```python labels = [-100] * len(input_ids) i = 0 while i < len(input_ids): if input_ids[i:i + len(self.bos_id)] == self.bos_id: start = i + len(self.bos_id) end = start while end < len(input_ids): if input_ids[end:end + len(self.eos_id)] == self.eos_id: break end += 1 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 ``` This code shows: - labels 初始全是 `-100`。 - 只有匹配到 assistant 开始标记后,才开始保留 label。 - 保留区域包含 assistant 内容和 eos 标记。 - user/system/padding 默认都不参与 loss。 ### 第 7 步:`__getitem__` 产出训练 batch 的单样本 File: `dataset/lm_dataset.py:106-119` Read this to understand: DataLoader 最终拿到的是 `input_ids` 和 `labels` 两个 tensor。 Code/config/template excerpt: ```python sample = self.samples[index] conversations = pre_processing_chat(sample['conversations']) prompt = self.create_chat_prompt(conversations) prompt = post_processing_chat(prompt) input_ids = self.tokenizer(prompt).input_ids[:self.max_length] input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids)) labels = self.generate_labels(input_ids) return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long) ``` This code shows: - prompt 会被截断到 `max_length`。 - 不足 `max_length` 的部分用 pad token 补齐。 - labels 和 input_ids 等长。 - 返回值和 PretrainDataset 一样都是 `(input_ids, labels)`,所以训练循环可以复用。 ### 第 8 步:Full SFT 复用 causal LM 训练循环 File: `trainer/train_full_sft.py:24-81` Read this to understand: SFT 的训练循环和 Pretrain 几乎同构。 Code/config/template excerpt: ```python for step, (input_ids, labels) in enumerate(loader, start=start_step + 1): input_ids = input_ids.to(args.device) labels = labels.to(args.device) lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate) ... with autocast_ctx: res = model(input_ids, labels=labels) loss = res.loss + res.aux_loss loss = loss / args.accumulation_steps scaler.scale(loss).backward() ``` This code shows: - SFT 仍然调用 `model(input_ids, labels=labels)`。 - `labels=-100` 决定哪些 token 不算 loss。 - optimizer、lr、AMP、checkpoint 逻辑和 Pretrain 一样。 - 因为 dataset 返回格式一致,训练循环不用关心这是 pretrain 还是 SFT。 ## 4. 本节必须会写 / 暂时不要求 必须会写: 1. SFT prompt 渲染: ```text conversations -> tokenizer.apply_chat_template(..., tokenize=False, add_generation_prompt=False) -> prompt ``` 2. assistant 区域 labels mask: $$ y_t = \begin{cases} x_t, & t \in \mathcal{A} \\ -100, & t \notin \mathcal{A} \end{cases} $$ 3. padding/truncation: ```text input_ids = tokenizer(prompt).input_ids[:max_length] input_ids += [pad_token_id] * (max_length - len(input_ids)) labels = generate_labels(input_ids) ``` 4. Full SFT 初始化: ```text from_weight = pretrain save_weight = full_sft dataset = SFTDataset train_loop = 和 pretrain 同构 ``` 暂时不要求: ```text 1. tools / tool_calls 的完整训练细节 2. thinking 数据比例控制 3. 多轮复杂截断策略优化 4. SFT 训练质量评估 5. LoRA SFT ``` ## 5. 手写模块 本节你要补的是: ```text course/impl/core/datasets.py::CourseSFTDataset ``` ### 5.1 补 `__init__` 对齐源码: ```text dataset/lm_dataset.py:58-66 ``` 你要实现的行为: - 读取 jsonl 数据。 - 保存 `tokenizer` 和 `max_length`。 - 构造 `bos_id`: ```python tokenizer(f"{tokenizer.bos_token}assistant\n", add_special_tokens=False).input_ids ``` - 构造 `eos_id`: ```python tokenizer(f"{tokenizer.eos_token}\n", add_special_tokens=False).input_ids ``` 第一版可以直接用 `datasets.load_dataset`,和原源码对齐。 ### 5.2 补 `create_chat_prompt` 接口: ```python def create_chat_prompt(self, conversations): ... ``` 对齐源码: ```text dataset/lm_dataset.py:71-86 ``` 你要实现的行为: - 遍历 `conversations`。 - 普通 message 用 `dict(message)` 复制一份。 - 如果有 `tools/tool_calls` 字符串,转成 JSON。 - 调用 `tokenizer.apply_chat_template`。 - `tokenize=False`。 - `add_generation_prompt=False`。 ### 5.3 补 `generate_labels` 接口: ```python def generate_labels(self, input_ids: list[int]) -> list[int]: ... ``` 对齐源码: ```text dataset/lm_dataset.py:88-104 ``` 你要实现的行为: - 创建 `labels = [-100] * len(input_ids)`。 - 扫描 `input_ids`。 - 找到 `bos_id` 后,从 assistant 内容开始。 - 找到 `eos_id` 后停止。 - 把 assistant 内容和 eos 区域的 labels 设为对应 `input_ids[j]`。 - 返回 labels。 ### 5.4 补 `__getitem__` 对齐源码: ```text dataset/lm_dataset.py:106-119 ``` 你要实现的行为: ```text sample -> pre_processing_chat -> create_chat_prompt -> post_processing_chat -> tokenizer -> truncate -> pad -> generate_labels -> return LongTensor(input_ids), LongTensor(labels) ``` 注意: - 要从原项目导入并复用 `pre_processing_chat` 和 `post_processing_chat`,不要重写这两个工程细节。 - 返回 tensor dtype 必须是 `torch.long`。 - `input_ids` 和 `labels` 长度都必须等于 `max_length`。 ## 6. 对齐测试 本节新增对齐测试: ```text course/impl/tests/test_sft_dataset_alignment.py ``` 运行命令: ```bash cd /home/sun/minimind python course/impl/tests/test_sft_dataset_alignment.py ``` 现在还没有实现时,这个测试会因为 `NotImplementedError` 失败。等你补完 `CourseSFTDataset` 后,它应该打印类似: ```text sft_sample_0_input_diff=0 sft_sample_0_label_diff=0 sft_sample_0_non_ignored_labels=... ... sft_dataset_alignment=passed ``` 这个测试做了什么: ```text 1. 用同一个 tokenizer 读取 tiny_sft.jsonl。 2. 实例化原版 SFTDataset。 3. 实例化 CourseSFTDataset。 4. 对每条样本比较 input_ids。 5. 对每条样本比较 labels。 6. 确认至少有 assistant label 没被 mask。 ``` 如果 input diff 是 0,但 label diff 不是 0,通常说明 `generate_labels` 的 assistant 区域边界写错了。 ## 7. 阶段组装 本节完成后,SFT 阶段会多出数据处理能力: ```text course/impl/core/datasets.py::CourseSFTDataset ``` 后续 `course/impl/train_sft_impl.py` 会这样组装: ```text load tokenizer -> load CourseMiniMindForCausalLM -> load course_pretrain checkpoint 或 MiniMind pretrain 权重 -> CourseSFTDataset -> DataLoader -> train_one_epoch -> save_course_checkpoint(save_weight="course_sft") ``` 当前 SFT 阶段还缺: ```text 1. 教学版模型 forward/loss 完整组装 2. train_sft_impl.py 脚本入口 3. 从 course_pretrain 到 course_sft 的权重加载策略 4. SFT 后推理验证 ``` 第 16 课先把 SFT 数据目标和训练脚本结构对齐。 ## 8. 本节检查 1. Full SFT 和 Pretrain 的训练循环为什么可以几乎一样? 2. Full SFT 真正不同的地方在哪里? 3. 为什么 SFT 仍然使用 causal LM next-token loss? 4. `labels=-100` 在 SFT 里屏蔽了哪些 token? 5. `add_generation_prompt=False` 的原因是什么? 6. `from_weight='pretrain'` 会加载哪个文件? 7. `save_weight='full_sft'` 会保存成什么文件? 8. 如果 `input_ids.shape = [B, S]`,写出 `labels`、`logits`、`loss` 的形状。 ## 9. 下一课 第 17 课讲从 pretrain 权重到 full_sft 权重。 下一课要解决: ```text 如何真正跑一条 tiny pretrain -> tiny SFT 链路; from_weight 和 from_resume 在阶段切换时有什么区别; 训练前后同一个 prompt 的输出如何变化; 教学版 train_sft_impl.py 如何组装并保存 course_sft 权重。 ```