指令微调
我们的小模型已经能续写一段故事。现在把问题改成这样:
只回答颜色。小林选了蓝色盒子,放到桌上。
你希望它回答“蓝”。基础模型却可能接着写“色盒子。”——它仍然在做自己熟悉的事:接着前面的文字往下写。
预训练让模型练习文本预测,指令微调则让它反复看到“这种请求,应当怎样回答”。这一章把之前的 Transformer 变成一个小小的信息提取助手,同时看看:学会新任务以后,旧任务有没有变差?
1. 基础模型与指令模型,差别在哪里
基础模型与指令模型可以使用同样的网络结构。变化主要发生在训练数据、目标以及使用时的输入格式上。
基础模型看到大量文本,学习后续词元的条件分布。指令微调把请求、上下文与示范回答组织成样本,让模型提高合适回答的概率。这类训练通常叫监督微调,英文缩写是 SFT。
一个样本可以非常短:
请求:只回答物品。小林选了蓝色盒子,放到桌上。
示范:盒子
“回答正确”还不够:如果要求只输出物品名,“这是一只蓝色盒子”虽然描述没有错,却不符合输出要求。所以示范需要同时表达任务、内容和形式。
Ouyang 等人的指令跟随研究 使用示范回答进行监督微调,再引入偏好反馈。我们先只做示范学习;奖励模型与偏好优化会在后面两章展开。
微调不是给模型装上一个能保证服从的开关。它改变的是概率分布,能否在新请求中稳定表现,仍要通过评估检查。
2. 对话格式:角色也要变成模型认识的输入
应用里看到的是聊天气泡,模型接收的却是一条词元序列。必须有一种规则,把用户说的话和助手说的话排进去。
本章使用自定义的单轮格式:
<BOS><USER>只回答颜色。小林选了蓝色盒子,放到桌上。<ASSISTANT>蓝<EOS>
这里的角色标记各对应一个专门的词元 ID。尖括号里的写法只是给人看的名字,不是先拆成许多个普通字符。前面的 BOS、EOS、PAD 也沿用这个思路。
在真实模型中,应使用模型训练时约定的对话模板。有的模板还包含系统消息、回合结束标记或工具消息。它们并没有一个跨模型通用的神奇拼写;训练时一种格式,推理时另一种格式,很可能改变模型看到的条件。
多轮对话也需要明确哪些回合参与训练:可以对每段助手回复计分,也可以只训练最后一段。决定之后,数据处理和损失掩码要一致。我们的示例只做一问一答,先把最基本的边界处理正确。
3. 让模型看见请求,只对回答计分
自回归训练会把输入与目标错开一位:
输入末尾:…… <ASSISTANT> 蓝
目标末尾:…… 蓝 <EOS>
我们希望模型学习回答,不要求它再把用户的问题复述一遍。因此,本章只在回答和 EOS 位置计算交叉熵,请求部分与 PAD 位置都不直接计分。
图 1:用户内容和角色标记仍参与前向计算。绿色位置才直接贡献监督损失,回答的 EOS 也在其中。
设第 个位置的目标损失为 ,掩码为 ,则:
回答及其终止符位置取 ,其余位置取 0。程序里用 -100 表示忽略目标,传给交叉熵的 ignore_index。
这里很容易犯一个错:把“提示不计损失”理解成“提示不能传递梯度”。模型仍需要读提示才能回答,提示的嵌入和注意力路径会影响回答损失;全量微调时,这些路径上的参数照样可以更新。我们只是没有单独要求它预测用户文本。
另一个常见错误是对齐差一位。若助手标记在输入中的位置是 ,这个位置的输出应该预测回答的第一个词元,不能把它也屏蔽掉。示例脚本根据前缀长度生成掩码,不靠目测删固定数量的列。
长回答是否会占更大权重
按所有回答词元求平均时,长回答通常贡献更多项。如果先对每个回答平均,再对样本平均,就会得到另一种权重。没有一种做法能脱离任务自动成为标准答案,但需要明确口径。
本章“颜色”回答有一个字符加 EOS,共 2 个目标;“物品”回答有两个字符加 EOS,共 3 个目标。我们采用整个批次的有效回答词元平均,沿用第二十三章的加权原则。
4. 示范数据:写得像答案,还要确实是答案
监督微调会模仿给定示范。如果示范里把蓝色写成红色,或者用户要求一个词、示范却总写一大段,训练损失仍然可能下降。下降只说明模型更贴近这些样本,不自动说明样本值得模仿。
构造数据时,可以先围绕几个具体问题检查:
- 请求本身有没有足够信息?若信息不足,示范应怎样表达不确定?
- 回答的事实、计算、引用与输出格式能否核对?
- 任务和表达方式是否覆盖实际使用场景,还是反复出现同一个模板?
- 训练与评估是否混进同一文档、同一对话或同一条改写的近亲?
使用模型生成示范,也需要保留来源并做质量检查。生成数量再大,系统性错误也可能一起扩大。
本书的例子容易核对:人物、颜色、物品、地点来自固定组合,答案直接由记录构造。代价是任务非常窄。模型答对这些短句,不代表能理解任意中文指令。
5. 第六篇实践:让故事模型学会提取信息
数据、模型与三组比较
沿用之前的 256 条自编故事,固定划分为 192 条训练、32 条验证和 32 条测试。为了加入“只回答颜色”“只回答物品”及角色标记,这次重新建立一个 43 项词表,并训练同样结构的两层、宽度 32 的 Transformer。
它有 28,992 个参数。基础预训练在 192 条训练故事上运行 600 次更新;接着,从其中前 96 条故事各构造颜色、物品两个问题,得到 192 条指令训练记录。验证和测试各有 64 个问题,来源故事分别属于各自保留集合。
这里的基础模型是为本章训练的小模型,并非直接读取第二十一章那份 35 项词表的文件。角色词元预先存在于词表,但基础预训练没有使用对话样本,角色的用法要在 SFT 阶段学习。
我们固定比较三种情况:
| 方案 | 后训练阶段做什么 |
|---|---|
| 基础模型 | 不进行指令微调,作为对照 |
| 仅指令微调 | 160 次更新,只训练回答位置 |
| 混合旧文本 | 同样 160 次更新,同时保留一份故事预测任务 |
后两组从同一基础权重开始,指令样本顺序相同。训练使用 AdamW、学习率 0.001、权重衰减 0.01、梯度范数裁剪 1,批次为 16。全程使用一个固定的训练随机设置,没有进行多种子结论推广。
混合方案把两个分别归一化的损失组合起来:
每步额外抽取 16 条旧故事。0.3 是旧任务平均损失的系数,不是说旧词元占训练词元的 30%。两个任务的目标长度不同,不能把两种比例混为一谈。
运行代码
使用 Python 3.12、PyTorch 2.14.1,CPU 单线程、双精度。无需 GPU,也不需要下载外部语料或模型。
uv run --python 3.12 --with-requirements requirements.txt python src/examples/instruction_tuning.py
单独下载时,把 instruction_tuning.py、tiny_transformer.py 和 small_language_model.py 放在同一目录;依赖见 requirements.txt。
输出如下:
词表=43;参数=28,992;基础预训练=600 步;指令训练记录=192
基础模型:验证回答损失=7.820;测试回答=0/64,格式=0/64;旧文本损失=0.212,旧续写=32/32
首个失败:应答=蓝;实际=色盒子。;正常终止=True
旧任务续写示例:最后小林拿走了蓝色盒子。
仅指令微调:验证回答损失=0.002;测试回答=64/64,格式=64/64;旧文本损失=1.448,旧续写=0/32
更新=160;回答目标=6367;旧文本目标=0;状态张量=906.1 KiB
旧任务续写示例:最后小林红
混合旧文本:验证回答损失=0.002;测试回答=64/64,格式=64/64;旧文本损失=0.212,旧续写=32/32
更新=160;回答目标=6367;旧文本目标=69120;状态张量=906.1 KiB
旧任务续写示例:最后小林拿走了蓝色盒子。
测试前没有依据测试分数选择训练步数或最佳种子。三组都按预先定义的端点评分,完整比较这次实验;验证损失用于观察回答预测情况。
“回答正确”要求生成内容与标准答案完全一致,并且正常生成 EOS。“格式正确”只要求输出属于允许的颜色名或物品名,并正常终止;一个格式正确的答案仍然可能提错了颜色。生成采用贪心选择,最多产生 6 个新词元。
旧任务另外给出故事的第一句,让模型续写后半句,最多生成 16 个新词元,要求整段内容和终止都正确。它与“给定真实前缀,只检查一个颜色位置”的指标不同:后者可能漏掉其他位置已经发生的退化。
图 2:指令测试有 64 个问题,旧任务有 32 段续写。图上使用比例便于对照,标注保留原始分子与分母。两种任务的含义不同,不能把它们合成一个无条件的“能力总分”。
新任务学会了,旧任务为什么变差
全量微调会调整同一套参数。这些参数原来共同支持故事预测,现在被用来提高简短回答的概率。只对新任务施加训练信号,可能损害旧任务;这种现象常被称为灾难性遗忘。
这里的退化不是推测。基础模型的旧续写为 32/32,纯 SFT 后变成 0/32;具体续写从完整句子变成“最后小林红”。与此同时,新任务从 0/64 变成 64/64。只展示新任务分数,就会错过另一半故事。
混合旧文本的方案在本次实验中保住了两项表现。不过它也花了更多计算。两组都更新 160 次,但纯 SFT 每次处理 个位置,混合方案再处理同样大小的一批旧文本,前向与反向计算量相应增加。
| 后训练方案 | 回答监督目标 | 旧文本监督目标 | 网络处理位置 |
|---|---|---|---|
| 仅指令微调 | 6,367 | 0 | 81,920 |
| 混合旧文本 | 6,367 | 69,120 | 163,840 |
随机抽样中两类回答的比例略有波动,所以有效回答目标不恰好等于期望值 6,400。无论计分位置有多少,当前实现仍在补齐后的 32 个位置上做网络计算。
输出里的“状态张量”统计参数、当前梯度和优化器状态实际占用的字节,不包括激活、临时工作区、Python 对象或内存分配器开销。两组这项相同,也不意味着训练峰值内存相同:混合方案还要保留额外计算图。下一章再细看怎样减少可训练状态。
保存实验记录
uv run --python 3.12 --with-requirements requirements.txt python src/examples/instruction_tuning.py --out runs/instruction.json --save-base runs/instruction-base.pt
JSON 保存三组的验证、测试、失败样本与状态字节数;基础快照留给下一章复用,包含权重、配置、词表及训练数据指纹。它是基础模型的推理权重,不是本次 SFT 的断点续训检查点。
展开完整代码:对话编码、回答掩码、微调与退化评估
"""第 26 章:把短句语言模型微调为颜色/物品提取助手,并检查旧任务退化。"""
import argparse
from copy import deepcopy
from hashlib import sha256
import json
from pathlib import Path
import torch
from torch import nn
from tiny_transformer import TinyTransformer
from small_language_model import corpus, make_windows, evaluate
PAD, BOS, EOS, USER, ASSISTANT = range(5)
IGNORE = -100
INSTRUCTIONS = {"颜色": "只回答颜色。", "物品": "只回答物品。"}
SPECIAL = ["<PAD>", "<BOS>", "<EOS>", "<USER>", "<ASSISTANT>"]
def setup():
torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)
def vocabulary(texts):
chars = sorted(set("".join(texts)+"".join(INSTRUCTIONS.values())))
return SPECIAL+chars
def instruction_records(texts):
records = []
for text in texts:
for task in INSTRUCTIONS:
answer = text[4] if task == "颜色" else text[6:8]
records.append(dict(prompt=INSTRUCTIONS[task]+text[:14], answer=answer,
task=task, source=text))
return records
def encode_prefix(prompt, vocab):
ids = {token: i for i, token in enumerate(vocab)}
return [BOS, USER]+[ids[c] for c in prompt]+[ASSISTANT]
def supervised_data(records, vocab, assistant_only=True):
ids = {token: i for i, token in enumerate(vocab)}
inputs, targets = [], []
for row in records:
prefix = encode_prefix(row["prompt"], vocab)
sequence = prefix+[ids[c] for c in row["answer"]]+[EOS]
x, y = sequence[:-1], sequence[1:]
if assistant_only:
y[:len(prefix)-1] = [IGNORE]*(len(prefix)-1)
if len(x) > 32:
raise ValueError("示例不截断对话;输入超出 32 个位置")
inputs.append(x+[PAD]*(32-len(x)))
targets.append(y+[IGNORE]*(32-len(y)))
return torch.tensor(inputs), torch.tensor(targets)
def loss_value(model, data, ignore=IGNORE):
x, y = data[:2]
return nn.functional.cross_entropy(model(x).flatten(0, 1), y.flatten(), ignore_index=ignore)
def persistent_bytes(model, optimizer):
# 当前实际存在的张量;不包括激活、临时工作区、Python 对象等。
weights = sum(p.numel()*p.element_size() for p in model.parameters())
gradients = sum(p.grad.numel()*p.grad.element_size() for p in model.parameters() if p.grad is not None)
states = sum(t.numel()*t.element_size() for state in optimizer.state.values()
for t in state.values() if torch.is_tensor(t))
return dict(weights=weights, gradients=gradients, optimizer=states)
def pretrain(train_texts, vocab, steps=600):
torch.manual_seed(2026)
model = TinyTransformer(len(vocab), width=32).double()
optimizer = torch.optim.AdamW(model.parameters(), lr=.003, weight_decay=.01)
sampler = torch.Generator().manual_seed(2027)
x, y, _ = make_windows(train_texts, vocab, 32)
for _ in range(steps):
selected = torch.randint(len(x), (16,), generator=sampler)
optimizer.zero_grad(set_to_none=True)
loss = loss_value(model, (x[selected], y[selected]), ignore=PAD)
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1, error_if_nonfinite=True)
optimizer.step()
model.zero_grad(set_to_none=True)
return model
def finetune(model, records, old_texts, vocab, steps=160, replay=0, learning_rate=.001):
model = deepcopy(model)
model.zero_grad(set_to_none=True)
trainable = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.AdamW(trainable, lr=learning_rate, weight_decay=.01)
sampler = torch.Generator().manual_seed(2028)
old_sampler = torch.Generator().manual_seed(2029)
x, y = supervised_data(records, vocab)
old_x, old_y, _ = make_windows(old_texts, vocab, 32)
targets, old_targets, history = 0, 0, []
for step in range(1, steps+1):
model.train()
selected = torch.randint(len(x), (16,), generator=sampler)
optimizer.zero_grad(set_to_none=True)
instruction_loss = loss_value(model, (x[selected], y[selected]))
loss = (1-replay)*instruction_loss
targets += (y[selected] != IGNORE).sum().item()
if replay:
old_selected = torch.randint(len(old_x), (16,), generator=old_sampler)
old_loss = loss_value(model, (old_x[old_selected], old_y[old_selected]), ignore=PAD)
loss = loss+replay*old_loss
old_targets += (old_y[old_selected] != PAD).sum().item()
if not torch.isfinite(loss):
raise RuntimeError("非有限训练损失")
loss.backward()
nn.utils.clip_grad_norm_(trainable, 1, error_if_nonfinite=True)
optimizer.step()
if step % 40 == 0:
history.append(dict(step=step, loss=loss.item()))
stats = dict(trainable=sum(p.numel() for p in trainable), steps=steps,
targets=targets, old_targets=old_targets, state_bytes=persistent_bytes(model, optimizer),
history=history)
model.zero_grad(set_to_none=True)
return model, stats
@torch.no_grad()
def generate_from_prefixes(model, prefixes, vocab, max_new):
model.eval()
if len({len(p) for p in prefixes}) != 1:
raise ValueError("本例批量生成要求等长提示")
if len(prefixes[0])+max_new-1 > model.config["max_context"]:
raise ValueError("提示与生成上限超出上下文")
ids = torch.tensor(prefixes)
answers = [[] for _ in prefixes]
finished = [False]*len(prefixes)
for _ in range(max_new):
logits = model(ids)[:, -1].clone()
logits[:, [PAD, BOS, USER, ASSISTANT]] = -torch.inf
chosen = logits.argmax(-1)
for i, token in enumerate(chosen.tolist()):
if not finished[i]:
if token == EOS:
finished[i] = True
else:
answers[i].append(vocab[token])
ids = torch.cat([ids, chosen[:, None]], dim=1)
if all(finished):
break
return [("".join(answer), stop) for answer, stop in zip(answers, finished)]
def generate_answers(model, records, vocab, max_new=6):
return generate_from_prefixes(model, [encode_prefix(row["prompt"], vocab) for row in records],
vocab, max_new)
@torch.no_grad()
def metrics(model, records, old_texts, vocab):
model.eval()
answer_loss = loss_value(model, supervised_data(records, vocab)).item()
generated = generate_answers(model, records, vocab)
correct, formatted = 0, 0
failures = []
for row, (answer, stopped) in zip(records, generated):
valid = answer in (["红", "蓝", "绿", "白"] if row["task"] == "颜色"
else ["杯子", "盒子", "本子", "袋子"])
formatted += valid and stopped
correct += answer == row["answer"] and stopped
if answer != row["answer"] or not stopped:
failures.append(dict(prompt=row["prompt"], expected=row["answer"], generated=answer, stopped=stopped))
old_loss, old_accuracy = evaluate(model, make_windows(old_texts, vocab, 32))
ids = {token: i for i, token in enumerate(vocab)}
old_prefixes = [[BOS]+[ids[c] for c in text[:14]] for text in old_texts]
old_generated = generate_from_prefixes(model, old_prefixes, vocab, 16)
old_exact = sum(answer == text[14:] and stopped for text, (answer, stopped) in zip(old_texts, old_generated))
return dict(answer_loss=answer_loss, correct=correct, formatted=formatted, count=len(records),
old_loss=old_loss, old_accuracy=old_accuracy, old_exact=old_exact, failures=failures,
old_example=dict(expected=old_texts[0][14:], generated=old_generated[0][0],
stopped=old_generated[0][1]))
def prepare():
setup()
train, validation, test = corpus()
vocab = vocabulary(train)
model = pretrain(train, vocab)
return model, (train, validation, test), vocab
def run():
base, (train, validation, test), vocab = prepare()
records = instruction_records(train[:96])
sft, sft_cost = finetune(base, records, train, vocab)
replay, replay_cost = finetune(base, records, train, vocab, replay=.3)
results = []
for name, model, cost in [("基础模型", base, None), ("仅指令微调", sft, sft_cost),
("混合旧文本", replay, replay_cost)]:
results.append(dict(name=name, validation=metrics(model, instruction_records(validation), validation, vocab),
test=metrics(model, instruction_records(test), test, vocab), cost=cost))
report = dict(vocab_size=len(vocab), parameters=sum(p.numel() for p in base.parameters()),
base_steps=600, instruction_records=len(records), results=results)
return report, base, vocab
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path)
parser.add_argument("--save-base", type=Path)
args = parser.parse_args()
report, base, vocab = run()
print(f"词表={report['vocab_size']};参数={report['parameters']:,};"
f"基础预训练=600 步;指令训练记录={report['instruction_records']}")
for row in report["results"]:
val, test = row["validation"], row["test"]
print(f"{row['name']}:验证回答损失={val['answer_loss']:.3f};"
f"测试回答={test['correct']}/{test['count']},格式={test['formatted']}/{test['count']};"
f"旧文本损失={test['old_loss']:.3f},旧续写={test['old_exact']}/32")
if row["cost"]:
cost = row["cost"]
print(f" 更新={cost['steps']};回答目标={cost['targets']};旧文本目标={cost['old_targets']};"
f"状态张量={sum(cost['state_bytes'].values())/1024:.1f} KiB")
if test["failures"]:
failure = test["failures"][0]
print(f" 首个失败:应答={failure['expected']};实际={failure['generated']};"
f"正常终止={failure['stopped']}")
print(f" 旧任务续写示例:{test['old_example']['generated']}")
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2))
print(f"实验记录:{args.out}")
if args.save_base:
args.save_base.parent.mkdir(parents=True, exist_ok=True)
train, _, _ = corpus()
torch.save(dict(state=base.state_dict(), config=base.config, vocab=vocab,
experiment="instruction-base-v1", torch_version=str(torch.__version__),
train_sha256=sha256("\n".join(train).encode()).hexdigest()), args.save_base)
print(f"基础模型:{args.save_base}")
if __name__ == "__main__":
main()
6. 把这个实验的边界说清楚
64/64 看起来很漂亮,但测试使用相同的模板和词汇,只是组合不同。把“只回答颜色”改成“请告诉我杯子的颜色”,加入否定、多个物品或无关句子,当前结果都没有覆盖。
混入旧数据也不是唯一的遗忘缓解办法。更小的更新幅度、不同的数据配比、参数高效方法或针对旧行为的约束都值得尝试;它们各自的效果和成本需要独立比较。我们没有从这一次小实验得出“0.3 是通用最佳配比”。
你可以为篇末实践增加一组新措辞测试,但应先固定题目与评分规则。若反复根据这份新测试改进训练,它就成了开发材料;下一轮最终验证需要新的独立样本。
7. 停下来试一试
- 基础模型能写出颜色,为什么仍可能不能按要求只回答一个颜色?
- 用户提示不直接计入损失,是否意味着提示的嵌入不会得到梯度?
- 为什么助手标记所在的输入位置不能被误当成无用位置?
- 一条颜色回答有 2 个目标,一条物品回答有 3 个目标。按词元平均与按样本平均有什么区别?
- 混合损失中的 0.3,为什么不等于旧词元占比 30%?
- 新任务与旧任务分别用什么指标,才能发现本例的退化?
- 两组参数和优化器状态一样大,是否说明训练成本一样?
- 写出五条不同措辞的提取请求,并规定“不知道”或“存在多个候选”时的答案格式。哪些数据应加入训练,哪些保留作最终检查?
参考答案与提示
- 知道某些文本关系与学会指定的输入协议、输出边界是不同的训练要求。
- 不意味着。提示会影响回答,梯度可以从回答损失沿注意力和嵌入路径传回。
- 它的输出负责预测回答的第一个词元。错误屏蔽会漏掉一项重要监督。
- 词元平均让这两条分别贡献 2、3 个项;样本平均先让两条各占同样权重。
- 两个损失先各自归一化,再乘系数;目标长度与抽样数量也影响实际词元数。
- 新任务检查精确回答、格式与终止;旧任务同时看文本损失和完整续写,不能只盯一个仍然答对的颜色位置。
- 不说明。混合训练多做旧文本的前向、反向,并可能保留更多激活。
- 先按来源和任务变化划分。训练样本帮助模型学习规则,保留样本检验规则能否推广;不要只换一个标点就把同一请求拆到两边。
8. 继续阅读
- Ouyang 等,Training Language Models to Follow Instructions with Human Feedback:从示范微调走向偏好反馈的研究。
- PyTorch:局部控制梯度计算:理解损失掩码、冻结参数与关闭梯度记录之间的区别。
下一章保持这个任务不变,换一种更新参数的方式:能不能只训练少量新增参数,让模型也学会回答?