跟着LAP啥都学 跟着LAP啥都学

第 7 章 指令微调:从文本补全到遵循指令

      +

      本章导读:通过指令微调把文本补全模型改造为遵循指令的模型, 并以 LLM-as-a-judge 完成自动化评估。第 6 章末尾遗留的 「你问它问题,它给你续写」问题,在本章解决。

      学完本章你将能够:

      1. 说明指令微调与预训练、分类微调在训练目标上的差异;

      2. 实现动态填充与 -100 掩码的批处理函数 custom_collate_fn;

      3. 完成全参数微调并正确解读其损失曲线;

      4. 搭建基于 Ollama 的自动化评分流程,并理解裁判模型的非确定性。

      产出与代码:chapters/chapter07_instruction_tuning/ (include/instruction.h、src/main.cpp;权重加载与 generate 复用第 5 章)。

      7.1 原理概述:指令微调改的是「目标」,不是「模型」

      第 6 章的分类微调动了结构:把 50257 维输出头整个换成一个 Linear(768, 2) 二分类头。指令微调不改模型结构——还是那个带 50257 输出头的 GPTModel,一行代码都不动。变的只有三处:

      1. 数据形态:每条样本是 (instruction, input, output) 三元组,拼成 Alpaca 风格的提示词文本;

      2. 损失掩码:填充词元用 ignore_index=-100 从损失里剔除—— 这是本章最核心的一个数字;

      3. 参数范围:355M 参数全部可训练。用 GPT-2 medium 而非 small, 因为 124M 撑不起指令跟随所需的泛化。

      一句话概括差异:预训练教模型「下一个词是什么」,指令微调教模型 「用户要我做什么」——前者是语言建模,后者是任务对齐。典型应用: 聊天机器人、个人助理、对话任务。

      为什么这次不用 124M?分类微调是「专业化」——把模型收窄到一个判别 问题上,小容量也能胜任;指令跟随是「泛化」——要同时覆盖拼写、语法 纠正、常识问答等一批异质任务。Stanford Alpaca 做同样的工作,用的是 LLaMA 7B:指令跟随所需的泛化能力对参数规模有硬要求。

      本章流程分三阶段:① 准备数据集(下载 → 格式化 → 分批,7.2–7.4); ② 模型配置、微调与生成(7.5–7.6);③ 性能评估(打分,7.7–7.8)。 批处理与掩码(7.3)最容易出错,是本章的重点。

      完整流水线如下:

      Diagram

      7.2 数据准备:Alpaca 三元组与模板

      第一步是准备数据。本教程使用 1100 条 JSON 三元组 (配套数据集 data/instruction-data.json):

      {
        "instruction": "Identify the correct spelling of the following word.",
        "input": "Ocassion",
        "output": "The correct spelling is 'Occasion.'"
      }

      三个字段分工明确:instruction 是任务描述,input 是可选附加材料, output 是期望回复。这个格式出自 Stanford Alpaca,后来几乎所有开源 指令数据集都沿用了它。Alpaca 的成本数据值得记一笔:52K 条指令数据用 self-instruct 方法从 175 条人工种子出发、由 text-davinci-003 自举 生成,数据成本低于 500 美元;微调 LLaMA 7B 在 8 张 A100 上训练 3 小时,算力成本低于 100 美元。指令微调的门槛没有想象中高。

      format_input 把三元组拼成 Alpaca 提示词模板;input 为空时,整个 # Input: 小节都会省去:

      inline std::string format_input(const InstructionEntry& entry) {
          std::string instruction_text =
              "Below is an instruction that describes a task. "
              "Write a response that appropriately completes the request."
              "\n\n### Instruction:\n" + entry.instruction;
          std::string input_text =
              entry.input.empty() ? "" : ("\n\n### Input:\n" + entry.input);
          return instruction_text + input_text;
      }
      // 训练文本 = format_input + "\n\n### Response:\n" + output

      这段硬编码的英文模板是模型唯一能看到的「任务说明书」:训练时怎么拼, 推理时就得怎么拼,必须逐字符一致。

      数据按 85/5/10 划分(train/val/test):train 935 / val 55 / test 110。

      7.3 custom_collate_fn:动态填充与 -100 掩码

      指令数据长短参差,批处理方式直接决定损失计算是否正确。 custom_collate_fn(include/instruction.h)是本章的核心:它负责 按批填充、构造目标序列、给填充词元加掩码。

      与第 6 章「统一填充到 120」不同,这里每个批次填充到本批最长序列 (省算力),并且目标序列要做两件精细的事:

      // include/instruction.h —— custom_collate_fn
      new_item.push_back(pad_token_id);            // 1. 句尾补一个 <|endoftext|>
      while (new_item.size() < batch_max_length)
          new_item.push_back(pad_token_id);        // 2. 填充到批次最长
      // 3. 目标左移一位
      std::vector<int64_t> inputs(new_item.begin(), new_item.end() - 1);
      std::vector<int64_t> targets(new_item.begin() + 1, new_item.end());
      // 4. 掩码:除第一个 pad 外,所有 pad 替换为 -100
      bool first_pad_seen = false;
      for (auto& t : targets) {
          if (t == pad_token_id) {
              if (first_pad_seen) t = ignore_index;   // -100
              first_pad_seen = true;
          }
      }

      用示例批次 {[0..4], [5,6], [7,8,9]} 逐值验证(demo 的实际输出):

      inputs:  [[0, 1, 2, 3, 4], [5, 6, 50256, 50256, 50256], [7, 8, 9, 50256, 50256]]
      targets: [[1, 2, 3, 4, 50256], [6, 50256, -100, -100, -100], [8, 9, 50256, -100, -100]]

      这个函数可以按三个草稿逐步实现,每一步增加一个职责:

      • 草稿一(只做填充):batch_max_length = max(len(item)+1) 里的 +1 先为结束符预留一个位置;填充到统一长度后,padded[:-1] 把 最后一个词元截掉作为输入,目标序列因此自然地以结束符结尾;

      • 草稿二(加目标):唯一改动是 targets = padded[1:] 左移一位—— 与第 2 章 GPTDatasetV1、第 5 章预训练完全一致;

      • 最终版(加 -100 掩码):targets[indices[1:]] = ignore_index—— 跳过索引 0,屏蔽索引 1 之后的所有 pad。

      为什么保留第一个 pad、掩掉其余? 目标序列里的填充词元有两个身份:

      身份 位置 处理 理由

      结束符(eos)

      目标中第一个 50256

      保留

      让模型学会何时停止——回复完成时输出 <|endoftext|>

      填充(padding)

      之后所有 50256

      替换为 -100

      纯粹为了对齐长度,不该产生损失

      同一个数字能身兼两职:第一个 pad 紧跟回复的最后一个真实词元,模型在 这里学到「该收尾了」;它后面的 pad 只用于对齐长度,对它们计算损失 等于教模型输出无意义内容,既浪费又有害。

      -100 没有特殊含义,是 PyTorch 交叉熵的默认 ignore_index:凡被 标为 -100 的位置,损失既不计算也不计入平均的分母。 demo_cross_entropy 用三组小 logits 验证:loss_1 = 1.1269(2 个 词元),加入第三个词元后 loss_2 = 0.7936,把第三个目标换成 -100 后 loss_3 = 1.1269 == loss_1——掩码精确生效。手算核对:三行各自的 -log 概率是 2.1269 / 0.1269 / 0.1269,2 行平均 1.1269 ✓,3 行平均 0.7936 ✓,忽略第 3 行又回到 1.1269 ✓。

      损失计算因此比第 5 章多一个选项(src/main.cpp):

      return torch::nn::functional::cross_entropy(
          logits.flatten(0, 1), target_batch.flatten(),
          torch::nn::functional::CrossEntropyFuncOptions().ignore_index(-100));

      与第 5 章的交叉熵只差一个掩码:分母只数有效位置,-100 的位置既不 产生损失,也不稀释平均:

      \[\mathcal{L} = -\frac{1}{\left|\{i : t_i \neq -100\}\right|} \sum_{i:\, t_i \neq -100} \log p_\theta\!\left(t_i \mid t_{<i}\right)\]

      逐位置画出来就是:蓝色的位置进分子也进分母,灰色的填充位置两边都不进。

      07 ignore index
      Figure 1. 公式图像:-100 掩码下的逐位置损失(分母 = 5 个有效位置,而非 7)

      batch_size=8、allowed_max_length=1024 截断后:116 train / 7 val / 14 test 个批次,每批长度各不相同([8,87] [8,67] [8,71] …​)—— 动态填充生效。

      指令部分的损失:掩码还是不掩码?——一个尚未定论的研究问题。掩码派认为只对回复计算损失能聚焦「生成准确回复」、减少 过拟合;不掩码派(Shi 等 2024,Instruction Tuning With Loss Over Instructions,NeurIPS 2024)发现不掩码反而提升性能:在 21 个 多样化基准上多数提升,最佳情况下 AlpacaEval 1.0 提升超过 100%。机制 指向减少对微调数据集的过拟合,在指令长、输出短、低资源的场景下 尤其有效。本教程选择不掩码指令部分,练习 7.2 让你自己试掩码版本。 不要把任何一边当成既定的最佳实践。

      与第 6 章的损失对比,两种微调在损失计算上根本不同:

      维度 第 6 章分类微调 本章指令微调

      损失作用范围

      仅最后一个词元

      全部词元(除填充)

      目标形状

      [8](类别索引)

      [8, L](词元 ID 序列)

      是否需要 -100

      不需要

      必需

      填充策略

      全数据集统一 120

      批次内动态

      7.4 加载 GPT-2 medium(355M)

      微调前先测基线:不记录微调前的输出,提升就无从谈起。加载逻辑与 第 5/6 章同一套 safetensors 代码,只是配置升级:

      ch4::GPTConfig cfg;
      cfg.context_length = 1024;
      cfg.drop_rate = 0.0;
      cfg.qkv_bias = true;
      cfg.emb_dim = 1024;      // 768 → 1024
      cfg.n_layers = 24;       // 12 → 24
      cfg.n_heads = 16;        // 12 → 16
      ch4::GPTModel model(cfg);
      load_gpt2_weights(model, data_dir + "/gpt2-medium.safetensors");  // 1.52 GB

      这是第 4 章「超参结构体」设计的回报:换模型不改一行模型代码。

      微调前在 val_data[0](「把主动句 The chef cooks the meal every day 改成被动语态」)上测基线。模型只是复述输入,没有执行指令:

      ### Response:
      The chef cooks the meal every day.
      ### Instruction:
      Convert the active sentence to passive: 'The chef cooks the

      要求改被动语态,它把题目原样抄了一遍——这是未做指令微调的基座模型的 真实水平:预训练目标里没有「指令」这个概念,它自然不知道指令意味着 什么。

      7.5 微调:AdamW 2 轮

      训练采用全参数微调:lr=5e-5, weight_decay=0.1,eval_freq=5, eval_iter=5;每轮结束在固定指令上生成回复,便于人工检查:

      torch::optim::AdamWOptions options(5e-5);
      options.weight_decay(0.1);
      auto optimizer = torch::optim::AdamW(model->parameters(), options);
      ch5::generate(model, encoded, /*max_new_tokens=*/100, /*context_size=*/1024,
                    /*temperature=*/0.0, /*top_k=*/c10::nullopt, /*eos_id=*/50256);

      初始损失与实测训练曲线(355M 参数全部可训练,RTX 4080 约 3-4 分钟):

      Training loss: 3.85124
      Validation loss: 3.76193
      
      Ep 1 (Step 000000): Train loss 2.609, Val loss 2.590
      Ep 1 (Step 000005): Train loss 0.930, Val loss 1.095
      Ep 1 (Step 000115): Train loss 0.522, Val loss 0.657
      Ep 2 (Step 000230): Train loss 0.373, Val loss 0.674

      2 轮结束后,同一个指令的回复变成(在 eos 处精确停止):

      ### Response: The meal is cooked every day by the chef.<|endoftext|>

      前后对照清晰:从复述输入到正确作答,只隔了 2 轮训练。注意输出末尾的 <|endoftext|>——7.3 节保留的第一个 pad,在这里兑现成「说完就停」 的能力。

      为什么只训 2 轮?损失已降到较低水平,延长到第三轮并无必要,甚至 可能加剧过拟合。对比第 6 章的 5 轮:差异来自数据集规模与任务难度。

      损失曲线解读:初期快速下降(2.609 → 0.930 只用 5 步——模型迅速 捕捉到有意义的模式);第二轮放缓(在已学特征上收敛到稳定解)。第 2 轮末 train 0.373 / val 0.674——训练损失持续降而验证损失停在 0.67 附近,说明第二轮已开始轻微过拟合。

      与第 5 章预训练「train 0.67 / val 6.42 的巨大分叉」相比,这里的 train/val 差距小得多——训练目标变了:不是教模型记住数据集,而是 教它格式(看到 # Response: 就输出回答,说完就停)。

      7.6 生成、抽取与保存

      训练完成后进入验收。对全部 110 条测试样本贪心解码 (temperature=0.0 + eos_id=50256,max_new_tokens=256);用 extract_response 从生成文本里切出回复(include/instruction.h), 存成 instruction-data-with-response.json。前 3 条对比:

      1) 明喻:   正确 "as fast as lightning"   模型 "as fast as a cheetah"  (合理)
      2) 雷暴云: 正确 cumulonimbus             模型 cumulus clouds           (同样的近似错误)
      3) 作者:   正确 Jane Austen              模型 ...is Jane Austen        (几乎一致)

      模型学到的是「任务语义」而非「背答案」:第 2 条的错误只是近似类别上的 偏差,属于模型能力边界,不是实现 bug。调试训练工程时, 先分清「模型不会」和「代码写错」。权重存为 gpt2-medium355M-sft.pth。

      回复质量如何量化,是下一节的主题。

      7.7 LLM-as-a-judge:Ollama 自动评分

      分类任务有准确率这样的客观指标;对话性能(理解上下文、细微差别与 意图,与人自然沟通的能力)没有唯一标准答案。常用三类主流方法:

      方法 代表 考查内容

      短答案与多选基准

      MMLU

      综合知识

      人类偏好比较

      LMSYS 聊天机器人竞技场

      人类主观偏好

      用其他 LLM 自动评估

      AlpacaEval

      对话性能

      人工评估 110 条回复成本过高;基准测试考察知识,考察不了指令跟随。 本章采用类 AlpacaEval 的自动化方法:让本地大模型当裁判 (query_ollama 通过 http://localhost:11434/api/chat 调用, seed=123, temperature=0 尽量保证评分稳定):

      std::string prompt =
          "Given the input `" + format_input(e) + "` and correct output `" +
          e.output + "`, score the model response `" + e.model_response +
          "` on a scale from 0 to 100, where 100 is the best score. "
          "Respond with the integer number only.";
      std::string score = query_ollama(prompt);      // 裁判:llama3

      评分提示词末尾的 "Respond with the integer number only" 让裁判返回 可解析的整数;但解析处的异常护栏(C++ 的 try/catch)仍是必要的—— LLM 偶尔不遵守「只返回整数」的指令。

      前置条件(脚本已在第 0 章备好):

      ./scripts/start-ollama.sh --pull     # 启动 ollama serve + 拉取 llama3(4.7 GB)

      全量 110 条的实测评分是平均 50.32。这个数字要放进参照系里看:

      模型 平均分

      本章微调的 GPT-2 medium (355M)

      50.32

      Llama 3 8B 基础模型(未指令微调)

      58.51

      Llama 3 8B 指令模型

      82.6

      50.32 不是亮眼成绩,但它是本教程教学规模(1100 条数据 + 355M 模型
      2 轮训练)下的合理基线;82.6 的商用级指令模型在数据规模与训练投入上 都远超本教程设置。可用的提升策略有四条:调超参(lr/批次/轮数)、 增加数据规模与多样性、尝试不同提示词格式、换更大的预训练模型。

      解读分数时还要考虑裁判自身的非确定性。实测的前 3 条样例打分 85 / 40 / 95(平均 ≈ 73),而全量平均才 50.32——可见裁判有随机性, 单次结果仅供量级参考;Ollama 在不同系统上并非完全确定,建议多次 评估取平均。LLM-as-a-judge 是目前成本与质量折中最优的方案,但解读 分数时要把裁判自己的随机性算进去。

      7.8 编译运行与预期输出

      ./scripts/download-data.sh                 # 指令数据集(幂等)
      ./scripts/download-weights.sh medium       # GPT-2 medium 权重 1.52 GB
      ./scripts/run.sh chapter07_instruction_tuning           # 默认 2 轮
      ./scripts/run.sh chapter07_instruction_tuning 0        # 0 轮:只看数据管道与微调前基准

      验收关注四个信号:collate 示例逐值一致、loss_3 == loss_1(掩码 生效)、微调前复述输入/微调后正确输出被动句、110 条回复全部落盘。

      本章小结

      • 指令微调不改变模型结构,改变的是数据形态(Alpaca 三元组)与损失 掩码(ignore_index=-100)——第 6 章换输出头,本章连头都不换。

      • custom_collate_fn 有三个职责:按批动态填充、目标左移一位、掩码 填充词元(保留第一个作为停止信号);-100 的位置既不算损失也不进 分母,loss_3 == loss_1 证明掩码精确生效。

      • GPT-2 medium + 1100 条 Alpaca 数据 + 2 轮微调,即可从复述输入 跃迁到遵循指令;第二轮 train/val 分叉提示轻微过拟合,2 轮后应 停止训练。

      • 对话性能评估依赖 LLM-as-a-judge(类 AlpacaEval);裁判具有 非确定性,平均分 50.32 是教学规模下的基线,单次评分只看量级。

      • 指令部分掩码与否仍是开放研究问题(Shi et al. 2024:不掩码反而在 低资源场景更优)——默认值也要自己验证。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. 1100 条 → 935/55/110,format_input 输出与 7.2 节示例逐字符一致

      2. collate 示例批次逐值一致;loss_3 == loss_1 验证 -100 掩码; 能说出「第一个 pad 保留」的双身份理由

      3. 116/7/14 个批次,每批长度不同(动态填充生效)

      4. 初始 loss ≈ 3.85/3.76,2 轮后 ≈ 0.37/0.67(第二轮轻微过拟合可识别)

      5. 微调前 val_data[0] 复述输入;微调后正确输出被动句且 eos 停止(前后对照)

      6. instruction-data-with-response.json 110 条、gpt2-medium355M-sft.pth 已保存

      7. Ollama 全量平均分在 50 上下(本教程实测基准 50.32;裁判有非确定性)

      8. 能说出「指令部分掩码 vs 不掩码」两派各自的论据与 Shi et al. 2024 的适用条件

      习题

      1. 把 Alpaca 模板换成 Phi-3 风格(<|user|>\n…​<|assistant|>)重新微调, 对比收敛速度——提示词模板本身也是「超参」。

      2. 把指令部分(# Response: 之前)的目标也掩成 -100,只对回复计算损失, 对比 2 轮后的 val loss。

      3. 用 Ollama 分别给「微调前模型」和「微调后模型」的回复打分, 量化指令微调前后的差距。

      4. 贪心(temperature=0)换成 top_k=25 + temperature=1.4 生成测试集回复, 观察评分方差的变化。

      拓展阅读