
SFT、LoRA 与 QLoRA:大模型微调的两条正交轴与实操指南
SFT、LoRA 与 QLoRA:大模型微调的两条正交轴与实操指南
SFT、LoRA、QLoRA 常被并列提及,但它们回答的其实是两个不同问题:SFT 决定「让模型学什么」,LoRA 与 QLoRA 决定「更新模型的哪些权重」。本文梳理这两条正交轴的关系,给出从数据准备到训练、保存与推理的完整流程,并说明秩、量化、显存与常见坑。
刚开始接触大模型微调时,最容易混淆的一组概念就是 SFT、LoRA 与 QLoRA。它们经常出现在同一份清单里,于是很容易被理解成三个互相替代的选项:要么做 SFT,要么做 LoRA,要么做 QLoRA。但真实情况是,这三者并不在同一个比较维度上。SFT 回答的是「让模型学什么」,LoRA 与 QLoRA 回答的是「把学到的内容写进模型的哪里」。理解这一点之后,很多说法就自然通了,比如「用 LoRA 做 SFT」并不是把两种方法混在一起,而是同时指定了学习方式和更新方式。
本文面向已经能跑通基础训练脚本、准备把预训练模型调整到具体任务上的读者。内容不深入推导公式,重点是把概念关系理清,并给出一条可以照着走完的实操路径。文中涉及的具体库版本、显存占用与量化配置会随生态更新而变化,实际使用时请以相关项目官网的当前信息为准。
准备工作
在动手之前,需要先确认几件事,否则训练脚本能跑起来,结果却未必符合预期。
明确你要解决的是哪一类问题
微调的目标大致可以分成两类。第一类是让模型掌握某种行为方式,例如稳定地按指令作答、按固定格式输出、在特定领域里使用正确术语。第二类是让模型的输出风格更贴近人的偏好,例如更礼貌、更安全、更符合某种表达习惯。前者适合用监督式微调(SFT)来做,后者通常需要在 SFT 之后再用偏好类方法继续调整。如果连目标都没分清,就很容易在数据准备阶段做无用功。
环境与依赖
常见的做法是使用 Hugging Face 生态中的 Transformers、PEFT 与 TRL 三个库:Transformers 负责加载模型与分词器,PEFT 负责注入 LoRA 适配器,TRL 负责训练循环与数据组织。此外还需要 PyTorch、Accelerate 以及可选的 bitsandbytes(用于 4bit 量化加载)。这些库的版本之间存在兼容性要求,安装前建议先查看各项目官网当前给出的版本说明,不要盲目固定一个旧版本组合。
硬件方面,全量微调对显存的要求最高,LoRA 次之,QLoRA 最低。具体能跑多大的模型,取决于模型参数量、序列长度、批大小以及是否开启梯度检查点等因素,没有一个可以照抄的固定数字。建议先用小模型和小数据集把流程跑通,再逐步放大。
数据格式
SFT 使用的数据是「输入」与「示范答案」的配对。在对话场景中,通常组织成多轮消息列表,每条消息带有角色标记(例如 system、user、assistant)。数据质量比数量更重要:示范答案要一致、格式要统一、不要混入互相矛盾的样本。如果数据里既有「回答要简洁」的样本,又有「回答要详尽」的样本,模型学到的就是随机性。
操作步骤
第一步:加载基础模型与分词器
先决定是否使用量化加载。如果显存紧张,可以用 4bit 量化把基础模型压缩后加载,这就是 QLoRA 的前提;如果显存充足,直接以半精度加载即可,对应普通 LoRA。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_id = "your-base-model"
tokenizer = AutoTokenizer.from_pretrained(model_id)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
)
如果不需要量化,把 quantization_config 这一项去掉即可。注意量化加载后,基础权重是被冻结的,训练只会更新后面注入的适配器。
第二步:配置 LoRA 适配器
LoRA 的思路是保持原始权重不动,在旁边挂上两个小矩阵,训练时只更新这两个小矩阵。以 4096×4096 的权重矩阵为例,原始参数量约 1680 万;若取秩 r=8,新增的两个矩阵分别是 4096×8 与 8×4096,合计约 6.5 万参数,只有原来的约 0.4%。这就是显存节省的来源。
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "v_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
几个关键参数的含义:r 是低秩矩阵的秩,越大表达能力越强、参数量越多;lora_alpha 是缩放系数,常取 r 的两倍左右;lora_dropout 用于正则化;target_modules 决定把适配器挂到哪些层上,常见做法是先挂注意力层的查询与值投影,效果不够再扩大范围。这些取值没有普适最优解,需要结合任务试验。
第三步:准备数据集
把原始数据整理成对话格式,再套用对话模板转成模型可读的 token 序列。下面是一个最小示例:
from datasets import Dataset
raw = [
{
"messages": [
{"role": "user", "content": "忘记密码了怎么办?"},
{"role": "assistant", "content": "可以在登录页点击「忘记密码」,通过邮箱重置。"},
]
},
{
"messages": [
{"role": "user", "content": "如何修改绑定手机号?"},
{"role": "assistant", "content": "进入账户设置,选择安全选项,按提示验证后即可更换。"},
]
},
]
dataset = Dataset.from_list(raw)
实际项目中还需要划分验证集,用来观察是否过拟合。验证集不要与训练集来自同一批模板化样本,否则指标会虚高。
第四步:配置训练参数并启动训练
TRL 提供了面向监督式微调的训练器,它把「学什么」和「更新哪里」这两件事分别交给数据集和 peft_config 来指定。下面这段代码同时体现了 SFT 与 LoRA 的组合关系:
from trl import SFTTrainer, SFTConfig
sft_config = SFTConfig(
output_dir="./out",
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=2e-4,
num_train_epochs=3,
logging_steps=10,
save_strategy="epoch",
max_length=1024,
gradient_checkpointing=True,
bf16=True,
)
trainer = SFTTrainer(
model=model,
args=sft_config,
train_dataset=dataset,
peft_config=lora_config,
)
trainer.train()
把 peft_config 这一行去掉,同样的数据与训练流程就变成了全量微调。这正是理解两者关系最直观的方式:LoRA 是附加在训练方法之上的一个选项,而不是与 SFT 并列的另一种训练方法。
第五步:保存与合并适配器
训练结束后,保存下来的是体积很小的适配器文件,而不是整个模型。这样可以为不同任务分别保存适配器,按需切换。
trainer.save_model("./out/lora-adapter")
tokenizer.save_pretrained("./out/lora-adapter")
如果希望得到一个独立的模型文件,可以把适配器权重合并回基础模型:
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, "./out/lora-adapter")
merged = merged.merge_and_unload()
merged.save_pretrained("./out/merged-model")
合并后的模型推理时不再需要 PEFT 库,部署更简单,但会失去「一个基础模型挂多个适配器」的灵活性。
第六步:推理验证
加载适配器做一次生成,确认训练确实生效:
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")
model = PeftModel.from_pretrained(base, "./out/lora-adapter")
model.eval()
messages = [{"role": "user", "content": "忘记密码了怎么办?"}]
inputs = tokenizer.apply_chat_template(
messages, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
outputs = model.generate(inputs, max_new_tokens=128)
print(tokenizer.decode(outputs[0][inputs.shape[-1]:], skip_special_tokens=True))
一个完整示例
下面把前面的片段串成一个可运行的最小流程,任务是用少量客服问答数据微调一个小规模因果语言模型。
import torch
from datasets import 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_id = "your-base-model"
# 1. 分词器
tokenizer = AutoTokenizer.from_pretrained(model_id)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# 2. 4bit 量化加载(QLoRA 路线;去掉 quantization_config 即为普通 LoRA)
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
model_id, quantization_config=bnb, device_map="auto"
)
model = prepare_model_for_kbit_training(model)
# 3. LoRA 配置
lora_config = LoraConfig(
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "v_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 4. 数据
raw = [
{"messages": [
{"role": "user", "content": "忘记密码了怎么办?"},
{"role": "assistant", "content": "在登录页点击「忘记密码」,通过邮箱重置。"},
]},
{"messages": [
{"role": "user", "content": "如何修改绑定手机号?"},
{"role": "assistant", "content": "进入账户设置的安全选项,验证后更换。"},
]},
]
dataset = Dataset.from_list(raw)
# 5. 训练
args = SFTConfig(
output_dir="./out",
per_device_train_batch_size=1,
gradient_accumulation_steps=8,
learning_rate=2e-4,
num_train_epochs=3,
logging_steps=1,
save_strategy="epoch",
max_length=1024,
gradient_checkpointing=True,
bf16=True,
)
trainer = SFTTrainer(
model=model,
args=args,
train_dataset=dataset,
peft_config=lora_config,
)
trainer.train()
trainer.save_model("./out/lora-adapter")
tokenizer.save_pretrained("./out/lora-adapter")
跑完之后,用第六步的推理代码加载 ./out/lora-adapter,对比微调前后对同一问题的回答,就能判断训练是否朝预期方向走。如果回答没有明显变化,优先检查数据是否被正确套用了对话模板、target_modules 是否命中了模型实际的层名、以及学习率是否过小。
注意事项
两条轴不要混为一谈。 SFT 属于「学什么」这一层,LoRA 与 QLoRA 属于「更新哪里」这一层。它们可以自由组合:SFT 可以配全量微调,也可以配 LoRA 或 QLoRA;偏好类方法同样可以配这三种更新方式。把「LoRA 微调」当成 SFT 的替代品,是最常见的理解偏差。
偏好对齐通常在 SFT 之后进行。 先用示范数据教会模型基本的行为方式,再用人类偏好数据调整风格,是较为常见的顺序。偏好类方法本身不负责教会模型新的知识,它调整的是在多个可能回答之间的倾向。
秩不是越大越好。 增大 r 会提升表达能力,也会增加可训练参数与显存占用,同时提高过拟合风险。小数据集上盲目调大秩,往往得到的是训练损失下降、验证效果变差。
量化会带来精度损失。 4bit 量化显著降低显存需求,但基础权重的精度损失是客观存在的。对数值敏感的任务,需要评估量化带来的影响,必要时改用半精度加载配合 LoRA。
适配器与基础模型必须匹配。 保存下来的适配器只对训练时使用的那一个基础模型有效。换基础模型、换分词器,适配器都不能直接复用。
显存占用受多个因素影响。 序列长度、批大小、梯度累积步数、是否开启梯度检查点、优化器状态都会影响峰值显存。遇到显存不足时,优先缩短序列长度、减小批大小、开启梯度检查点,而不是直接换更小的模型。
数据质量决定上限。 训练超参数只能让模型逼近数据所描述的行为。示范答案不一致、格式混乱、包含错误信息的数据集,无论怎么调参都得不到稳定结果。
版本与配置以官网为准。 相关库的接口、参数名与默认值会随版本变化,量化与显存相关的具体数值也依赖硬件与模型规模。动手前请查阅各项目官网当前发布的文档,不要依赖旧教程中的固定数值。