
我照常見教學裡的設定,準備在 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,行為走微調」當口訣很好用,但它是工程上的優先選擇,不是能力邊界。微調也能學知識,只是不保證精確記憶、不好更新,本身也不提供可靠的來源追溯。
所以最高頻的反模式仍是用微調把公司文件灌進權重:文件一改就得重訓,答不出「這句出自哪一段」,學到的容易是風格不是事實。

圖 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,換過去這條式子就不成立。
開場那句「32 層只掛到 8 層」是點名出來的。方法很土:AutoConfig 讀設定,再由模型類別在 meta device 上建出骨架——不配置完整的權重儲存空間,幾秒鐘——然後走一遍 named_modules() 數 nn.Linear。

圖 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 自己解析,結果差得比想像中多。

圖 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 層線性注意力該不該掛我沒有證據,但至少現在掛的每一個,都是我知道自己在掛的。
資料量先給直覺:幾百條對的勝過幾萬條垃圾,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,得另外留一份沒動過的測試集。
全參數微調先出局: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。
剩下的兩格才是這張卡的勝負手,而只看模型參數量,算不出這兩筆帳。

圖 4:兩軸各自獨立,所以是四格不是三格;每一格都是已列項目的帳面總和。
第一軸是非量化權重的 dtype。 bnb 只換 nn.Linear,embed_tokens 是 nn.Embedding、lm_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 # 預算完不完整

圖 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")
它是用來驗證那張預算的,不是預先保證對得上;allocated、reserved 與 nvidia-smi 要分開記。完整實錄留到加碼篇,今天一個 tok/s、一個訓練時間都沒給。
(α/r)·BA。r 是每個矩陣的秩上限,α/r 是標準 LoRA 的乘數——固定 α 拉大 r 會把乘數縮小,但那不等於學不到東西。Qwen3.5-9B 上差 23 倍;Ministral-3-8B 那組 116 個模組有 48 個落在視覺塔。掛之前先點名,掛完再驗一次視覺塔命中是 0。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.1 的 utils/other.py(prepare_model_for_kbit_training,註解 cast all non INT8 parameters to fp32)/完整 logits:transformers v5.15.1 的 loss/loss_utils.py(ForCausalLMLoss 第一行 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-9B 的 bos_token 是 None、pad `< |
| 一個沒追下去的 | 載入 Ministral 的 tokenizer 時 transformers 會印警告要 fix_mistral_regex=True。沒處理不代表訓練與服務必然切法不同——要確認得固定 tokenizer revision,再比較同一句的 token IDs |