15 KiB
第 5 课:SFT 数据、Prompt 与 Labels
这一课只解决一个问题:SFT 数据里的 conversations 如何变成模型训练用的 input_ids 和 labels。
目录
- 0. 本节主线
- 1. 本节要懂的 5 个原理
- 2. 变量流转
- 3. 原理一:SFT 样本格式
- 4. 原理二:conversations 如何变成 prompt
- 5. 原理三:labels 为什么要 mask
- 6. 原理四:padding 和 truncation
- 7. 原理五:loss 如何使用 labels
- 8. 实验验证
- 9. 本节检查
- 10. 下一课
0. 本节主线
SFT 数据处理的本质是:
读取 conversations
-> 用 chat template 渲染成 prompt
-> tokenizer 编码成 input_ids
-> 构造 labels
-> user/system/padding 位置设为 -100
-> assistant 回复位置保留 token id
-> 交给 causal LM loss 训练 assistant 回复
这条链对应本节的 5 个原理:
SFT 样本格式 -> prompt 构造 -> labels mask -> padding/truncation -> loss 如何使用 labels
本节不是讲“怎么训练出好模型”,而是讲清楚 SFT 数据在进入模型前到底变成了什么。
1. 本节要懂的 5 个原理
| 原理 | 要理解什么 | 源码证据 |
|---|---|---|
| SFT 样本格式 | SFT 数据以多轮 conversations 表达 user/assistant 消息 |
course/labs/tiny_sft.jsonl:1-3, dataset/lm_dataset.py:59-66 |
| prompt 构造 | conversations 先经过 chat template 变成纯文本 prompt | dataset/lm_dataset.py:71-86 |
| labels mask | SFT 不是所有 token 都算 loss,只训练 assistant 区域 | dataset/lm_dataset.py:65-66, dataset/lm_dataset.py:88-104 |
| padding/truncation | prompt 被截断/补齐到固定长度,padding 不参与 loss | dataset/lm_dataset.py:106-119 |
| loss 使用 labels | 模型 forward 会 shift logits/labels,并忽略 -100 |
model/model_minimind.py:245-253, trainer/train_full_sft.py:27-37 |
学完本节,你应该能看懂一条 SFT 样本中哪些 token 参与训练,哪些 token 被忽略。
2. 变量流转
把本节主线对应到变量:
sample: jsonl 里的一条样本
conversations: [{"role": "...", "content": "..."}]
prompt: chat template 渲染后的字符串
input_ids: prompt 编码后的 token id,并 pad 到 max_length
labels: 和 input_ids 等长;assistant 区域是 token id,其它位置是 -100
loss: 只在 labels != -100 的位置计算
你要盯住一个核心差异:
input_ids 是模型看到的完整上下文;
labels 是哪些位置要让模型学习。
3. 原理一:SFT 样本格式
原理讲解
预训练样本通常是一段普通文本;SFT 样本是对话。
SFT 的目标不是让模型背整段 prompt,而是让模型在看到 user/system 上下文后,学会生成 assistant 回复。
所以 SFT 数据要保留角色信息:
user 问了什么
assistant 应该怎么答
system 是否给了行为约束
这些信息在 jsonl 里用 conversations 表示。每条 message 至少有:
role: user / assistant / system
content: 消息正文
源码证据 A:tiny SFT 样本长什么样
文件:course/labs/tiny_sft.jsonl:1-3
看它是为了理解:SFT 数据的最小结构。
{"conversations":[
{"role":"user","content":"MiniMind 是什么?"},
{"role":"assistant","content":"MiniMind 是一个小型语言模型教学项目,用来学习从预训练到后训练的完整流程。"}
]}
这段数据说明:
- 一条样本的核心字段是
conversations。 user是输入上下文的一部分。assistant是我们希望模型学会生成的目标回复。
源码证据 B:SFTDataset 声明 conversations schema
文件:dataset/lm_dataset.py:59-66
看它是为了理解:代码期望 SFT 样本有哪些字段。
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)
这段代码说明:
SFTDataset会从 jsonl 读取conversations。- 除了
role/content,它还预留了 reasoning、tools、tool_calls 字段。 - 本节只关心普通 user/assistant 对话,工具调用后面再讲。
理解到这一步就够
你应该能说清楚:
SFT 数据不是普通文本,而是带 role 的对话列表;
user/system 提供上下文,assistant 提供训练目标。
暂时不用看:
tools和tool_calls的完整格式。- DPO/RL 数据格式。
4. 原理二:conversations 如何变成 prompt
原理讲解
模型不能直接吃 Python 列表:
[{"role": "user", "content": "..."}]
它只能吃 token id,而 token id 来自字符串。所以 SFTDataset 必须先把 conversations 渲染成 prompt 字符串。
这一步和第 4 课的 chat template 是同一件事,只是训练时有一个关键差异:
推理时:add_generation_prompt=True,因为还没有 assistant 回复,要提示模型开始回答。
SFT 时:add_generation_prompt=False,因为样本里已经有完整 assistant 回复。
源码证据 A:SFTDataset 调用 chat template
文件:dataset/lm_dataset.py:71-86
看它是为了理解:SFT prompt 是怎么从 conversations 生成的。
def create_chat_prompt(self, conversations):
messages = []
tools = None
for message in conversations:
message = dict(message)
...
messages.append(message)
return self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=False,
tools=tools
)
这段代码说明:
messages是从conversations整理出来的。apply_chat_template(..., tokenize=False)返回 prompt 字符串。add_generation_prompt=False,因为训练样本已经包含 assistant 回复。- 如果有 tools,会传给模板;普通 SFT 样本可以先忽略。
源码证据 B:训练前可能做轻量 prompt 后处理
文件:dataset/lm_dataset.py:31-35
看它是为了理解:prompt 生成后可能会随机移除空 thinking 标签。
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
这段代码说明:
- SFT prompt 中可能有空
<think></think>块。 - 作者会按概率移除空 thinking 标签。
- 这是轻量数据增强,不影响本节核心:最终训练的是 prompt token 序列。
理解到这一步就够
你应该能说清楚:
SFTDataset 先把 conversations 变成完整 prompt;
SFT 用 add_generation_prompt=False,因为 assistant 回复已经在 prompt 里。
暂时不用看:
- tool 模板分支。
- random system prompt 的训练策略细节。
5. 原理三:labels 为什么要 mask
原理讲解
SFT 的关键不是“把整段 prompt 都学一遍”。
一条 SFT prompt 里通常包含:
system 指令
user 问题
assistant 回复
padding
模型训练时应该主要学习 assistant 怎么回答,而不是学习 user 问题本身。
所以 labels 会做 mask:
user/system/padding 位置 -> -100
assistant 回复位置 -> 对应 token id
在 PyTorch 的 cross entropy 里,ignore_index=-100 表示这个位置不参与 loss。
源码证据 A:SFTDataset 先找 assistant 起止标记
文件:dataset/lm_dataset.py:65-66
看它是为了理解:代码如何识别 assistant 回复区域。
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
这段代码说明:
bos_id不是普通 BOS,而是 assistant 消息开头:<|im_start|>assistant\n。eos_id是消息结束:<|im_end|>\n。- 后面
generate_labels会靠这两个 token 序列定位 assistant 区域。
源码证据 B:labels 默认全是 -100
文件:dataset/lm_dataset.py:88-90
看它是为了理解:默认所有位置都不参与 loss。
def generate_labels(self, input_ids):
labels = [-100] * len(input_ids)
i = 0
这段代码说明:
- labels 和 input_ids 等长。
- 初始状态下每个位置都是
-100。 - 后面只有 assistant 区域会被改成真实 token id。
源码证据 C:只把 assistant 区域写回 labels
文件:dataset/lm_dataset.py:91-104
看它是为了理解:哪些 token 参与 SFT loss。
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]
这段代码说明:
- 找到
<|im_start|>assistant\n后,assistant 正文从start开始。 - 一直找到
<|im_end|>\n为止。 - 只有这个区间的 labels 会从
-100改为真实 token id。 - user/system 仍然是
-100,不会贡献 loss。
理解到这一步就够
你应该能说清楚:
input_ids 是完整 prompt;
labels 是训练目标;
SFT 只让 assistant 回复区域参与 loss,其它位置用 -100 忽略。
暂时不用看:
- DPO 的 chosen/rejected mask。
- PPO/GRPO 的 rollout mask。
6. 原理四:padding 和 truncation
原理讲解
训练通常按 batch 处理样本。一个 batch 里的 tensor 需要统一长度。
所以每条 prompt 会被处理成固定长度:
太长 -> 截断到 max_length
太短 -> 用 pad_token_id 补齐
padding 只是为了形状对齐,不应该参与训练。因此 padding 对应的 labels 也应该保持 -100。
源码证据 A:SFTDataset 截断并补齐 input_ids
文件:dataset/lm_dataset.py:106-113
看它是为了理解:prompt 如何变成固定长度的 input_ids。
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)
这段代码说明:
self.tokenizer(prompt).input_ids把 prompt 编码成 token id。[:self.max_length]截断过长样本。- 不足
max_length的部分用pad_token_id补齐。 generate_labels(input_ids)在补齐后生成 labels,padding 位置会保持-100。
源码证据 B:DataLoader 取到的是 input_ids 和 labels
文件:dataset/lm_dataset.py:119
看它是为了理解:SFTDataset 最终向训练脚本返回什么。
return torch.tensor(input_ids, dtype=torch.long), torch.tensor(labels, dtype=torch.long)
这段代码说明:
- 每条样本最终返回两个 tensor。
- 两者长度相同。
- 训练脚本不再关心原始文本,只看
input_ids和labels。
理解到这一步就够
你应该能说清楚:
SFTDataset 把不同长度的 prompt 变成固定长度 input_ids;
padding 是形状占位,不参与 loss;
labels 和 input_ids 等长。
暂时不用看:
- DDP sampler。
- DataLoader 的性能参数。
7. 原理五:loss 如何使用 labels
原理讲解
Causal LM 的训练目标是预测下一个 token。
如果输入是:
[t0, t1, t2, t3]
模型在位置 0 的 logits 用来预测 t1,位置 1 的 logits 用来预测 t2,以此类推。
所以计算 loss 时会做 shift:
logits[..., :-1, :] 对齐 labels[..., 1:]
这意味着:labels[j] 监督的是前一个位置对 token j 的预测。
当 labels[j] == -100 时,这个目标会被忽略。SFT 正是通过这个机制只训练 assistant 区域。
源码证据 A:训练脚本把 labels 传给模型
文件:trainer/train_full_sft.py:27-37
看它是为了理解:SFT 训练真正使用的是 model(input_ids, labels=labels)。
for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
input_ids = input_ids.to(args.device)
labels = labels.to(args.device)
...
res = model(input_ids, labels=labels)
loss = res.loss + res.aux_loss
这段代码说明:
- DataLoader 从
SFTDataset得到(input_ids, labels)。 - labels 会被传进模型 forward。
- 模型内部计算
res.loss。
源码证据 B:模型 forward 做 shift 并忽略 -100
文件:model/model_minimind.py:245-253
看它是为了理解:labels 如何真正进入 cross entropy。
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
这段代码说明:
logits[..., :-1, :]去掉最后一个位置,因为最后一个位置没有下一个 token 可预测。labels[..., 1:]去掉第一个 label,让每个位置预测下一个 token。ignore_index=-100表示 label 为-100的位置不参与 loss。
理解到这一步就够
你应该能说清楚:
SFTDataset 决定哪些 token 是训练目标;
model.forward 通过 shift 做 next-token loss;
-100 是“这个位置不要算 loss”的标记。
暂时不用看:
aux_loss的 MoE 细节。- 混合精度、梯度累积、优化器。
8. 实验验证
实验 A:打印一条 tiny SFT 样本
这个实验验证:
conversations -> prompt -> input_ids -> labels
运行:
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_sft_dataset.py --show_rows 70
记录:
input_ids.shape =
labels.shape =
non_ignored_labels =
assistant span 的起始 token =
第一段 label != -100 的位置 =
padding 位置的 label =
你应该看到:
user token 的 label 是 -100;
assistant 回复 token 的 label 是对应 token id;
padding token 的 label 是 -100。
实验 B:换一条样本
运行:
PYTHONDONTWRITEBYTECODE=1 python course/labs/inspect_sft_dataset.py --index 2 --show_rows 90
记录:
system 消息是否出现在 prompt 里?
system/user token 是否参与 loss?
assistant token 从哪里开始参与 loss?
9. 本节检查
如果你真懂了本节,应该能不看答案说清楚:
- SFT 的
conversations和普通文本预训练样本有什么不同。 - 为什么 SFT 用
add_generation_prompt=False。 input_ids和labels分别表示什么。- 为什么 user/system token 的 label 要设为
-100。 labels[..., 1:]和logits[..., :-1, :]为什么要 shift。- padding token 为什么不应该参与 loss。
10. 下一课
第 6 课会正式对比 PretrainDataset 和 SFTDataset:同样是 causal LM loss,为什么数据格式、labels 构造和训练目标不同。