iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
AI Engineering

從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程系列 第 20

[ Model Fine-Tuning ] Day 20 — 執行 SFT 微調與 Loss 曲線判讀:從訓練損失看見工具能力

  • 分享至 

  • xImage
  •  

Day 20 今日地圖:今天在整條閉環的位置、承接與產出

I. 前言:一個會誤導人的指標

前面十九天的準備工作,都是為了今天按下訓練鍵的這一刻。

而按下之後,最直覺的判斷依據就是 loss 曲線 —— 它平穩下降,我們會覺得訓練順利;它停滯不動,我們會開始檢查設定。這個直覺在一般的語言模型微調上大致成立。

但在 Agentic Data 的訓練上,這個直覺會產生相當大的偏差。原因在於這類資料的 token 分佈極度不均。

看一段典型的訓練樣本:

{"role": "assistant", "tool_calls": [{"type": "function", "function": {
  "name": "update_leave_status",
  "arguments": {"leave_id": "LV-7f3a91", "status": "submitted"}
}}]}

這裡面絕大多數的 token 是什麼?是 {":typefunctionarguments 這些結構性符號。它們在每一筆樣本裡都長得一樣,模型只要學會 JSON 的形狀就能準確預測。

而真正決定成敗的是什麼?是 LV-7f3a91submitted 這幾個低頻 token —— 它們決定了工具呼叫對不對,卻只佔整段序列的一小部分。

結論是:模型只要學會 JSON 的形狀,整體 loss 就會大幅下降,但這完全不代表它會選對工具、填對參數。

以下的內容,將會提供完整的訓練腳本、逐一說明關鍵設定的理由、剖析四種曲線形態的意義,以及一個比看曲線更有效的驗證方式。

II. 完整訓練腳本

從資料到 adapter 的訓練管線與關鍵設定

先確認環境:

import torch

print("CUDA:", torch.cuda.is_available())
print("裝置:", torch.cuda.get_device_name(0))
print("顯存:", torch.cuda.get_device_properties(0).total_memory / 1e9, "GB")
print("bf16 支援:", torch.cuda.is_bf16_supported())

bf16 的支援與否值得先確認。SFTConfig 在未設 fp16 時,bf16 預設為 True;而較舊的顯卡(如 T4)不支援 bf16,必須改用 fp16。

完整腳本如下:

import json
from datasets import Dataset
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer

MODEL_ID = "google/gemma-4-E4B"


def load_jsonl(path: str) -> Dataset:
    rows = [json.loads(line) for line in open(path, encoding="utf-8")]
    return Dataset.from_list(rows)


train_ds = load_jsonl("data/train_v1.jsonl")
eval_ds = load_jsonl("data/valid_v1.jsonl")

peft_config = LoraConfig(
    r=16,                        # LoRA rank
    lora_alpha=32,               # 通常設為 r 的兩倍
    lora_dropout=0.05,
    target_modules="all-linear", # 對所有線性層套用
    task_type="CAUSAL_LM",
)

args = SFTConfig(
    output_dir="/content/drive/MyDrive/leave-copilot/out/v1",  # 寫到 Drive

    # ── 資料 ──────────────────────────────
    max_length=2048,             # 依 Day 19 的長度分佈設定
    assistant_only_loss=True,    # ★ 只在 assistant 回應上算 loss
    packing=False,               # 第一次訓練建議關閉

    # ── 優化 ──────────────────────────────
    learning_rate=1e-4,          # ★ LoRA 用 1e-4,不是預設的 2e-5
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,   # 有效 batch = 16
    lr_scheduler_type="cosine",
    warmup_steps=20,
    max_grad_norm=1.0,

    # ── 精度與記憶體 ────────────────────────
    bf16=True,
    gradient_checkpointing=True,     # SFTConfig 預設就是 True

    # ── 記錄與檢查點 ────────────────────────
    logging_steps=5,
    eval_strategy="steps",
    eval_steps=50,
    save_strategy="steps",
    save_steps=50,
    save_total_limit=3,
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    report_to="none",                # 要用 W&B 就改成 "wandb"

    seed=42,
)

trainer = SFTTrainer(
    MODEL_ID,
    args=args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    peft_config=peft_config,
)

trainer.train()
trainer.save_model()

III. 三個非預設但必要的設定

腳本裡有三個參數必須手動指定,它們的共同特徵是:用錯了不會報錯,訓練會正常跑完,但結果不如預期

1. learning_rate=1e-4

SFTConfig 的預設值是 2e-5,那是為 Full Fine-Tuning 設計的。

LoRA 只訓練新增的少量參數(約 0.1%),需要更大的學習率才能有效更新。TRL 文件建議約 1e-4

用預設值訓 LoRA 的症狀:loss 降得極慢,看起來像是資料有問題或模型學不會 —— 很容易讓人往錯誤的方向排查。

2. assistant_only_loss=True

Day 15 與 Day 17 詳細說明過。這裡再強調一次它的失效症狀:若 chat template 缺少 {% generation %} 標記,這個設定會靜默失效,模型會把 user 訊息也算進 loss。

如果 Day 19 的過擬合測試通過了,這一項就沒問題。 那正是那個測試存在的理由。

3. gradient_accumulation_steps=8

有效 batch size = per_device_train_batch_size × gradient_accumulation_steps = 16。

顯存不足時的調整順序是:先降 batch size、同時提高 accumulation(讓有效 batch 維持不變),而不是直接降低有效 batch —— 後者會改變訓練的動態特性,影響收斂行為。

為什麼第一次訓練要關閉 packing

packing=True 會把多個樣本打包進固定長度的區塊,減少 padding 浪費、提升訓練效率。這在正式訓練時是值得開啟的優化。

第一次訓練建議關閉,理由是:打包會改變樣本的邊界,若 loss mask 或 chat template 存在任何問題,打包會讓症狀更難診斷。

原則是先求穩定,再求效率。 跑通之後再開啟即可,packing_strategy 預設為 "bfd"(best-fit decreasing)。值得注意的是,TRL 文件提到當 packing 使用 "bfd" 策略時會自動啟用 padding-free,不論 padding_free 參數如何設定。

IV. Loss 曲線的四種形態

Loss 曲線的四種樣子,以及為什麼 loss 下降不等於工具呼叫變準

1. 正常收斂(Healthy Descent)

Train Loss 與 Eval Loss 同步平穩下降,最後趨於平緩。

處置:不需要調整。但仍要記錄 Eval Loss 開始平緩的步數,那個資訊在調整 epoch 數時會用到。

2. 過擬合(Overfitting)

Train Loss 持續降低,但 Eval Loss 開始反彈回升。

這是小資料集最常見的情況。我們的資料集只有幾千筆,三個 epoch 之後過擬合的機率不低。

處置:減少 epoch 數、增加資料量,或提高 lora_dropout。腳本中的 load_best_model_at_end=True 搭配 metric_for_best_model="eval_loss" 會自動取回驗證損失最低的檢查點,但您仍應該知道它在第幾步開始惡化 —— 那個數字決定下一輪要不要減少 epoch。

3. 欠擬合(Underfitting)

Loss 下降極度緩慢,或卡在某個值不動。

處置:依序檢查學習率(是否誤用了預設的 2e-5)、資料格式、以及 LoRA rank 是否太小。

4. 梯度爆炸或發散

Loss 突然飆升,或出現 NaN。

處置:檢查 bf16 支援情況(舊卡需改 fp16)、降低學習率、確認 max_grad_norm 有生效。另外也要檢查資料中是否有異常的超長樣本。

一個額外的警訊:Loss 一開始就極低

這種情況不在上述四類之中,但更需要警覺 —— 它通常代表 loss mask 沒有生效

判斷方式是觀察 mean_token_accuracy起始值。正常情況下模型在第一步應該預測不準;若它從一開始就超過 0.9,八成是模型在「抄」它看得到的輸入。

V. 比看曲線更有效的做法

既然 loss 對「參數值對不對」不敏感,那要怎麼知道模型真的變好了?

答案是:直接測。 而且不必等訓練結束,訓到一半就可以載入中間檢查點驗證。

from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer

ckpt = "/content/drive/MyDrive/leave-copilot/out/v1/checkpoint-100"
model = AutoPeftModelForCausalLM.from_pretrained(
    ckpt, dtype="bfloat16", device_map="auto"
)
tok = AutoTokenizer.from_pretrained(ckpt)

messages = [
    {"role": "system", "content": SYSTEM_PROMPT_C},   # Day 19 的配置 C
    {"role": "user", "content": "把我那張家庭旅遊的特休送出審核"},
]
text = tok.apply_chat_template(
    messages, tools=TOOLS_SCHEMA, tokenize=False, add_generation_prompt=True
)
out = model.generate(
    **tok(text, return_tensors="pt").to(model.device),
    max_new_tokens=256,
)
print(tok.decode(out[0], skip_special_tokens=False))

推論時的 System Prompt 必須與訓練時一致(Day 19 強調過)。用配置 C 訓練就用配置 C 測試,否則得到的結果無法反映真實表現。

四個難點的快速測試

準備四句話,各對應一個難點:

表格:測試指令、期望行為、檢查什麼

第四個測試需要手動組裝訊息,把 {"status":"declined"} 的 tool 回應放進去,觀察模型的下一步。

這四個測試三分鐘就能跑完,卻比盯著曲線半小時更能反映真實狀況。

VI. 超參數的調整順序

如果快速測試的結果不理想,按這個順序調整:

1. Epoch 數(最敏感)

小資料集對這個參數最敏感。

  • 過擬合(Eval Loss 上升)→ 減到 2
  • 明顯欠擬合 → 加到 4–5,但要持續觀察 Eval Loss

2. LoRA rank

r=16 是常見起點,lora_alpha 通常設為 r 的兩倍。

  • 學不到領域規則 → 提高到 32 或 64
  • 過擬合嚴重 → 降到 8

3. 學習率

1e-4 是 TRL 建議的 LoRA 起點。

  • 曲線震盪 → 降到 5e-5
  • 下降太慢 → 提到 2e-4(再高容易不穩定)

4. 資料(最後也最重要)

如果前三項都調過還是不理想,問題八成在資料,而不在超參數。

回頭檢查:負面樣本比例是否太低(難點 ② 學不會的典型原因)、各工具的分佈是否嚴重失衡、擴增出來的資料是否同質性太高(去重不夠徹底)。

這是最花時間、但也最有效的方向 —— 超參數的收益有天花板,資料沒有。

VII. 記錄每一輪訓練

每次訓練都留下記錄,明天決定要不要重訓時會用到:

表格:欄位、內容

這張表的數字必須來自實際訓練。在真的跑過之前,不要填任何數字 —— 包括寫文章時。編造的訓練結果沒有任何價值,而且讀者看得出來。

若使用 Colab,colab log -o experiments/run_v1.md 可以把整個 session 的執行記錄自動匯出,比事後憑記憶重建可靠得多。

VIII. 結語

訓練腳本本身並不長,但每一個參數的選擇都建立在前面十九天的分析之上 —— 這也是為什麼資料準備花了六天,而訓練本身只需要幾小時。

總結來說,今天有三個重點值得帶走:

  • Loss 下降不等於工具呼叫變準: Agentic 資料中大量的結構性 token 極易預測,會讓 loss 掉得相當漂亮。但決定成敗的是參數值那幾個低頻 token,而整體 loss 對它們幾乎不敏感。曲線能告訴我們「訓練過程有沒有壞掉」,但無法告訴我們「模型有沒有變好」。
  • 四個難點的快速測試比看曲線有效: 三分鐘就能跑完,而且直接對應到我們真正在意的能力。要注意推論時的 System Prompt 必須與訓練時完全一致。
  • 超參數的收益有天花板,資料沒有: 如果 epoch、LoRA rank、學習率都調過還是不理想,問題八成在資料的比例與多樣性,而不在訓練配置。

明天保留給實際遇到的問題。從訓練到最後驗收是整條路上風險最高的一段,因此我刻意留了一整天的空間來處理踩坑與二次訓練決策。

Day 20 Cheat Sheet:指令、參數與容易踩的地方


參考來源

  • TRL・SFT Trainer——SFTConfig 完整參數、logged metrics、LoRA 學習率建議、packing_strategy 與 padding-free 的關係、gradient_checkpointingbf16 預設值
  • PEFT 文件——LoraConfig 參數與 AutoPeftModelForCausalLM
  • Transformers・Chat templating——apply_chat_templatetoolsadd_generation_prompt

查證日期:2026-08-24


I am Simon

大家好,我是 Simon 劉育維,是一位 AI 領域解決方案專家,目前也擔任 Google Cloud AI 領域開發者專家 (GDE),期待能夠幫助企業導入人工智慧相關技術解決問題。如果這篇文章對您有幫助,歡迎在我的 Linkedin 上留言提供意見,並與我一起討論有關人工智慧的主題,期待能夠對大家有所幫助!

我的個人部落格資訊:https://medium.com/@simon3458


上一篇
[ Model Fine-Tuning ] Day 19 — Prompt Template 取捨與訓練前 Sanity Check:先跑通再放大
下一篇
[ Training & Deployment ] Day 21 — 微調踩坑實錄與二次訓練決策樹:失敗才是最好的老師
系列文
從 MCP 到專屬 Agentic 模型:30 天走完一條可評測、可微調、可自架的 AI Agent 模型與服務製作流程30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言