一、问题背景:为什么我要在24GB卡上折腾7B模型

先说结论:不是所有微调都需要A100集群。我这次的需求很具体——给一个垂直领域的客服场景做模型适配,基座选Qwen2.5-7B-Instruct,手头只有一张RTX 4090(24GB)。全参数微调7B模型,光优化器状态(AdamW fp32)就要吃掉约112GB显存,直接出局。所以LoRA是必选项,而为了把batch size撑起来、把序列长度拉到2048,我又叠加了QLoRA的4-bit量化。

这里有个常见误区值得提前说清楚:LoRA和QLoRA不是二选一的对立关系。QLoRA指的是“基座模型4-bit量化 + LoRA适配器用bf16训练”这套组合拳,本质是LoRA的一种省显存实现。很多人把两者混着说,配置的时候就会写出load_in_4bit=True却忘了bnb_4bit_compute_dtype,结果训练速度掉一半,这个后面踩坑部分会细讲。

最终目标:领域问答准确率(人工标注的200条测试集)从基座的61.2%提到80%以上,同时推理延迟不能恶化太多,因为要上线上服务。

二、环境与版本:版本号对不上,后面全是玄学

先把版本钉死,这类训练对版本极其敏感,我见过transformers 4.36和4.40在同一个脚本上loss曲线完全不同的情况。

Python 3.10.13
torch 2.3.1+cu121
transformers 4.41.2
peft 0.11.1
bitsandbytes 0.43.1
accelerate 0.30.1
datasets 2.19.1
trl 0.9.4
显卡:RTX 4090 24GB,驱动 550.54.14,CUDA 12.1

几个关键约束:bitsandbytes必须≥0.43才稳定支持4-bit的NF4量化在40系卡上的推理;peft 0.11开始LoraConfig的target_modules支持正则匹配,写起来省事;trl的SFTTrainer在0.9.x版本对packing参数的行为和0.8不一样,如果你照着老博客抄会踩坑。

三、方案设计:r=16、NF4、target_modules怎么选

方案上有几个决策点,我逐个说理由:

量化方案:4-bit NF4 + double quant + bf16计算。NF4(NormalFloat4)是QLoRA论文里针对正态分布权重设计的量化格式,实测比fp4在7B模型上 perplexity 低约0.3。double quant进一步压缩量化常数,省约0.4GB显存。计算类型必须设成bf16,设成fp16在4090上会偶发溢出。

LoRA超参:r=16,alpha=32,dropout=0.05。r=16是我在8.6k数据量下的经验值,r=8欠拟合(验证loss卡在1.1下不去),r=32过拟合明显(第2个epoch验证loss就开始回升)。alpha=2r是常规做法。

target_modules:这是最容易糊弄过去的地方。全量q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj都挂LoRA,可训练参数约40M,效果最好但显存和速度都吃紧。我最终选了q_proj, k_proj, v_proj, o_proj + gate_proj, up_proj,跳过down_proj,可训练参数约21M,验证集表现和全挂差距在0.5%以内,训练速度快了约18%。

训练配置:batch_size=4,gradient_accumulation_steps=4(等效batch 16),max_seq_length=2048,learning_rate=2e-4,cosine调度,warmup_ratio=0.03,3个epoch。优化器用paged_adamw_8bit,这是QLoRA的标配,能把优化器状态也压到8-bit分页内存里。

四、核心实现:数据准备与训练代码

4.1 数据准备

数据是8.6k条领域问答,原始格式是jsonl,每条含instruction、input、output。清洗做了三件事:去重(按instruction的simhash,去掉约600条)、过滤output长度1500的样本、把多轮对话拼成单轮(这个场景不需要多轮)。

关键点是必须留出验证集,我按98:2切了8.4k训练 / 200验证。很多人微调不看验证loss,训完直接上测试集,过拟合了都不知道。

from datasets import load_dataset

def format_sample(example):
    if example.get("input"):
        prompt = f"### 指令:\n{example['instruction']}\n### 输入:\n{example['input']}\n### 回答:\n"
    else:
        prompt = f"### 指令:\n{example['instruction']}\n### 回答:\n"
    return {"text": prompt + example["output"]}

ds = load_dataset("json", data_files={"train": "data/train.jsonl", "valid": "data/valid.jsonl"})
ds = ds.map(format_sample, remove_columns=ds["train"].column_names)
print(ds["train"][0]["text"][:300])

4.2 训练脚本

下面是完整可运行的训练代码,注意bnb_4bit_compute_dtype和prepare_model_for_kbit_training这两处,漏了任何一个都会出问题。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig

model_name = "Qwen/Qwen2.5-7B-Instruct"

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)

tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False

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"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 21,233,664 || all params: 7,636,289,536 || trainable%: 0.278

sft_config = SFTConfig(
    output_dir="./output/qwen2.5-7b-lora",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=20,
    eval_strategy="steps",
    eval_steps=100,
    save_strategy="steps",
    save_steps=400,
    save_total_limit=2,
    bf16=True,
    max_seq_length=2048,
    packing=False,
    dataset_text_field="text",
    optim="paged_adamw_8bit",
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=sft_config,
    train_dataset=ds["train"],
    eval_dataset=ds["valid"],
    tokenizer=tokenizer,
)
trainer.train()
trainer.model.save_pretrained("./output/qwen2.5-7b-lora/final")

packing=False是我特意关的。packing会把多条短样本拼进一个2048窗口提升吞吐,但这个场景的样本大多在600-900 token,packing后跨样本注意力会串味,验证loss反而不如不开。如果你数据都是短样本且同质,可以开。

五、踩坑与优化:三个真实问题

坑1:loss从第1步就是0或者nan。 原因是prepare_model_for_kbit_training没调用,或者调用前已经把模型挂上了LoRA。正确顺序是先prepare再get_peft_model,且prepare时use_gradient_checkpointing=True(默认就是)。另外Qwen2.5的tokenizer要手动设pad_token,否则pad位置参与loss计算。

坑2:显存OOM在eval阶段而不是train阶段。 训练时batch 4没事,eval_steps触发评估时爆了。原因是SFTTrainer评估默认用per_device_eval_batch_size继承train的4,但评估不累积梯度,瞬时显存更高。改成per_device_eval_batch_size=2解决。峰值显存从OOM前的23.1GB降到18.3GB。

坑3:4-bit模型保存后加载推理,输出乱码。 直接save_pretrained保存的是4-bit量化权重,加载时如果没配同样的BitsAndBytesConfig就会解析错。正确做法是只保存LoRA adapter(几十MB),推理时加载基座+adapter,或者用merge_and_unload()合并成bf16全精度再存(约15GB)。我选后者做线上部署,合并脚本:

from peft import PeftModel
from transformers import AutoModelForCausalLM

base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct", torch_dtype="bfloat16", device_map="cpu")
merged = PeftModel.from_pretrained(base, "./output/qwen2.5-7b-lora/final")
merged = merged.merge_and_unload()
merged.save_pretrained("./output/qwen2.5-7b-merged", safe_serialization=True)

优化收益:关掉down_proj的LoRA省18%训练时间;gradient_checkpointing(prepare里默认开)省约3GB显存但慢约12%,权衡后保留;把logging_steps从10调到20,wandb开销可以忽略。

六、效果数据:loss曲线与推理对比

Loss曲线:训练loss从1.38(step 20)平滑降到0.61(step 1560),验证loss从1.42降到0.87,第3个epoch末验证loss有轻微抬头(0.87→0.89),说明3个epoch是合理上限。全程无spike,cosine调度在step 1200后学习率降到5e-5以下,曲线明显变平。

准确率:200条人工标注测试集,基座61.2%(122/200),微调后83.5%(167/200),提升22.3个百分点。分类型看,术语解释类提升最大(54%→88%),多条件约束类提升较小(58%→71%),后者可能需要更多结构化数据。

推理延迟:A100 80G上,合并后的bf16模型,输入512 token输出256 token,基座平均1.82s,微调后1.97s(+8.2%),主要来自输出变长(微调后回答平均多37个token)。如果用4-bit + adapter不合并,延迟约2.4s,所以线上还是合并划算。

一个反直觉的发现:微调后模型在通用能力上确实有退化。用C-Eval的抽样200题测,基座68.5%,微调后64.0%,掉了4.5个点。这是灾难性遗忘的典型表现,缓解办法是混入约10%的通用指令数据一起训,我第二轮实验混了800条,通用能力回到67.2%,领域准确率只掉0.8个点。这个取舍建议提前想清楚。

七、总结

这轮实践的核心结论:24GB卡微调7B模型完全可行,QLoRA + LoRA r=16 + 21M可训练参数是性价比很高的配置,3个epoch、4.5小时、18.3GB峰值显存,换来领域准确率+22.3个点。几个值得记住的点:版本要钉死、prepare_model_for_kbit_training不能漏、评估batch要单独调小、保存adapter而不是量化权重、以及一定要监控通用能力的退化。下一步我打算试试DoRA和rsLoRA,看能不能在r=8下达到r=16的效果,进一步压训练成本。代码已整理成脚本,需要的话评论区说一声。