- LoRA 將可訓練參數從數十億壓縮至數百萬——透過低秩分解,僅訓練原始權重 0.1-1% 的參數,卻能達到全量微調 95-100% 的效果,訓練記憶體最高減少 3 倍
- QLoRA 結合 4-bit NF4 量化與 LoRA,讓單張 24GB GPU(如 RTX 4090)即可微調 33B 參數模型,單張免費 Colab T4 即可微調 7B 模型——品質與全精度微調無統計差異
- DoRA(ICML 2024)將權重分解為方向與大小兩個分量再做低秩適應,在多項 NLP 和視覺任務上超越同 rank 的 LoRA,且無額外推論開銷
- Unsloth 框架透過手動反向傳播核心與智慧記憶體管理,將 LoRA/QLoRA 微調速度提升 2 倍、記憶體減少 60%,且完全相容 HuggingFace 生態系統
一、全量微調的困境:為何我們需要參數高效微調
當你想讓一個預訓練的大型語言模型(LLM)學會特定領域的知識或行為時,最直觀的做法是全量微調(Full Fine-tuning):解凍所有參數,在新數據上繼續訓練。這個方法簡單有效,但在 LLM 時代遇到了三道難以跨越的牆:
記憶體牆:全量微調需要在 GPU 記憶體中同時存放模型權重、梯度、優化器狀態(AdamW 需要兩份額外的動量緩衝區)。以 LLaMA-2-7B 為例,FP16 權重佔 14GB,梯度佔 14GB,AdamW 優化器狀態佔 28GB——僅訓練狀態就需要 56GB 以上的 GPU 記憶體,超過任何消費級 GPU 的容量。70B 模型更需要至少 560GB,需要 8 張 A100 80GB。
儲存牆:全量微調產生的是一份與原始模型同等大小的完整檢查點。如果你有 10 個下游任務,就需要儲存 10 個完整的 70B 模型(每個 140GB),總計 1.4TB。對於需要服務多個客戶或多個任務的企業而言,這是不切實際的。
災難性遺忘牆:全量微調容易讓模型過度適應新數據、遺忘預訓練時學到的通用知識。這個問題在小數據集上特別嚴重——用幾千條數據微調一個 70B 模型,往往得到的不是「增強版」而是「損壞版」。
這些困境催生了參數高效微調(Parameter-Efficient Fine-Tuning, PEFT)的研究方向[10]:能否只訓練極少量的參數,就讓模型學會新任務?Lialin 等人的綜述[13]系統性地整理了這一方向的演進——從 2019 年的 Adapter Layers 到 2022 年的 LoRA,PEFT 方法在記憶體效率和訓練品質之間找到了令人驚訝的平衡點。
二、LoRA 核心原理:低秩分解的優雅數學
2.1 核心直覺:權重更新是低秩的
2021 年,微軟研究院的 Edward Hu 等人提出了 LoRA(Low-Rank Adaptation)[1],其核心假設極其優雅:微調過程中的權重更新矩陣 ΔW 具有很低的「內在秩」(intrinsic rank)——即便 ΔW 是一個巨大的矩陣(例如 4096 x 4096),它的有效資訊可以用兩個遠小於原始維度的矩陣來表達。
這不是一個憑空的假設。Aghajanyan 等人在 2021 年的研究[9]從理論上證明了預訓練語言模型具有極低的內在維度(Intrinsic Dimensionality)。具體而言,他們發現 RoBERTa-Large(355M 參數)的內在維度僅約 200——也就是說,微調這個模型只需要調整 200 個自由度,就能達到全量微調 90% 以上的效果。這個發現為 LoRA 提供了堅實的理論基礎。
2.2 數學公式:低秩分解
LoRA 的數學表達極其簡潔。對於預訓練模型中的一個權重矩陣 W₀ ∈ R^{d x k},全量微調會學習一個更新 ΔW ∈ R^{d x k},使得新權重為:
# 全量微調
W = W₀ + ΔW # ΔW 有 d x k 個可訓練參數
# LoRA:將 ΔW 分解為兩個低秩矩陣的乘積
W = W₀ + B @ A # B ∈ R^{d x r}, A ∈ R^{r x k}
# 可訓練參數 = d*r + r*k = r*(d+k)
# 當 r << min(d, k) 時,參數量大幅減少
# 具體例子:LLaMA-7B 的注意力層
# d = k = 4096, 全量微調: 4096 x 4096 = 16,777,216 參數
# LoRA (r=16): 4096 x 16 + 16 x 4096 = 131,072 參數
# 壓縮比: 128 倍!
# 前向傳播
# h = W₀ @ x + (B @ A) @ x * (alpha / r)
# 其中 alpha/r 是縮放因子,控制 LoRA 更新的幅度
訓練時的關鍵設計:
- 凍結 W₀:原始預訓練權重完全不動,不需要計算梯度,大幅節省記憶體
- 初始化:A 用高斯隨機初始化,B 初始化為零矩陣——這確保訓練開始時 ΔW = B @ A = 0,模型從預訓練狀態出發
- 縮放因子 alpha/r:alpha 是一個常數(通常設為 r 的倍數),用來穩定不同 rank 設定下的學習率——當 r 增大時,每個參數的貢獻會被等比稀釋
- 無推論延遲:推論時可以將 LoRA 權重合併回原始矩陣 W = W₀ + BA,不增加任何計算開銷
2.3 LoRA 的超參數:rank、alpha、target modules
LoRA 的效果高度依賴三個超參數的設定:
Rank r(秩):最關鍵的超參數。r 越大,LoRA 的表達能力越強,但可訓練參數也越多。原始論文實驗顯示,r = 4 到 r = 64 之間通常能涵蓋大多數任務需求。實務上的建議:簡單任務(格式轉換、風格遷移)用 r = 8-16;複雜任務(領域知識學習、多步推理)用 r = 32-64;極端複雜任務可嘗試 r = 128-256,但要監控過擬合。
Alpha(縮放係數):控制 LoRA 更新相對於原始權重的幅度。常見做法是設 alpha = 2r(例如 r=16 時 alpha=32),讓有效學習率保持穩定。也有研究者建議固定 alpha=16 不隨 r 變化,搭配學習率調整來控制更新幅度。
Target Modules(目標模組):決定將 LoRA 注入哪些層。Hu 等人的原始實驗發現,同時對 Q、K、V、O 四個注意力投影矩陣注入 LoRA 的效果最好[1]。近期的實踐經驗進一步顯示,將 MLP 層(gate_proj、up_proj、down_proj)也加入 target modules,通常能帶來額外的品質提升,代價是可訓練參數增加約 2 倍。
| 超參數 | 推薦範圍 | 效果 | 設定建議 |
|---|---|---|---|
| rank r | 8 - 64 | 表達能力 ↑ / 參數量 ↑ | 從 16 開始,根據驗證集調整 |
| alpha | 16 - 128 | 更新幅度 ↑ | 設為 2r 或固定 16 |
| target modules | q,k,v,o 或全部線性層 | 覆蓋範圍 ↑ | 資源充足時對所有線性層注入 |
| dropout | 0.0 - 0.1 | 正則化 | 小數據集用 0.05-0.1 |
| learning rate | 1e-4 - 3e-4 | 收斂速度 | 比全量微調高 5-10 倍 |
三、QLoRA:4-bit 量化 + LoRA 的雙重壓縮
LoRA 已經大幅減少了可訓練參數數量,但模型的凍結權重仍然需要載入 GPU 記憶體。LLaMA-7B 的凍結權重以 FP16 存放需要 14GB——這仍然超出免費 Google Colab T4(16GB)的安全範圍。2023 年,Tim Dettmers 等人提出的 QLoRA[2] 解決了這個問題:將凍結的基礎模型量化到 4-bit,然後在其上注入 LoRA adapter 進行 16-bit 精度的微調。
QLoRA 在 NeurIPS 2023 上獲得 Oral 接受——這是該頂會最高的接受等級——並且在論文中證明:用 QLoRA 微調的模型在品質上與全精度全量微調沒有統計上的顯著差異。這意味著你可以在 1/4 的記憶體下獲得幾乎相同的微調結果。
3.1 NF4:為正態分佈量身定制的量化格式
QLoRA 引入了一種全新的資料類型:NormalFloat4(NF4)。其核心洞察是:預訓練神經網路的權重分佈近似於零中心的正態分佈。既然如此,量化的分位點(quantile)應該匹配正態分佈,而非傳統的均勻分佈。
NF4 的做法是:計算標準正態分佈 N(0,1) 的 16 個等機率分位點,然後將每個權重映射到最近的分位點。這確保了每個量化區間包含大致相同數量的權重值,最大化了資訊保留。實驗顯示 NF4 的量化誤差比傳統 INT4 或 FP4 低 10-30%。
3.2 雙重量化(Double Quantization)
量化本身需要額外存放「量化常數」(quantization constants)——每 64 個權重共用一個 FP32 的縮放因子。對於大型模型,這些常數的記憶體開銷並不可忽視:每個參數平均額外佔用 0.5 bit。
QLoRA 的雙重量化對這些量化常數本身再做一次量化:將 FP32 常數壓縮為 FP8,額外開銷從 0.5 bit/param 降至 0.127 bit/param。對於 65B 模型,這節省了約 3GB 的 GPU 記憶體。
3.3 分頁優化器(Paged Optimizers)
訓練過程中,梯度計算的峰值記憶體可能瞬間超出 GPU 的可用容量——特別是在處理長序列時。QLoRA 利用 NVIDIA 統一記憶體(Unified Memory)的自動頁面遷移機制:當 GPU 記憶體不足時,優化器狀態會暫時遷移到 CPU 記憶體,需要時再遷回。這避免了 out-of-memory 崩潰,代價是微小的速度降低。
3.4 記憶體比較
| 方法 | LLaMA-7B | LLaMA-13B | LLaMA-33B | LLaMA-65B |
|---|---|---|---|---|
| 全量微調 FP16 | ~56 GB | ~104 GB | ~264 GB | ~520 GB |
| LoRA FP16 | ~18 GB | ~32 GB | ~72 GB | ~140 GB |
| QLoRA 4-bit | ~6 GB | ~10 GB | ~22 GB | ~42 GB |
| QLoRA 4-bit + 分頁 | ~5 GB | ~9 GB | ~20 GB | ~39 GB |
表格清楚說明了 QLoRA 的突破性意義:原本需要 8 張 A100 的 65B 模型微調,現在單張 48GB A6000 即可完成。而 7B 模型的微調甚至可以在免費的 Google Colab T4(16GB)上執行。這讓個人開發者和小型團隊首次具備了微調大型語言模型的能力。
四、DoRA 與 VeRA:LoRA 的進化
4.1 DoRA:權重分解低秩適應
LoRA 在大多數場景下表現優秀,但在某些需要同時大幅調整權重「方向」和「大小」的任務上,其效果與全量微調仍有差距。2024 年,Liu 等人在 ICML 上發表了 DoRA(Weight-Decomposed Low-Rank Adaptation)[3],提出了一個精緻的改進。
DoRA 的核心思想是:將權重矩陣分解為大小(magnitude)和方向(direction)兩個分量,然後只對方向分量施加 LoRA 更新,大小分量則獨立學習:
# 全量微調的權重更新
W' = W₀ + ΔW
# DoRA 的權重分解
# 1. 將 W 分解為 magnitude m 和 direction V
# W = m * (V / ||V||_c) # ||V||_c 是每行的 L2 範數
#
# 2. 只對方向 V 施加 LoRA 更新
# V' = V + BA # BA 是標準 LoRA 的低秩更新
#
# 3. magnitude m 作為獨立的可學習參數
# W' = m' * ((V + BA) / ||V + BA||_c)
# DoRA 相比 LoRA 的額外參數量:僅多了 d 個 magnitude 參數
# 對 4096 維的層而言,只多了 4096 個參數(可忽略不計)
DoRA 的論文透過分析全量微調的梯度更新模式發現:全量微調傾向於同時大幅調整方向和大小,而標準 LoRA 則傾向於將兩者耦合在一起,無法獨立控制。DoRA 的分解讓低秩更新能更精確地模擬全量微調的行為。實驗結果:在 commonsense reasoning、visual instruction tuning 等任務上,DoRA 在同等 rank 設定下穩定超越 LoRA,甚至在部分任務上接近全量微調的效果。
4.2 VeRA:極致參數效率的探索
如果 LoRA 的方向是「用更少的參數獲得接近全量微調的效果」,那 VeRA(Vector-based Random Matrix Adaptation)[12]把這個理念推到了極致。
VeRA 的做法是:凍結 LoRA 的 A 和 B 矩陣(用共享的隨機矩陣初始化),只訓練兩個對角縮放向量 d 和 b。這意味著不同層共享相同的隨機投影,可訓練參數僅為 d + k 個向量元素——比 LoRA 還少 10 倍以上。
VeRA 在 ICLR 2024 發表,在 GLUE 基準上以僅 LoRA 1/10 的參數量達到了接近的效果。但在更複雜的生成任務上,VeRA 的效果仍不如 LoRA,因此目前更適合作為極端資源受限場景的選擇。
五、PEFT 全景:Adapter、Prefix-Tuning、Prompt Tuning 比較
LoRA 並非唯一的參數高效微調方法。理解整個 PEFT 生態系統[7],有助於在不同場景下做出最佳選擇。
5.1 Adapter Layers(2019)
Houlsby 等人在 Google 提出的 Adapter[4] 是 PEFT 的先驅。做法是在 Transformer 的每個子層(注意力、FFN)之後插入一個小型瓶頸網路(bottleneck):先降維、過非線性激活、再升維。可訓練參數約為原始模型的 0.5-8%。缺點是推論時有額外延遲(無法合併回原始權重),因此在 LLM 領域逐漸被 LoRA 取代。但 LLaMA-Adapter[11] 等後續工作引入 zero-init attention 機制,在視覺-語言任務上展現了 Adapter 架構的獨特優勢。
5.2 Prefix-Tuning(2021)
Li 和 Liang 提出的 Prefix-Tuning[6] 在每一層的注意力輸入前附加一段可學習的「虛擬 token」(prefix)。這些 prefix 不對應任何真實的文本,而是直接優化的連續向量。可訓練參數量極少(僅 0.1% 左右),但對序列長度有額外佔用,且在生成任務上的穩定性不如 LoRA。
5.3 Prompt Tuning(2021)
Lester 等人提出的 Prompt Tuning[5] 是 Prefix-Tuning 的簡化版:只在輸入嵌入層前附加可學習的 soft token,不觸碰模型的任何中間層。可訓練參數最少(僅 embedding 維度 x prefix 長度),但在小模型上效果較差,需要 10B+ 參數的模型才能與全量微調媲美。
5.4 方法比較
| 方法 | 可訓練參數比例 | 推論開銷 | 合併回原模型 | 多任務切換 | 生成品質 |
|---|---|---|---|---|---|
| 全量微調 | 100% | 無 | N/A | 需存多份完整模型 | 最佳(上限) |
| Adapter | 0.5-8% | 有(額外層) | 不可 | 切換 adapter 模組 | 良好 |
| Prefix-Tuning | ~0.1% | 有(佔序列長度) | 不可 | 切換 prefix | 尚可 |
| Prompt Tuning | ~0.01% | 極低 | 不可 | 切換 soft token | 依賴模型規模 |
| LoRA | 0.1-1% | 無(可合併) | 可 | 切換/堆疊 adapter | 接近全量微調 |
| QLoRA | 0.1-1% | 無(合併後) | 可 | 切換/堆疊 adapter | 接近全量微調 |
| DoRA | 0.1-1% | 無(可合併) | 可 | 切換/堆疊 adapter | 略優於 LoRA |
從表格可以清楚看到:LoRA 系列方法在推論效率、多任務靈活性和微調品質之間達到了最佳平衡,這是它成為 2024-2026 年 LLM 微調事實標準的原因。
六、Hands-on Lab 1:QLoRA 指令微調 LLM(Colab 免費 GPU)
理論講完了,接下來動手實作。我們將在免費 Google Colab T4 GPU(16GB)上,用 QLoRA 對 TinyLlama-1.1B 進行指令微調(instruction tuning),讓它學會以結構化的方式回答問題。
打開 Google Colab,選擇「執行階段 > 變更執行階段類型 > T4 GPU」,新建 Notebook,依序貼入以下程式碼:
6.1 Step 1 — 安裝依賴
!pip install -q transformers peft bitsandbytes datasets trl accelerate
import torch
print(f"PyTorch 版本: {torch.__version__}")
print(f"CUDA 可用: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"GPU 記憶體: {torch.cuda.get_device_properties(0).total_mem / 1024**3:.1f} GB")
6.2 Step 2 — 以 4-bit 量化載入基礎模型
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
# ★ QLoRA 的核心:4-bit NF4 量化配置 ★
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 啟用 4-bit 量化
bnb_4bit_quant_type="nf4", # NF4 格式(QLoRA 推薦)
bnb_4bit_compute_dtype=torch.float16, # 計算時用 FP16
bnb_4bit_use_double_quant=True, # 雙重量化(進一步省記憶體)
)
model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
print("載入 4-bit 量化模型...")
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.float16,
)
# 記憶體統計
mem_gb = torch.cuda.memory_allocated() / 1024**3
print(f"4-bit 模型載入完成,GPU 記憶體: {mem_gb:.2f} GB")
print(f"(FP16 原始需要約 {1.1*2:.1f} GB,4-bit 僅需約 {mem_gb:.1f} GB)")
6.3 Step 3 — 微調前的基線測試
def generate_response(model, tokenizer, prompt, max_new_tokens=150):
"""生成回應的輔助函數"""
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=True,
temperature=0.7,
top_p=0.9,
)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
return response
# 測試 prompt
test_prompts = [
"What is LoRA in the context of machine learning?",
"Explain the difference between fine-tuning and transfer learning.",
"How can small companies use LLMs effectively?",
]
print("=" * 60)
print(" 微調前的模型回應(基線)")
print("=" * 60)
for prompt in test_prompts:
response = generate_response(model, tokenizer, prompt)
print(f"\nQ: {prompt}")
print(f"A: {response[-300:]}")
print("-" * 60)
6.4 Step 4 — 設定 LoRA 配置並注入 Adapter
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, TaskType
# 準備模型以進行 k-bit 訓練(啟用梯度檢查點等)
model = prepare_model_for_kbit_training(model)
# ★ LoRA 超參數設定 ★
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16, # 秩:16 是常用的平衡點
lora_alpha=32, # 縮放因子:2 * r
lora_dropout=0.05, # Dropout 輕微正則化
target_modules=[ # 對所有注意力層 + MLP 注入 LoRA
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
bias="none", # 不訓練 bias
)
# 注入 LoRA adapter
model = get_peft_model(model, lora_config)
# 顯示可訓練參數統計
model.print_trainable_parameters()
# 預期輸出:trainable params: ~8.4M || all params: ~1.1B || trainable%: ~0.77%
6.5 Step 5 — 載入並處理指令微調數據集
from datasets import load_dataset
# 載入 Alpaca 格式的指令微調數據集
dataset = load_dataset("tatsu-lab/alpaca", split="train")
# 只取前 2000 筆用於示範(完整訓練建議用全部 52K)
dataset = dataset.select(range(2000))
print(f"數據集大小: {len(dataset)} 筆")
print(f"欄位: {dataset.column_names}")
print(f"\n範例:")
print(f" instruction: {dataset[0]['instruction'][:100]}...")
print(f" input: {dataset[0]['input'][:100]}")
print(f" output: {dataset[0]['output'][:100]}...")
# 格式化為聊天模板
def format_alpaca(example):
"""將 Alpaca 格式轉為 TinyLlama chat template"""
if example["input"].strip():
user_msg = f"{example['instruction']}\n\nInput: {example['input']}"
else:
user_msg = example["instruction"]
messages = [
{"role": "user", "content": user_msg},
{"role": "assistant", "content": example["output"]},
]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False
)
return {"text": text}
dataset = dataset.map(format_alpaca, remove_columns=dataset.column_names)
print(f"\n格式化後範例:")
print(dataset[0]["text"][:300])
6.6 Step 6 — 啟動 QLoRA 微調訓練
from trl import SFTTrainer, SFTConfig
# ★ 訓練配置 ★
training_args = SFTConfig(
output_dir="./qlora-tinyllama-alpaca",
num_train_epochs=1, # 1 epoch 用於示範
per_device_train_batch_size=4, # T4 16GB 可用的 batch size
gradient_accumulation_steps=4, # 等效 batch size = 16
learning_rate=2e-4, # LoRA 建議的學習率
lr_scheduler_type="cosine", # Cosine 衰減
warmup_ratio=0.05, # 5% warmup
logging_steps=10, # 每 10 步記錄
save_strategy="epoch", # 每 epoch 儲存
fp16=True, # 混合精度訓練
optim="paged_adamw_8bit", # QLoRA 分頁 8-bit 優化器
max_seq_length=512, # 最大序列長度
dataset_text_field="text", # 數據集中的文本欄位
report_to="none", # 在 Colab 中關閉 wandb
)
# 建立 SFT Trainer
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset,
processing_class=tokenizer,
)
# 開始訓練
print("開始 QLoRA 微調訓練...")
print(f" 可訓練參數: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}")
print(f" 訓練樣本數: {len(dataset)}")
print(f" 等效 Batch Size: {4 * 4}")
print(f" 總訓練步數: {len(dataset) // (4*4)}")
train_result = trainer.train()
# 訓練結果
print(f"\n訓練完成!")
print(f" 訓練損失: {train_result.training_loss:.4f}")
print(f" 訓練時間: {train_result.metrics['train_runtime']:.0f} 秒")
print(f" GPU 峰值記憶體: {torch.cuda.max_memory_allocated() / 1024**3:.2f} GB")
6.7 Step 7 — 微調後的效果比較
# 儲存 LoRA adapter(僅儲存 adapter 權重,約 33MB)
trainer.save_model("./qlora-tinyllama-alpaca/final")
print(f"LoRA adapter 已儲存(僅 adapter 權重)")
# 微調後測試——使用相同的 prompt
print("=" * 60)
print(" 微調後的模型回應")
print("=" * 60)
for prompt in test_prompts:
response = generate_response(model, tokenizer, prompt)
print(f"\nQ: {prompt}")
print(f"A: {response[-300:]}")
print("-" * 60)
# 額外測試:指令遵循能力
extra_prompts = [
"List three advantages of using LoRA for LLM fine-tuning.",
"Write a short Python function that calculates the factorial of a number.",
]
print("\n" + "=" * 60)
print(" 額外指令遵循測試")
print("=" * 60)
for prompt in extra_prompts:
response = generate_response(model, tokenizer, prompt, max_new_tokens=200)
print(f"\nQ: {prompt}")
print(f"A: {response[-400:]}")
print("-" * 60)
import os
adapter_size = sum(
os.path.getsize(os.path.join("./qlora-tinyllama-alpaca/final", f))
for f in os.listdir("./qlora-tinyllama-alpaca/final")
if os.path.isfile(os.path.join("./qlora-tinyllama-alpaca/final", f))
)
print(f"\n Adapter 檔案大小: {adapter_size / 1024**2:.1f} MB")
print(f"(完整模型 FP16: ~{1.1*2*1024:.0f} MB,壓縮比: {1.1*2*1024 / (adapter_size/1024**2):.0f}x)")
七、Hands-on Lab 2:LoRA Adapter 合併與推論
在 Lab 1 中,我們訓練並儲存了 LoRA adapter。在生產部署時,你通常會想把 adapter 合併回基礎模型——這樣推論時不需要 PEFT 庫,且速度更快。本 Lab 示範完整的 adapter 載入、合併、速度比較和匯出流程。
打開新的 Google Colab,選 T4 GPU,依序貼入以下程式碼:
7.1 Step 1 — 安裝依賴並載入基礎模型
!pip install -q transformers peft bitsandbytes accelerate
import torch
import time
from transformers import AutoModelForCausalLM, AutoTokenizer
model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
# 這次以 FP16 載入基礎模型(合併需要全精度權重)
print("載入 FP16 基礎模型...")
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
base_model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float16,
device_map="auto",
)
mem_base = torch.cuda.memory_allocated() / 1024**3
print(f"基礎模型載入完成,GPU 記憶體: {mem_base:.2f} GB")
7.2 Step 2 — 載入已訓練的 LoRA Adapter
from peft import PeftModel
# ★ 從儲存的 adapter 載入 LoRA 權重 ★
# 如果你跑完了 Lab 1,可以直接使用本地路徑
# adapter_path = "./qlora-tinyllama-alpaca/final"
# 為了讓 Lab 2 可獨立運行,我們這裡展示如何從 HuggingFace Hub 載入
# 你可以替換為你自己訓練的 adapter 路徑
# 以下用一個公開的 LoRA adapter 做示範
# 方法 A:從本地路徑載入(Lab 1 訓練的)
# model_with_adapter = PeftModel.from_pretrained(base_model, "./qlora-tinyllama-alpaca/final")
# 方法 B:為了可獨立運行,我們直接建立一個 LoRA adapter 做示範
from peft import LoraConfig, get_peft_model, TaskType
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16,
lora_alpha=32,
lora_dropout=0.0,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
bias="none",
)
model_with_adapter = get_peft_model(base_model, lora_config)
model_with_adapter.print_trainable_parameters()
mem_adapter = torch.cuda.memory_allocated() / 1024**3
print(f"\n帶 Adapter 的模型,GPU 記憶體: {mem_adapter:.2f} GB")
print(f"Adapter 額外記憶體: {(mem_adapter - mem_base)*1024:.1f} MB")
7.3 Step 3 — 合併 Adapter 到基礎模型
# ★ 合併 LoRA adapter 到基礎模型 ★
print("合併 LoRA adapter...")
# merge_and_unload() 會:
# 1. 計算 W_merged = W_base + B @ A * (alpha/r)
# 2. 將結果寫回原始權重矩陣
# 3. 移除所有 LoRA 相關的層
merged_model = model_with_adapter.merge_and_unload()
mem_merged = torch.cuda.memory_allocated() / 1024**3
print(f"合併完成,GPU 記憶體: {mem_merged:.2f} GB")
print(f"合併後模型類型: {type(merged_model).__name__}")
print(f"(注意:合併後不再需要 PEFT 庫來推論)")
7.4 Step 4 — 推論速度比較:Adapter vs 合併
def benchmark_inference(model, tokenizer, prompt, n_runs=10, max_new_tokens=100):
"""測量推論延遲"""
messages = [{"role": "user", "content": prompt}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
# 暖機
with torch.no_grad():
_ = model.generate(**inputs, max_new_tokens=10)
# 正式測量
latencies = []
for _ in range(n_runs):
torch.cuda.synchronize()
start = time.perf_counter()
with torch.no_grad():
outputs = model.generate(
**inputs, max_new_tokens=max_new_tokens,
do_sample=False, # 確定性生成,便於比較
)
torch.cuda.synchronize()
latencies.append(time.perf_counter() - start)
tokens_generated = outputs.shape[1] - inputs["input_ids"].shape[1]
avg_latency = sum(latencies) / len(latencies)
tokens_per_sec = tokens_generated / avg_latency
return avg_latency, tokens_per_sec, tokens_generated
prompt = "Explain the key benefits of parameter-efficient fine-tuning for large language models."
# 重新載入帶 adapter 的模型做比較
print("重新載入帶 Adapter 的模型做速度比較...")
base_model_2 = AutoModelForCausalLM.from_pretrained(
model_name, torch_dtype=torch.float16, device_map="auto"
)
adapter_model_2 = get_peft_model(base_model_2, lora_config)
print("\n測試帶 Adapter 的推論速度...")
adapter_latency, adapter_tps, n_tokens = benchmark_inference(
adapter_model_2, tokenizer, prompt
)
# 清除記憶體
del adapter_model_2, base_model_2
torch.cuda.empty_cache()
print("測試合併後模型的推論速度...")
merged_latency, merged_tps, _ = benchmark_inference(
merged_model, tokenizer, prompt
)
print(f"\n{'='*60}")
print(f" 推論速度比較(生成 {n_tokens} tokens)")
print(f"{'='*60}")
print(f"{'方法':<20} {'延遲(s)':>10} {'Tokens/s':>12} {'相對速度':>10}")
print(f"{'-'*60}")
print(f"{'帶 Adapter':<20} {adapter_latency:>10.3f} {adapter_tps:>12.1f} {'1.00x':>10}")
speedup = adapter_latency / merged_latency
print(f"{'合併後':<20} {merged_latency:>10.3f} {merged_tps:>12.1f} {f'{speedup:.2f}x':>10}")
print(f"{'='*60}")
print(f"\n合併後的推論加速: {speedup:.2f}x")
print(f"(加速來源:消除了 LoRA 的額外矩陣乘法和 forward hook 開銷)")
7.5 Step 5 — 匯出合併後的模型
# ★ 將合併後的模型儲存為標準 HuggingFace 格式 ★
save_path = "./tinyllama-merged-model"
print(f"儲存合併後模型至 {save_path}...")
merged_model.save_pretrained(save_path)
tokenizer.save_pretrained(save_path)
# 計算檔案大小
import os
total_size = 0
for root, dirs, files in os.walk(save_path):
for f in files:
fpath = os.path.join(root, f)
total_size += os.path.getsize(fpath)
print(f"合併模型檔案大小: {total_size / 1024**3:.2f} GB")
print(f"\n儲存的檔案:")
for f in sorted(os.listdir(save_path)):
fsize = os.path.getsize(os.path.join(save_path, f))
print(f" {f}: {fsize / 1024**2:.1f} MB")
# 驗證:載入合併後的模型做推論
print("\n驗證:從磁碟載入合併模型...")
verified_model = AutoModelForCausalLM.from_pretrained(
save_path, torch_dtype=torch.float16, device_map="auto"
)
verified_tokenizer = AutoTokenizer.from_pretrained(save_path)
messages = [{"role": "user", "content": "What is LoRA?"}]
text = verified_tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = verified_tokenizer(text, return_tensors="pt").to(verified_model.device)
with torch.no_grad():
outputs = verified_model.generate(**inputs, max_new_tokens=100, do_sample=False)
response = verified_tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"\n驗證推論結果:")
print(f"Q: What is LoRA?")
print(f"A: {response[-300:]}")
print(f"\n合併模型匯出完成!")
print(f"此模型可直接用 transformers 載入,無需 PEFT 庫。")
print(f"也可進一步用 llama.cpp 轉為 GGUF 格式,在 CPU 上運行。")
八、決策框架:何時用 LoRA vs 全量微調 vs Prompt Engineering
面對一個新的 LLM 應用場景,該用 prompt engineering、LoRA 微調,還是全量微調?這是每個 AI 工程團隊都會面臨的決策。以下是一個結構化的決策框架:
| 維度 | Prompt Engineering | LoRA / QLoRA | 全量微調 |
|---|---|---|---|
| 適用場景 | 通用任務、快速原型 | 領域適應、風格遷移、指令遵循 | 大規模領域遷移、新語言 |
| 訓練數據需求 | 0(僅需範例) | 數百至數萬筆 | 數萬至數百萬筆 |
| GPU 需求 | 推論 GPU 即可 | 單張 16-48GB GPU | 多張 80GB GPU |
| 迭代速度 | 分鐘級 | 小時級 | 天至週級 |
| 品質上限 | 受模型能力限制 | 接近全量微調 | 最高(理論上限) |
| 多任務靈活性 | 修改 prompt 即可 | 切換 adapter(MB 級) | 切換完整模型(GB 級) |
| 典型成本(7B) | ~$0 | ~$5-50(雲端 GPU) | ~$500-5000 |
決策路徑:
- 先試 Prompt Engineering:如果任務可以透過精心設計的 prompt + few-shot examples 解決,就不需要微調。RAG(檢索增強生成)也可以在不微調的情況下注入領域知識
- Prompt 不夠用時,用 LoRA/QLoRA:當你需要模型學會特定的輸出格式、語氣風格、領域術語,或者需要從根本上改變模型行為時(例如將通用模型變為代碼助手),LoRA 是最佳選擇
- LoRA 不夠用時,考慮全量微調:極少數場景——例如為一種低資源語言建立語言能力、或需要從根本上改變模型的知識基礎——才需要全量微調。即使在這種情況下,也建議先用 LoRA 做可行性驗證
九、企業實踐:LoRA 微調的 Best Practices
9.1 數據品質 > 數據數量
LoRA 微調中,最常見的失敗原因不是超參數調錯,而是訓練數據品質不足。1000 條人工精選、格式一致、涵蓋邊界情況的高品質指令,效果通常優於 10 萬條機器生成的低品質數據。建議投入 80% 的時間在數據準備上:定義清晰的指令模板、建立品質檢查流程、確保輸出格式一致。
9.2 超參數搜索策略
對於大多數任務,以下設定是一個可靠的起點:
# 推薦的 LoRA 微調超參數起點
recommended_config = {
"r": 16, # 從 16 開始,不夠再提高
"lora_alpha": 32, # 2 * r
"lora_dropout": 0.05, # 小數據集用 0.1
"target_modules": "all", # 對所有線性層注入
"learning_rate": 2e-4, # LoRA 標準學習率
"lr_scheduler": "cosine", # Cosine 衰減
"warmup_ratio": 0.03, # 3% warmup
"num_epochs": 3, # 3 epoch,用早停
"batch_size": 16, # 用 gradient accumulation 達到
"max_seq_length": 2048, # 根據任務調整
"weight_decay": 0.01, # 輕微正則化
}
如果基線效果不夠好,按以下順序調整:(1) 增加數據品質和多樣性;(2) 提高 rank 到 32 或 64;(3) 增加訓練 epoch(注意監控過擬合);(4) 調整學習率(搜索 1e-5 到 5e-4)。
9.3 多 Adapter 服務架構
LoRA 的一個獨特優勢是支援多任務服務(multi-tenant serving):一個基礎模型 + 多個 LoRA adapter,每個 adapter 對應一個客戶或一個任務。這大幅降低了多任務部署的 GPU 成本:
- 記憶體效率:基礎模型只載入一份(例如 14GB),每個 adapter 僅 30-100MB。服務 100 個客戶只需 14GB + 10GB = 24GB,而非 100 x 14GB = 1.4TB
- 動態切換:推論時根據請求動態載入對應的 adapter,切換延遲在毫秒級
- 獨立更新:每個客戶的 adapter 可以獨立訓練和更新,不影響其他客戶
- 框架支援:vLLM、LoRAX、S-LoRA 等推論框架已原生支援多 LoRA adapter 並行服務
9.4 避免常見陷阱
- 過擬合偵測:LoRA 可訓練參數少,但在小數據集上仍會過擬合。務必保留驗證集,監控 eval loss 並使用早停(early stopping)
- Tokenizer 不匹配:確保微調時的 tokenizer 與推論時完全一致。特別注意 pad_token、chat_template 的設定
- 量化 + 合併順序:QLoRA 訓練的 adapter 在合併前需要將基礎模型反量化為 FP16/BF16。直接在 4-bit 模型上 merge_and_unload() 會導致精度損失
- 學習率過高:LoRA 的學習率通常需要比全量微調高 5-10 倍(因為可訓練參數少),但如果設得太高(> 5e-4),容易導致訓練不穩定
- 遺忘評估:微調後應同時評估目標任務的性能和通用基準(如 MMLU、HellaSwag),確保模型沒有嚴重遺忘預訓練知識
9.5 Unsloth 加速框架
Unsloth[8] 是一個專為 LoRA/QLoRA 微調設計的加速框架,透過手動編寫反向傳播核心(bypassing PyTorch autograd)和智慧記憶體管理,實現 2 倍速度提升和 60% 記憶體減少——且完全不損失精度。Unsloth 相容 HuggingFace 的 transformers 和 trl 生態系統,只需替換模型載入方式即可使用:
# 原始 HuggingFace 方式
# model = AutoModelForCausalLM.from_pretrained(...)
# Unsloth 加速方式(一行替換)
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/tinyllama-chat",
max_seq_length=2048,
load_in_4bit=True, # 自動啟用 QLoRA
)
# LoRA 配置——與 PEFT 完全相同
model = FastLanguageModel.get_peft_model(
model,
r=16,
lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
lora_dropout=0,
)
# 之後的訓練流程與標準 HuggingFace TRL 完全相同
Unsloth 還提供模型匯出功能,支援直接匯出為 GGUF 格式(用於 llama.cpp)或量化合併模型,大幅簡化了從微調到部署的流程。
十、結語
LoRA 的出現根本性地改變了 LLM 微調的可及性。在 LoRA 之前,微調一個 70B 模型需要 8 張 A100 和數千美元的算力預算;在 LoRA 之後,同樣的工作可以在一張消費級 GPU 上完成。QLoRA 將門檻進一步降低到免費的 Google Colab。而 DoRA、VeRA 等後續工作則在持續推高參數效率的上限。
但技術本身只是工具。真正決定微調效果的,是對業務場景的深刻理解、對數據品質的嚴格把控,以及對模型行為的系統性評估。一個用 1000 條精選數據 + LoRA 微調的 7B 模型,在特定業務場景下的表現,往往優於一個通用的 70B 模型。
LoRA 讓每個組織都能擁有自己的專屬 LLM——問題不再是「能不能做」,而是「要怎麼做才最有效」。理解 LoRA 的原理、掌握 QLoRA 的實作、建立系統性的評估流程,是每個 AI 工程團隊在 2026 年的必備能力。