Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

指令微调

我们的小模型已经能续写一段故事。现在把问题改成这样:

只回答颜色。小林选了蓝色盒子,放到桌上。

你希望它回答“蓝”。基础模型却可能接着写“色盒子。”——它仍然在做自己熟悉的事:接着前面的文字往下写。

预训练让模型练习文本预测,指令微调则让它反复看到“这种请求,应当怎样回答”。这一章把之前的 Transformer 变成一个小小的信息提取助手,同时看看:学会新任务以后,旧任务有没有变差?

1. 基础模型与指令模型,差别在哪里

基础模型与指令模型可以使用同样的网络结构。变化主要发生在训练数据、目标以及使用时的输入格式上。

基础模型看到大量文本,学习后续词元的条件分布。指令微调把请求、上下文与示范回答组织成样本,让模型提高合适回答的概率。这类训练通常叫监督微调,英文缩写是 SFT。

一个样本可以非常短:

请求:只回答物品。小林选了蓝色盒子,放到桌上。
示范:盒子

“回答正确”还不够:如果要求只输出物品名,“这是一只蓝色盒子”虽然描述没有错,却不符合输出要求。所以示范需要同时表达任务、内容和形式。

Ouyang 等人的指令跟随研究 使用示范回答进行监督微调,再引入偏好反馈。我们先只做示范学习;奖励模型与偏好优化会在后面两章展开。

微调不是给模型装上一个能保证服从的开关。它改变的是概率分布,能否在新请求中稳定表现,仍要通过评估检查。

2. 对话格式:角色也要变成模型认识的输入

应用里看到的是聊天气泡,模型接收的却是一条词元序列。必须有一种规则,把用户说的话和助手说的话排进去。

本章使用自定义的单轮格式:

<BOS><USER>只回答颜色。小林选了蓝色盒子,放到桌上。<ASSISTANT>蓝<EOS>

这里的角色标记各对应一个专门的词元 ID。尖括号里的写法只是给人看的名字,不是先拆成许多个普通字符。前面的 BOS、EOS、PAD 也沿用这个思路。

在真实模型中,应使用模型训练时约定的对话模板。有的模板还包含系统消息、回合结束标记或工具消息。它们并没有一个跨模型通用的神奇拼写;训练时一种格式,推理时另一种格式,很可能改变模型看到的条件。

多轮对话也需要明确哪些回合参与训练:可以对每段助手回复计分,也可以只训练最后一段。决定之后,数据处理和损失掩码要一致。我们的示例只做一问一答,先把最基本的边界处理正确。

3. 让模型看见请求,只对回答计分

自回归训练会把输入与目标错开一位:

输入末尾:…… <ASSISTANT> 蓝
目标末尾:……      蓝    <EOS>

我们希望模型学习回答,不要求它再把用户的问题复述一遍。因此,本章只在回答和 EOS 位置计算交叉熵,请求部分与 PAD 位置都不直接计分。

图 1:用户内容和角色标记仍参与前向计算。绿色位置才直接贡献监督损失,回答的 EOS 也在其中。

设第 tt 个位置的目标损失为 ℓt\ell_t,掩码为 mtm_t,则:

LSFT=∑tmtℓt∑tmt \mathcal L_{\mathrm{SFT}} =\frac{\sum_t m_t\ell_t}{\sum_t m_t}

回答及其终止符位置取 mt=1m_t=1,其余位置取 0。程序里用 -100 表示忽略目标,传给交叉熵的 ignore_index。

这里很容易犯一个错:把“提示不计损失”理解成“提示不能传递梯度”。模型仍需要读提示才能回答,提示的嵌入和注意力路径会影响回答损失;全量微调时,这些路径上的参数照样可以更新。我们只是没有单独要求它预测用户文本。

另一个常见错误是对齐差一位。若助手标记在输入中的位置是 kk,这个位置的输出应该预测回答的第一个词元,不能把它也屏蔽掉。示例脚本根据前缀长度生成掩码,不靠目测删固定数量的列。

长回答是否会占更大权重

按所有回答词元求平均时,长回答通常贡献更多项。如果先对每个回答平均,再对样本平均,就会得到另一种权重。没有一种做法能脱离任务自动成为标准答案,但需要明确口径。

本章“颜色”回答有一个字符加 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。全程使用一个固定的训练随机设置,没有进行多种子结论推广。

混合方案把两个分别归一化的损失组合起来:

Lmix=0.7LSFT+0.3Lold \mathcal L_{\mathrm{mix}} =0.7\mathcal L_{\mathrm{SFT}} +0.3\mathcal L_{\mathrm{old}}

每步额外抽取 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 每次处理 16×3216\times32 个位置,混合方案再处理同样大小的一批旧文本,前向与反向计算量相应增加。

后训练方案回答监督目标旧文本监督目标网络处理位置
仅指令微调6,367081,920
混合旧文本6,36769,120163,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. 停下来试一试

  1. 基础模型能写出颜色,为什么仍可能不能按要求只回答一个颜色?
  2. 用户提示不直接计入损失,是否意味着提示的嵌入不会得到梯度?
  3. 为什么助手标记所在的输入位置不能被误当成无用位置?
  4. 一条颜色回答有 2 个目标,一条物品回答有 3 个目标。按词元平均与按样本平均有什么区别?
  5. 混合损失中的 0.3,为什么不等于旧词元占比 30%?
  6. 新任务与旧任务分别用什么指标,才能发现本例的退化?
  7. 两组参数和优化器状态一样大,是否说明训练成本一样?
  8. 写出五条不同措辞的提取请求,并规定“不知道”或“存在多个候选”时的答案格式。哪些数据应加入训练,哪些保留作最终检查?
参考答案与提示
  1. 知道某些文本关系与学会指定的输入协议、输出边界是不同的训练要求。
  2. 不意味着。提示会影响回答,梯度可以从回答损失沿注意力和嵌入路径传回。
  3. 它的输出负责预测回答的第一个词元。错误屏蔽会漏掉一项重要监督。
  4. 词元平均让这两条分别贡献 2、3 个项;样本平均先让两条各占同样权重。
  5. 两个损失先各自归一化,再乘系数;目标长度与抽样数量也影响实际词元数。
  6. 新任务检查精确回答、格式与终止;旧任务同时看文本损失和完整续写,不能只盯一个仍然答对的颜色位置。
  7. 不说明。混合训练多做旧文本的前向、反向,并可能保留更多激活。
  8. 先按来源和任务变化划分。训练样本帮助模型学习规则,保留样本检验规则能否推广;不要只换一个标点就把同一请求拆到两边。

8. 继续阅读

下一章保持这个任务不变,换一种更新参数的方式:能不能只训练少量新增参数,让模型也学会回答?