iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
AI Engineering

不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天系列 第 29

Day 29 - 32 層只掛到 8 層:微調前,先查你的 LoRA 掛在哪裡

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20183550AgaXcp48pl.png

我照常見教學裡的設定,準備在 Qwen3.5-9B 上掛 LoRA:target_modules=["q_proj","v_proj"]。開訓前先點了一次名——這組設定在它的 32 個語言層裡只掛到 8 層,另外 24 層叫別的名字。

會不會因此訓不出東西,我沒做受控對照,不能說。但它在開訓前就定了,一分鐘內查得出來。

昨天收在生成端:段落餵對了,小模型還是會在一堆數字裡挑錯一格。但有一類抱怨連正解那一段都接不住——答案對,口氣完全不像我們公司。檢索只把正解撈回來,未必能穩定改掉語氣與術語用法。

今天講這一季最後一把工具:用 QLoRA 做 SFT——在量化過的基座上訓練一組 LoRA(與 DPO/GRPO 的分界寫在文末)。

先問該不該動,而且它不是一條直線

心智模型只有一條:輸出 = 權重 + 上下文。上下文在推論時動態換,權重只有訓練能改。

輸出不對 -> 缺什麼?
|- 缺可更新、可追溯的內容 -----------------------> RAG
|- 內容有了, 但格式亂、語氣不對、術語用錯
   |- 只是輸出結構不穩 --------------------------> schema / 受限解碼 + 驗證
   |- system prompt + few-shot 修得好 ----------> 就到這裡
   |- 修不好 / prompt 太長太貴
      |- 有數百筆以上高品質樣本 -----------------> LoRA 微調
      |- 沒有 -----------------------------------> 先補資料

先建 prompt 基線,這一步沒有例外。但「prompt → RAG → 微調」不是必經的直線:語氣不對,試過 prompt 之後可以直接評估微調,沒必要先繞一趟檢索。

「知識走 RAG,行為走微調」當口訣很好用,但它是工程上的優先選擇,不是能力邊界。微調也能學知識,只是不保證精確記憶、不好更新,本身也不提供可靠的來源追溯。

所以最高頻的反模式仍是用微調把公司文件灌進權重:文件一改就得重訓,答不出「這句出自哪一段」,學到的容易是風格不是事實。

LoRA 掛在哪裡

https://ithelp.ithome.com.tw/upload/images/20260909/20183550KiCDbjP9Ij.png
圖 1:圖上沒講的是為什麼 B 從零開始,以及這條 scaling 只對標準 LoRA 成立——見下面兩段。

前向從 h = Wx 變成 h = Wx + (α/r)·B(Ax)。輸入兵分兩路,不是「W 加上 A」:上路 W 完全凍結,下路先被 A 壓到 r 維、再被 B 映射到 W 的輸出維度(W 可以是長方形,兩端不一定一樣寬)。

r 是每個目標矩陣的秩上限。ΔW 的 rank 至多為 r,而 A、B 張出來的子空間本身也會隨訓練改變。背後的直覺是 intrinsic dimensionality,出處放文末。

B 從零開始,是標準 LoRA 與 PEFT 的預設初始化。第 0 步 BA = 0,adapter 分支為零,數學上不改變基座的映射。兩個都設零則梯度恆零;兩個都隨機則第一步就往輸出注入亂數。PEFT 另外支援 PiSSA、LoftQ 等初始化方式,那些不從零開始。

標準 LoRA 的 scaling 是 α/r。開場那組若配 r=64、α=16,乘數其實是 0.25;固定 α 把 r 從 8 拉到 64,乘數就從 2.0 掉到 0.25。實際的更新幅度還受 A、B、學習率、optimizer 與訓練步數影響,乘數變小不等於學不到東西。QLoRA 官方 repo 用的正是 r=64, α=16,大量教學抄走數字卻沒抄走它的 lr 與訓練長度。本文選 α = 2r 把 scaling 固定在 2,那是配方選擇不是通用最佳值;而 rsLoRA 用的是 α/√r,換過去這條式子就不成立。

2026 的 8B 級模型,逐層長得不一樣

開場那句「32 層只掛到 8 層」是點名出來的。方法很土:AutoConfig 讀設定,再由模型類別在 meta device 上建出骨架——不配置完整的權重儲存空間,幾秒鐘——然後走一遍 named_modules()nn.Linear

https://ithelp.ithome.com.tw/upload/images/20260909/20183550zgNsw4crQA.png
圖 2:橘色那 24 層不是沒有注意力,是換了一套模組名(linear_attn.in_proj_*out_proj)。

Qwen3.5-9B 的語言塔 32 層裡,只有 8 層是 self_attn.q/k/v/o_proj,而且它們均勻散在 index 3、7、11 一路到 31(full_attention_interval: 4),不是集中在前段;其餘 24 層是 Gated DeltaNet。Ministral-3-8B-Instruct-2512 則是 34 層純 GQA,每層都有。兩顆都各自掛著一座視覺塔(27 與 24 層),而文字微調的樣本不會走進去。

把兩組 target 丟給 peft 自己解析,結果差得比想像中多。

https://ithelp.ithome.com.tw/upload/images/20260909/20183550DoiGUzBZWc.png
圖 3:0.024% 對 0.545%,是同一顆模型上兩種差了一個數量級的可訓練容量。

Ministral 那一列更值得看:["q_proj","v_proj"] 掛出 116 個模組,其中 48 個在視覺塔上。沒有影像輸入時視覺塔不進 forward,那 48 個整輪訓練都拿不到梯度——你以為掛了 116 個,會被更新的是 68 個。

所以「掛滿 all-linear」這條 2023 年的建議,2026 要補一句:掛滿之前先看它掛到哪裡。 只掛 attention 會限制可調整的位置(MLP 佔掉非字典參數的六成以上),把 MLP 納入是值得比較的設定;但在多模態基座上無腦 all-linear,又會把 adapter 灑進一座不會被觸發的塔。

那我最後怎麼掛

盤點完要有結論。列成完整路徑的白名單,不要只比模組名的最後一段——我第一版就是只比尾名再排除 visual|vision_tower,結果 model.image_encoder.….q_proj 那種路徑照樣會被掛上:

wanted = sorted(n for n, m in model.named_modules()          # 保留完整路徑
                if isinstance(m, torch.nn.Linear)
                and not re.search(r"vis|image|vision", n)
                and not n.endswith("lm_head"))
pattern = "|".join(re.escape(n) for n in wanted)             # peft 走 re.fullmatch
peft_model = get_peft_model(model, LoraConfig(
    r=16, lora_alpha=32, target_modules=pattern, task_type="CAUSAL_LM"))

Qwen3.5-9B 上跑出來是 248 個模組、43,278,336 個可訓練參數、視覺塔命中 0,實際命中與預定清單逐一相同——正好是圖 3 那 358 扣掉視覺塔的 110。

這是本文用來做比較的設定,不是最佳解——那 24 層線性注意力該不該掛我沒有證據,但至少現在掛的每一個,都是我知道自己在掛的。

labels 裡到底有什麼

資料量先給直覺:幾百條對的勝過幾萬條垃圾,LIMA 用 1,000 筆精選資料就達成不錯的對齊。真正咬人的不是量,是訓練時監督了哪些 token

把三則訊息餵進 Qwen3.5-9B 的 template,在這組範例訊息中,assistant 那一段是 11 個 token,而開頭 4 個是一組空的 <think>\n\n</think>enable_thinking=False 也拿不掉。這不自動等於做錯——要看的是訓練時的 labels 與部署時的格式對不對得起來:那些 token 由誰生成、誰解析、推論時放在哪裡。看到空的思考區塊就自己刪掉,反而會製造 train/serve 落差。

第二件事是監督範圍。本文為了把監督集中在回答上,採 assistant-only;TRL 1.12.0 的 assistant_only_loss 預設是 False,要對話資料且 template 能提供 assistant mask,而 completion_only_loss 預設是 None(prompt-completion 格式時自動生效)。把 prompt 也納入 loss 本身是一種訓練目標,已有研究在部分條件下觀察到好處,不是普遍的正誤判準。

不管選哪一種,開訓前印一次就知道自己選到了什麼:

batch = next(iter(trainer.get_train_dataloader()))
print(tokenizer.decode(batch["input_ids"][0]))    # 完整輸入長什麼樣
ids = batch["labels"][0]
print(tokenizer.decode(ids[ids != -100]))         # 真正被算 loss 的部分

第二行印出來的,要符合你這次預定的監督範圍與結束標記。只展開 template 還看不到這件事,得看 Trainer 最後餵進去的那個 batch。

loss 下降不等於學會了。 四千萬個參數擬合幾千筆資料太容易,train loss 幾乎一定會降。

與其記固定門檻,不如看相對量:第一步 loss 有沒有異常偏離同條件基座的 NLL、驗證集趨勢有沒有反轉、grad_norm 有沒有跳出這一輪自己的區間。再配一組 10–20 題的定樁題,每個 checkpoint 跑同一組直接 diff——人眼比看曲線更早發現「開始變笨」。但只要你照它調參,它就是開發集;要宣稱贏過 prompt-only,得另外留一份沒動過的測試集。

12 GiB 的預算,被兩項實作選擇改寫

全參數微調先出局:bf16 + AdamW、每個可訓練參數 16 bytes(權重 2 + 梯度 2 + fp32 master 4 + Adam 的 m 與 v 各 4),乘下去是 140.2 GiB。LoRA 把那 14 bytes 的涵蓋範圍縮到 adapter 的 0.5%,QLoRA 再把基座的 2 bytes 壓成 0.5。

剩下的兩格才是這張卡的勝負手,而只看模型參數量,算不出這兩筆帳

https://ithelp.ithome.com.tw/upload/images/20260909/20183550I8ClNCAi3t.png
圖 4:兩軸各自獨立,所以是四格不是三格;每一格都是已列項目的帳面總和。

第一軸是非量化權重的 dtype。 bnb 只換 nn.Linearembed_tokensnn.Embeddinglm_head 預設被跳過。但 peft 的 prepare_model_for_kbit_training() 會把所有非量化的 fp16/bf16 參數轉成 FP32(原始碼註解就寫著 cast all non INT8 parameters to fp32),Qwen 那本 248,320 的字典因此從 3.79 變 7.58 GiB。要轉回 BF16 得自己做。

第二軸是 loss 怎麼算 logits。 transformers 的一般 causal-LM 路徑第一行就是 logits = logits.float();batch 1、seq 2048、字典 248,320,這張張量的 BF16 加 FP32 副本是 2.84 GiB

trl 1.12.0 未啟用 Liger、未指定 loss_type走的是 chunked_nll——lm_head 只投影沒被 ignore 的 token,再分塊做 cross-entropy。所以上一節那個 mask 決定的不只是算不算 loss,還決定了多少 token 走進投影。完整 logits 那條路徑可見於一般的模型 forward,也能在 TRL 裡明設 loss_type="nll"

兩軸沒有人幫你一起選。 TRL 只決定 loss 那一軸;字典是不是 BF16 是另一軸,prepare_model_for_kbit_training() 沒有人會替你撤銷。四格排出來是 15.1/12.3/11.3/8.5,而在本表已列項目的估算裡,只看 dtype 那一軸就決定落在 12 GiB 的哪一邊——照本文前面那套流程再配 TRL 的預設 loss,落在 12.3 那一格。

⚠️ 這是帳面不是實測峰值,四格都不能拿來下「跑得完」的結論。 分塊 loss 本身也有每塊的暫存,那一格寫 0 是因為本表沒算它,不是它免費。我原本還用 1.0 GiB 帶過反量化 buffer 與碎片——那是猜的,與其讓一個猜測混在算式裡,不如把它移進「沒算」那一欄。

今天的實驗需要什麼

不用 GPU。四支腳本在 research/day29/,換成你自己的模型 id 就能重跑:

python3 research/day29/scan_lora.py Qwen/Qwen3.5-9B      # 掛下去會掛到什麼
python3 research/day29/pick_targets.py Qwen/Qwen3.5-9B   # 最後決定掛哪些
python3 research/day29/probe_template.py Qwen/Qwen3.5-9B # labels 裡有什麼
python3 research/day29/mem.py                            # 預算完不完整

https://ithelp.ithome.com.tw/upload/images/20260909/20183550kI04Z7zK1J.png
圖 5:上面是要跑的,下面是「做完了」的判準——三個都滿足,才輪到開 GPU。

需要 GPU 的只有一次 smoke run:20 筆,至少跑完一個完整的 optimizer step、餵一筆你預定的最長序列,並確定觸發過一次存檔再重新載入——這樣對 optimizer state 與 OOM 的檢查才有代表性。peak 必須由訓練那個 process 自己印,CUDA 的峰值統計只活在該 process 裡:

print(f"peak allocated: {torch.cuda.max_memory_allocated()/2**30:.2f} GiB")

它是用來驗證那張預算的,不是預先保證對得上;allocatedreservednvidia-smi 要分開記。完整實錄留到加碼篇,今天一個 tok/s、一個訓練時間都沒給。

小結

  • 知識走 RAG,行為走微調,但那是工程優先序不是能力邊界;先建 prompt 基線,格式不穩先試 schema 與受限解碼,不必每次都繞一趟檢索。
  • LoRA = 凍結 W + 旁掛 (α/r)·BAr 是每個矩陣的秩上限,α/r 是標準 LoRA 的乘數——固定 α 拉大 r 會把乘數縮小,但那不等於學不到東西。
  • 2026 的模型逐層不再長一樣。 換一組 target 在 Qwen3.5-9B 上差 23 倍;Ministral-3-8B 那組 116 個模組有 48 個落在視覺塔。掛之前先點名,掛完再驗一次視覺塔命中是 0。
  • 12 GiB 的帳被兩軸改寫:非量化權重的 dtype、loss 怎麼算 logits。四格是 15.1/12.3/11.3/8.5,在已列項目的估算裡,只看 dtype 那一軸就決定落在哪一邊。那是帳面不是峰值,開訓後要用 max_memory_allocated() 回頭對。

明天 Day 30〈大結局:45 秒到 2 秒的完整帳,以及錢花在哪一階才有感〉,把這一季砍掉的每一刀重新排成一張帳,四張地圖全部回收,然後回答最貴的那個問題——從 T1 到 T4,錢花在哪一階才真的有感

咱們明天見。

這篇的條件與來源

項目
環境 transformers 5.15.1、peft 0.18.1、trl 1.12.0、torch 2.7.0;本機 CPU,沒有 GPU 實測
兩段被引用的原始碼 非量化權重轉 FP32:peft v0.18.1utils/other.pyprepare_model_for_kbit_training,註解 cast all non INT8 parameters to fp32)/完整 logits:transformers v5.15.1loss/loss_utils.pyForCausalLMLoss 第一行 logits = logits.float()
模組盤點 meta device 建骨架、不下載權重;掛載模組數與可訓練參數由 peft 解析後點名,非手算
受測模型與 revision Qwen/Qwen3.5-9B c2022362(32 語言層、full 在 index 3/7/…/31、字典 248,320)、mistralai/Ministral-3-8B-Instruct-2512 5b26027e(34 層純 GQA、字典 131,072);皆 apache-2.0、非 gated
跨模型的那組數字 同一套四格算法換成 Ministral 是 10.5/9.0/8.5/7.0。⚠️ 那是同時換了層數、基座大小與字典的比較;同一顆模型上 FP32→BF16 省下的 3.79 GiB 才是乾淨的字典證據,所以圖 4 只畫 Qwen
template 用的範例訊息 system 你是客服/user 退貨要幾天/assistant 七個工作天。——「11 個 token、開頭 4 個是空思考區塊」是這一組的結果,不是模型的固定行為
記憶體帳的模型 Qwen 用實際會被載入的 9,409,813,744(checkpoint 是 9,653,104,368,差額是 mtp_num_hidden_layers: 1 那顆 MTP head,這版 transformers 不建它);Ministral 用官方的 Ministral-3-8B-Instruct-2512-BF16(8,918,026,240)——不帶後綴的那個 repo 是原生 FP8 checkpoint,走 bnb NF4 要另外交代轉換
記憶體帳條件 QLoRA、r=16、batch 1、seq 2048、開 gradient checkpointing、AdamW;GiB = 2^30 bytes。是預算不是實測峰值
尚未計入 反向重算的層內暫存、optimizer 的 fp32 副本以外的 workspace、反量化 buffer、CUDA context 與 allocator 碎片、資料載入與 padding 的批次變動、分塊 loss 每一塊的 logits 與暫存
activation 係數 本文四種組合都開 gradient checkpointing,只算逐層保存的輸入 2·b·s·h·L;反向重算的暫存另計,見「尚未計入」
本文的範圍 SFT:用 LoRA 限制可訓練參數、QLoRA 壓低基座儲存成本。DPO、GRPO 是不同的訓練目標,同樣可以搭 LoRA,本文不涉及
引用 intrinsic dimensionality(經驗觀察不是定理)/16 bytes 的拆法LIMA 的 1,000 筆把 prompt 納入 loss 也是一種訓練目標
TRL 的兩個 loss 開關 assistant_only_loss 預設 False,要對話資料且 template 提供 assistant mask;completion_only_loss 預設 None,prompt-completion 格式時自動生效。版本會變,開訓前印一次 labels 最實在
四個情境(已列項目的帳面總和) 字典 FP32:完整 logits 15.1/分塊 loss 12.3+未估暫存。字典 BF16:完整 logits 11.3/分塊 loss 8.5+未估暫存。兩軸各自獨立:TRL 的預設 loss 路徑採分塊計算(sft_config.py__post_init__"nll" if use_liger_kernel else "chunked_nll"),字典是否為 BF16 仍須另外確認——那要靠 QLoRA 原始腳本那一步轉回,TRL 不會替你做
兩個順手發現 Qwen3.5-9Bbos_tokenNone、pad `<
一個沒追下去的 載入 Ministral 的 tokenizer 時 transformers 會印警告要 fix_mistral_regex=True沒處理不代表訓練與服務必然切法不同——要確認得固定 tokenizer revision,再比較同一句的 token IDs

上一篇
Day 28 - RAG 明明撈到答案,小模型為什麼還會抓錯數字?
下一篇
Day 30 - 大結局:45 秒到 2 秒的完整帳,以及錢花在哪一階才有感
系列文
不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言