预训练之后的训练都可以叫后训练,监督微调是其中的第一步。
预训练出来的模型,已经学到了一些中文的样子。但你要是问它一个问题,它多半不会回答,而是顺着你的问题继续往下写,像在续写一篇文章。
这很正常。预训练只教会了它"接话":看到前文,写出下文。它从来没见过"一个人问、另一个人答"的格式,也不知道答完了该停下来。
这一节用对话数据再训练一轮,教它一问一答。这一步叫监督微调,也有人叫它指令微调。
这两个名字拆开看就懂了。"监督"是说每道题都配好了标准答案,模型照着答案学;"指令"就是人给模型的问题或要求;"微调"是说不从头训练,而是在已经训练好的模型上,再轻轻调一调。
"微调"有点像收音机:预训练已经把旋钮拧到了电台附近,能听见声音了。监督微调只是再轻轻拧一点,让它播出我们想听的节目,用不着把旋钮拧回零重新找台。
SFT 是英文 Supervised Fine-Tuning 的缩写,就是"监督微调"。
学完你能做到:说清楚对话数据怎么变成训练样本,为什么只让回答部分计算损失,并且训练出一个能简单对话的模型。
一、从"接话"到"听话"
先用还没微调过的模型问两句。在 llm-course 目录下新建一个 ask.py,把问题按对话格式发给它:
# 按对话格式问几个问题,看模型怎么接话
# 用法:uv run python ask.py 你好 介绍一下长城
import sys
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
path = "out/pretrain" # 前面训练出来的模型
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
tok = AutoTokenizer.from_pretrained(path)
model = AutoModelForCausalLM.from_pretrained(path).to(device).eval()
for question in sys.argv[1:] or ["你好"]:
# 按模板拼提示词,末尾是"轮到我说话了",模型接着这段往下写
prompt = tok.apply_chat_template([{"role": "user", "content": question}], tokenize=False, add_generation_prompt=True)
ids = tok(prompt, return_tensors="pt", add_special_tokens=False).input_ids.to(device)
with torch.no_grad():
out = model.generate(ids, max_new_tokens=200, do_sample=True, temperature=0.7, top_p=0.8,
eos_token_id=tok.eos_token_id, pad_token_id=tok.pad_token_id)
print(f"\n你:{question}")
print("模型:" + tok.decode(out[0, ids.shape[1]:], skip_special_tokens=True).strip())运行:
uv run python ask.py 你好 介绍一下长城在我们的 CPU 缩小版测试里,它的反应是这样的:
你:你好
模型:我是谁
你为什么想我?
我是谁
你是谁
你在哪里
……
你:介绍一下长城
模型:解释:
一、这个村子在江西的东南边,是长城宙的起点,后来变成了现在的样子。
二、这个村子是长城宙的起点,长城宙的起点在北部的一个地方,后来变成了现在的样子。
……
九、这个村子在长城宙的起问它问题,它不接茬:问"你好",它回一串"我是谁""你在哪里",来回打转;问长城,它一本正经地分条列项,一直到第九条,讲的全是"长城宙的起点"这一句的变体,最后被程序掐断在"九、"的半截——不是它自己说完的。
我们用 16 个问题各问了 3 次,这 48 次里,它一次都没有自己停下来。
200 个 token 的上限,是 ask.py 里设的(max_new_tokens=200)。模型自己不会累,也不知道什么时候该收尾,所以程序替它数着,数到第 200 个就强行掐断。
这里说的"自己停下来",是指模型在 200 个 token 以内,自己写出了表示"说完了"的结束标记。被程序掐断的不算。
监督微调要教给它三件事:
- 认识对话格式:知道哪段是用户说的,哪段该自己说。
- 针对问题回答:回答的内容要和问题有关。
- 说完就停:回答结束时输出
<|im_end|>,而不是无休止地写下去。
上面说的 <|im_end|>,就是那个结束标记。它看着像一串字符,其实是分词器里预留的特殊 token,整个只算一个 token,有自己的编号。
为什么输出一个 token,就算"停"了?因为模型其实不会真的停,它只会一个接一个地往外吐 token。程序每拿到一个就看一眼:是 <|im_end|>,就不再让它往下写。
所以"说完就停",对模型来说其实是"说完就写一个 <|im_end|>"。这件事和写别的字一样,是要靠训练学会的。
方法和预训练一样,还是"猜下一个 token"。区别只在于数据:这次用的是一条条对话,并且按对话模板拼好。
二、对话怎么变成训练样本
1. 按模板拼起来
分词器里已经存了一个对话模板。一条两轮的对话,拼起来是这样的:
<|im_start|>user
你好<|im_end|>
<|im_start|>assistant
你好!有什么可以帮你的吗?<|im_end|>
<|im_start|>user
介绍一下长城<|im_end|>
<|im_start|>assistant
长城是……<|im_end|>上面这个模板,可以当成剧本来读。剧本里每句台词前面都写着谁在说,这里也一样:<|im_start|> 表示"有人开口了",紧跟着的是角色名;<|im_end|> 表示"这句说完了"。
角色只有两个:user 是用户,也就是提问的人;assistant 是助手,也就是模型自己。
推理时,我们把用户的问题按同样的格式拼好,末尾加上 <|im_start|>assistant 和一个换行,然后让模型接着往下写。
它写到 <|im_end|> 就停下来,中间写出的就是回答。训练和推理用同一个模板,模型才知道什么时候轮到它说话。
"推理"就是训练好以后拿模型来用,比如和它聊天。推理时一步步发生的事,画出来是这样:

注意,模型还是在"接话",这一点没有变。变的是,训练时它见过成千上万次:<|im_start|>assistant 后面接的总是一段回答,回答完是 <|im_end|>。
所以我们把开头搭好,它"接话"接出来的,恰好就是回答。
2. 只让回答计算损失
拼好的整段文字里,用户的问题也是 token。要不要让模型学着"预测用户的问题"?不要。我们希望它把能力都用在学怎么回答上。
做法是给每个位置一个标签。助手回答的部分,标签就是正确的 token;其余部分,标签填 -100,这个特殊值表示"这个位置不计算损失":
token: <|im_start|> user \n 你好 <|im_end|> \n <|im_start|> assistant \n 你好! <|im_end|> \n
标签: -100 -100 -100 -100 -100 -100 -100 -100 -100 你好! <|im_end|> -100注意回答末尾的 <|im_end|> 是计算损失的,模型靠它学会说完就停。
表里的 \n 表示换行。
为什么偏偏用 -100?token 的编号都是从 0 开始的整数,不会是负数,所以 -100 不会和任何真实的 token 撞车。PyTorch 计算损失时,默认就会跳过标签是 -100 的位置。
你可能会问:标签和 token 一模一样,模型照抄不就行了?不行。计算损失时,模型会自动把标签错开一位:写在"你好!"下面的标签,意思是"只看它前面的所有 token,猜出'你好!'"。模型只能看前面,看不到自己要猜的那个。

整段对话就像一张试卷。用户的问题是印好的题目,模型要读,但不用它写,也不判分;助手的回答是答题区,每个 token 都要判分。损失就是判卷扣的分。
所以"不计算损失"不等于"看不见"。用户的问题照样是输入,模型猜回答时一直看着它,只是不用去猜它。
多轮对话也是同样的规则:不管有几轮,每一轮助手的回答都计算损失,每一轮用户的话都不计算。
模型在学第二轮回答时,能看到第一轮的问答,所以它也能学到怎么接着前面的话说。
拿前面那条两轮的对话来看,哪些判分、学第二轮回答时能看到什么,画出来是这样:
