模型微调与 PEFT:LoRA、QLoRA、Unsloth 实战与部署转换

系统性讲解 LLM 参数高效微调(PEFT):LoRA / QLoRA / DoRA 原理与超参数调优、HuggingFace PEFT + TRL (SFTTrainer/DPOTrainer) 完整训练流程、Unsloth 2x 加速训练、数据集准备(Alpaca/ShareGPT)、评估与模型合并、GGUF/AWQ/ONNX 部署转换。附带可运行的训练脚本与微调 vs RAG 决策树。

目录

  1. 微调基础:Full Fine-tuning vs PEFT
  2. LoRA / QLoRA / DoRA 原理
  3. 数据集准备
  4. PEFT + TRL 完整训练流程
  5. Unsloth 加速训练
  6. DPO 与对齐训练
  7. 模型合并与评估
  8. 部署转换:GGUF / AWQ / ONNX
  9. 微调 vs RAG 决策树

1. 微调基础:Full Fine-tuning vs PEFT

方法训练参数量显存需求(7B)适用场景缺点
Full Fine-tuning100%160GB+数据充足、领域迁移计算成本高、灾难性遗忘
LoRA0.1-1%16GB绝大多数场景表达能力有限(可通过 rank 调节)
QLoRA0.1-1%10GB消费级 GPU量化引入误差
Prefix Tuning0.1%14GB分类/摘要不适用生成任务
Prompt Tuning<0.01%12GB少样本场景对提示敏感

PEFT 核心思想:冻结预训练模型的大部分参数,只训练少量新增参数(低秩矩阵、前缀嵌入等),大幅降低成本的同时保留模型能力。


2. LoRA / QLoRA / DoRA 原理

2.1 LoRA(Low-Rank Adaptation)

对于权重矩阵 $W \in \mathbb{R}^{d \times k}$,LoRA 不直接训练 $W$,而是训练低秩分解:

$$W’ = W + \Delta W = W + BA$$

其中 $B \in \mathbb{R}^{d \times r}$,$A \in \mathbb{R}^{r \times k}$,$r \ll \min(d, k)$(典型值 $r=8,16,64$)。

训练时 $W$ 冻结,只训练 $A$(高斯初始化)和 $B$(零初始化)。

from peft import LoraConfig, get_peft_model, TaskType

lora_config = LoraConfig(
    r=16,                          # 低秩维度,越大表达越强,显存越高
    lora_alpha=32,                 # 缩放因子,通常 = 2 * r
    target_modules=[               # 应用 LoRA 的模块
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_dropout=0.05,             # 防止过拟合
    bias="none",
    task_type=TaskType.CAUSAL_LM,  # 自回归语言模型
)

# 应用 LoRA 到基础模型
model = get_peft_model(base_model, lora_config)
model.print_trainable_parameters()  # 通常 < 1%

2.2 QLoRA(4-bit 量化 + LoRA)

QLoRA 将基础模型量化为 4-bit(NormalFloat),大幅降低显存占用:

import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import prepare_model_for_kbit_training

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,      # 嵌套量化,进一步压缩
    bnb_4bit_quant_type="nf4",           # Normal Float 4-bit
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto",                   # 自动分配到 GPU/CPU
    trust_remote_code=True,
)

# 为量化模型准备训练
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)

QLoRA 显存对比(Llama-2-7B):

  • Full Fine-tuning: ~80GB
  • LoRA (16-bit): ~22GB
  • QLoRA (4-bit): ~10GB

2.3 DoRA(Weight-Decomposed Low-Rank Adaptation,2024)

DoRA 将权重分解为幅度(magnitude)和方向(direction),对方向应用 LoRA:

$$W’ = m \cdot \frac{W + BA}{|W + BA|_c}$$

表现优于 LoRA 且训练更稳定,阿里巴巴 Qwen3 已默认使用。

from peft import LoraConfig

# PEFT 0.11+ 支持 use_dora
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    use_dora=True,  # 启用 DoRA
)

2.4 LoRA 超参数调优

参数推荐范围说明
r8-128简单任务 8-16,复杂任务 64-128
lora_alpha16-256通常 2*r,越大影响越强
lora_dropout0.0-0.1数据少时提高,大时设为 0
target_modulesattention + mlp全部 target = 更强但更多参数
modules_to_saveembed_tokens, lm_head需要时也训练输入/输出层

3. 数据集准备

3.1 标准格式(Alpaca / ShareGPT)

// Alpaca 格式
{
  "instruction": "解释什么是 LoRA",
  "input": "",
  "output": "LoRA(Low-Rank Adaptation)是一种参数高效微调方法..."
}

// ShareGPT 格式(对话式)
{
  "conversations": [
    {"from": "human", "value": "什么是 QLoRA?"},
    {"from": "gpt", "value": "QLoRA 结合了 4-bit 量化和 LoRA..."},
    {"from": "human", "value": "和 LoRA 有什么区别?"},
    {"from": "gpt", "value": "主要区别在于量化..."}
  ]
}

// OpenAI messages 格式(推荐)
{
  "messages": [
    {"role": "system", "content": "你是一个有帮助的AI助手。"},
    {"role": "user", "content": "解释 LoRA"},
    {"role": "assistant", "content": "LoRA 是..."}
  ]
}

3.2 数据集处理管道

from datasets import load_dataset, Dataset
import json

def load_alpaca_dataset(path: str) -> Dataset:
    """加载 Alpaca 格式数据集并转换为 messages 格式。"""
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)

    messages = []
    for item in data:
        user_content = item["instruction"]
        if item.get("input"):
            user_content += f"\n\n输入:{item['input']}"

        messages.append({
            "messages": [
                {"role": "user", "content": user_content},
                {"role": "assistant", "content": item["output"]},
            ]
        })

    return Dataset.from_list(messages)

# 加载 HuggingFace 公开数据集
class DatasetBuilder:
    def __init__(self, tokenizer, max_length: int = 2048):
        self.tokenizer = tokenizer
        self.max_length = max_length
        # 对话模板
        self.tokenizer.chat_template = "{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}"

    def format_chat(self, example: dict) -> str:
        """将 messages 列表格式化为训练文本。"""
        return self.tokenizer.apply_chat_template(
            example["messages"],
            tokenize=False,
            add_generation_prompt=False,
        )

    def tokenize(self, examples: dict):
        """Tokenize 并创建标签掩码(只计算 assistant 部分的 loss)。"""
        texts = [self.format_chat(ex) for ex in examples["messages"]]

        # Tokenize
        batch = self.tokenizer(
            texts,
            truncation=True,
            max_length=self.max_length,
            padding="max_length",
            return_tensors=None,
        )

        # 创建 labels:user 部分设为 -100(不计算 loss)
        labels = []
        for text in texts:
            tokens = self.tokenizer(text, truncation=True, max_length=self.max_length)
            label = tokens["input_ids"].copy()

            # 标记 assistant 回答的起始位置
            # 策略:找到 "assistant" 对应 token,之后为训练目标
            # 简化方案:找到最后的 "<|im_start|>assistant" 并设置此后的 token 为有效 label
            # 实际实现需根据 tokenizer 精确匹配
            labels.append(label)

        batch["labels"] = labels
        return batch

3.3 数据质量检查

def validate_dataset(dataset: Dataset) -> dict:
    """数据集质量检查。"""
    stats = {
        "total_samples": len(dataset),
        "avg_input_length": 0,
        "avg_output_length": 0,
        "empty_outputs": 0,
        "duplicates": 0,
    }

    seen = set()
    for ex in dataset:
        messages = ex.get("messages", [])
        if not messages:
            continue

        user_msgs = [m for m in messages if m["role"] == "user"]
        assistant_msgs = [m for m in messages if m["role"] == "assistant"]

        stats["avg_input_length"] += sum(len(m["content"]) for m in user_msgs)
        stats["avg_output_length"] += sum(len(m["content"]) for m in assistant_msgs)

        if assistant_msgs and not assistant_msgs[-1]["content"].strip():
            stats["empty_outputs"] += 1

        key = json.dumps(messages, ensure_ascii=False)
        if key in seen:
            stats["duplicates"] += 1
        seen.add(key)

    stats["avg_input_length"] /= len(dataset)
    stats["avg_output_length"] /= len(dataset)
    return stats

4. PEFT + TRL 完整训练流程

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

def train_lora_model(
    model_name: str = "Qwen/Qwen2.5-7B-Instruct",
    dataset_path: str = "data/alpaca.json",
    output_dir: str = "./lora-output",
):
    # 1. Tokenizer
    tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
    tokenizer.pad_token = tokenizer.eos_token
    tokenizer.padding_side = "right"

    # 2. 4-bit 量化模型
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.bfloat16,
    )

    model = AutoModelForCausalLM.from_pretrained(
        model_name,
        quantization_config=bnb_config,
        device_map="auto",
        trust_remote_code=True,
        torch_dtype=torch.bfloat16,
    )

    # 3. PEFT 配置
    peft_config = LoraConfig(
        r=64,
        lora_alpha=128,
        target_modules=[
            "q_proj", "k_proj", "v_proj", "o_proj",
            "gate_proj", "up_proj", "down_proj",
        ],
        lora_dropout=0.05,
        bias="none",
        task_type="CAUSAL_LM",
    )

    model = prepare_model_for_kbit_training(model)
    model = get_peft_model(model, peft_config)
    model.print_trainable_parameters()

    # 4. 数据集
    dataset = load_dataset("json", data_files=dataset_path, split="train")

    # 5. 训练参数
    training_args = TrainingArguments(
        output_dir=output_dir,
        num_train_epochs=3,
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,       # 有效 batch = 2 * 4 = 8
        learning_rate=2e-4,
        max_grad_norm=0.3,
        warmup_ratio=0.03,
        lr_scheduler_type="cosine",
        logging_steps=10,
        save_strategy="epoch",
        fp16=False,
        bf16=True,
        optim="paged_adamw_8bit",            # QLoRA 推荐优化器
        group_by_length=True,                # 相似长度样本分组,提高效率
        report_to="tensorboard",
    )

    # 6. 训练
    trainer = SFTTrainer(
        model=model,
        tokenizer=tokenizer,
        train_dataset=dataset,
        peft_config=peft_config,
        dataset_text_field="text",
        max_seq_length=2048,
        args=training_args,
        packing=True,                        # 多个短样本打包进一个序列
    )

    trainer.train()
    trainer.model.save_pretrained(f"{output_dir}/final")
    tokenizer.save_pretrained(f"{output_dir}/final")

    return trainer

5. Unsloth 加速训练

Unsloth 通过手动优化 CUDA kernel,使训练速度提升 2-5x,显存降低 50-80%

from unsloth import FastLanguageModel
from trl import SFTTrainer
from transformers import TrainingArguments

# Unsloth 支持的模型列表见 https://github.com/unslothai/unsloth
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/Llama-3.2-3B-Instruct",
    max_seq_length=2048,
    dtype=None,  # auto detect fp16/bf16
    load_in_4bit=True,
)

# 添加 LoRA
model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_alpha=16,
    lora_dropout=0,
    bias="none",
    use_gradient_checkpointing="unsloth",  # Unsloth 优化版 checkpoint
    random_state=42,
)

# 训练
trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=2048,
    args=TrainingArguments(
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        warmup_steps=5,
        max_steps=60,
        learning_rate=2e-4,
        fp16=not torch.cuda.is_bf16_supported(),
        bf16=torch.cuda.is_bf16_supported(),
        logging_steps=1,
        optim="adamw_8bit",
        weight_decay=0.01,
        seed=42,
        output_dir="unsloth-output",
    ),
)

trainer.train()

# 推理加速:Unsloth 自动应用 optimized generation
from unsloth.chat_templates import get_chat_template
tokenizer = get_chat_template(tokenizer, chat_template="llama-3.1")

6. DPO 与对齐训练

6.1 DPO(Direct Preference Optimization)

DPO 直接用偏好数据(chosen > rejected)训练,无需单独的 reward model:

from trl import DPOTrainer
from peft import PeftModel

# DPO 数据格式:prompt + chosen + rejected
dpo_dataset = load_dataset("json", data_files="dpo_data.json")

dpo_trainer = DPOTrainer(
    model=model,                    # SFT 后的模型
    ref_model=ref_model,            # 原始参考模型
    args=TrainingArguments(
        output_dir="./dpo-output",
        per_device_train_batch_size=1,
        num_train_epochs=1,
        learning_rate=5e-7,         # DPO 学习率更低
        beta=0.1,                   # DPO 温度参数
    ),
    train_dataset=dpo_dataset["train"],
    tokenizer=tokenizer,
)
dpo_trainer.train()

6.2 ORPO(Odds Ratio Preference Optimization)

将 SFT 和偏好对齐合并到一步完成,省去参考模型:

from trl import ORPOTrainer

orpo_trainer = ORPOTrainer(
    model=model,
    train_dataset=dataset,
    tokenizer=tokenizer,
    args=TrainingArguments(
        output_dir="./orpo-output",
        learning_rate=1e-5,
        lr_scheduler_type="cosine",
        optim="adamw_torch",
    ),
)
orpo_trainer.train()

7. 模型合并与评估

7.1 合并 LoRA 权重到基础模型

from peft import PeftModel
from transformers import AutoModelForCausalLM

base_model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-7B-Instruct",
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

# 加载 LoRA 权重
peft_model = PeftModel.from_pretrained(base_model, "./lora-output/final")

# 合并并保存完整模型
merged_model = peft_model.merge_and_unload()
merged_model.save_pretrained("./merged-model")

7.2 持续预训练(Domain Adaptation)

# 在领域数据上先进行继续预训练(CPT),再做 SFT
cpt_args = TrainingArguments(
    output_dir="./cpt-output",
    per_device_train_batch_size=4,
    num_train_epochs=1,
    learning_rate=1e-5,          # CPT 学习率比 SFT 更低
    warmup_ratio=0.01,
    save_steps=500,
    logging_steps=10,
    bf16=True,
)

# CPT 使用无监督数据,labels = input_ids(自回归预测下一个 token)
trainer = Trainer(
    model=base_model,
    args=cpt_args,
    train_dataset=unsupervised_dataset,
    data_collator=DataCollatorForLanguageModeling(tokenizer, mlm=False),
)
trainer.train()

7.3 评估指标

from evaluate import load

class ModelEvaluator:
    def __init__(self, model, tokenizer):
        self.model = model
        self.tokenizer = tokenizer
        self.bleu = load("bleu")
        self.rouge = load("rouge")

    def evaluate_generation(self, test_data: list[dict]) -> dict:
        predictions = []
        references = []

        for item in test_data:
            prompt = item["input"]
            expected = item["output"]

            inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
            outputs = self.model.generate(
                **inputs,
                max_new_tokens=256,
                temperature=0.7,
                top_p=0.9,
                do_sample=True,
            )
            generated = self.tokenizer.decode(outputs[0], skip_special_tokens=True)

            predictions.append(generated)
            references.append([expected])

        bleu = self.bleu.compute(predictions=predictions, references=references)
        rouge = self.rouge.compute(predictions=predictions, references=[r[0] for r in references])

        return {"bleu": bleu, "rouge": rouge}

8. 部署转换:GGUF / AWQ / ONNX

8.1 GGUF(llama.cpp 格式)

最适合 CPU 推理和边缘设备:

# 安装 llama.cpp 转换工具
pip install llama-cpp-python

# 从 transformers 模型转 GGUF(使用 HuggingFace 转换脚本)
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
python convert_hf_to_gguf.py ../merged-model \
    --outfile model-f16.gguf \
    --outtype f16

# 量化(推荐 Q4_K_M,平衡质量与速度)
./llama-quantize model-f16.gguf model-Q4_K_M.gguf Q4_K_M

# Python 推理
from llama_cpp import Llama

llm = Llama(
    model_path="model-Q4_K_M.gguf",
    n_ctx=4096,
    n_threads=8,
    verbose=False,
)

output = llm(
    "Q: 什么是 LoRA?\nA: ",
    max_tokens=256,
    temperature=0.7,
    stop=["Q:", "\n"],
)
print(output["choices"][0]["text"])

8.2 AWQ(Activation-aware Weight Quantization)

保持激活精度,适合 GPU 推理:

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

# 量化
model = AutoAWQForCausalLM.from_pretrained("merged-model")
quant_config = {"zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM"}
model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized("awq-model")

# 推理
from autoawq import AutoAWQForCausalLM
model = AutoAWQForCausalLM.from_quantized("awq-model")

8.3 ONNX 导出

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model = AutoModelForCausalLM.from_pretrained("merged-model")
tokenizer = AutoTokenizer.from_pretrained("merged-model")

# 导出为 ONNX
dummy_input = tokenizer("Hello", return_tensors="pt")
torch.onnx.export(
    model,
    (dummy_input["input_ids"],),
    "model.onnx",
    input_names=["input_ids"],
    output_names=["logits"],
    dynamic_axes={
        "input_ids": {0: "batch", 1: "sequence"},
        "logits": {0: "batch", 1: "sequence"},
    },
    opset_version=17,
)

# ONNX Runtime 推理
import onnxruntime as ort
session = ort.InferenceSession("model.onnx", providers=["CUDAExecutionProvider"])
outputs = session.run(None, {"input_ids": dummy_input["input_ids"].numpy()})

8.4 部署格式对比

格式大小(7B)推理速度硬件适用场景
FP1614GB基准GPU开发/高精度需求
AWQ 4-bit4GB1.5xGPU生产 GPU 部署
GPTQ 4-bit4GB1.5xGPU生产 GPU 部署
GGUF Q4_K_M4GB0.3x (CPU)CPU边缘设备/桌面
ONNX14GB1.2xCPU/GPU跨平台部署

9. 微调 vs RAG 决策树

新任务?
├── 需要模型掌握新知识(如内部文档)?
│   ├── 知识频繁更新? → RAG(动态检索)
│   └── 知识固定?
│       ├── 数据量 < 10K? → Few-shot Prompt
│       ├── 10K-100K? → LoRA 微调
│       └── > 100K? → Full Fine-tuning 或 QLoRA
├── 需要改变模型行为/风格?
│   ├── 对话风格 → SFT(LoRA/QLoRA)
│   ├── 对齐价值观 → DPO/ORPO
│   └── 拒绝某些回答 → 安全微调 + 输出过滤
├── 需要提升特定任务准确率?
│   ├── 已有充足标注数据 → LoRA 微调
│   └── 标注数据少 → RAG + Few-shot
└── 延迟要求 < 100ms?
    └── 是 → 考虑模型蒸馏为小模型后再微调

组合策略(生产中常见):

  1. 基础模型 + LoRA 领域适配(CPT + SFT)
  2. RAG 提供实时知识
  3. DPO 对齐输出偏好
  4. vLLM 加速推理部署

交叉链接:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页