一文看懂 LoRA 微调和 QLoRA:原理、场景与代码实战

一文看懂 LoRA 微调和 QLoRA:原理、场景与代码实战

大模型时代,很多人都会遇到同一个问题:

“我只有一块 24G 显卡,怎么微调一个几十亿参数的模型?”

如果你也有这样的困惑,那你基本上已经站在了 LoRA 和 QLoRA 的门口。

它们都是 参数高效微调(PEFT, Parameter-Efficient Fine-Tuning) 技术,让你在显存有限存储有限的前提下,也能把大模型变成你的专属模型。

本文会从三个角度来讲清楚 LoRA 和 QLoRA:

  • 原理:到底在模型里“动了哪里”
  • 应用场景:什么场景适合用哪种方案
  • 代码实战:基于 transformers + peft + bitsandbytes 的最小可跑样例

一、为什么需要参数高效微调(PEFT)

传统的 全参数微调(Full Fine-tuning) 做法是:

  • 把预训练好的大模型完整加载到显存里
  • 对所有参数都进行反向传播和更新

这会带来几个现实问题:

  1. 显存压力巨大

    • 一个 7B 模型,FP16 约 14GB;13B / 34B / 70B 更是轻松突破单卡极限。
    • 训练时还要存梯度、优化器状态,显存直接爆掉。
  2. 存储成本高

    • 每做一个下游任务,就要多存一份完整模型权重(几十 GB 起步)。
    • 想做十几个任务,磁盘都吃不消。
  3. 部署不灵活

    • 一个基础模型 + N 个任务 = N 份完整模型。
    • 实际上很多任务之间共享了绝大部分知识,只是任务特定的“小差异”不同。

于是就有了 PEFT 这种思路:

冻结绝大部分预训练参数,只在少量新增参数上学习任务特定信息。

LoRA 和 QLoRA 就是目前最主流的两种 PEFT 技术路线。


二、LoRA:低秩适配(Low-Rank Adaptation)的核心原理

2.1 直观理解

在 Transformer 中,最“重”的模块就是各种线性层(矩阵乘法),例如:

  • 自注意力层中的 W_q, W_k, W_v, W_o
  • 前馈网络中的 W_1, W_2

这些权重矩阵都很大,直接更新它们的代价也很高。

LoRA 的核心想法是:

  • 不再直接更新原始权重矩阵 W(把它完全冻结)
  • 只学习一个“低秩”的增量矩阵 ΔW,并且让 ΔW 可以写成:

    [
    \Delta W = B A
    ]

    其中:

    • ( W \in \mathbb{R}^{d\text{out} \times d\text{in}} )
    • ( B \in \mathbb{R}^{d_\text{out} \times r} )
    • ( A \in \mathbb{R}^{r \times d_\text{in}} )
    • ( r \ll \min(d\text{out}, d\text{in}) ) 是一个很小的秩(rank)超参数

推理时真正用到的权重是:

[
W_\text{eff} = W + \Delta W = W + B A
]

而训练时只更新 AB 这两个小矩阵。

好处是:

  • 原始矩阵参数量:(d\text{out} \times d\text{in})
  • LoRA 新增参数量:(r \times (d\text{out} + d\text{in}))

r 远小于 d_in, d_out 时,新增的参数只是原来的一小部分,但依然能对模型行为产生足够大的影响。

2.2 LoRA 在 Transformer 里“插”在哪里?

一般来说,LoRA 会插在如下模块的线性层上:

  • 注意力部分:q_proj, k_proj, v_proj, o_proj
  • FFN 部分:前馈网络的线性层

做法是:对这些模块的权重 W,额外加一个 BA 分支,训练时只更新 A, B

在 Hugging Face 的 peft 库中,你会通过配置 target_modules 来指定要加 LoRA 的模块名。

2.3 常见超参数与经验

  • r(秩):8 / 16 / 32
    • 越大表示表达能力越强,但参数和显存都会增加。
  • lora_alpha:16 / 32 / 64
    • 相当于缩放因子,影响更新幅度。
  • lora_dropout:0~0.1
    • 给 LoRA 分支加一点 dropout,一定程度上能防止过拟合。
  • target_modules
    • 最常见是 ["q_proj", "v_proj"],也有一起加上 k_proj, o_proj 的。

直观理解:
LoRA 就是给每个被选中的线性层挂了一个“小外挂”,用极少的新增参数表达“任务特定偏移”。


三、QLoRA:在量化基础上的 LoRA

LoRA 解决的是“只改少量参数”的问题,但有一个前提:

你依然要能把基础模型(通常是 FP16/FP32)完整加载到显存中。

当你想玩的是 13B、34B、70B 这类模型时,仅仅是把模型 load 进显存本身就已经是一道坎。

QLoRA 的核心思路可以概括为两点:

  1. 基础模型量化到 4-bit,极大压缩显存占用;
  2. 在量化后的模型上套一层 LoRA,只训练 LoRA 参数。

3.1 QLoRA 做了什么?

transformers + bitsandbytes 为例:

  1. 使用 BitsAndBytesConfig 把模型权重加载为4-bit 量化权重(比如 NF4 格式);
  2. 冻结这些 4-bit 权重,不更新它们;
  3. 在指定模块上加 LoRA 层(通常使用 16-bit / bfloat16)进行训练。

好处非常直接:
原本 7B 模型 FP16 需要十几 GB 显存,量化到 4-bit 后,显存需求直接砍到原来的 1/4 左右,配合 LoRA 的参数高效更新,可以让很多人在单 24G 显卡上完成大模型微调

3.2 QLoRA 与 LoRA 的对比

可以用一张简单的表来总结:

维度 LoRA QLoRA
基础模型精度 FP16 / FP32 4-bit 量化(NF4 等)
显存节省来源 只更新少量 LoRA 参数 基础模型量化 + 只更新 LoRA
可支持模型规模 中等规模(7B~13B 很合适) 更大规模(几十 B 模型也有机会)
工程复杂度 相对简单 稍复杂,需要 bitsandbytes 等依赖
推理部署 可选合并 LoRA 或动态加载 同理,但要考虑量化推理的兼容性

一句话对比:

  • LoRA: 适合你“模型勉强能 load 进显存”的情况;
  • QLoRA: 适合你“模型根本 load 不进显存,但我又想玩大模型”的情况。

四、应用场景:什么时候用 LoRA,什么时候用 QLoRA?

可以从三个维度来决策:显存大小、模型规模、任务复杂度

4.1 显存资源与模型规模

  1. 单卡 24G 左右,模型 7B / 8B:

    • LoRA 和 QLoRA 都可行;
    • 更推荐先用 LoRA,上手简单、依赖少、调试方便。
  2. 想玩 13B/33B/70B 这种更大模型:

    • QLoRA 非常有价值;
    • 4-bit 量化 + LoRA,可以在单机上把“不可能”变成“勉强可能”。

4.2 任务类型

  1. 轻量任务:分类、情感分析、小规模文本匹配

    • 通常只需要对高层进行轻度适配,LoRA 已经足够;
    • 有些场景甚至只微调输出头也能得到不错效果。
  2. 复杂生成任务:指令微调、角色扮演、多轮对话

    • 适合用 LoRA 或 QLoRA;
    • 任务越复杂、模型越大,QLoRA 的优势越明显(能使用更大的基础模型)。
  3. 多任务/多领域适配

    • LoRA/QLoRA 的一个 bonus 是:
      不同任务只需要各自保存一份 LoRA 权重(几十 MB),
      推理时按需加载,或者做线性组合,部署成本远低于多份完整模型。

五、实战代码:基于 LoRA 的微调示例

下面是一个使用 LoRA 微调 Causal LM 模型 的最小化示例,基于:

  • transformers
  • peft
  • datasets

你可以把它改成你自己的数据集和模型名称。

5.1 环境准备

pip install "transformers[torch]" peft datasets accelerate bitsandbytes

5.2 LoRA 微调代码

import torch
from datasets import load_dataset
from transformers import (
    AutoTokenizer,
    AutoModelForCausalLM,
    TrainingArguments,
    Trainer,
)
from peft import LoraConfig, get_peft_model, TaskType

# 1. 选择基础模型(这里以 LLaMA-2 为例,需有访问权限)
base_model_name = "meta-llama/Llama-2-7b-hf"

tokenizer = AutoTokenizer.from_pretrained(base_model_name, use_fast=False)
tokenizer.pad_token = tokenizer.eos_token

# 2. 加载基础模型(不做量化,普通 LoRA)
model = AutoModelForCausalLM.from_pretrained(
    base_model_name,
    torch_dtype=torch.float16,
    device_map="auto",  # 自动分配到可用 GPU
)

# 3. 配置 LoRA
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=8,
    lora_alpha=16,
    lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"],  # 具体模块名称视模型实现而定
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# 4. 准备数据集(以 Alpaca 数据为例)
dataset = load_dataset("tatsu-lab/alpaca")

def format_example(example):
    # 简单的指令微调格式:用户 + 助手
    text = f"用户: {example['instruction']}\n助手: {example['output']}"
    tokenized = tokenizer(
        text,
        truncation=True,
        max_length=512,
        padding="max_length",
    )
    tokenized["labels"] = tokenized["input_ids"].copy()
    return tokenized

train_dataset = dataset["train"].map(
    format_example,
    remove_columns=dataset["train"].column_names,
)

# 5. 训练配置
training_args = TrainingArguments(
    output_dir="./lora-llama2-7b",
    per_device_train_batch_size=1,
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    learning_rate=2e-4,
    fp16=True,
    logging_steps=10,
    save_steps=500,
    save_total_limit=2,
    report_to="none",
)

# 6. 启动训练
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)
trainer.train()

# 7. 只保存 LoRA 适配器(而不是整个基础模型)
model.save_pretrained("./lora_adapter")
tokenizer.save_pretrained("./lora_adapter")

5.3 推理时加载 LoRA

推理阶段,你可以这样加载基础模型 + LoRA 适配器:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

base_model_name = "meta-llama/Llama-2-7b-hf"
adapter_path = "./lora_adapter"

tokenizer = AutoTokenizer.from_pretrained(base_model_name, use_fast=False)
model = AutoModelForCausalLM.from_pretrained(
    base_model_name,
    torch_dtype=torch.float16,
    device_map="auto",
)

# 加载 LoRA 适配器
model = PeftModel.from_pretrained(model, adapter_path)
model.eval()

prompt = "请用三句话介绍一下 LoRA 微调的原理。"
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)

with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=128,
        do_sample=True,
        temperature=0.7,
        top_p=0.9,
    )

print(tokenizer.decode(outputs[0], skip_special_tokens=True))

六、实战代码:基于 QLoRA 的微调示例(4-bit 量化 + LoRA)

QLoRA 的代码与 LoRA 很像,关键差异在于模型加载阶段
我们需要用 BitsAndBytesConfig 指定 4-bit 量化参数。

6.1 QLoRA:4-bit 量化加载模型

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

base_model_name = "meta-llama/Llama-2-7b-hf"

# 1. 定义 4-bit 量化配置(以 NF4 为例)
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",        # NormalFloat4
    bnb_4bit_compute_dtype=torch.bfloat16,  # 运算精度,可根据硬件选择
)

# 2. 加载 4-bit 量化后的基础模型
model = AutoModelForCausalLM.from_pretrained(
    base_model_name,
    quantization_config=bnb_config,
    device_map="auto",
)

tokenizer = AutoTokenizer.from_pretrained(base_model_name, use_fast=False)
tokenizer.pad_token = tokenizer.eos_token

6.2 在量化模型上叠加 LoRA 并训练(完整示例)

import torch
from datasets import load_dataset
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    TrainingArguments,
    Trainer,
)
from peft import LoraConfig, get_peft_model, TaskType

base_model_name = "meta-llama/Llama-2-7b-hf"

# 1. 4-bit 量化配置
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,  # 若不支持 bf16,可改成 torch.float16
)

# 2. 加载量化基础模型
model = AutoModelForCausalLM.from_pretrained(
    base_model_name,
    quantization_config=bnb_config,
    device_map="auto",
)

tokenizer = AutoTokenizer.from_pretrained(base_model_name, use_fast=False)
tokenizer.pad_token = tokenizer.eos_token

# 3. 配置 LoRA(与普通 LoRA 类似)
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"],  # 可按需添加更多模块
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

# 4. 准备数据集(仍以 Alpaca 为例)
dataset = load_dataset("tatsu-lab/alpaca")

def format_example(example):
    text = f"用户: {example['instruction']}\n助手: {example['output']}"
    tokenized = tokenizer(
        text,
        truncation=True,
        max_length=512,
        padding="max_length",
    )
    tokenized["labels"] = tokenized["input_ids"].copy()
    return tokenized

train_dataset = dataset["train"].map(
    format_example,
    remove_columns=dataset["train"].column_names,
)

# 5. 训练配置(通常会搭配更大的梯度累积)
training_args = TrainingArguments(
    output_dir="./qlora-llama2-7b",
    per_device_train_batch_size=1,
    gradient_accumulation_steps=16,
    num_train_epochs=3,
    learning_rate=2e-4,
    bf16=True,  # 如果 GPU 不支持 bf16,可改用 fp16=True
    logging_steps=10,
    save_steps=500,
    save_total_limit=2,
    report_to="none",
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
)
trainer.train()

# 6. 保存 QLoRA 适配器
model.save_pretrained("./qlora_adapter")
tokenizer.save_pretrained("./qlora_adapter")

推理阶段,与 LoRA 类似,只是基础模型依然需要用 4-bit 量化方式加载,再叠加 QLoRA 适配器。


七、总结:一张脑图式回顾

可以用一句话和一张“脑内图”来记住 LoRA 和 QLoRA:

  • LoRA:
    冻结原始大模型,只在少量“低秩矩阵”上学习增量,从而大幅减少训练参数和显存开销。

  • QLoRA:
    先把基础模型做 4-bit 量化,再叠加 LoRA 更新,用更少的显存在更大模型上做微调。

在实际项目中,你可以这样快速决策:

  1. 显存够用(7B/13B 勉强能放下): 先用 LoRA,上手快、问题少。
  2. 显存吃紧但想玩更大的模型: 尝试 QLoRA,量化 + LoRA 的组合让大模型“落地”变得可能。
  3. 多任务、多领域适配: 使用多个 LoRA/QLoRA 适配器按需加载,极大降低部署与存储成本。