一、问题背景:全参微调?不存在的
上周Leader丢给我一个任务:把ChatGLM2-6B在内部客服语料上微调一下,让模型学会我们产品的问答风格。我第一反应是全参微调,结果一算显存直接劝退——6B模型fp16权重就要12G,加上梯度、优化器状态(AdamW需要8字节/参数),训练态轻松突破60G,单卡3090连个影子都看不到。
于是转向LoRA(Low-Rank Adaptation)和QLoRA。LoRA通过在attention层注入低秩矩阵,只训练0.1%~1%的参数;QLoRA更进一步,把基础模型量化到4-bit,同时引入NF4(NormalFloat4)数据类型和双重量化。理论上QLoRA能把7B模型的微调显存压到6G以内。
本文的完整代码已上传至我的GitHub仓库,这里只贴核心片段,重点讲思路和坑。
二、环境与版本锁定
实验环境必须明确,不然复现全是玄学:
torch==2.0.1
transformers==4.35.2
peft==0.7.1
bitsandbytes==0.41.3
datasets==2.15.0
accelerate==0.25.0
注意:bitsandbytes在Windows上支持不好,我是在Ubuntu 22.04 + CUDA 11.8下跑的。另外peft版本不要低于0.6.0,否则prepare_model_for_kbit_training接口有变动。
三、方案设计:LoRA还是QLoRA?
我的目标很明确:在单卡3090上,用有限的客服数据(约2万条)完成领域适配。设计了两套方案做对比:
| 方案 | 基座模型 | 量化 | 可训练参数 | 理论显存 |
|---|---|---|---|---|
| A: LoRA | ChatGLM2-6B (fp16) | 无 | ~760万 | ~21G |
| B: QLoRA | ChatGLM2-6B (4bit NF4) | 4bit | ~760万 | ~6G |
LoRA配置统一采用r=8, alpha=32, dropout=0.1,只对query和value层注入适配器。我试过同时注入key和dense_h_to_4h,效果提升有限但显存增加明显,性价比不高。
四、核心实现:数据准备与训练配置
4.1 数据准备:格式决定上限
客服语料是典型的“用户问→助手答”结构。我用datasets库做loading,关键一步是构造指令模板。ChatGLM2的原生模板是[Round 1]\n\n问:...\n答:...,但我在实践中发现,用更短的模板+系统提示,收敛更快:
from datasets import Dataset
def build_prompt(sample):
system = "你是某公司智能客服,请基于产品文档给出简洁准确的回答。"
return f"{system}\n用户:{sample['question']}\n客服:{sample['answer']}"
# 预处理:tokenize并截断到512长度
def preprocess(example, tokenizer, max_len=512):
text = build_prompt(example)
inputs = tokenizer(text, truncation=True, max_length=max_len, padding=False)
inputs["labels"] = inputs["input_ids"].copy()
return inputs
dataset = Dataset.from_json("data/train.json")
tokenized_ds = dataset.map(preprocess, fn_kwargs={"tokenizer": tokenizer}, remove_columns=dataset.column_names)
这里有个小坑:不要用tokenizer.pad_token,因为ChatGLM2默认没有pad_token。我直接设置pad_token=eos_token,并保证DataCollator里padding=True,否则训练时batch内长度不一直接报错。
4.2 训练配置:QLoRA关键参数
LoRA和QLoRA的PEFT配置几乎一样,区别在于加载基座模型的方式。QLoRA必须走BitsAndBytesConfig:
import torch
from transformers import AutoModel, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
# QLoRA专属:4bit量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4数据类型
bnb_4bit_use_double_quant=True, # 双重量化,节省0.5G
bnb_4bit_compute_dtype=torch.bfloat16 # 计算时反量化为bf16
)
model = AutoModel.from_pretrained(
"THUDM/chatglm2-6b",
quantization_config=bnb_config,
device_map="auto", # 自动分配GPU/CPU
trust_remote_code=True
)
model = prepare_model_for_kbit_training(model) # 冻结基座,启用gradient checkpointing
# LoRA配置(LoRA和QLoRA通用)
lora_config = LoraConfig(
r=8,
lora_alpha=32,
target_modules=["query_key_value"], # ChatGLM2的attention合并层
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 7,603,712 || all params: 6,242,783,232 || trainable%: 0.1218
训练超参数(两组方案共用一套,保证公平对比):
- 学习率:
1e-4(LoRA常用范围1e-4~5e-4,我试过2e-4会loss发散) - batch_size: LoRA=8, QLoRA=8(后者实际上可以上16,但为了对比保持一致)
- 梯度累积: 4步,等效batch=32
- 优化器:
paged_adamw_8bit(QLoRA推荐,能省显存) - 训练轮数: 3轮
- 学习率调度:
cosine,warmup_ratio=0.03
训练循环直接用Trainer,代码不赘述,重点看训练曲线。
五、踩坑与优化:三个让我熬夜的bug
坑1:QLoRA加载后推理显存反而更高?
首次加载QLoRA模型后,我跑了一次推理测试,发现显存占用12G。后来排查发现是device_map="auto"把部分层放到了CPU,推理时来回搬运导致显存峰值飙升。解决:显式指定device_map={"": 0},强制所有层在GPU0。
坑2:训练loss在第一个step直接NaN
原因是我在preprocess里没做max_length截断,导致某些长样本的labels长度不一致。虽然Trainer会pad labels,但-100掩码没设置好,导致计算loss时混入了pad token。解决:在preprocess里明确设置labels截断到与input_ids相同长度。
坑3:QLoRA训练速度比LoRA慢20%
这是预期内的,因为4bit反量化需要额外计算。优化方案是把bnb_4bit_compute_dtype设为bfloat16(而不是fp16),在3090上速度提升约8%。另外确保torch.backends.cuda.matmul.allow_tf32 = True,能白嫖5%速度。
六、效果数据:Loss曲线与推理对比
6.1 训练Loss曲线对比
两套方案的loss曲线形态几乎一致:第1轮从2.8快速降到1.2,第2轮到0.8,第3轮尾部到0.65。但注意:
- LoRA的loss震荡更小(fp16精度高)
- QLoRA的loss在每轮开头有轻微“跳变”,这是量化误差带来的,不影响最终收敛
关键指标(3轮训练,单卡3090,2万条数据):
| 方案 | 显存峰值 | 训练时间/轮 | 最终loss | 推理显存 |
|---|---|---|---|---|
| LoRA (fp16) | 21.3G | 28min | 0.63 | 14.2G |
| QLoRA (4bit) | 6.1G | 35min | 0.68 | 5.8G |
QLoRA用28%的显存换来了80%的训练速度(每轮多7分钟,但显存宽裕可以加大batch),完全值得。
6.2 推理效果对比(同一测试集,10条人工评估)
我用微调后的模型分别回答同样的问题,对比基座模型、LoRA和QLoRA:
测试问题:“你们的退款政策是什么?我昨天买的东西不想要了。”
基座模型输出(未微调):
“退款政策因产品而异。请登录官网查看具体条款。如有疑问请联系客服。”
LoRA微调后输出:
“您好,感谢您的咨询。我们支持7天无理由退货(需保持商品完好),您可以在‘我的订单’中申请退款,款项将在1-3个工作日内原路退回。请问您的订单号是多少?我帮您查询具体进度。”
QLoRA微调后输出:
“您好,针对您的退款需求:1. 商品未使用且包装完整,可在7天内申请退货;2. 退款审核通过后1-3天到账;3. 您需提供订单号以便我们快速定位。请问方便提供订单号吗?”
人工评分(1-5分,3人平均):基座2.3分,LoRA 4.5分,QLoRA 4.6分。QLoRA在表述严谨性上甚至略胜一筹,可能因为4bit量化间接起到了正则化作用。
七、总结与建议
- LLM微调首选LoRA/QLoRA,别碰全参:除非你有8卡A100,否则全参微调是资源浪费。LoRA在24G卡上能跑,但显存吃紧;QLoRA让6G显存的卡也能微调7B模型,门槛降低了一个量级。
- 数据质量 > 数据量:我试过只用5k条优质数据,效果比20k条脏数据好30%。清洗数据时保留问题-答案对,剔除多轮无关对话。
- QLoRA的NF4+双重量化是标配:实测
fp4精度下loss掉0.1,而NF4几乎无损。双重量化能省0.5-1G显存,建议默认开启。 - 调试工具:显存优化用
torch.cuda.max_memory_allocated()打点,别依赖nvidia-smi(有CUDA context占用误差)。
最后说句实话:LoRA和QLoRA的最终效果差距在±2%以内,选型完全取决于你的显卡显存。如果你手头的是4090(24G),直接上LoRA,省心;如果是3060(12G),老老实实QLoRA。就这样,有问题评论区聊。