024、PEFT实战用LoRA在单卡上微调LLaMA模型上周帮团队调一个LLaMA-7B的微调任务同事在A100上跑了三天OOM了三次。我过去一看代码里把整个模型参数都设成了requires_gradTrueoptimizer里塞了70亿个参数——这能不炸吗后来换成LoRA单卡V10016G显存跑了不到两小时效果还比全参数微调好了一截。今天就把这套实战流程拆开揉碎了讲清楚。为什么LoRA能救你的显存先别急着上代码理解LoRA的核心逻辑比调参重要。全参数微调相当于你要给整栋楼重新装修LoRA只是在每层楼加了几根承重柱——它冻结原始权重在Transformer的attention层插入低秩矩阵通常是秩r8或16。假设原始权重矩阵是d×kLoRA分解成d×r和r×k两个小矩阵参数量从dk降到dr rk。以LLaMA-7B的QKV投影为例d4096k4096原始参数量约16MLoRA只用40968*2≈65K直接省了250倍。这里踩过坑别天真地以为LoRA只在attention层生效就够。实测发现在LLaMA的gate_proj和up_proj上也加LoRA对数学推理类任务提升明显。但down_proj加了反而掉点可能是低秩瓶颈限制了信息压缩。环境准备别在CUDA版本上翻车pipinstalltransformers4.31.0 datasets accelerate peft bitsandbytes注意transformers版本必须≥4.30否则LlamaForCausalLM的from_pretrained不支持load_in_8bit。bitsandbytes在Windows上容易报错建议直接上WSL2或者Linux。如果显存小于24G务必加--load_in_8bit但8bit量化后LoRA的精度会受影响——我试过在8bit基座上微调MMLU分数掉了1.2个点。折中方案用4bit量化加载基座LoRA保持float16。加载模型一个参数省3GB显存fromtransformersimportLlamaForCausalLM,LlamaTokenizerimporttorch model_namedecapoda-research/llama-7b-hf# 别这样写model LlamaForCausalLM.from_pretrained(model_name)# 这样直接加载float327B模型占28GB显存单卡根本扛不住modelLlamaForCausalLM.from_pretrained(model_name,torch_dtypetorch.float16,# 半精度显存直接砍半device_mapauto,# 自动分配到多卡单卡也能用load_in_8bitTrue,# 8bit量化再砍一半trust_remote_codeTrue# LLaMA需要这个否则报错)这里踩过坑device_mapauto在单卡上没问题但如果你有多卡它会自动把不同层分配到不同GPU。这时候LoRA的target_modules如果指定了所有attention层反向传播时跨卡通信会炸。解决方案手动指定device_map{: 0}强制单卡或者用accelerate的dispatch_model。配置LoRAr8还是r16frompeftimportLoraConfig,get_peft_model lora_configLoraConfig(r8,# 秩8是安全起点16适合复杂任务lora_alpha32,# 缩放系数通常设为2r但32是经验值target_modules[q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj],# 别漏了gate_proj和up_projLLaMA的FFN结构特殊lora_dropout0.05,# 防止过拟合但别超过0.1biasnone,# 不训练bias省显存task_typeCAUSAL_LM# 因果语言模型别写成SEQ_2_SEQ)modelget_peft_model(model,lora_config)model.print_trainable_parameters()# 输出trainable params: 4,194,304 || all params: 6,742,609,920 || trainable: 0.062%看到0.062%这个数字了吗70亿参数里只训练了400万这就是LoRA的魔力。但注意lora_alpha不是学习率它是缩放因子。前向传播时LoRA的输出会乘以lora_alpha / r。如果r8lora_alpha32相当于缩放4倍。这个值调太大容易梯度爆炸调太小微调效果不明显。数据准备别让tokenizer坑了你fromdatasetsimportload_dataset datasetload_dataset(json,data_filestrain.jsonl)# 数据格式{instruction: ..., output: ...}deftokenize_function(examples):# 别这样写直接拼接instruction和output# 需要加上LLaMA的对话模板texts[]forinst,outinzip(examples[instruction],examples[output]):textf### Instruction:\n{inst}\n\n### Response:\n{out}texts.append(text)tokenizerLlamaTokenizer.from_pretrained(model_name)tokenizer.pad_tokentokenizer.eos_token# LLaMA没有pad_token必须手动设置# 这里踩过坑max_length设太小会截断关键信息设太大显存爆炸# 根据你的数据分布统计一下最长样本长度tokenizedtokenizer(texts,truncationTrue,paddingmax_length,max_length512,# 先设512后续根据显存调整return_tensorspt)# 因果LM需要labels通常和input_ids相同tokenized[labels]tokenized[input_ids].clone()# 但要把padding部分的labels设为-100否则loss会计算无意义的部分tokenized[labels][tokenized[attention_mask]0]-100returntokenized tokenized_datasetdataset.map(tokenize_function,batchedTrue)别这样写直接用tokenizer(examples[text])而不处理padding。LLaMA的tokenizer默认没有pad_token会报错。另外max_length不要设成2048——那是推理时的长度训练时设这么长单卡V100直接OOM。我一般先统计数据集的95分位长度再向上取整到128的倍数。训练配置梯度累积是救命稻草fromtransformersimportTrainingArguments,Trainer training_argsTrainingArguments(output_dir./llama-lora-checkpoints,per_device_train_batch_size4,# 先试4OOM就降到2gradient_accumulation_steps8,# 等效batch_size4*832learning_rate2e-4,# LoRA的学习率通常比全参数大10倍warmup_steps100,num_train_epochs3,logging_steps10,save_steps500,evaluation_strategysteps,eval_steps500,fp16True,# 混合精度必须开optimadamw_torch,# 别用adamw_8bit虽然省显存但收敛慢lr_scheduler_typecosine,report_tonone,# 不想装wandb就设成noneremove_unused_columnsFalse,# 保留原始数据列方便调试gradient_checkpointingTrue,# 梯度检查点用时间换显存ddp_find_unused_parametersFalse# 单卡训练不用管多卡必须设)这里踩过坑gradient_checkpointingTrue会显著降低训练速度大约慢30%但能省下40%的显存。如果你的显存刚好够可以关掉它换更快的训练。另外per_device_train_batch_size不要贪大我试过设成8结果显存占用飙到23.8G差一点就OOM。设成4配合梯度累积稳定在18G左右。开始训练盯着loss曲线trainerTrainer(modelmodel,argstraining_args,train_datasettokenized_dataset[train],eval_datasettokenized_dataset[test],data_collatorNone,# 用默认的collator因为我们已经padding好了)# 别直接trainer.train()就跑先看看模型能不能正常forward# 写个简单测试test_inputtokenizer(### Instruction:\nHello\n\n### Response:\n,return_tensorspt)test_outputmodel(**test_input)print(test_output.loss)# 应该输出一个非NaN的值# 没问题就开跑trainer.train()训练过程中loss应该从3.x左右开始逐步下降到1.x。如果loss一开始就小于0.5说明数据泄露了比如验证集混进了训练集。如果loss不降反升检查学习率是不是太大或者lora_alpha设得太高。保存和加载别只保存adapter# 保存LoRA权重只有几MBmodel.save_pretrained(./lora-adapter)# 加载时frompeftimportPeftModel base_modelLlamaForCausalLM.from_pretrained(model_name,torch_dtypetorch.float16,device_mapauto)lora_modelPeftModel.from_pretrained(base_model,./lora-adapter)别这样写只保存model.state_dict()然后下次加载时重新初始化LoRA再加载。因为PeftModel的state_dict里包含了基座模型的引用直接保存会存下整个模型。正确做法是用save_pretrained它只保存adapter的权重和配置文件。推理测试看看微调效果defgenerate_response(instruction):promptf### Instruction:\n{instruction}\n\n### Response:\ninputstokenizer(prompt,return_tensorspt).to(cuda)# 这里踩过坑do_sampleTrue时temperature设太低会生成重复文本outputsmodel.generate(**inputs,max_new_tokens256,temperature0.7,top_p0.9,do_sampleTrue,repetition_penalty1.1# 防止重复)responsetokenizer.decode(outputs[0],skip_special_tokensTrue)returnresponse.split(### Response:\n)[-1]print(generate_response(用Python写一个快速排序))如果生成的文本全是重复的“好的好的好的”检查repetition_penalty是不是设成了1.0默认值。如果输出全是乱码检查tokenizer的add_eos_token是不是设成了True——LLaMA的tokenizer默认不加eos但微调时如果加了推理时就会在第一个token后停止。个人经验LoRA调参的五个血泪教训r值不是越大越好。我试过r64参数量翻了8倍但MMLU分数只涨了0.3%训练时间却长了3倍。对于大多数任务r8到16是最优区间。如果任务特别复杂比如代码生成可以试试r32但要做好过拟合的准备。target_modules要覆盖全。很多教程只写q_proj和v_proj但LLaMA的FFN层gate_proj, up_proj, down_proj占了模型参数的大头。我做过消融实验只微调attention层在GSM8K数学题上准确率只有42%加上gate_proj和up_proj后直接跳到58%。down_proj加了反而掉到55%可能是低秩分解破坏了信息整合。学习率要大胆。全参数微调通常用1e-5LoRA可以直接上2e-4甚至5e-4。因为LoRA只更新少量参数梯度信号弱需要更大的步长。但注意配合warmup前100步从0线性增长到目标学习率防止初期震荡。数据质量比数量重要。我用500条高质量指令数据微调效果比5000条噪声数据好得多。LoRA的参数量少拟合能力有限喂垃圾数据只会学到垃圾模式。建议每条数据都人工检查确保instruction和response对齐。混合精度训练必须开。fp16不仅省显存还能加速训练。但注意loss可能变成NaN——如果遇到检查数据里有没有特别长的序列或者学习率是不是太大。实在不行就换bf16如果显卡支持bf16的动态范围更大不容易溢出。最后说句实在话LoRA不是万能药。如果你的任务需要模型学习全新的知识比如从零学一门编程语言LoRA的低秩瓶颈会限制效果。这时候可以考虑DoRAWeight-Decomposed Low-Rank Adaptation或者AdaLoRA自适应秩分配但那是另一个故事了。对于大多数指令微调场景LoRA单卡V100足够你玩转7B模型了。