Key Findings
  • 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 更新的幅度

訓練時的關鍵設計:

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 r8 - 64表達能力 ↑ / 參數量 ↑從 16 開始,根據驗證集調整
alpha16 - 128更新幅度 ↑設為 2r 或固定 16
target modulesq,k,v,o 或全部線性層覆蓋範圍 ↑資源充足時對所有線性層注入
dropout0.0 - 0.1正則化小數據集用 0.05-0.1
learning rate1e-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-7BLLaMA-13BLLaMA-33BLLaMA-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需存多份完整模型最佳(上限)
Adapter0.5-8%有(額外層)不可切換 adapter 模組良好
Prefix-Tuning~0.1%有(佔序列長度)不可切換 prefix尚可
Prompt Tuning~0.01%極低不可切換 soft token依賴模型規模
LoRA0.1-1%無(可合併)切換/堆疊 adapter接近全量微調
QLoRA0.1-1%無(合併後)切換/堆疊 adapter接近全量微調
DoRA0.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 EngineeringLoRA / QLoRA全量微調
適用場景通用任務、快速原型領域適應、風格遷移、指令遵循大規模領域遷移、新語言
訓練數據需求0(僅需範例)數百至數萬筆數萬至數百萬筆
GPU 需求推論 GPU 即可單張 16-48GB GPU多張 80GB GPU
迭代速度分鐘級小時級天至週級
品質上限受模型能力限制接近全量微調最高(理論上限)
多任務靈活性修改 prompt 即可切換 adapter(MB 級)切換完整模型(GB 級)
典型成本(7B)~$0~$5-50(雲端 GPU)~$500-5000

決策路徑:

九、企業實踐: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 成本:

9.4 避免常見陷阱

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 年的必備能力。