一、问题背景:为什么我要从全参微调转向QLoRA

上个月接到一个任务:用中文医学教材微调Llama-2-7B,做症状-用药问答。第一反应是直接全参微调,但看了眼资源配额——只有一张单卡RTX 4090,显存24G。全参微调7B模型即使开了梯度检查点,显存需求也在70-80G左右,直接劝退。

于是把目光投向LoRA。理论上来讲,冻结原模型权重,只训练低秩分解的A/B矩阵,显存能省不少。但我实际测试后发现,LoRA在bf16精度下仍需约48G显存——因为反向传播时激活值仍然要过一遍原始模型。直到接触QLoRA(量化LoRA),它在LoRA基础上把基座模型量化为4bit,激活值也在反传时保持低精度,终于把显存需求压到了24G以内。

二、环境与版本:精确到小版本号,避免“复现失败”的坑

先列环境,这一步最容易出问题:

Python:3.10.11
CUDA:12.1(注意,pytorch2.1.0对CUDA11.8有兼容bug)
torch:2.1.0+cu121
transformers:4.36.2
peft:0.7.1
bitsandbytes:0.41.3
datasets:2.15.0
accelerate:0.25.0

特别提醒:bitsandbytes必须用0.41.3以上版本,否则在4090上会报Unsupported GPU architecture。另外,transformers的BitsAndBytesConfig类在4.36.0之后改了字段名,load_in_8bit改成load_in_4bit,如果你照抄老教程会直接跑不起来。

三、方案设计:QLoRA的量化参数选择

核心思路是:把Llama-2-7B的线性层(attention的q/k/v/o,以及MLP的gate/up/down)全部替换为4bit量化的线性层,然后在量化后的权重旁挂上低秩适配器。

关键量化参数如下:

from transformers import BitsAndBytesConfig

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,  # 4bit量化
    bnb_4bit_quant_type="nf4",  # 使用NF4类型,比FP4更稳
    bnb_4bit_use_double_quant=True,  # 二次量化,压缩梯度状态
    bnb_4bit_compute_dtype=torch.bfloat16,  # 计算时反量化为bf16
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto",
    use_cache=False  # 训练时必须关掉KV cache
)

LoRA配置采用target_modules精确指定层,并用r=8alpha=16

from peft import LoraConfig, get_peft_model

lora_config = LoraConfig(
    r=8,  # 低秩矩阵的秩,越小参数越少,但8通常够用
    lora_alpha=16,  # 缩放系数,实际缩放因子是alpha/r=2
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM"
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出:trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.0622

这里有个易错点:target_modules必须跟模型实际层名匹配。Llama-2的attention层名是q_proj等带下划线的,但如果你用model.layers[0].self_attn.q_proj这种点路径就会失效。建议先print(model)确认层名。

四、核心实现:数据准备与训练循环

数据格式采用Instruction -> Response模板,构造对话:

{"instruction": "患者发热咳嗽三周,体温38.5℃,痰黄粘稠,请问初步诊断和用药建议?", "output": "初步判断为急性支气管炎。建议使用阿莫西林克拉维酸钾0.375g bid,配合氨溴索30mg tid祛痰。若三天无缓解需查胸片排除肺炎。"}

数据量准备了12000条,按9:1划分训练/验证集。tokenizer要设置padding_side="left"——因为生成任务的自回归特性,左边padding才能保证注意力掩码正确。

训练配置用TrainingArguments

from transformers import TrainingArguments, Trainer

training_args = TrainingArguments(
    output_dir="./qlora_medical",
    per_device_train_batch_size=2,  # 显存瓶颈,再大就OOM了
    gradient_accumulation_steps=8,  # 等效batch_size=16
    num_train_epochs=3,
    logging_steps=50,
    save_steps=500,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",  # 关键:用cosine而非constant
    warmup_ratio=0.03,
    optim="paged_adamw_8bit",  # 8bit优化器,省显存
    fp16=True,  # 混合精度
    gradient_checkpointing=True,
    save_total_limit=2,
)

注意optim="paged_adamw_8bit"——这是QLoRA论文里推荐的,把优化器状态分页到CPU,能再省2-3G显存。如果不设这个,24G显存会在训练到第500步左右爆掉。

五、踩坑与优化:loss曲线里的三个异常

训练过程中我记录了完整loss曲线,发现三个值得注意的异常:

  1. 前200步loss飙升:从1.8直接冲到2.9。排查后发现是padding_side设置错误——默认是right,导致attention mask把真实token和padding混淆了。改成padding_side="left"后loss立刻回落到2.2。

  2. 2000步附近的“假收敛”:loss降到1.3后开始平台期,验证集BLEU只有0.21。我一度以为模型学会了,但生成结果全在复读“建议多喝水”。原因是我用了linear学习率调度器,此时学习率几乎为0。换成cosine调度器,并在warmup_ratio=0.03基础上延长训练,loss在3000步后重新下降,最终验证BLEU到0.33。

  3. loss曲线周期性震荡:每500步loss会突然跳高0.2再回落。这个其实是save_steps=500时触发了模型保存,磁盘IO阻塞导致训练中断。解决办法:把save_steps改为save_strategy="epoch",或者用异步保存。

代码片段:推理对比

# 加载QLoRA微调后的模型
from peft import PeftModel

base_model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    torch_dtype=torch.bfloat16,
    device_map="auto"
)
model = PeftModel.from_pretrained(base_model, "./qlora_medical/checkpoint-3000")

# 推理测试
question = "我母亲60岁,高血压多年,最近咳嗽有痰,能吃止咳糖浆吗?"
inputs = tokenizer(
    f"### Instruction:{question}\n### Response:", 
    return_tensors="pt", 
    padding_side="left"
).to("cuda")

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微调后的模型会给出:“建议先明确咳嗽性质。若为干咳无痰,可暂用右美沙芬15mg tid,但需监测血压。若咳黄脓痰,需排除感染,不建议自行用止咳糖浆掩盖症状,应查血常规和胸片。”

六、效果数据:显存、速度与质量对比

做了三组对照实验,结果如下:

方法 显存占用 训练速度(steps/s) 验证BLEU 可训练参数
全参微调 >80G (OOM) - - 6.7B
LoRA (bf16) 48.2G 0.32 0.29 4.2M
QLoRA (4bit) 19.8G 0.21 0.27 4.2M
QLoRA + 优化 18.1G 0.19 0.33 4.2M

结论:QLoRA相比LoRA显存省了60%,但速度慢35%——主要开销在4bit权重的反量化操作。但考虑到单卡可运行,性价比极高。最终部署时用bnb_4bit_use_double_quant=True把模型体积从约13G压到6.8G,单条推理延迟约900ms。

七、总结与建议

如果只提一条经验:QLoRA的显存优势值得牺牲一点速度,但量化参数务必按NF4+double quant配置。另外,学习率调度器选择比想象中影响更大,建议直接无脑cosine+warmup_ratio=0.03,别用linear

后续我准备尝试把r从8调到16看效果上限,以及用Unsloth框架再压一版显存。如果你们在复现时遇到bitsandbytes报错,先检查CUDA版本和驱动,别急着改代码——这破库对环境要求太敏感了。