搜 索

LoRA/PEFT:穷人的微调指南

  • 3阅读
  • 2025年04月19日
  • 0评论
首页 / AI/大数据 / 正文

前言:微调的"贫富差距"

你想微调一个 LLaMA-7B 模型,让它学会你的业务知识。

全量微调需要什么?

# 7B 模型参数量
params = 7 * 1e9

# 训练时的显存占用(Adam 优化器)
# = 模型参数 + 梯度 + 优化器状态(2个动量)
memory_bytes = params * (2 + 2 + 4 + 4)  # fp16 模型 + fp16 梯度 + fp32 优化器
memory_gb = memory_bytes / 1e9

print(f"全量微调显存需求: {memory_gb:.1f} GB")
# 输出: 全量微调显存需求: 84.0 GB

84GB 显存! 一张 A100 80GB 都不够用!

这就是为什么需要 PEFT(Parameter-Efficient Fine-Tuning,参数高效微调)

graph LR subgraph 全量微调 A1[7B 参数] --> B1[全部更新] B1 --> C1[84GB 显存] end subgraph LoRA微调 A2[7B 参数] --> B2[冻结] A2 --> B3[0.1% 参数] B3 --> C2[8GB 显存] end style C1 fill:#ff6b6b style C2 fill:#4ecdc4

一、为什么全量微调这么贵?

1.1 显存占用分析

训练时,显存里需要存储:

组件精度大小(7B 模型)
模型参数FP1614 GB
梯度FP1614 GB
优化器状态(Adam)FP3256 GB
激活值(取决于 batch)FP16可变
总计-84+ GB
pie showData title 训练显存占用分布 "模型参数 (FP16)" : 14 "梯度 (FP16)" : 14 "优化器状态 (FP32)" : 56

优化器状态是大头! Adam 需要为每个参数存储两个动量(m 和 v),而且必须是 FP32。

1.2 穷人的困境

GPU显存能全量微调?
RTX 309024 GB❌ 不行
RTX 409024 GB❌ 不行
A100 40GB40 GB❌ 不行
A100 80GB80 GB⚠️ 勉强
8×A100 80GB640 GB✅ 可以

大部分人根本没有条件全量微调!

1.3 PEFT 的思路

核心洞察:微调时,模型的大部分参数其实不需要更新

预训练模型已经学到了丰富的知识,微调只是"微调",不是从头学习。

PEFT 的策略

  • 冻结大部分参数
  • 只训练少量新增参数
  • 显存占用大幅下降
graph TB subgraph 全量微调 A1[所有参数] --> B1[都要更新] B1 --> C1[都要存梯度] C1 --> D1[都要存优化器状态] end subgraph PEFT A2[原始参数] --> B2[冻结 ❄️] A3[新增参数 0.1%] --> B3[更新 🔥] B3 --> C2[只存这部分的梯度] C2 --> D2[只存这部分的优化器状态] end style B2 fill:#87CEEB style B3 fill:#ff6b6b

二、LoRA:低秩适配器

2.1 核心思想

LoRA(Low-Rank Adaptation) 的核心假设:

微调时的权重变化 ΔW 是低秩的。

什么意思?

一个 4096×4096 的矩阵有 1600 万个参数。但微调时,这个矩阵的"有效变化"可能只需要用一个 4096×16 和 16×4096 的矩阵来表示(只有 13 万参数)。

graph LR subgraph 原始权重更新 W["W (4096×4096)"] --> DW["ΔW (4096×4096)"] DW --> WNew["W' = W + ΔW"] end subgraph LoRA低秩分解 W2["W (4096×4096)"] --> Frozen["冻结 ❄️"] A["A (4096×r)"] --> BA["B×A"] B["B (r×4096)"] --> BA BA --> Add["W + BA"] end style Frozen fill:#87CEEB style A fill:#4ecdc4 style B fill:#4ecdc4

2.2 数学表达

原始的线性层:

$$ h = Wx $$

LoRA 修改后:

$$ h = Wx + \frac{\alpha}{r}BAx $$

其中:

  • $W \in \mathbb{R}^{d \times k}$:原始权重(冻结)
  • $B \in \mathbb{R}^{d \times r}$:LoRA 矩阵 B
  • $A \in \mathbb{R}^{r \times k}$:LoRA 矩阵 A
  • $r$:秩(rank),通常 4~64
  • $\alpha$:缩放因子

参数量对比

  • 原始:$d \times k$
  • LoRA:$d \times r + r \times k = r(d+k)$

当 $r << d, k$ 时,参数量大幅减少!

# 以 4096×4096 矩阵为例
d, k = 4096, 4096
r = 16  # LoRA 秩

original_params = d * k  # 16,777,216
lora_params = r * (d + k)  # 131,072

reduction = original_params / lora_params
print(f"原始参数: {original_params:,}")
print(f"LoRA 参数: {lora_params:,}")
print(f"参数减少: {reduction:.0f}x")

# 输出:
# 原始参数: 16,777,216
# LoRA 参数: 131,072
# 参数减少: 128x

2.3 LoRA 的初始化

关键技巧:让 LoRA 在训练开始时对输出没有影响

# A 用高斯初始化
A = torch.randn(r, k) * 0.01

# B 用零初始化
B = torch.zeros(d, r)

# 训练开始时: BA = 0,不影响原始输出

这样,模型在训练开始时的行为和原始模型完全一致。

2.4 LoRA 代码实现

import torch
import torch.nn as nn
import torch.nn.functional as F
import math


class LoRALinear(nn.Module):
    """带 LoRA 的线性层"""
    
    def __init__(
        self,
        in_features: int,
        out_features: int,
        r: int = 8,              # LoRA 秩
        lora_alpha: int = 16,    # 缩放因子
        lora_dropout: float = 0.1,
    ):
        super().__init__()
        
        self.in_features = in_features
        self.out_features = out_features
        self.r = r
        self.lora_alpha = lora_alpha
        self.scaling = lora_alpha / r
        
        # 原始权重(冻结)
        self.weight = nn.Parameter(torch.empty(out_features, in_features))
        self.bias = nn.Parameter(torch.zeros(out_features))
        
        # LoRA 参数
        self.lora_A = nn.Parameter(torch.empty(r, in_features))
        self.lora_B = nn.Parameter(torch.zeros(out_features, r))
        
        # Dropout
        self.lora_dropout = nn.Dropout(lora_dropout) if lora_dropout > 0 else nn.Identity()
        
        # 初始化
        self.reset_parameters()
        
        # 冻结原始权重
        self.weight.requires_grad = False
        self.bias.requires_grad = False
    
    def reset_parameters(self):
        # 原始权重初始化
        nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5))
        
        # LoRA A: 高斯初始化
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        
        # LoRA B: 零初始化(确保初始时 LoRA 无影响)
        nn.init.zeros_(self.lora_B)
    
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # 原始线性变换
        result = F.linear(x, self.weight, self.bias)
        
        # LoRA 增量
        lora_output = self.lora_dropout(x) @ self.lora_A.T @ self.lora_B.T
        result = result + lora_output * self.scaling
        
        return result
    
    def merge_weights(self):
        """将 LoRA 权重合并到原始权重(推理时用)"""
        self.weight.data += (self.lora_B @ self.lora_A) * self.scaling
        # 合并后可以删除 LoRA 参数
        del self.lora_A
        del self.lora_B


class LoRALayer(nn.Module):
    """将 LoRA 应用到现有的 nn.Linear"""
    
    def __init__(
        self,
        original_layer: nn.Linear,
        r: int = 8,
        lora_alpha: int = 16,
        lora_dropout: float = 0.1,
    ):
        super().__init__()
        
        self.original_layer = original_layer
        self.r = r
        self.lora_alpha = lora_alpha
        self.scaling = lora_alpha / r
        
        in_features = original_layer.in_features
        out_features = original_layer.out_features
        
        # 冻结原始层
        for param in original_layer.parameters():
            param.requires_grad = False
        
        # LoRA 参数
        self.lora_A = nn.Parameter(torch.zeros(r, in_features))
        self.lora_B = nn.Parameter(torch.zeros(out_features, r))
        
        # 初始化 A
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        
        # Dropout
        self.lora_dropout = nn.Dropout(lora_dropout) if lora_dropout > 0 else nn.Identity()
    
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # 原始输出
        result = self.original_layer(x)
        
        # LoRA 增量
        lora_output = self.lora_dropout(x) @ self.lora_A.T @ self.lora_B.T
        result = result + lora_output * self.scaling
        
        return result


def apply_lora_to_model(model, r=8, lora_alpha=16, target_modules=None):
    """给模型添加 LoRA"""
    
    if target_modules is None:
        # 默认只对 attention 的 q, v 投影加 LoRA
        target_modules = ['q_proj', 'v_proj']
    
    for name, module in model.named_modules():
        if any(target in name for target in target_modules):
            if isinstance(module, nn.Linear):
                # 获取父模块
                parent_name = '.'.join(name.split('.')[:-1])
                child_name = name.split('.')[-1]
                parent = model.get_submodule(parent_name) if parent_name else model
                
                # 替换为 LoRA 层
                lora_layer = LoRALayer(
                    original_layer=module,
                    r=r,
                    lora_alpha=lora_alpha,
                )
                setattr(parent, child_name, lora_layer)
    
    return model


# 使用示例
def count_trainable_params(model):
    """统计可训练参数"""
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    total = sum(p.numel() for p in model.parameters())
    return trainable, total


# 演示
if __name__ == "__main__":
    # 模拟一个简单模型
    class SimpleModel(nn.Module):
        def __init__(self):
            super().__init__()
            self.q_proj = nn.Linear(4096, 4096)
            self.k_proj = nn.Linear(4096, 4096)
            self.v_proj = nn.Linear(4096, 4096)
            self.o_proj = nn.Linear(4096, 4096)
        
        def forward(self, x):
            q = self.q_proj(x)
            k = self.k_proj(x)
            v = self.v_proj(x)
            return self.o_proj(q + k + v)
    
    model = SimpleModel()
    
    # 应用 LoRA 前
    trainable, total = count_trainable_params(model)
    print(f"LoRA 前: {trainable:,} / {total:,} 可训练 ({100*trainable/total:.2f}%)")
    
    # 应用 LoRA
    model = apply_lora_to_model(model, r=16, target_modules=['q_proj', 'v_proj'])
    
    # 应用 LoRA 后
    trainable, total = count_trainable_params(model)
    print(f"LoRA 后: {trainable:,} / {total:,} 可训练 ({100*trainable/total:.4f}%)")

# 输出:
# LoRA 前: 67,108,864 / 67,108,864 可训练 (100.00%)
# LoRA 后: 262,144 / 67,371,008 可训练 (0.3891%)

2.5 LoRA 应用在哪些层?

通常对 Transformer 的 Attention 层应用 LoRA:

graph TB subgraph Transformer层 Input[输入] --> QProj["Q Projection ✅"] Input --> KProj["K Projection"] Input --> VProj["V Projection ✅"] QProj --> Attn[Attention] KProj --> Attn VProj --> Attn Attn --> OProj["O Projection ✅"] OProj --> FFN1["FFN Up ✅"] FFN1 --> FFN2["FFN Down ✅"] end
目标模块效果参数增量
q_proj, v_proj基础效果
q_proj, k_proj, v_proj, o_proj更好效果中等
+ gate_proj, up_proj, down_proj最佳效果较多

推荐配置:至少包含 q_projv_proj,资源充足时可以加更多。


三、QLoRA:4-bit 量化 + LoRA

3.1 为什么需要 QLoRA?

LoRA 只减少了可训练参数,但模型本身还是要完整加载到显存。

7B 模型 FP16 需要 14GB,加上激活值,还是需要 20GB+ 显存。

QLoRA 的思路:用 4-bit 量化存储基础模型!

FP16: 7B × 2 bytes = 14 GB
INT4: 7B × 0.5 bytes = 3.5 GB

3.2 QLoRA 的三大创新

mindmap root((QLoRA)) 4-bit NormalFloat NF4 数据类型 针对正态分布优化 双重量化 量化缩放因子 进一步压缩 分页优化器 显存不足时 卸载到 CPU

1. NF4(4-bit NormalFloat)

专门为神经网络权重设计的 4-bit 数据类型。

神经网络权重通常服从正态分布,NF4 的量化点针对这个分布优化,比普通 INT4 更精确。

2. 双重量化(Double Quantization)

量化需要存储缩放因子(scale),这部分也占显存。

双重量化:对缩放因子也进行量化,进一步节省显存。

3. 分页优化器(Paged Optimizer)

当显存不足时,自动将优化器状态卸载到 CPU 内存。

3.3 QLoRA 显存对比

方法7B 模型显存能在什么 GPU 上跑
全量微调 FP1684 GB8×A100
LoRA FP1620 GBA100 40GB
QLoRA NF46 GBRTX 3060 12GB

6GB 显存就能微调 7B 模型!

3.4 QLoRA 代码实践

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
)
from peft import (
    LoraConfig,
    get_peft_model,
    prepare_model_for_kbit_training,
)
from trl import SFTTrainer


def train_with_qlora(
    model_name: str,
    dataset,
    output_dir: str,
    r: int = 64,
    lora_alpha: int = 16,
    target_modules: list = None,
):
    """使用 QLoRA 微调模型"""
    
    # 1. 4-bit 量化配置
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,                    # 4-bit 加载
        bnb_4bit_quant_type="nf4",           # NF4 量化
        bnb_4bit_compute_dtype=torch.bfloat16,  # 计算时用 bf16
        bnb_4bit_use_double_quant=True,      # 双重量化
    )
    
    # 2. 加载量化模型
    model = AutoModelForCausalLM.from_pretrained(
        model_name,
        quantization_config=bnb_config,
        device_map="auto",
        trust_remote_code=True,
    )
    
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    tokenizer.pad_token = tokenizer.eos_token
    
    # 3. 准备模型用于 k-bit 训练
    model = prepare_model_for_kbit_training(model)
    
    # 4. LoRA 配置
    if target_modules is None:
        target_modules = [
            "q_proj", "k_proj", "v_proj", "o_proj",
            "gate_proj", "up_proj", "down_proj",
        ]
    
    lora_config = LoraConfig(
        r=r,
        lora_alpha=lora_alpha,
        lora_dropout=0.05,
        target_modules=target_modules,
        bias="none",
        task_type="CAUSAL_LM",
    )
    
    # 5. 应用 LoRA
    model = get_peft_model(model, lora_config)
    model.print_trainable_parameters()
    
    # 6. 训练配置
    training_args = TrainingArguments(
        output_dir=output_dir,
        num_train_epochs=3,
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,
        learning_rate=2e-4,
        weight_decay=0.001,
        warmup_ratio=0.03,
        lr_scheduler_type="cosine",
        logging_steps=10,
        save_steps=500,
        save_total_limit=3,
        bf16=True,
        gradient_checkpointing=True,
        optim="paged_adamw_8bit",  # 分页优化器
        max_grad_norm=0.3,
    )
    
    # 7. 创建 Trainer
    trainer = SFTTrainer(
        model=model,
        args=training_args,
        train_dataset=dataset,
        tokenizer=tokenizer,
        max_seq_length=2048,
    )
    
    # 8. 训练
    trainer.train()
    
    # 9. 保存 LoRA 权重
    model.save_pretrained(output_dir)
    tokenizer.save_pretrained(output_dir)
    
    return model, tokenizer


# 合并 LoRA 权重到基础模型
def merge_lora_weights(
    base_model_name: str,
    lora_weights_path: str,
    output_path: str,
):
    """将 LoRA 权重合并到基础模型"""
    from peft import PeftModel
    
    # 加载基础模型(这次用 FP16)
    base_model = AutoModelForCausalLM.from_pretrained(
        base_model_name,
        torch_dtype=torch.float16,
        device_map="auto",
    )
    
    # 加载 LoRA 权重
    model = PeftModel.from_pretrained(base_model, lora_weights_path)
    
    # 合并权重
    merged_model = model.merge_and_unload()
    
    # 保存合并后的模型
    merged_model.save_pretrained(output_path)
    
    return merged_model


# 使用示例
if __name__ == "__main__":
    from datasets import load_dataset
    
    # 加载数据集
    dataset = load_dataset("tatsu-lab/alpaca", split="train[:1000]")
    
    # QLoRA 训练
    model, tokenizer = train_with_qlora(
        model_name="meta-llama/Llama-2-7b-hf",
        dataset=dataset,
        output_dir="./qlora_output",
        r=64,
        lora_alpha=16,
    )

3.5 QLoRA 显存监控

import torch

def print_gpu_memory():
    """打印 GPU 显存使用情况"""
    if torch.cuda.is_available():
        for i in range(torch.cuda.device_count()):
            allocated = torch.cuda.memory_allocated(i) / 1024**3
            reserved = torch.cuda.memory_reserved(i) / 1024**3
            total = torch.cuda.get_device_properties(i).total_memory / 1024**3
            
            print(f"GPU {i}: {allocated:.1f}GB allocated, "
                  f"{reserved:.1f}GB reserved, {total:.1f}GB total")


# 在训练的关键节点调用
print("加载模型后:")
print_gpu_memory()

print("开始训练后:")
print_gpu_memory()

四、其他 PEFT 方法

4.1 PEFT 方法全景

mindmap root((PEFT 方法)) 加法类 LoRA AdaLoRA LoRA+ 适配器类 Adapter Parallel Adapter 前缀类 Prefix-Tuning P-Tuning v2 提示类 Prompt Tuning P-Tuning 其他 IA3 BitFit

4.2 Adapter:插入小模块

Adapter 在 Transformer 层中间插入小型可训练模块:

graph TB subgraph Transformer层+Adapter Input[输入] --> Attn[Self-Attention] Attn --> Add1[Add & Norm] Add1 --> Adapter1["Adapter ✅"] Adapter1 --> FFN[Feed Forward] FFN --> Add2[Add & Norm] Add2 --> Adapter2["Adapter ✅"] Adapter2 --> Output[输出] end
class Adapter(nn.Module):
    """Adapter 模块"""
    
    def __init__(self, hidden_size, adapter_size=64):
        super().__init__()
        
        self.down_proj = nn.Linear(hidden_size, adapter_size)
        self.up_proj = nn.Linear(adapter_size, hidden_size)
        self.act = nn.GELU()
    
    def forward(self, x):
        # 残差连接
        return x + self.up_proj(self.act(self.down_proj(x)))

Adapter vs LoRA

  • Adapter 增加了推理延迟(串行)
  • LoRA 可以合并权重(无延迟)
  • LoRA 更主流

4.3 Prefix-Tuning:可学习前缀

在每一层的 attention 前添加可学习的"虚拟 token":

class PrefixTuning(nn.Module):
    """Prefix-Tuning"""
    
    def __init__(self, num_layers, hidden_size, prefix_length=10):
        super().__init__()
        
        # 每层的 key 和 value 前缀
        self.prefix_key = nn.Parameter(
            torch.randn(num_layers, prefix_length, hidden_size)
        )
        self.prefix_value = nn.Parameter(
            torch.randn(num_layers, prefix_length, hidden_size)
        )
    
    def get_prefix(self, layer_idx, batch_size):
        """获取某一层的前缀"""
        key = self.prefix_key[layer_idx].unsqueeze(0).expand(batch_size, -1, -1)
        value = self.prefix_value[layer_idx].unsqueeze(0).expand(batch_size, -1, -1)
        return key, value
原始 attention:
Q @ K^T → Attention → @ V

Prefix-Tuning:
Q @ [prefix_K; K]^T → Attention → @ [prefix_V; V]

4.4 Prompt Tuning:软提示

只在输入层添加可学习的 embedding:

class PromptTuning(nn.Module):
    """Prompt Tuning: 最简单的 PEFT"""
    
    def __init__(self, embedding_dim, prompt_length=20):
        super().__init__()
        
        # 可学习的软提示
        self.soft_prompt = nn.Parameter(
            torch.randn(prompt_length, embedding_dim)
        )
    
    def forward(self, input_embeddings):
        batch_size = input_embeddings.shape[0]
        
        # 扩展到 batch
        prompt = self.soft_prompt.unsqueeze(0).expand(batch_size, -1, -1)
        
        # 拼接: [soft_prompt, input_embeddings]
        return torch.cat([prompt, input_embeddings], dim=1)

优点:参数量极少
缺点:效果通常不如 LoRA

4.5 方法对比

方法参数量效果推理开销复杂度
LoRA0.1-1%⭐⭐⭐⭐无(可合并)
QLoRA0.1-1%⭐⭐⭐⭐
Adapter0.5-2%⭐⭐⭐
Prefix-Tuning0.1%⭐⭐⭐
Prompt Tuning<0.1%⭐⭐最低
BitFit0.05%⭐⭐最低

结论:LoRA/QLoRA 是目前的最佳实践!


五、使用 PEFT 库

5.1 PEFT 库基础用法

from peft import (
    LoraConfig,
    get_peft_model,
    TaskType,
    PeftModel,
)
from transformers import AutoModelForCausalLM, AutoTokenizer


# 1. 加载基础模型
base_model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

# 2. 配置 LoRA
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,                      # 秩
    lora_alpha=32,             # 缩放因子
    lora_dropout=0.05,         # Dropout
    target_modules=[           # 目标模块
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ],
    bias="none",
)

# 3. 创建 PEFT 模型
peft_model = get_peft_model(base_model, lora_config)

# 4. 查看可训练参数
peft_model.print_trainable_parameters()
# 输出类似:trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.0622

# 5. 正常训练...
# trainer.train()

# 6. 保存 LoRA 权重(只保存增量)
peft_model.save_pretrained("./lora_weights")
# 只有几十 MB!

# 7. 加载 LoRA 权重
loaded_model = PeftModel.from_pretrained(
    base_model,
    "./lora_weights"
)

# 8. 合并权重(推理时可选)
merged_model = loaded_model.merge_and_unload()

5.2 不同任务的配置

from peft import TaskType

# 因果语言模型(GPT 类)
config_clm = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
)

# 序列分类(BERT 类)
config_seq_cls = LoraConfig(
    task_type=TaskType.SEQ_CLS,
    r=8,
    lora_alpha=16,
    target_modules=["query", "value"],
)

# Seq2Seq(T5 类)
config_seq2seq = LoraConfig(
    task_type=TaskType.SEQ_2_SEQ_LM,
    r=16,
    lora_alpha=32,
    target_modules=["q", "v"],
)

# Token 分类(NER)
config_token_cls = LoraConfig(
    task_type=TaskType.TOKEN_CLS,
    r=8,
    lora_alpha=16,
    target_modules=["query", "value"],
)

5.3 多个 LoRA 适配器

from peft import PeftModel

# 基础模型
base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")

# 加载第一个 LoRA(中文能力)
model = PeftModel.from_pretrained(base_model, "./lora_chinese", adapter_name="chinese")

# 加载第二个 LoRA(代码能力)
model.load_adapter("./lora_code", adapter_name="code")

# 切换适配器
model.set_adapter("chinese")  # 使用中文 LoRA
output_zh = model.generate(...)

model.set_adapter("code")     # 切换到代码 LoRA
output_code = model.generate(...)

# 列出所有适配器
print(model.peft_config)

# 禁用 LoRA(使用原始模型)
model.disable_adapter()

# 重新启用
model.enable_adapter()

5.4 推理优化:合并权重

def optimize_for_inference(peft_model_path, base_model_name, output_path):
    """将 LoRA 合并到基础模型,优化推理"""
    
    # 加载基础模型
    base_model = AutoModelForCausalLM.from_pretrained(
        base_model_name,
        torch_dtype=torch.float16,
    )
    
    # 加载 PEFT 模型
    peft_model = PeftModel.from_pretrained(base_model, peft_model_path)
    
    # 合并权重
    merged_model = peft_model.merge_and_unload()
    
    # 保存
    merged_model.save_pretrained(output_path)
    
    print(f"合并后的模型已保存到 {output_path}")
    
    return merged_model


# 合并后的模型可以直接用,没有额外开销
merged_model = AutoModelForCausalLM.from_pretrained("./merged_model")

六、LoRA 超参数调优

6.1 关键超参数

参数说明推荐值
r秩(rank)8-64
lora_alpha缩放因子通常 = 2×r
lora_dropoutDropout0-0.1
target_modules目标层至少 q_proj, v_proj
bias是否训练 bias"none"

6.2 秩(r)的选择

graph LR subgraph 秩的影响 R1["r=4
参数少
欠拟合风险"] R2["r=16
平衡选择"] R3["r=64
参数多
效果更好"] R4["r=256
接近全量微调"] end style R2 fill:#4ecdc4

经验法则

  • 简单任务(分类):r=8 足够
  • 复杂任务(指令遵循):r=16-64
  • 资源充足:r=64 效果最好

6.3 缩放因子(lora_alpha)

lora_alpha / r 决定了 LoRA 更新的"强度"。

# 常见配置
config_1 = LoraConfig(r=8, lora_alpha=16)   # scale = 2
config_2 = LoraConfig(r=16, lora_alpha=32)  # scale = 2
config_3 = LoraConfig(r=64, lora_alpha=128) # scale = 2

# 一般 lora_alpha = 2 * r,即 scale = 2

6.4 目标模块的选择

# 最小配置(效果一般)
target_modules = ["q_proj", "v_proj"]

# 推荐配置
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj"]

# 完整配置(效果最好)
target_modules = [
    "q_proj", "k_proj", "v_proj", "o_proj",  # Attention
    "gate_proj", "up_proj", "down_proj",      # FFN
]

# 不同模型的模块名可能不同
# LLaMA: q_proj, k_proj, v_proj, o_proj
# GPT-2: c_attn, c_proj
# BERT:  query, key, value

6.5 学习率

LoRA 通常需要比全量微调更高的学习率:

# 全量微调
learning_rate = 2e-5

# LoRA 微调
learning_rate = 1e-4 ~ 3e-4  # 高 5-10 倍

七、实战:完整训练流程

7.1 使用 LLaMA-Factory

# 安装
git clone https://github.com/hiyouga/LLaMA-Factory.git
cd LLaMA-Factory
pip install -e .

# 启动 WebUI
python src/webui.py

配置示例(yaml):

# llama2_lora_sft.yaml
model_name_or_path: meta-llama/Llama-2-7b-hf
stage: sft
do_train: true
finetuning_type: lora

# LoRA 配置
lora_rank: 64
lora_alpha: 128
lora_dropout: 0.05
lora_target: q_proj,v_proj,k_proj,o_proj,gate_proj,up_proj,down_proj

# 数据
dataset: alpaca_zh
template: llama2
cutoff_len: 2048

# 训练
per_device_train_batch_size: 4
gradient_accumulation_steps: 4
learning_rate: 2e-4
num_train_epochs: 3
lr_scheduler_type: cosine
warmup_ratio: 0.1

# 量化(QLoRA)
quantization_bit: 4

# 输出
output_dir: ./output/llama2-7b-lora
# 命令行训练
llamafactory-cli train llama2_lora_sft.yaml

7.2 完整训练脚本

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset


def main():
    # ============ 配置 ============
    MODEL_NAME = "meta-llama/Llama-2-7b-hf"
    OUTPUT_DIR = "./output/llama2-7b-qlora"
    
    # ============ 量化配置 ============
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.bfloat16,
        bnb_4bit_use_double_quant=True,
    )
    
    # ============ 加载模型 ============
    print("加载模型...")
    model = AutoModelForCausalLM.from_pretrained(
        MODEL_NAME,
        quantization_config=bnb_config,
        device_map="auto",
        trust_remote_code=True,
    )
    
    tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
    tokenizer.pad_token = tokenizer.eos_token
    tokenizer.padding_side = "right"
    
    # ============ 准备 LoRA ============
    model = prepare_model_for_kbit_training(model)
    
    lora_config = LoraConfig(
        r=64,
        lora_alpha=128,
        lora_dropout=0.05,
        target_modules=[
            "q_proj", "k_proj", "v_proj", "o_proj",
            "gate_proj", "up_proj", "down_proj",
        ],
        bias="none",
        task_type="CAUSAL_LM",
    )
    
    model = get_peft_model(model, lora_config)
    model.print_trainable_parameters()
    
    # ============ 加载数据 ============
    print("加载数据...")
    dataset = load_dataset("tatsu-lab/alpaca", split="train")
    
    def format_instruction(example):
        if example.get("input"):
            text = f"""Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
{example['instruction']}

### Input:
{example['input']}

### Response:
{example['output']}"""
        else:
            text = f"""Below is an instruction that describes a task. Write a response that appropriately completes the request.

### Instruction:
{example['instruction']}

### Response:
{example['output']}"""
        return text
    
    # ============ 训练配置 ============
    training_args = TrainingArguments(
        output_dir=OUTPUT_DIR,
        num_train_epochs=3,
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,
        learning_rate=2e-4,
        weight_decay=0.001,
        warmup_ratio=0.03,
        lr_scheduler_type="cosine",
        logging_steps=10,
        save_steps=500,
        save_total_limit=3,
        bf16=True,
        gradient_checkpointing=True,
        optim="paged_adamw_8bit",
        max_grad_norm=0.3,
        report_to="none",
    )
    
    # ============ 训练 ============
    print("开始训练...")
    trainer = SFTTrainer(
        model=model,
        args=training_args,
        train_dataset=dataset,
        tokenizer=tokenizer,
        formatting_func=format_instruction,
        max_seq_length=2048,
    )
    
    trainer.train()
    
    # ============ 保存 ============
    print("保存模型...")
    trainer.model.save_pretrained(OUTPUT_DIR)
    tokenizer.save_pretrained(OUTPUT_DIR)
    
    print(f"训练完成!模型保存在 {OUTPUT_DIR}")


if __name__ == "__main__":
    main()

八、总结

LoRA 核心要点

mindmap root((LoRA/PEFT)) 原理 低秩分解 冻结原始权重 只训练增量 优势 显存大幅减少 训练速度快 可以合并无开销 QLoRA 4-bit 量化 NF4 数据类型 极低显存 实践 r=16-64 alpha=2r 目标层选择

关键 Takeaway

  1. LoRA 的核心是低秩分解:用两个小矩阵近似权重更新
  2. QLoRA = 4-bit 量化 + LoRA:7B 模型只需 6GB 显存
  3. 推荐配置

    • r=64, lora_alpha=128
    • 目标模块:q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj
    • 学习率:2e-4
  4. LoRA 可以合并:推理时无额外开销
  5. 工具推荐

    • PEFT 库:官方实现
    • TRL:配合 SFTTrainer
    • LLaMA-Factory:中文友好,一站式

显存需求速查

模型全量微调LoRA (FP16)QLoRA (4-bit)
7B84 GB20 GB6 GB
13B156 GB36 GB10 GB
70B840 GB160 GB48 GB

下一步学习

  • [ ] 模型量化:让大模型更轻量
  • [ ] vLLM 推理加速:高效部署
  • [ ] 本地部署:Ollama 实战

参考资料

  1. LoRA Paper - LoRA: Low-Rank Adaptation of Large Language Models
  2. QLoRA Paper - QLoRA: Efficient Finetuning of Quantized LLMs
  3. PEFT Library - HuggingFace PEFT
  4. LLaMA-Factory - 一站式微调框架
  5. Adapter Paper - Parameter-Efficient Transfer Learning

评论区
暂无评论
avatar