一、为什么我要在24GB卡上折腾7B模型
先说背景。我接的需求是给一个内部知识问答场景做微调,基座选Qwen2.5-7B-Instruct,因为它在中文指令跟随上表现稳定,而且社区生态好。手里只有一张RTX 3090,24GB显存。
第一反应是直接全参数微调,结果刚加载完模型、优化器状态一初始化,显存就爆了。7B模型全参微调,光权重fp16就要14GB,加上AdamW的优化器状态(fp32的一阶二阶动量)差不多56GB,再加激活值,24GB根本不够看。上多卡?预算不批。上A100?更不现实。
所以路线很明确:参数高效微调(PEFT)。LoRA是首选,QLoRA是备选升级。我决定两个都跑一遍,用数据说话,看哪个更适合落地。
二、环境与版本
环境这块我踩过版本坑,先把确定能跑通的组合列出来:
- GPU:RTX 3090 24GB
- CUDA:12.1
- PyTorch:2.4.0+cu121
- transformers:4.44.2
- peft:0.13.2
- bitsandbytes:0.43.3(QLoRA必需)
- datasets:2.21.0
- trl:0.10.1(用它的SFTTrainer省事)
- Python:3.10.14
特别提醒:bitsandbytes和CUDA版本强绑定,0.43.x配CUDA 12.1是稳的。我之前用0.41.0配CUDA 11.8,4-bit量化加载时直接报CUDA illegal memory access,换版本才好。
三、方案设计
整体思路是两条线并行对比:
方案A:LoRA(fp16基座 + LoRA适配器)
基座模型以fp16加载,冻结全部原始参数,只在注意力层的q_proj、k_proj、v_proj、o_proj和FFN的gate_proj、up_proj、down_proj上挂LoRA。rank=16,alpha=32,dropout=0.05。
方案B:QLoRA(4-bit量化基座 + LoRA适配器)
基座用bitsandbytes做NF4量化加载,计算时反量化到bf16。LoRA配置同上。这是省显存的关键。
数据格式统一用ChatML风格的对话模板,因为Qwen2.5本身就用这个。
四、核心实现
4.1 数据准备
我的数据是内部FAQ整理的,大约3200条,格式是{"instruction": ..., "output": ...}。先转成对话格式:
import json
from datasets import Dataset
def build_dataset(raw_path):
samples = []
with open(raw_path, "r", encoding="utf-8") as f:
for line in f:
item = json.loads(line)
messages = [
{"role": "system", "content": "你是一个专业的知识助手,回答要准确简洁。"},
{"role": "user", "content": item["instruction"]},
{"role": "assistant", "content": item["output"]},
]
samples.append({"messages": messages})
return Dataset.from_list(samples)
dataset = build_dataset("data/faq_train.jsonl")
dataset = dataset.train_test_split(test_size=0.05, seed=42)
print(dataset)
# DatasetDict({'train': 3040, 'test': 160})
数据质量比数量重要。我一开始塞了8000条,里面混了不少重复和低质样本,loss降得很快但推理时开始胡言乱语。后来清洗到3200条,效果反而更好。
4.2 QLoRA训练脚本
这是核心代码,LoRA和QLoRA只差一个BitsAndBytesConfig:
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
MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"
# ---- QLoRA 4-bit 量化配置 ----
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
quantization_config=bnb_config, # LoRA方案就把这行删掉,改 torch_dtype=torch.bfloat16
device_map="auto",
trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
# ---- LoRA 配置 ----
lora_config = 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_config)
model.print_trainable_parameters()
# trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.5273
可训练参数只有4037万,占全量的0.53%,这就是LoRA省资源的本质。
训练参数:
training_args = TrainingArguments(
output_dir="./output_qwen_lora",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效batch size = 16
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.03,
num_train_epochs=3,
bf16=True,
logging_steps=10,
save_strategy="epoch",
gradient_checkpointing=True,
optim="paged_adamw_8bit", # QLoRA必配,省显存
max_grad_norm=0.3,
report_to="none",
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
tokenizer=tokenizer,
max_seq_length=1024,
packing=False,
)
trainer.train()
trainer.model.save_pretrained("./lora_adapter_final")
几个关键参数说明:学习率2e-4是LoRA的常用起点,比全参微调大一个数量级;paged_adamw_8bit是QLoRA的标配优化器,优化器状态用8bit分页存储;max_grad_norm=0.3比默认的1.0小,能抑制loss尖刺。
五、踩坑与优化
坑1:显存还是不够。 第一次跑QLoRA,batch_size=4直接OOM。降到2并开启gradient_checkpointing后,峰值显存10.3GB,稳了。gradient_checkpointing是用时间换空间,训练速度慢约15%,但值得。
坑2:loss不收敛,一直在2.5附近抖。 排查发现是warmup没设,前几百步学习率太高把适配器带偏了。加上warmup_ratio=0.03后曲线立刻顺滑。
坑3:推理时输出重复。 这是典型的过拟合,训练了5个epoch。减到3个epoch,并把dropout从0提到0.05,问题消失。
优化点:target_modules的选择。 只挂q、v两个投影时,测试集准确率是84.1%;挂上全部7个模块后提升到89.2%,训练时间只多了约12%。所以别省这点,全挂。
六、效果数据
Loss曲线(QLoRA方案,3 epoch):
- 第1 epoch结束:train_loss 1.42,eval_loss 1.38
- 第2 epoch结束:train_loss 0.87,eval_loss 0.91
- 第3 epoch结束:train_loss 0.61,eval_loss 0.79
训练总耗时约2小时18分,平均每步0.74秒。LoRA方案(fp16基座)耗时1小时52分,快约18%,但峰值显存21.4GB,几乎是QLoRA的两倍。
显存对比:
| 方案 | 峰值显存 | 训练时长 | 可训练参数 |
|------|---------|---------|-----------|
| LoRA (fp16) | 21.4GB | 1h52m | 40.4M |
| QLoRA (4-bit) | 10.3GB | 2h18m | 40.4M |
推理效果对比(自建测试集160条,人工判定准确率):
- 基座模型:61.5%
- LoRA微调后:88.7%
- QLoRA微调后:89.2%
有意思的是QLoRA反而略高于LoRA,我猜是4-bit量化带来了一点正则化效果,但这个差距在统计噪声范围内,不必过度解读。
举一个具体case。问:"订单超过48小时未发货怎么处理?"基座模型回答得泛泛而谈,提到"联系客服";微调后能准确说出内部流程的四个步骤和对应时限,这正是训练数据里的内容。
七、总结
几点结论供参考:
- 显存紧张就上QLoRA,10GB出头就能微调7B模型,代价是训练慢不到20%,效果几乎无损。
- LoRA的rank不用太大,16已经够用,我试过r=32和r=64,测试集准确率只差0.3%左右,但显存和训练时间都上去了。
- 数据质量 >> 数据数量,3200条干净数据打败了8000条脏数据。
- target_modules尽量全覆盖,收益明显。
- 如果显存充足(比如40GB以上),直接LoRA更省心,少一层量化反量化的复杂度。
代码已经整理成脚本,换掉数据路径就能跑。QLoRA这套组合在消费级显卡上做7B微调,目前看是性价比最高的方案,没有之一。