Hugging Face 實戰(三):微調實戰 — Datasets、Trainer 與 LoRA/QLoRA

大多數人在 prompt 調不好的時候,第一個念頭是「那我來微調吧」。 正確答案是:八成的情況下你需要的是更好的 prompt 或 RAG,微調解決的是另一類問題。 大多數人以為微調的難點在 GPU。 正確答案是:難點在資料。1,000 筆乾淨資料勝過 10 萬筆雜訊,而後者還會讓模型變笨。


前兩篇我們學會了用現成模型並把它服務化。這一篇處理「現成模型不夠好」的情況——但在寫任何訓練程式碼之前,先確認你真的需要微調。


一、決策:你真的需要微調嗎

1.1 四階梯決策樹

  問題:模型輸出不符合需求
        │
        ▼
  ┌─────────────────────────────────────────────────┐
  │ 階梯 1:Prompt Engineering                       │
  │   成本:幾小時   改善幅度:0–40 個百分點          │
  │   先試:明確指令、輸出格式範例、思考步驟拆解       │
  └──────────────┬──────────────────────────────────┘
                 │ 還不夠?
                 ▼
  ┌─────────────────────────────────────────────────┐
  │ 階梯 2:Few-shot(在 prompt 裡放 3–10 個範例)    │
  │   成本:一天     改善幅度:5–25 個百分點          │
  │   代價:每次請求多 500–2000 input token          │
  └──────────────┬──────────────────────────────────┘
                 │ 缺的是「知識」而非「行為」?
                 ▼
  ┌─────────────────────────────────────────────────┐
  │ 階梯 3:RAG(檢索增強)                          │
  │   成本:1–2 週   解決:事實正確性、知識時效        │
  │   注意:RAG 解決不了「格式」與「語氣」問題         │
  └──────────────┬──────────────────────────────────┘
                 │ 缺的是「行為模式」?
                 ▼
  ┌─────────────────────────────────────────────────┐
  │ 階梯 4:微調                                     │
  │   成本:2–6 週   解決:格式穩定性、領域語氣、      │
  │                        延遲/成本(小模型取代大模型)│
  └─────────────────────────────────────────────────┘

1.2 微調能解決 / 不能解決什麼

問題類型微調有效嗎應該用什麼
輸出 JSON 格式不穩定✅ 非常有效微調,或先試結構化輸出(grammar / guided decoding)
回答語氣不像我們品牌✅ 非常有效微調
不懂公司內部術語✅ 有效微調(術語)+ RAG(事實)
需要引用最新文件內容❌ 無效RAG(知識會過期,微調進去的也會)
常常編造不存在的產品編號⚠️ 部分有效RAG + 微調拒答行為
想讓 1.5B 達到 7B 的特定任務水準✅ 有效微調(蒸餾式)
數學推理能力不足❌ 通常無效換模型,或用工具呼叫

一條很實用的判準: 如果你能用 20 個 few-shot 範例讓模型做對,那微調就會有效(你只是把範例「訓練進去」了)。如果 20 個範例也沒用,微調大概率也沒用——那是能力問題不是對齊問題。

1.3 微調的隱藏成本

成本項目一次性持續
資料標注(1,000 筆 × 3 分鐘)~50 小時人力每季更新 10–20%
GPU 訓練(7B QLoRA, 3 epoch)~$40每次迭代重跑
評估集建置與人工評分~30 小時每次迭代都要重評
部署與版本管理~1 週工程模型版本 × 環境的矩陣維護
底模升級時全部重做這是最容易被低估的一項

最後一項值得展開:你花六週微調的 Qwen2.5-7B,三個月後 Qwen3 出來、基準分數高了 12 分。此時你要嘛守著舊底模,要嘛把整套流程重跑一次。所以微調流程本身必須是自動化的——一鍵重跑,而不是某位工程師筆電上的 notebook。


二、資料才是主角

2.1 資料品質的量級效應

實測(同一個 7B 底模、同一組超參數、同一評估集):

  訓練資料                          任務準確率      格式合規率
  ────────────────────────────────────────────────────────────
  未微調                              68.4%          41.0%
  50,000 筆網路爬取(未清洗)           64.1%          77.3%   ← 比不微調還差
  5,000 筆規則過濾                     81.7%          93.1%
  1,000 筆人工精選                     89.5%          97.8%
  1,000 筆人工精選 + 200 筆難例         91.2%          98.6%

5 萬筆髒資料讓模型變差。 這不是誇飾——雜訊資料會教模型學到錯誤的模式,而且因為量大,這些錯誤模式的權重還特別高。

2.2 三種資料格式

TRL 的 SFTTrainer 支援三種格式,選對可以省掉大量前處理程式碼:

 1# 格式 A:conversational(推薦,最不容易出錯)
 2{"messages": [
 3    {"role": "system",    "content": "你是客服助理。"},
 4    {"role": "user",      "content": "我的訂單還沒到"},
 5    {"role": "assistant", "content": "{\"category\":\"物流\",\"urgency\":\"medium\"}"},
 6]}
 7
 8# 格式 B:instruction(prompt-completion)
 9{"prompt": "分類以下工單:我的訂單還沒到",
10 "completion": "{\"category\":\"物流\",\"urgency\":\"medium\"}"}
11
12# 格式 C:raw text(持續預訓練用,不建議做 SFT)
13{"text": "<|im_start|>user\n...<|im_end|>\n<|im_start|>assistant\n...<|im_end|>"}

用格式 A。 TRL 會自動套用底模的 chat_template,換底模時(Qwen → Llama)你完全不用改資料。用格式 C 等於把 template 寫死,換底模就要重做資料集。

2.3 用 datasets 處理資料

 1from datasets import load_dataset, Dataset
 2
 3# 從 Hub 載入
 4ds = load_dataset("your-username/support-tickets-zh", split="train")
 5
 6# 從本機 JSONL 載入
 7ds = load_dataset("json", data_files="data/train.jsonl", split="train")
 8
 9# 從 Python list 建立
10ds = Dataset.from_list([{"messages": [...]}, ...])
11
12# 超大資料集:串流,不落地
13ds = load_dataset("HuggingFaceFW/fineweb-2", "cmn_Hani",
14                  split="train", streaming=True)
15for row in ds.take(3):
16    print(row["text"][:100])

datasets 底層是 Apache Arrow + memory-map,所以「比記憶體大的資料集」不是問題——它只在需要時把該段落映射進來。一個 200GB 的資料集可以在 16GB RAM 的機器上處理。

2.4 一條實用的清洗管線

 1import json
 2import hashlib
 3from datasets import load_dataset
 4
 5ds = load_dataset("json", data_files="data/raw.jsonl", split="train")
 6print(f"原始:{len(ds):,}")
 7
 8# --- 1. 結構檢查 ---
 9def well_formed(x):
10    msgs = x.get("messages")
11    if not msgs or len(msgs) < 2:
12        return False
13    if msgs[-1]["role"] != "assistant":
14        return False
15    return all(m.get("content", "").strip() for m in msgs)
16
17ds = ds.filter(well_formed, num_proc=8)
18print(f"結構檢查後:{len(ds):,}")
19
20# --- 2. 長度過濾(過短沒資訊、過長浪費算力)---
21def length_ok(x):
22    total = sum(len(m["content"]) for m in x["messages"])
23    reply = len(x["messages"][-1]["content"])
24    return 20 <= reply <= 4000 and total <= 12000
25
26ds = ds.filter(length_ok, num_proc=8)
27print(f"長度過濾後:{len(ds):,}")
28
29# --- 3. 去重(用回覆內容的 hash)---
30seen = set()
31def dedupe(x):
32    h = hashlib.md5(x["messages"][-1]["content"].encode()).hexdigest()
33    if h in seen:
34        return False
35    seen.add(h)
36    return True
37
38ds = ds.filter(dedupe)          # 去重不能平行化(共用 state)
39print(f"去重後:{len(ds):,}")
40
41# --- 4. 任務特定驗證(本例:回覆必須是合法 JSON)---
42def valid_json_reply(x):
43    try:
44        obj = json.loads(x["messages"][-1]["content"])
45        return {"category", "urgency"} <= obj.keys()
46    except Exception:
47        return False
48
49ds = ds.filter(valid_json_reply, num_proc=8)
50print(f"JSON 驗證後:{len(ds):,}")
51
52# --- 5. 切分(依 ticket_id 分組,避免同一工單同時進訓練與驗證)---
53split = ds.train_test_split(test_size=0.1, seed=42)
54split["train"].to_json("data/train.jsonl", force_ascii=False)
55split["test"].to_json("data/eval.jsonl", force_ascii=False)
56print(f"train={len(split['train']):,}  eval={len(split['test']):,}")

最危險的資料錯誤是洩漏(leakage)。 如果同一位客戶的三則相似工單分別落在訓練集與驗證集,你的驗證分數會虛高 10–20 個百分點,上線後才發現真實表現差很多。切分要依「實體」(客戶、文件、工單群組)而非依「筆數」隨機切。


三、三個演進階段

╔══════════════════════════════════════════════════════════════════╗
║  Phase 1:POC(< 2,000 筆 / 單卡 / 一次性)                       ║
╚══════════════════════════════════════════════════════════════════╝

  ┌────────────┐   ┌──────────────────────┐   ┌────────────────┐
  │ train.jsonl│──▶│  Colab T4 / 本機 3090 │──▶│ LoRA adapter   │
  │  1,000 筆  │   │  QLoRA 4bit, 1.5–7B  │   │  ~40 MB        │
  └────────────┘   │  1–2 小時             │   └────────────────┘
                   └──────────────────────┘

  可接受的妥協:手動評估 20 題、沒有實驗追蹤、超參數用預設值
  成本:$0–8      能回答的問題:「微調有沒有用?」
  不能回答:「能不能上線?」
╔══════════════════════════════════════════════════════════════════╗
║  Phase 2:MVP(2K–50K 筆 / 單機多卡 / 可重跑)                     ║
╚══════════════════════════════════════════════════════════════════╝

  ┌──────────────┐
  │ HF Dataset   │  版本化的資料集(private repo)
  │ repo + tag   │
  └──────┬───────┘
         ▼
  ┌───────────────────────────────────────────────┐
  │  訓練(1× A100 80G 或 2× A10G)                │
  │  · accelerate + DeepSpeed ZeRO-2               │
  │  · LoRA r=16–32                                │
  │  · 每 100 step 評估 + early stopping           │
  └──────┬────────────────────────┬────────────────┘
         ▼                        ▼
  ┌──────────────┐        ┌──────────────────────┐
  │ W&B / TB     │        │ 自動評估              │
  │ 實驗追蹤      │        │ 格式合規 + 任務指標    │
  └──────────────┘        └──────────┬───────────┘
                                     ▼
                          ┌──────────────────────┐
                          │ 推 adapter 到 Hub     │
                          │ 打 tag + Model Card   │
                          └──────────────────────┘

  新增:資料版本化、實驗追蹤、自動評估、early stopping、模型註冊
  成本:~$120/次訓練    解決:可重現、可比較、可回滾
  未解決:無法快速嘗試多組超參數、上線仍是手動
╔══════════════════════════════════════════════════════════════════╗
║  Phase 3:生產訓練管線(50K+ / 多節點 / CI 驅動)                  ║
╚══════════════════════════════════════════════════════════════════╝

  資料變更 / 底模升級
        │  (git push 或排程觸發)
        ▼
  ┌────────────────────────────────────────────────────────────┐
  │  CI Pipeline                                               │
  │  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐  │
  │  │ 資料驗證  │▶│ 超參掃描  │▶│ 訓練      │▶│ 評估閘門       │  │
  │  │ schema   │ │ 8 組平行  │ │ ZeRO-3   │ │ 未達標則失敗   │  │
  │  │ 洩漏檢查  │ │          │ │ 多節點    │ │ 含回歸測試     │  │
  │  └──────────┘ └──────────┘ └──────────┘ └───────┬───────┘  │
  └───────────────────────────────────────────────┬─┘          │
                                                  ▼            │
                    ┌──────────────────────────────────────┐   │
                    │ 合併 + AWQ 量化 + 推 Registry        │   │
                    └──────────────┬───────────────────────┘   │
                                   ▼                           │
                    ┌──────────────────────────────────────┐   │
                    │ Canary 部署(5% 流量)→ 觀測 → 全量   │◀──┘
                    └──────────────────────────────────────┘

  新增:資料驗證閘門、超參數掃描、評估閘門(不過就不上)、canary、自動回滾
  成本:~$800–3,000/次完整管線
  解決:底模升級只需改一個變數並按下 run
  代價:需要 MLOps 平台投入(2–3 人月建置)

四、LoRA 與 QLoRA:只訓練 0.1% 的參數

4.1 核心概念

全參數微調要更新 70 億個參數;LoRA 的假設是**「微調造成的權重變化 ΔW 是低秩的」**,所以可以用兩個小矩陣的乘積來近似:

  原始線性層:           LoRA 改造後:

    x                       x
    │                       ├──────────────┐
    ▼                       ▼              ▼
  ┌─────┐              ┌─────────┐   ┌──────────┐
  │  W  │              │    W    │   │  A (d×r) │  r = 16
  │d × d│              │  凍結    │   └────┬─────┘
  └──┬──┘              └────┬────┘        ▼
     │                      │        ┌──────────┐
     ▼                      │        │  B (r×d) │  初始化為 0
     h                      │        └────┬─────┘
                            │             │ × (α/r)
                            └──────┬──────┘
                                   ▼
                                   h

  可訓練參數:d×d = 16,777,216  →  2×d×r = 131,072   (減少 99.2%)

B 初始化為零,所以訓練開始時 ΔW = 0,模型行為與原本完全相同——這是 LoRA 訓練穩定的關鍵。

QLoRA = 4-bit 量化底模 + LoRA。 底模用 nf4 量化後凍結(省 4 倍 VRAM),只有 LoRA 參數以 bf16 訓練:

  Qwen2.5-7B 微調的 VRAM 需求:

  方式          底模權重   梯度      優化器狀態   啟動值    總計
  ─────────────────────────────────────────────────────────────
  全參數 bf16     14 GB    14 GB     56 GB       8 GB    92 GB  ← A100×2
  LoRA bf16       14 GB   0.05 GB    0.2 GB      8 GB    22 GB  ← A10G 勉強
  QLoRA nf4      4.5 GB   0.05 GB    0.2 GB      6 GB    11 GB  ← 3090/4090 可行

4.2 超參數決策表

參數建議值什麼時候調整
r(秩)16任務簡單(格式轉換)→ 8;任務複雜(領域知識)→ 32–64
lora_alpha32(= 2r)保持 alpha = 2 × r 是穩健的預設
lora_dropout0.05資料 < 1,000 筆 → 0.1;> 20,000 筆 → 0.0
target_modules全部 linear只調 q,v 省 40% 記憶體但效果較差;建議全上
learning_rate2e-4LoRA 用 1e-4 ~ 3e-4(比全參數微調高 10–100 倍)
num_train_epochs2–3> 3 幾乎必定過擬合,除非資料 > 50K
max_seq_length2048依 P95 樣本長度設定,設太大是純浪費

最常見的超參數錯誤: 把全參數微調的 lr=2e-5 直接拿來用在 LoRA 上。LoRA 只有 0.1% 的參數在動,用這麼小的學習率等於幾乎沒訓練——loss 曲線會很平、評估分數幾乎不變,然後你會誤以為「微調沒用」。

4.3 target_modules 該放哪些

 1# Llama / Qwen / Mistral 架構的完整 linear 層
 2TARGETS = [
 3    "q_proj", "k_proj", "v_proj", "o_proj",      # attention
 4    "gate_proj", "up_proj", "down_proj",         # MLP
 5]
 6
 7# 不確定模型架構時,列出所有 Linear 層名稱
 8def find_linear_names(model):
 9    import torch.nn as nn
10    names = set()
11    for name, module in model.named_modules():
12        if isinstance(module, nn.Linear):
13            names.add(name.split(".")[-1])
14    names.discard("lm_head")     # 輸出層通常不加 LoRA
15    return sorted(names)

實測差異(7B、5,000 筆、其餘超參相同):

  target_modules            可訓練參數    VRAM     任務準確率
  ──────────────────────────────────────────────────────────
  q_proj, v_proj              4.2 M      9.8 GB     84.1%
  全部 attention              8.4 M     10.4 GB     86.7%
  全部 attention + MLP       20.0 M     11.6 GB     91.2%   ← 建議

多花 1.8GB 換 7 個百分點,幾乎永遠划算。


五、完整訓練程式碼

以下是一份可以直接跑的 QLoRA SFT 腳本,用 TRL 的 SFTTrainer

  1# train_sft.py
  2import torch
  3from datasets import load_dataset
  4from transformers import (
  5    AutoModelForCausalLM,
  6    AutoTokenizer,
  7    BitsAndBytesConfig,
  8)
  9from peft import LoraConfig, prepare_model_for_kbit_training
 10from trl import SFTTrainer, SFTConfig
 11
 12BASE_MODEL = "Qwen/Qwen2.5-7B-Instruct"
 13OUTPUT_DIR = "./out/support-qlora"
 14
 15# ────────────────────────── 1. 資料 ──────────────────────────
 16train_ds = load_dataset("json", data_files="data/train.jsonl", split="train")
 17eval_ds = load_dataset("json", data_files="data/eval.jsonl", split="train")
 18print(f"train={len(train_ds):,}  eval={len(eval_ds):,}")
 19
 20# ────────────────────────── 2. 模型 ──────────────────────────
 21bnb = BitsAndBytesConfig(
 22    load_in_4bit=True,
 23    bnb_4bit_quant_type="nf4",
 24    bnb_4bit_compute_dtype=torch.bfloat16,
 25    bnb_4bit_use_double_quant=True,
 26)
 27
 28tok = AutoTokenizer.from_pretrained(BASE_MODEL)
 29if tok.pad_token is None:
 30    tok.pad_token = tok.eos_token
 31tok.padding_side = "right"          # 訓練時 right,推論時 left
 32
 33model = AutoModelForCausalLM.from_pretrained(
 34    BASE_MODEL,
 35    quantization_config=bnb,
 36    device_map={"": 0},              # QLoRA 訓練不要用 "auto"
 37    attn_implementation="flash_attention_2",   # 沒裝就改 "sdpa"
 38    torch_dtype=torch.bfloat16,
 39)
 40model.config.use_cache = False       # 與 gradient checkpointing 衝突
 41model = prepare_model_for_kbit_training(
 42    model, use_gradient_checkpointing=True
 43)
 44
 45# ────────────────────────── 3. LoRA ──────────────────────────
 46peft_config = LoraConfig(
 47    r=16,
 48    lora_alpha=32,
 49    lora_dropout=0.05,
 50    bias="none",
 51    task_type="CAUSAL_LM",
 52    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
 53                    "gate_proj", "up_proj", "down_proj"],
 54)
 55
 56# ────────────────────────── 4. 訓練設定 ──────────────────────
 57cfg = SFTConfig(
 58    output_dir=OUTPUT_DIR,
 59    num_train_epochs=3,
 60
 61    # 有效 batch size = 4 × 8 = 32
 62    per_device_train_batch_size=4,
 63    gradient_accumulation_steps=8,
 64    per_device_eval_batch_size=4,
 65
 66    learning_rate=2e-4,
 67    lr_scheduler_type="cosine",
 68    warmup_ratio=0.03,
 69    optim="paged_adamw_8bit",        # QLoRA 搭配的優化器,省記憶體
 70    max_grad_norm=0.3,
 71    weight_decay=0.01,
 72
 73    bf16=True,
 74    gradient_checkpointing=True,
 75    gradient_checkpointing_kwargs={"use_reentrant": False},
 76
 77    max_seq_length=2048,
 78    packing=False,                   # 對話資料不要 packing(見下方說明)
 79
 80    logging_steps=10,
 81    eval_strategy="steps",
 82    eval_steps=100,
 83    save_strategy="steps",
 84    save_steps=100,
 85    save_total_limit=2,
 86    load_best_model_at_end=True,
 87    metric_for_best_model="eval_loss",
 88    greater_is_better=False,
 89
 90    report_to="tensorboard",         # 或 "wandb"
 91    seed=42,
 92)
 93
 94# ────────────────────────── 5. 訓練 ──────────────────────────
 95trainer = SFTTrainer(
 96    model=model,
 97    args=cfg,
 98    train_dataset=train_ds,
 99    eval_dataset=eval_ds,
100    peft_config=peft_config,
101    processing_class=tok,
102)
103
104trainer.model.print_trainable_parameters()
105# trainable params: 20,185,088 || all params: 7,635,801,088 || trainable%: 0.2643
106
107trainer.train()
108trainer.save_model(f"{OUTPUT_DIR}/final")
109tok.save_pretrained(f"{OUTPUT_DIR}/final")

5.1 幾個關鍵設定的理由

packing=False packing 會把多個短樣本拼進同一個序列以提高 GPU 使用率,但它會讓不同對話的內容互相「看見」。對指令微調來說,這會造成模型學到跨樣本的假關聯。只有在做持續預訓練(純文本)時才開 packing。

optim="paged_adamw_8bit" 標準 AdamW 每個參數要存 2 個 fp32 狀態(8 bytes)。8-bit 版本省 4 倍,而 paged 版本會在 VRAM 吃緊時自動把優化器狀態換到 CPU RAM——這是 QLoRA 能在 24GB 卡上跑 7B 的關鍵之一。

gradient_checkpointing_kwargs={"use_reentrant": False} 不加這個,PyTorch 2.x 會噴一堆警告,而且在某些情況下 LoRA 的梯度會是 None(模型看起來在訓練,但參數完全沒動)。這是很難察覺的靜默失敗。

model.config.use_cache = False KV cache 是推論用的最佳化,與 gradient checkpointing 衝突。不關掉會多耗記憶體且噴警告。訓練完要推論前記得改回 True

5.2 有效 batch size 的算法

  有效 batch size = per_device_batch × grad_accum × GPU 數量

  例:per_device=4, grad_accum=8, 2 張卡  →  4 × 8 × 2 = 64

  建議範圍:
    < 1,000 筆資料     → 有效 batch 8–16(樣本少,需要更多次更新)
    1,000–20,000 筆    → 有效 batch 32(預設起點)
    > 20,000 筆        → 有效 batch 64–128(穩定梯度)

  VRAM 不夠時的調整順序:
    1. 降 per_device_batch_size,同步提高 grad_accum(有效 batch 不變)
    2. 降 max_seq_length(記憶體隨長度平方成長)
    3. 開 gradient_checkpointing(換 20–30% 速度)
    4. 從 LoRA 改成 QLoRA

六、評估:不要只看 loss

6.1 為什麼 eval_loss 不夠

eval_loss 衡量的是「模型對參考答案的機率有多高」,但它無法區分「換句話說」與「答錯」。一個回答如果語意完全正確但用詞不同,loss 會很高;一個回答如果格式對但事實錯誤,loss 可能很低。

  評估層級          衡量什麼              成本      什麼時候用
  ──────────────────────────────────────────────────────────────────
  eval_loss        擬合程度              免費      訓練中偵測過擬合
  格式合規率        輸出可解析嗎           極低      每次評估必跑
  任務指標          準確率 / F1 / EM      低        有標準答案時
  LLM-as-judge     語意品質、有用性       中        開放式生成
  人工評估          真實體驗              高        上線前最終把關

四層都要有,但成本遞增,所以頻率遞減。 前兩層每次訓練都跑,第三層每個候選模型跑,第四層只在上線前跑。

6.2 一份實用的評估腳本

 1# evaluate.py
 2import json
 3import torch
 4from datasets import load_dataset
 5from transformers import AutoTokenizer, AutoModelForCausalLM
 6from peft import PeftModel
 7
 8BASE = "Qwen/Qwen2.5-7B-Instruct"
 9ADAPTER = "./out/support-qlora/final"
10
11tok = AutoTokenizer.from_pretrained(ADAPTER)
12tok.padding_side = "left"              # 推論時必須 left padding
13base = AutoModelForCausalLM.from_pretrained(
14    BASE, torch_dtype=torch.bfloat16, device_map="auto"
15)
16model = PeftModel.from_pretrained(base, ADAPTER)
17model.eval()
18
19eval_ds = load_dataset("json", data_files="data/eval.jsonl", split="train")
20
21
22@torch.no_grad()
23def generate_batch(message_lists, batch_size=8):
24    outs = []
25    for i in range(0, len(message_lists), batch_size):
26        batch = message_lists[i:i + batch_size]
27        prompts = [
28            tok.apply_chat_template(m, tokenize=False, add_generation_prompt=True)
29            for m in batch
30        ]
31        enc = tok(prompts, return_tensors="pt", padding=True,
32                  truncation=True, max_length=2048).to(model.device)
33        gen = model.generate(
34            **enc, max_new_tokens=256,
35            do_sample=False,               # 評估用 greedy,確保可重現
36            pad_token_id=tok.pad_token_id,
37        )
38        for j, seq in enumerate(gen):
39            new = seq[enc["input_ids"].shape[1]:]
40            outs.append(tok.decode(new, skip_special_tokens=True).strip())
41    return outs
42
43
44# 準備輸入(去掉最後一則 assistant 訊息)
45inputs = [row["messages"][:-1] for row in eval_ds]
46golds = [row["messages"][-1]["content"] for row in eval_ds]
47preds = generate_batch(inputs)
48
49# ── 指標 1:格式合規率 ──
50def parse(s):
51    try:
52        return json.loads(s)
53    except Exception:
54        return None
55
56parsed = [parse(p) for p in preds]
57format_ok = sum(p is not None for p in parsed) / len(parsed)
58
59# ── 指標 2:欄位級準確率 ──
60correct_cat = correct_urg = valid = 0
61for p, g in zip(parsed, golds):
62    if p is None:
63        continue
64    gold = json.loads(g)
65    valid += 1
66    correct_cat += (p.get("category") == gold["category"])
67    correct_urg += (p.get("urgency") == gold["urgency"])
68
69print(f"格式合規率     : {format_ok:.1%}")
70print(f"category 準確率: {correct_cat / max(valid, 1):.1%}")
71print(f"urgency  準確率: {correct_urg / max(valid, 1):.1%}")
72
73# ── 指標 3:混淆矩陣(找出錯在哪一類)──
74from collections import Counter
75errors = Counter()
76for p, g in zip(parsed, golds):
77    if p is None:
78        continue
79    gold = json.loads(g)
80    if p.get("category") != gold["category"]:
81        errors[(gold["category"], p.get("category"))] += 1
82
83print("\n最常見的分類錯誤(真實 → 預測):")
84for (real, pred), n in errors.most_common(5):
85    print(f"  {real:>8}{str(pred):<8}  {n} 次")

混淆矩陣是最被低估的診斷工具。 「準確率 91%」只告訴你有 9% 錯了;混淆矩陣告訴你「其中 6% 是把『退貨』誤判成『換貨』」——這是可以用 50 筆針對性資料修好的問題。

6.3 LLM-as-judge(開放式生成的評估)

 1from openai import OpenAI
 2
 3judge = OpenAI(base_url="http://localhost:8000/v1", api_key="x")
 4
 5RUBRIC = """你是嚴格的評審。依據下列標準為「候選回覆」評分(1–5 分):
 6
 75 = 完全正確、語氣專業、無多餘內容
 84 = 正確但有小瑕疵(略囉唆或語氣稍偏)
 93 = 大致正確但遺漏重要資訊
102 = 部分錯誤或答非所問
111 = 完全錯誤或編造資訊
12
13只輸出 JSON:{"score": <int>, "reason": "<20 字內>"}
14"""
15
16def judge_one(question, reference, candidate):
17    resp = judge.chat.completions.create(
18        model="my-chat",
19        messages=[
20            {"role": "system", "content": RUBRIC},
21            {"role": "user", "content":
22                f"問題:{question}\n\n參考答案:{reference}\n\n候選回覆:{candidate}"},
23        ],
24        temperature=0.0,
25    )
26    return json.loads(resp.choices[0].message.content)

Judge 的三個陷阱: ① 用同一個模型評自己會有自我偏好(分數虛高 8–15%),盡量用不同家族的模型當評審;② 位置偏誤——A/B 比較時要正反各跑一次取平均;③ 長度偏誤——judge 傾向給長回答高分,rubric 裡要明確扣「多餘內容」的分。


七、合併、量化與部署

7.1 合併 LoRA 回底模

 1# merge.py
 2import torch
 3from transformers import AutoModelForCausalLM, AutoTokenizer
 4from peft import PeftModel
 5
 6BASE = "Qwen/Qwen2.5-7B-Instruct"
 7ADAPTER = "./out/support-qlora/final"
 8MERGED = "./out/support-merged"
 9
10# 關鍵:合併時要用「未量化」的底模載入
11base = AutoModelForCausalLM.from_pretrained(
12    BASE, torch_dtype=torch.bfloat16, device_map="cpu"
13)
14model = PeftModel.from_pretrained(base, ADAPTER)
15model = model.merge_and_unload()          # ΔW 加回 W,移除 LoRA 結構
16
17model.save_pretrained(MERGED, safe_serialization=True, max_shard_size="4GB")
18AutoTokenizer.from_pretrained(ADAPTER).save_pretrained(MERGED)
19print("合併完成")

不要用 4-bit 載入的底模來合併。 那會把量化誤差永久烘進權重,實測任務準確率會掉 2–5 個百分點。QLoRA 訓練時用 4-bit 是為了省記憶體,合併時必須回到 bf16——這一步在 CPU 上做即可,只需要足夠的 RAM(7B 約需 20GB)。

7.2 部署選擇

  ┌────────────────────────────────────────────────────────────────┐
  │  選項 A:不合併,vLLM 動態載入 adapter                          │
  │    vllm serve Qwen/Qwen2.5-7B-Instruct \                       │
  │      --enable-lora --lora-modules support=./out/final          │
  │    優點:一個底模服務多個 adapter,VRAM 只需一份底模             │
  │    缺點:推論慢 5–15%                                           │
  │    適合:多租戶、多個微調版本並存                                │
  ├────────────────────────────────────────────────────────────────┤
  │  選項 B:合併 + AWQ 量化 + vLLM                                 │
  │    優點:最快、VRAM 最省                                        │
  │    缺點:每個版本都是獨立的完整模型(16GB × N)                  │
  │    適合:單一模型的生產服務                                      │
  └────────────────────────────────────────────────────────────────┘
 1# quantize_awq.py(選項 B 的量化步驟)
 2from awq import AutoAWQForCausalLM
 3from transformers import AutoTokenizer
 4
 5MERGED, QUANT = "./out/support-merged", "./out/support-awq"
 6
 7model = AutoAWQForCausalLM.from_pretrained(MERGED, device_map="auto")
 8tok = AutoTokenizer.from_pretrained(MERGED)
 9
10# 校準資料要用「與生產分布相近」的樣本,不要用隨機文本
11calib = [row["messages"][0]["content"]
12         for row in load_dataset("json", data_files="data/eval.jsonl",
13                                 split="train").select(range(128))]
14
15model.quantize(tok, quant_config={
16    "zero_point": True, "q_group_size": 128,
17    "w_bit": 4, "version": "GEMM",
18}, calib_data=calib)
19
20model.save_quantized(QUANT)
21tok.save_pretrained(QUANT)

7.3 推 adapter 到 Hub(只有 40MB,很划算)

1from huggingface_hub import create_repo
2
3REPO = "your-username/qwen2.5-7b-support-lora"
4create_repo(REPO, private=True, exist_ok=True)
5
6trainer.model.push_to_hub(REPO, commit_message="r16 a32, 3ep, acc 91.2%")
7tok.push_to_hub(REPO)

推 adapter 而非合併後模型的三個好處: 40MB vs 16GB 的儲存與傳輸差異、base_model 欄位讓依賴關係一目瞭然、底模更新時可以快速測試「舊 adapter + 新底模」的組合。


八、為什麼選 X 不選 Y

8.1 LoRA vs 全參數微調

選擇              選 LoRA 的理由                   選全參數的理由
──────────────────────────────────────────────────────────────────────
LoRA              VRAM 少 4–8×,成本低 10×          需要學習全新語言或領域
                  訓練快 2–3×                       資料量 > 100K 且品質高
                  災難性遺忘輕微得多                 追求最後 2–3 個百分點
                  adapter 只有 40MB,好版本管理      模型 < 1.5B(差距不大)
──────────────────────────────────────────────────────────────────────
翻轉條件:資料 > 100K 筆且是新語言/新模態 → 全參數(或持續預訓練)。
          其餘 95% 的商業場景,LoRA 的性價比壓倒性勝出

8.2 QLoRA vs LoRA

選擇              選 QLoRA 的理由                  選 LoRA (bf16) 的理由
──────────────────────────────────────────────────────────────────────
QLoRA             VRAM 再省 60%(7B: 22G → 11G)    訓練快 25–40%
                  24GB 卡就能訓 13B                 無量化誤差累積
                  單卡就能訓 70B(80GB A100)       合併時不需額外考量
──────────────────────────────────────────────────────────────────────
翻轉條件:VRAM 夠用就選 LoRA(快且乾淨)。VRAM 不夠才用 QLoRA。
          品質差距實測約 0.5–1.5 個百分點——不是選擇的主要考量

8.3 TRL SFTTrainer vs 原生 Trainer

選擇              選 SFTTrainer 的理由             選原生 Trainer 的理由
──────────────────────────────────────────────────────────────────────
SFTTrainer        自動套 chat_template              需要完全自訂 loss
                  自動處理 completion-only loss     非標準的訓練目標
                  packing / peft 整合開箱即用       已有既存的 Trainer 程式碼
                  之後接 DPO/GRPO 完全同一套 API
──────────────────────────────────────────────────────────────────────
翻轉條件:做非 causal-LM 任務(分類、NER、seq2seq)→ 原生 Trainer。
          做指令微調 → 一律 SFTTrainer,不要自己刻

8.4 資料多 vs 資料精

選擇              選「精」的理由                   選「多」的理由
──────────────────────────────────────────────────────────────────────
1K 精選           1K 精選 ≈ 50K 雜訊的效果          涵蓋長尾情境
                  標注成本可控                      邊界案例需要量
                  可完整人工審查                    多樣性防止過擬合
──────────────────────────────────────────────────────────────────────
翻轉條件:實務最佳解是「精選 1,000 筆核心 + 針對性補充難例」。
          先用 1,000 筆訓一版 → 跑評估 → 看混淆矩陣 →
          針對錯最多的那類補 100–200 筆 → 重訓。這個迴圈比盲目加量有效 5–10×

8.5 微調小模型 vs 直接用大模型

選擇              選「微調 1.5B」的理由            選「直接用 7B/API」的理由
──────────────────────────────────────────────────────────────────────
微調小模型         推論成本低 8–15×                 零訓練成本
                  延遲低 3–5×(P50 從 900ms→200ms) 泛化能力強,新情境不用重訓
                  可跑在便宜的 GPU 甚至 CPU         不需要維護訓練管線
──────────────────────────────────────────────────────────────────────
翻轉條件(成本交叉點):
  微調投入約 $8,000(人力 + 算力),每月省下的推論成本若 > $1,500,
  約 5 個月回本 → 值得。
  月推論帳單 < $500 的專案,微調小模型幾乎一定不划算

九、六種失敗模式與診斷

  症狀                      loss 曲線             真正原因            解法
  ──────────────────────────────────────────────────────────────────────────────
  1. loss 幾乎不降           平坦線               lr 太小(用了 2e-5)  改 2e-4
                                                 或梯度沒接上          檢查 print_
                                                                      trainable_params

  2. loss 降但輸出變差        train↓ eval↑ (100步後) 過擬合              降 epoch 到 1–2
                                                                      加 dropout 到 0.1

  3. loss 變 NaN             驟升後 NaN           lr 太大 / fp16 溢位   改 bf16
                                                                      max_grad_norm=0.3

  4. 通用能力大幅下降         都正常               災難性遺忘            降 r、混入 5–10%
                             (但通用測試崩)                          通用指令資料

  5. 輸出重複同一句話         正常                 資料裡有重複樣本      去重
                                                 或 epoch 過多         repetition_penalty

  6. 驗證分數超高但上線爛      完美                資料洩漏              依實體重新切分
                                                                      建獨立的 holdout

9.1 最隱蔽的一種:災難性遺忘

微調後模型在你的任務上 95%,但問它「今天天氣如何」它回你一個 JSON 工單分類——這就是災難性遺忘。診斷方式是在每次評估時同時跑一組「通用能力回歸測試」

 1REGRESSION_SET = [
 2    "台灣的首都是哪裡?",
 3    "用 Python 寫一個判斷質數的函式",
 4    "把這句翻成英文:今天天氣很好",
 5    "1 加到 100 等於多少?",
 6]
 7
 8# 微調前後都跑一次,人工看有沒有崩壞
 9for q in REGRESSION_SET:
10    print(f"\nQ: {q}\nA: {generate_batch([[{'role':'user','content':q}]])[0]}")

緩解方式(依效果排序):① 在訓練資料中混入 5–10% 的通用指令資料;② 降低 r 與 epoch 數;③ 降低學習率。方法 ① 最有效,而且成本最低——從 Hub 上取一個通用指令集抽 500 筆混進去就好。


十、系列導航

本文是「Hugging Face 實戰」系列的第 3 篇。

上一篇: Hugging Face 實戰(二):用模型、跑 App、推送自己的模型

下一篇: Hugging Face 實戰(四):後訓練 — 從 SFT 到 DPO、ORPO 與 GRPO

系列索引:

  1. 入門與生態系
  2. 用模型、跑 App、推送自己的模型
  3. 微調(Fine-tuning) ← 目前
  4. 後訓練(Post-training)
  5. 端到端實戰:打造完整 LLM 應用

延伸閱讀:

Yen

Yen

Yen