LoRA 与 QLoRA 微调
为什么 LoRA 让单卡微调成为可能、关键超参怎么设、以及一份可直接跑的完整脚本。
这是普通人最现实的训练路径:一张 16 G 的消费级显卡就能微调 8B 模型。这一篇讲原理够用的部分、参数怎么设,以及一份能直接跑的脚本。
为什么全量微调不现实
全量微调 8B 模型需要的显存:
权重 16 GB (bf16)
梯度 16 GB
Adam 状态 32 GB (m 和 v 各一份 fp32)
激活 几 GB
─────────────────
合计 70 GB+ → 要 A100 80G 起步LoRA 的思路:冻住原始权重,只训练一组小得多的「增量」矩阵。
原始: W (4096×4096 = 1600 万参数)
LoRA: W + B·A A: 4096×16 B: 16×4096 = 13 万参数
可训练参数量降到不到 1%于是梯度和优化器状态都只针对那 1%:
LoRA 微调 8B:
权重(冻结) 16 GB ← 只读,不需要梯度
LoRA 参数 0.1 GB
梯度 + 优化器 0.3 GB
激活 几 GB
─────────────────
合计 约 20 GB → 24 G 卡能跑QLoRA 更进一步:把冻结的基座权重也量化成 4bit。
QLoRA 微调 8B:
权重(4bit 冻结) 5 GB
LoRA + 梯度 + 优化器 0.4 GB
激活 几 GB
─────────────────
合计 约 10 GB → 12~16 G 卡能跑LoRA 学的是「风格与格式」,不是「新知识」。
这个认知很重要,它决定了 LoRA 适合什么:
| 适合 | 不适合 |
|---|---|
| 固定输出格式(JSON、特定结构) | 灌输大量新事实 |
| 特定语气 / 人设 | 教会全新的语言 |
| 领域术语与表达习惯 | 提升通用推理能力 |
| 遵循特定的任务范式 | 更新知识(用 RAG) |
想让模型「知道」公司文档的内容,RAG 比微调有效得多也便宜得多。 微调是让它「用你希望的方式说话」。
装环境
pip install -U transformers peft trl datasets accelerate bitsandbytes一份完整的 QLoRA 脚本
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
MODEL = "Qwen/Qwen3-8B"
# ── 1. 4bit 加载基座 ──────────────────────────────
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
tok = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForCausalLM.from_pretrained(
MODEL,
quantization_config=bnb,
torch_dtype=torch.bfloat16,
device_map="auto",
attn_implementation="sdpa",
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False # 训练时必须关,否则和梯度检查点冲突
# ── 2. 挂 LoRA ───────────────────────────────────
lora = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
)
model = get_peft_model(model, lora)
model.print_trainable_parameters()
# trainable params: 43,646,976 || all params: 8,234,... || trainable%: 0.53
# ── 3. 数据 ──────────────────────────────────────
ds = load_dataset("json", data_files="train.jsonl", split="train")
ds = ds.train_test_split(test_size=0.05, seed=42)
# ── 4. 训练 ──────────────────────────────────────
cfg = SFTConfig(
output_dir="./out",
num_train_epochs=3,
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效 batch = 2×8 = 16
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=100,
save_total_limit=2,
bf16=True,
gradient_checkpointing=True,
max_length=2048,
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=cfg,
train_dataset=ds["train"],
eval_dataset=ds["test"],
processing_class=tok,
)
trainer.train()
trainer.save_model("./out/final")数据格式见做自己的数据集——SFTTrainer 认 {"messages": [...]},会自动套 chat template。
关键超参
r 与 lora_alpha
r=16, lora_alpha=32 # alpha = 2r,最常用r | 可训练参数 | 适合 |
|---|---|---|
| 4~8 | 极少 | 数据 < 1000 条,只改风格 |
| 16 | 少 | 默认起点,覆盖多数场景 |
| 32~64 | 中 | 数据多、任务复杂 |
| 128+ | 多 | 接近全量微调,很少需要 |
lora_alpha 通常设为 2×r。它和 r 的比值决定 LoRA 的「影响强度」(缩放因子是 alpha/r),所以按比例设就好。
别一上来就把 r 调大。 常见误区是「效果不好 → 加大 r」,但多数情况下效果不好是数据问题,加大 r 只会更快过拟合。
正确顺序:先查数据 → 再调学习率与轮数 → 最后才动 r。
target_modules
# 全部线性层(推荐)
target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"]
# 只注意力(更省,效果略弱)
target_modules=["q_proj","k_proj","v_proj","o_proj"]
# 让 peft 自己找
target_modules="all-linear""all-linear" 是最省事的写法,尤其换模型架构时不用改名字。研究普遍显示挂满所有线性层比只挂注意力效果好。
learning_rate
learning_rate=2e-4 # LoRA 的典型值LoRA 的学习率比全量微调高一到两个数量级(全量通常 1e-5~5e-5)。因为可训练参数少、初始化为零,需要更大的步子。
| 情况 | 建议 |
|---|---|
| 默认 | 2e-4 |
| 数据少(< 1000) | 1e-4,防过拟合 |
| loss 不降 | 调到 3e-4~5e-4 |
| loss 震荡 / 变 NaN | 降到 5e-5 |
batch size 与梯度累积
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效 batch = 16显存放不下大 batch 就用梯度累积——效果接近,只是慢一些。等效 batch 建议 16~32。
显存不够时的开关
按牺牲程度从小到大:
gradient_checkpointing=True, # ① 省很多,慢约 20~30%
per_device_train_batch_size=1, # ② 配大 gradient_accumulation_steps
max_length=1024, # ③ 缩短序列,省得最多
optim="paged_adamw_8bit", # ④ 8bit 优化器,再省一点
r=8, # ⑤ 最后才动这个max_length 是最有效的显存旋钮。 激活显存与序列长度大致成正比,从 2048 降到 1024 能省近一半。
但要确认你的数据实际有多长——如果 95% 的样本都在 800 token 以内,设 2048 纯属浪费:
lens = [len(tok.apply_chat_template(x["messages"])) for x in ds.select(range(500))]
print(sorted(lens)[int(len(lens)*0.95)]) # 95 分位按 95 分位设 max_length,剩下 5% 截断掉影响不大。
用训练好的 LoRA
直接加载适配器
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype="auto", device_map="auto")
model = PeftModel.from_pretrained(base, "./out/final")
model.eval()适配器只有几十到几百 MB,可以随便分发。用户自己下基座。
合并进基座
要部署到 vLLM 或转 GGUF,得先合并:
merged = model.merge_and_unload()
merged.save_pretrained("./merged", safe_serialization=True)
tok.save_pretrained("./merged")合并必须用未量化的基座。 从 4bit 模型合并出来的权重质量会有损失。
正确流程:用 4bit 训练(QLoRA) → 加载 bf16 基座 → 挂上适配器 → 合并。
base = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16) # 不量化
m = PeftModel.from_pretrained(base, "./out/final")
merged = m.merge_and_unload()这一步需要能装下完整 bf16 权重的内存——显存不够可以 device_map="cpu" 在内存里做。
vLLM 也支持直接挂载 LoRA,不合并:
vllm serve Qwen/Qwen3-8B --enable-lora --lora-modules my-lora=./out/final小结
- LoRA 学风格与格式,不学新知识——要「知道」内容用 RAG。
- QLoRA(4bit 基座 + LoRA)让 12~16 G 卡能微调 8B。
- 起手参数:
r=16、alpha=32、lr=2e-4、target_modules="all-linear"。 use_cache=False训练时必须关。- 效果不好的排查顺序:数据 → 学习率与轮数 → 最后才动 r。
- 显存不够按序开:梯度检查点 → 减 batch + 累积 → 缩
max_length→ 8bit 优化器。 max_length按数据的 95 分位设,别照抄 2048。- 合并要用未量化的 bf16 基座,否则质量受损。
下一篇:训练实操。