CH·7從零打造 LLM/ 指令跟隨微調

Build a Large Language Model (From Scratch) · Chapter 7

指令跟隨微調Fine-tuning to follow instructions

第 6 章教會模型「分類」,但分類器只會回答是或否。全書最後一章要補上關鍵的一步:用 1,100 筆「指令 → 理想回應」的資料微調 GPT-2,讓它聽得懂人話、照指令回答——這正是 ChatGPT 這類助理誕生的那一步。過程會實作本章最棘手的 custom collate(padding+−100 遮罩)、換上更大的 355M 模型、微調 2 個 epoch,最後請另一個 LLM(Llama 3)當裁判打分數。頁面中的關鍵數值都與書中執行輸出一致(示意值會另行標明)。

00

本章定位:全書最後一塊拼圖

整本書的三階段地圖,本章走到終點——第三階段的第 9 步:把預訓練好的 foundation model 微調成個人助理(personal assistant):

STAGE 1(ch2–4)
資料 · attention · 架構 ✓
→ STAGE 2(ch5)
預訓練+載入權重 ✓
→ STAGE 3(ch6 分類 ✓ · 本章)
指令微調 instruction fine-tuning資料準備 · 355M · 微調 · LLM 評分
→ output:一個會聽指令的 GPT
(全書旅程完成 🎉)

本章依書中圖 7.3 分成三個階段、9 個步驟——會隨你往下讀自動打勾(也可以點擊跳到該節):

🔰 先建立心態

指令微調一句話就講完:同一套訓練迴圈,換一批資料。第 5 章的 cross_entropy、AdamW、train_model_simple() 原封不動搬過來,只是餵進去的不再是小說原文,而是「指令 → 好回應」的配對。模型的「接下一個字」本能,就這樣被導向「回應指令」。本章九成的功夫其實花在資料怎麼整理——這也是實務上指令微調的真實比例。

暖身:本章的四個關鍵數字

# 先預告,全部都會親手做出來(書中真實輸出) 資料集 1,100 筆 instruction–response pairs(train 935 / val 55 / test 110) 模型 GPT-2 medium 355M(124M 容量不夠,第一次換大模型) 訓練 2 epochs · AdamW(lr=5e-5) · A100 只要 0.86 分鐘 成績 Llama 3 評分平均 50.32 / 100(對照:Llama 3 8B base 58.51)
7.1

Introduction to instruction fine-tuning(指令微調是什麼)

input:預訓練 LLM
很會「接話」的 text completion 機器
→supervised instruction fine-tuning用「指令→理想回應」配對繼續訓練→output:會「照指令回答」的 LLM
chatbot/助理的基礎

第 5 章預訓練出來的模型是一台 text completion 機器:給它半句話,它能接得頭頭是道。但丟給它一個具體指令——「把這段文字的文法改對」「把這句話改成被動語態」——它常常接著出題而不是回答(7.5 節會看到活生生的例子)。instruction fine-tuning(又稱 supervised instruction fine-tuning)就是解法:用大量「指令 → 理想回應」配對繼續訓練。點下面三個書中真實例子(圖 7.2),感受一下目標行為:

和第 6 章的分類微調差在哪?分類微調的輸出是「N 選 1 的類別」(spam/not spam);指令微調的輸出是自由文字——答案不唯一、對錯有程度。這讓它更通用(一個模型應付千百種任務),但也讓 7.8 節的「評估」變得比算 accuracy 難得多。
7.2

Preparing a dataset for supervised instruction fine-tuning(準備資料集)

input:instruction-data.json
1,100 筆 · 204 KB
→format_input(Alpaca 模板)+ 85/5/10 切分下載 → 檢視 → 格式化 → train/val/test→output:三份格式化資料
935 / 55 / 110 筆

資料集是作者專為本書製作的 1,100 筆 instruction–response pairs(一個 204 KB 的 JSON 檔,download_and_load_file() 抓下來後 len(data) = 1100)。每筆是一個三欄位字典。點三筆真實 entry 看結構:

Alpaca prompt 格式:把三個欄位組成一段文字

光有欄位還不夠——訓練時要把它們組成一段模型看得懂的提示文字。本書採用 Alpaca 風格(最早公開指令微調流程的模型之一,格式因此成為經典)。按「下一步」看 format_input() 逐段組裝,並切換兩筆 entry 比較「有/無 input」的差別:

另一種流派:Phi-3 prompt style。同一筆 data[50] 也可以包成特殊 token 的形式(微軟 Phi-3 的做法,書中圖 7.4):
<|user|> Identify the correct spelling of the following word: 'Ocassion' <|assistant|> The correct spelling is 'Occasion'.
本書全程用 Alpaca 風格;書中練習 7.1 邀你微調完改用 Phi-3 格式,比較回應品質有無差別。

切成三份:85% / 5% / 10%

# Listing 7.3 — 真實輸出:Training set length: 935 / Validation set length: 55 / Test set length: 110
train_portion = int(len(data) * 0.85)             # 935 筆拿去訓練
test_portion  = int(len(data) * 0.1)              # 110 筆留給最終評估
val_portion   = len(data) - train_portion - test_portion   # 剩下 55 筆當驗證
train 935(85%)validation 55(5%)test 110(10%)(每個色塊 = 55 筆)
和第 6 章的 spam 資料一樣切三份:train 調權重、val 在訓練中監控、test 只在最後評估用(7.7–7.8 的 110 筆考題就是它)。val 只有 55 筆偏少——教學示範可接受,書中也提醒實務上資料愈大愈好(練習 7.3:52,002 筆的原版 Alpaca 資料集)。
7.3

Organizing data into training batches(組 batch:padding 與 −100)

input:長度不一的 token 序列
(InstructionDataset 已模板化+tokenize)
→custom_collate_fnpad 到批內最長 · +50256 · 右移 · −100 遮罩→output:inputs / targets 兩個 tensor
可直接餵給訓練迴圈

第 6 章的 batch 是 DataLoader 用預設 collate 自動組的;這一章句子長度差異太大,得自己寫一個 custom collate function。它是全章技術含量最高的一段,做四件事,缺一不可。先由 InstructionDataset 把每筆資料「套模板+pretokenize」(7.2 的成果),接著用書中的迷你例子逐步走一遍 collate(序列 [0,1,2,3,4]、[5,6]、[7,8,9],輸出與書中 tensor 完全一致):

# Listing 7.5 的核心(custom_collate_fn):
batch_max_length = max(len(item)+1 for item in batch)  # +1:等下每條先加一個 50256
new_item += [pad_token_id]                             # ① 每條先補一個 <|endoftext|>(ID 50256)
padded = new_item + [pad_token_id] * (batch_max_length - len(new_item))  # ② pad 到批內最長
inputs  = torch.tensor(padded[:-1])                    # ③ 截掉最後一個 → inputs
targets = torch.tensor(padded[1:])                     #    右移一格     → targets
mask = targets == pad_token_id                         # ④ 找出 targets 裡所有 50256
indices = torch.nonzero(mask).squeeze()
if indices.numel() > 1:
    targets[indices[1:]] = ignore_index               #    第一個以外全換 -100
if allowed_max_length is not None:                     # 可選:截斷到 1024(GPT-2 上限)
    inputs, targets = inputs[:allowed_max_length], targets[:allowed_max_length]

−100 憑什麼讓 loss 視而不見?

因為 −100 是 PyTorch cross_entropy 的預設 ignore_index——函數簽名寫著 cross_entropy(..., ignore_index=-100)。書中用一個三行 logits 的小例子證明它「真的完全不算」。切換三種 targets,觀察 loss 的計算範圍(loss 為書中真實輸出,各位置的 −log p 為即時推導):

為什麼保留第一個 50256?它不是 padding,是「我講完了」的訊號——模型要學會在回應結尾生成 <|endoftext|>,之後 7.5/7.7 的 generate(..., eos_id=50256) 才知道何時停筆。其餘的 50256 是純粹為了對齊長度的填充,學它毫無意義,所以交給 −100。這正是 5.1.2 那套 cross entropy 六步驟——只是多認識了一個參數。
要不要連 instruction 一起遮?書中圖 7.13 示範另一種做法:把 targets 裡「instruction+input」段也全換 −100,讓 loss 只算 Response 段——模型專心學「怎麼回答」、不去背題目。但研究上仍有分歧:Shi et al.(2024)《Instruction Tuning With Loss Over Instructions》發現不遮往往表現更好。本書選擇只遮 padding、不遮 instruction,把後者留作練習 7.2。
7.4

Creating data loaders for an instruction dataset(建立 data loader)

input:InstructionDataset ×3
+ custom_collate_fn
→DataLoaderbatch_size=8 · train shuffle+drop_last · partial() 綁 device→output:train / val / test loader
每個 batch 形狀各不相同

零件到齊,插上 PyTorch 的 DataLoader 就完工。有個小巧思:用 functools.partial 把 device 和 allowed_max_length=1024 預先「綁」進 collate——資料在 loader 的背景流程就先搬上 GPU,不佔訓練主迴圈的時間:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")  # Apple Silicon 可改 "mps"(結果可能略異)
customized_collate_fn = partial(custom_collate_fn, device=device, allowed_max_length=1024)
train_loader = DataLoader(train_dataset, batch_size=8, collate_fn=customized_collate_fn,
                          shuffle=True, drop_last=True, num_workers=0)   # val / test:兩者皆 False

印出 train loader 的每個 batch 形狀(書中真實輸出)——注意第二個維度每批都不一樣:

# torch.manual_seed(123) Train loader: torch.Size([8, 61]) torch.Size([8, 61]) torch.Size([8, 76]) torch.Size([8, 76]) torch.Size([8, 73]) torch.Size([8, 73]) ... torch.Size([8, 74]) torch.Size([8, 74]) torch.Size([8, 69]) torch.Size([8, 69])

切換兩種 padding 策略,看「逐 batch pad」到底省了什麼(batch 長度為書中真實值;斜紋=浪費的 padding,為示意):

7.5

Loading a pretrained LLM(載入 355M 預訓練模型)💬 推論

input:OpenAI 的 gpt2-medium 權重
下載約 1.42 GB
→load_weights_into_gpt第 5.5 節寫好的函數,直接重用→output:355M 的 GPTModel
會英文,但還不會聽指令

指令跟隨比「二選一的分類」難得多,124M 的容量不夠——小模型學不動高品質指令跟隨所需的細膩模式。所以本章第一次換大模型:gpt2-medium(355M)。載入程式碼和 5.5/6.4 完全相同,點每一行複習它為什麼長這樣(全是前幾章埋的伏筆):

👆 點一行看說明(注意 CHOOSE_MODEL 那行——全章唯一的「升級」開關)

四種尺寸複習(第 5 章的老朋友;點選查看,粗體為對 124M 的改動):

微調前的 baseline:它會怎麼回應指令?

載入完先做一件重要的事:拿驗證集第一題測「微調前」的行為,之後才有對照組。生成用的正是第 5 章的 generate()(搭配 text_to_token_ids/token_ids_to_text,全部 from chapter05 import):

# torch.manual_seed(123);input_text = format_input(val_data[0]): Below is an instruction that describes a task. Write a response that appropriately completes the request. ### Instruction: Convert the active sentence to passive: 'The chef cooks the meal every day.'
355M 微調前的 response_text →
"### Response: The chef cooks the meal every day. ### Instruction: Convert the active sentence to passive: 'The chef cooks the"
它做了三件「很會接話」的事:照抄 ### Response: 標題、把原句原封不動抄回來、然後自己再出一次題——把 prompt 的「格式」延續下去,卻完全沒有執行指令。這就是 text completion 與 instruction following 的差距,也是接下來微調要修理的行為。
伏筆回收:load_weights_into_gpt 是 5.5 節寫的那個「對名入座+assign 形狀檢查」函數——當時搬 124M,現在只把 CHOOSE_MODEL 換成 "gpt2-medium (355M)",同一套程式照用。這就是「同一架構、不同規模」的可擴展性紅利。
7.6

Fine-tuning the LLM on instruction data(在指令資料上微調)🏋️ 訓練

input:355M 模型 + train/val loader→train_model_simple2 epochs × 116 batches · AdamW(lr=5e-5, weight_decay=0.1)→output:會照指令回答的模型
(外加兩條 loss 曲線)

訓練程式一行都不用重寫:from chapter05 import calc_loss_loader, train_model_simple。−100 的遮罩效果已經內建在 targets 裡,cross_entropy 自動跳過。先量微調前的 loss:

# with torch.no_grad(): calc_loss_loader(..., num_batches=5)——書中真實輸出 Training loss: 3.825908660888672 Validation loss: 3.7619335651397705 # 對照第 5 章「未訓練」的 10.98:355M 預訓練過、早就會英文, # 起點只有 3.8——微調是「導正行為」,不是從零學語言。

按「下一步」播放訓練過程:曲線上實心點=書中印出的真實 loss;空心點=依書中圖 7.17 形狀的平滑內插。下方是即時輸出與每個 epoch 結束的生成樣本——看模型怎麼一步步學會被動語態:

超參數的兩個「為什麼」:① lr=5e-5,比第 5 章預訓練的 4e-4 小了近一個量級——微調是「微微地調」,太大步會把預訓練學到的語言能力踩壞;② 只跑 2 epochs——書中明說:模型在兩輪內已有效學習,練到第 3 輪不必要、還可能開始 overfitting(第 5 章的教訓還記得吧)。

訓練要花多久?點一個裝置看書中的實測表(兩個 epoch):

驗收:同一題,微調前 vs 微調後

7.7

Extracting and saving responses(抽取並保存回應)💬 推論

input:110 筆 test 指令→generate → 切掉 prompt → striptqdm 迭代 · max_new_tokens=256 · eos_id=50256→output:instruction-data-with-response.json
每筆多一個 model_response 欄位

微調完,換 test set 上場。先肉眼看前三題——generate() 回傳的是「prompt+回應」全文,所以要用 generated_text[len(input_text):] 把 prompt 切掉、再 .replace("### Response:", "").strip()。點三題看「正解 vs 模型回應」(書中真實輸出):

三題兩對一半錯——但「半錯」該給幾分?這正是指令微調評估的難處。書中列了三條主流路線:

  • 選擇題測驗:如 MMLU——考知識,好計分,但測不出「對話能力」。
  • 人類偏好對戰:如 LMSYS chatbot arena——兩個模型的回答讓真人盲選。
  • LLM 當裁判:如 AlpacaEval——用 GPT-4 之類的強模型自動評分。7.8 就走這條路(換成本機的 Llama 3)。

評分前先把 110 筆的回應全部生出來、存檔備用(tqdm 顯示進度條):

# for i, entry in tqdm(enumerate(test_data), total=len(test_data)) —— 書中真實輸出 100%|██████████████████████████████| 110/110 [01:05<00:00, 1.68it/s] # A100 約 1 分鐘;M3 MacBook Air 約 6 分鐘 print(test_data[0]) → {'instruction': 'Rewrite the sentence using a simile.', 'input': 'The car is very fast.', 'output': 'The car is as fast as lightning.', 'model_response': 'The car is as fast as a bullet.'} # → 存成 instruction-data-with-response.json(indent=4) # 模型另存 gpt2-medium355M-sft.pth,之後 load_state_dict 即可重用
7.8

Evaluating the fine-tuned LLM(用另一個 LLM 評分)

input:指令+正解+模型回應
(110 筆)
→評分 prompt → Llama 3 8B本機 Ollama · REST API · temperature 0→output:0–100 的整數分 ×110
平均 50.32

110 筆逐句人工看太慢,書中採 LLM-as-a-judge(AlpacaEval 式的作法):在本機用 Ollama 跑 Meta 的 Llama 3 8B(instruct 版),請它給每個回應打 0–100 分。點每一行看評分環境怎麼架:

👆 點一行看說明(Ollama 只做推論,不能拿來訓練)

評分 prompt 長這樣(書中原文;最後一句是 Listing 7.11 加上的,讓它只回整數、方便取平均):

prompt = (
    f"Given the input `{format_input(entry)}` "
    f"and correct output `{entry['output']}`, "
    f"score the model response `{entry['model_response']}`"
    f" on a scale from 0 to 100, where 100 is the best score. "
    f"Respond with the integer number only."
)

點三題看 Llama 3 的評分與理由(書中真實輸出的濃縮):

對全部 110 筆取平均:50.32 分

# generate_model_scores(test_data, "model_response")——書中真實輸出(M3 MacBook Air 約 1 分鐘) Scoring entries: 100%|████████████████████████| 110/110 [01:10<00:00, 1.56it/s] Number of scores: 110 of 110 Average score: 50.32

50.32 分是好是壞?點下面三個刻度,用同一套評分流程的參照系來讀它(數值皆書中真實):

兩個提醒:① Ollama 目前並非跨作業系統完全 deterministic——分數可能略有出入,可多跑幾次取平均;② 想要更權威的裁判,書中附了改用 OpenAI GPT-4 API 評分的選配 notebook——裁判愈強,分數愈可信。想繼續進步?書中列了四條路:調超參數、加大資料集、換 prompt 格式、換更大的底模。
7.9

Conclusions(結語:旅程的終點)

到這裡,你已經從 tokenizer、attention、GPT 架構、預訓練、分類微調,一路走到指令微調——真的「從零」打造了一個會聽指令的 LLM。這章也是全書 LLM 開發循環的最後一站。

ch2 資料 ✓→ ch3 attention ✓→ ch4 GPT 架構 ✓→ ch5 預訓練 ✓→ ch6 分類微調 ✓→ ch7 指令微調 ✓你在這裡——終點
🔰 接下來去哪?

書中給的路線圖:① 偏好微調(preference fine-tuning)——指令微調之後可選的下一步,讓模型更貼合人類偏好(RLHF/DPO 一族;本書 GitHub 的 04_preference-tuning-with-dpo 資料夾有完整實作);② LoRA(附錄 E+練習 7.4)——參數高效微調,同樣的事用更少的算力;③ 更大的資料(練習 7.3)——52,002 筆的原版 Alpaca 資料集;④ 實務工具鏈——作者推薦 Axolotl、LitGPT;⑤ 跟上進展——arXiv 的 cs.LG、r/LocalLLaMA、作者部落格。

▣

本章小結

  • 指令微調=老迴圈+新資料:cross_entropy、AdamW、train_model_simple 全部沿用第 5 章,資料換成 1,100 筆「指令→回應」配對(85/5/10 切成 935/55/110)。
  • Alpaca 格式:固定開場白+### Instruction/### Input/### Response;input 為空就整段省略。
  • custom_collate_fn 四件事:每條 +1 個 50256 → pad 到批內最長 → inputs=padded[:-1]、targets=padded[1:] → targets 中第一個以外的 50256 換 −100;可選 allowed_max_length=1024。
  • −100 = cross_entropy 的預設 ignore_index:被標的位置完全不參與 loss(書中證明 loss_1 == loss_3);保留一個 50256 讓模型學會「講完了」。遮不遮 instruction 研究仍分歧,本書不遮。
  • 355M 取代 124M:小模型容量不足以學好指令跟隨;載入沿用 5.5 的 load_weights_into_gpt。微調前的 355M 只會把指令抄回來。
  • 訓練實測:2 epochs、lr=5e-5——loss 3.83 → train 0.300 / val 0.657;A100 0.86 分鐘、M3 筆電 15.78 分鐘;第 3 epoch 可能反而 overfitting。
  • 評估:generate 抽取 110 筆回應存 JSON → Ollama+Llama 3 8B 逐筆打 0–100 分 → 平均 50.32(參照:Llama 3 8B base 58.51、instruct 82.6)。
  • 下一步:偏好微調(DPO)、LoRA、更大資料集——以及整個 LLM 開源生態。
✓

知識檢核

// 七題,作答後立即看解析

從零打造 LLM · 第 7 章互動教材 | 講解為原創文字,程式碼與數值對應 Sebastian Raschka《Build a Large Language Model (From Scratch)》Ch.7 的公開程式與執行輸出,非原文翻譯
頁面中的計算皆為瀏覽器即時運算 · 快捷鍵 T 切換主題