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)當裁判打分數。頁面中的關鍵數值都與書中執行輸出一致(示意值會另行標明)。
本章定位:全書最後一塊拼圖
整本書的三階段地圖,本章走到終點——第三階段的第 9 步:把預訓練好的 foundation model 微調成個人助理(personal assistant):
資料 · attention · 架構 ✓→ STAGE 2(ch5)
預訓練+載入權重 ✓→ STAGE 3(ch6 分類 ✓ · 本章)
指令微調 instruction fine-tuning資料準備 · 355M · 微調 · LLM 評分→ output:一個會聽指令的 GPT
(全書旅程完成 🎉)
本章依書中圖 7.3 分成三個階段、9 個步驟——會隨你往下讀自動打勾(也可以點擊跳到該節):
指令微調一句話就講完:同一套訓練迴圈,換一批資料。第 5 章的 cross_entropy、AdamW、train_model_simple() 原封不動搬過來,只是餵進去的不再是小說原文,而是「指令 → 好回應」的配對。模型的「接下一個字」本能,就這樣被導向「回應指令」。本章九成的功夫其實花在資料怎麼整理——這也是實務上指令微調的真實比例。
暖身:本章的四個關鍵數字
Introduction to instruction fine-tuning(指令微調是什麼)
很會「接話」的 text completion 機器→supervised instruction fine-tuning用「指令→理想回應」配對繼續訓練→output:會「照指令回答」的 LLM
chatbot/助理的基礎
第 5 章預訓練出來的模型是一台 text completion 機器:給它半句話,它能接得頭頭是道。但丟給它一個具體指令——「把這段文字的文法改對」「把這句話改成被動語態」——它常常接著出題而不是回答(7.5 節會看到活生生的例子)。instruction fine-tuning(又稱 supervised instruction fine-tuning)就是解法:用大量「指令 → 理想回應」配對繼續訓練。點下面三個書中真實例子(圖 7.2),感受一下目標行為:
Preparing a dataset for supervised instruction fine-tuning(準備資料集)
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」的差別:
<|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 筆當驗證
Organizing data into training batches(組 batch:padding 與 −100)
(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 為即時推導):
generate(..., eos_id=50256) 才知道何時停筆。其餘的 50256 是純粹為了對齊長度的填充,學它毫無意義,所以交給 −100。這正是 5.1.2 那套 cross entropy 六步驟——只是多認識了一個參數。Creating data loaders for an instruction dataset(建立 data loader)
+ 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 形狀(書中真實輸出)——注意第二個維度每批都不一樣:
切換兩種 padding 策略,看「逐 batch pad」到底省了什麼(batch 長度為書中真實值;斜紋=浪費的 padding,為示意):
Loading a pretrained LLM(載入 355M 預訓練模型)💬 推論
下載約 1.42 GB→load_weights_into_gpt第 5.5 節寫好的函數,直接重用→output:355M 的 GPTModel
會英文,但還不會聽指令
指令跟隨比「二選一的分類」難得多,124M 的容量不夠——小模型學不動高品質指令跟隨所需的細膩模式。所以本章第一次換大模型:gpt2-medium(355M)。載入程式碼和 5.5/6.4 完全相同,點每一行複習它為什麼長這樣(全是前幾章埋的伏筆):
四種尺寸複習(第 5 章的老朋友;點選查看,粗體為對 124M 的改動):
微調前的 baseline:它會怎麼回應指令?
載入完先做一件重要的事:拿驗證集第一題測「微調前」的行為,之後才有對照組。生成用的正是第 5 章的 generate()(搭配 text_to_token_ids/token_ids_to_text,全部 from chapter05 import):
"### Response: The chef cooks the meal every day. ### Instruction: Convert the active sentence to passive: 'The chef cooks the"
load_weights_into_gpt 是 5.5 節寫的那個「對名入座+assign 形狀檢查」函數——當時搬 124M,現在只把 CHOOSE_MODEL 換成 "gpt2-medium (355M)",同一套程式照用。這就是「同一架構、不同規模」的可擴展性紅利。Fine-tuning the LLM on instruction data(在指令資料上微調)🏋️ 訓練
(外加兩條 loss 曲線)
訓練程式一行都不用重寫:from chapter05 import calc_loss_loader, train_model_simple。−100 的遮罩效果已經內建在 targets 裡,cross_entropy 自動跳過。先量微調前的 loss:
按「下一步」播放訓練過程:曲線上實心點=書中印出的真實 loss;空心點=依書中圖 7.17 形狀的平滑內插。下方是即時輸出與每個 epoch 結束的生成樣本——看模型怎麼一步步學會被動語態:
訓練要花多久?點一個裝置看書中的實測表(兩個 epoch):
驗收:同一題,微調前 vs 微調後
Extracting and saving responses(抽取並保存回應)💬 推論
每筆多一個 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 顯示進度條):
Evaluating the fine-tuned LLM(用另一個 LLM 評分)
(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 分。點每一行看評分環境怎麼架:
評分 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 分
50.32 分是好是壞?點下面三個刻度,用同一套評分流程的參照系來讀它(數值皆書中真實):
Conclusions(結語:旅程的終點)
到這裡,你已經從 tokenizer、attention、GPT 架構、預訓練、分類微調,一路走到指令微調——真的「從零」打造了一個會聽指令的 LLM。這章也是全書 LLM 開發循環的最後一站。
書中給的路線圖:① 偏好微調(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 開源生態。
知識檢核
// 七題,作答後立即看解析
頁面中的計算皆為瀏覽器即時運算 · 快捷鍵 T 切換主題