ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型微调三选一:全参、LoRA与QLoRA实战取舍指南

大模型微调三选一:全参、LoRA与QLoRA实战取舍指南

简介:面向希望掌握大模型微调实战的开发者,提供基于LLama3.1的全参微调、LoRA微调与QLoRA微调完整项目源码及流程教程。资源覆盖数据准备、环境配置、微调脚本编写、模型评估与优化等关键环节,适合具备一定Python与深度学习基础、正在开展大模型应用落地的学习者参考。压缩包共45个文件,以Python脚本(24个)、Shell脚本(13个)为主,辅以JSON配置、README说明与依赖清单,包含多种微调与推理预测脚本,并配有Zero2/Zero3等分布式训练配置,可按需选择不同规模模型的微调方案。已有454人学习下载。通过源码与教程配合,读者可深入理解不同微调方式的计算开销、过拟合控制与收敛策略,并结合实际任务调整参数、评估效果,从而更高效地将LLama3.1等预训练模型应用到具体业务场景中。

1. 大模型微调三选一:从 Llama-3.1 的全参、LoRA 到 QLoRA 该怎么下手

如果你手里刚好有一批垂直领域数据,想让 Llama-3.1、Qwen 这类开源大模型在具体任务上真正好用,那你大概率绕不开全参微调、LoRA 和 QLoRA 这三条路。很多人以为三者是一个比另一个高级,其实它们只是在显存、数据量和效果之间做三种不同取舍。这个项目正好把这三条路都拆成了能直接跑的源码:全参微调脚本、LoRA 脚本、QLoRA 脚本,加上 Zero2/Zero3 的 DeepSpeed 配置,以及覆盖 Qwen、GLM-4、InternLM2.5、Yi-1.5 多个基座的 finetune 和 predict 脚本。适合没完整跑过微调的人照着复现,也适合熟手对比不同微调策略的实际差距。下面按「先理解底层开销,再动手调参」的顺序展开。

2. 全参微调与 DeepSpeed 配置:先把 8B 模型的显存账算清楚

2.1 全参微调为什么值得先看:显存构成与过拟合边界

全参微调的概念不复杂:预训练权重全部参与梯度更新。它的上限最高,因为模型所有表达能力都能被任务数据重新塑形,但代价也很直接——显存开销不是按模型体积线性涨,而是按「参数 + 梯度 + 优化器状态 + 激活值」四块叠加。以一个 8B 参数模型为例,常用 AdamW 优化器时,参数本身 fp16 约 16GB,梯度再 16GB,优化器状态还要维护 fp32 主权重、一阶动量 m 和二阶动量 v,摊下来接近每参数 16 字节。也就是说,仅权重、梯度和优化器状态三项就要 8B × 12~16 字节,约 96~128GB,这还不是全部。激活值在长序列下再翻一份,单卡 24GB 根本装不下,这也是全参微调必须上 DeepSpeed 的根本原因。

很多人在这里有个误区:以为全参微调只是「显存需求大」这一个缺点。实际上它还有一个更容易翻车的面——数据量不够时过拟合非常快。全参把模型的每一层都往任务上压,数据若只有几千条,模型会把训练集的格式特征当成通用规则,损失函数看着降得很好,换一批新样本生成质量立刻打回原形。所以全参微调的适用场景通常是有较干净的万级指令数据、且你希望模型整体能力向任务方向偏移,而不是只学一个输出模板。

2.2 ZeRO2 与 ZeRO3:两个配置文件的取舍

项目里给了三个 DeepSpeed 配置,分别是 ds_config_zero2.json、ds_config_zero3.json、ds_config_zero3_72B.json,正好对应两种典型场景。ZeRO-2 的核心是把优化器状态和梯度做分片,每张卡只维护自己那份,参数仍是全局复制。对 8B 模型来说,用 ZeRO-2 跑多卡全参微调是性价比最高的组合,通信开销小、训练速度快,显存压力由多卡分担。ZeRO-3 则更进一步,连参数本身都分片,每张卡只在用到某层时才把它物化出来,这意味着单卡也能勉强跑 8B 全参,代价是频繁的 all-gather 通信,显存换速度,跑起来明显变慢。

从项目文件看,finetune_fullparameter.sh 指向的就是这一类全参训练入口,配合 finetune_llama3.py 或 finetune_qwen1.5.py 这类训练脚本使用。常见的 ZeRO-3 配置文件骨架大致如下,项目里那份也是同一个套路,只是 stage 和 offload 字段按需调整:

{ "zero_optimization": { "stage": 3, "offload_optimizer": { "device": "cpu", "pin_memory": true }, "offload_param": { "device": "cpu", "pin_memory": true }, "overlap_comm": true, "contiguous_gradients": true, "reduce_bucket_size": 500000000, "stage3_prefetch_bucket_size": 50000000, "stage3_param_persistence_threshold": 1000000 }, "train_batch_size": "auto", "train_micro_batch_size_per_gpu": "auto", "gradient_accumulation_steps": "auto" }

参数说明:stage 决定分片级别,80G 显存以上的多机多卡可以锁 stage 2 换取吞吐;offload_optimizer 和 offload_param 都指到 cpu 后,显存占用显著下降,但 CPU 内存和 PCIe 带宽会成为新瓶颈,训练速度可能掉一半以上。overlap_comm 打开后,通信与计算部分重叠,对 ZeRO-3 尤其重要。batch 相关字段保持 auto,由 Trainer 从训练参数里自动推算,避免两边手改不一致。我一般会这样选:单卡跑 7B/8B 全参、且能接受慢速训练时用 ZeRO-3;有多卡、节奏要求高时用 ZeRO-2;那个 ds_config_zero3_72B.json 我把它当作「72B 级别参数分片 + 极致 offload」的模板用,而不是真的指望单卡跑 72B。

2.3 什么情况下该放弃全参微调

全参不是万金油,项目里同时给出 LoRA 和 QLoRA 的原因也在这里。以我的实践经验,判断标准不是「哪个效果好」,而是「你的资源边界和数据边界在哪」。下面这张表是三种方式在同一任务上的典型差异,数值是常见经验值,具体以你的显卡和批次为准:

微调方式显存需求(8B 模型)训练速度对数据量的要求过拟合倾向
全参微调多卡或 80G+ 单卡最快万级起步数据不足时明显
LoRA约 18~24GB较快数千条可启动较低
QLoRA 4bit约 12GB 以内较慢数千条可启动最低

判断是否放弃全参的标准,我建议看两个信号:一是单卡显存低于 80G、又拿不到集群资源时,全参复现周期会拖到不可接受;二是训练 loss 降下去了,但测试集生成明显背诵训练集格式,这时全参的容量已经超出了任务复杂度,换 LoRA 反而更稳。项目实战里 LoRA 和 QLoRA 是多数人的落点,下一章重点拆这两种方式的参数套路。

3. LoRA 与 QLoRA:在有限显存里逼近全参效果的参数套路

3.1 LoRA 低秩分解:为什么只更新两个小矩阵

LoRA 的直觉在于一个前提假设:预训练模型本身的权重已经足够强,微调其实只需要在原始权重旁边叠一个「小规模的增量」。这个增量不直接加到每层权重里,而是拆成低秩矩阵 A 和 B 的乘积,前向时输出从 h = W0x 变成 h = W0x + BAx。初始时 B 为零矩阵,整个增量不起作用,模型保持原输出;训练过程中 A 和 B 学习出适合当前任务的偏移。r 控制这个增量的秩,r 越大表达空间越大,所需显存也越高;lora_alpha 是缩放系数,决定增量在输出中的影响力,习惯上取 lora_alpha 为 r 的 2~4 倍。

项目里多个 finetune 脚本都走同一套 PEFT 流程,核心片段长这样:

from peft import LoraConfig, get_peft_model from transformers import AutoModelForCausalLM base_model = AutoModelForCausalLM.from_pretrained( "/path/to/Llama-3.1-8B-Instruct", torch_dtype="auto", device_map="auto" ) lora_config = LoraConfig( r=8, lora_alpha=32, target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(base_model, lora_config) model.print_trainable_parameters()

逻辑说明:先加载完整基座,再通过 LoraConfig 声明要注入低秩矩阵的模块,get_peft_model 把原模型冻结并替换目标模块的前向路径。print_trainable_parameters 会把可训练参数量打印出来,8B 模型 r=8、只注入 attention 四件套时,可训练参数通常只占全部参数的 0.5%~1% 左右。这里最常见的调参点是 target_modules——它必须和基座的内部模块命名完全一致。不同系列模型的注意力模块命名差异很大,这也是很多人第一次加载 adapter 后模型乱答的直接原因:

基座模型注意力模块常见命名LoRA 常见 target_modules
Llama-3 / 3.1、Yi-1.5q_proj, k_proj, v_proj, o_proj上述四个
Qwen、Qwen1.5、Qwen2q_proj, k_proj, v_proj, o_proj上述四个,也可加 gate_proj/up_proj/down_proj
GLM-4-9Bquery_key_value, densequery_key_value, dense

3.2 从 shell 脚本看 LoRA 超参:学习率与 target_modules

项目里的 script 目录是很好的「参数手册」,fintune_lora_llama3.1_8B_Instruct.sh 和 finetune_lora_qwen2.sh 之间的差异,暴露了不同基座在超参上的真实偏好。拿一份典型的 LoRA 启动脚本来看,结构如下:

torchrun --nproc_per_node=1 finetune_llama3.py \ --model_name_or_path /path/to/Llama-3.1-8B-Instruct \ --data_path data/Belle_sampled_qwen.json \ --output_dir output/llama3.1_lora \ --num_train_epochs 3 \ --per_device_train_batch_size 2 \ --gradient_accumulation_steps 8 \ --learning_rate 2e-4 \ --lr_scheduler_type cosine \ --warmup_ratio 0.03 \ --lora_r 8 \ --lora_alpha 32

逻辑说明:torchrun 是 PyTorch 官方多进程启动器,nproc_per_node=1 表示单卡;per_device_train_batch_size 是单卡单步真实样本数,gradient_accumulation_steps 把多个微批累积后再更新,两者相乘才是等效 batch size,上面就是 2 × 8 = 16。LoRA 微调最值得记住的一个血泪经验是:学习率不要沿用全参的 1e-5,LoRA 增量矩阵需要更大的步长,2e-4 附近是常见起点。lr_scheduler_type 用 cosine 并配一个小的 warmup_ratio,训练前期更稳,避免增量矩阵在初期被大步长冲偏。

如果你打算喂更多数据,一般先加 gradient_accumulation_steps 而不是直接加 per_device_train_batch_size,这样显存曲线更平缓。多卡场景把 nproc_per_node 改成卡数,但要注意不同模型脚本里 target_modules 未必一样,照抄脚本前先确认基座模块名,这一点在预测脚本里体现得更明显。

3.3 QLoRA 的 4bit/8bit 底座:量化基座 + LoRA 增量

QLoRA 不是 LoRA 之外的新方法,它是在 LoRA 前面加了一步:把基座模型权重量化到 4bit 或 8bit,再在量化后的底座上训练 LoRA 增量。项目里 predict 目录同时出现了 llama3_qlora_4bit_predict.py、qwen_qlora_4bit_predict.py,以及 qwen_qlora_8bit_predict.py,说明这一套代码把 4bit 和 8bit 两条路径都覆盖了。4bit 路径的关键代码是用 BitsAndBytesConfig 控制加载方式:

from transformers import BitsAndBytesConfig bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.float16 ) base_model = AutoModelForCausalLM.from_pretrained( model_path, quantization_config=bnb_config, device_map="auto" )

参数说明:load_in_4bit=True 让基座以 4bit 常驻显存,这是显存骤降的主要原因;quant_type 选 nf4 而非 fp4,nf4 是 QLoRA 论文里验证更适合激活值分布的量化方式;use_double_quant 开启双重量化,把量化缩放系数再量化一次,省下的显存虽小但积少成多;compute_dtype 保持 fp16 则是因为 4bit 权重在反向传播时要临时反量化成高精度计算,太低会损害梯度质量。

QLoRA 的梯度只流经 LoRA 增量矩阵,基座权重虽然被量化成 4bit,但前向时会被反量化回 fp16 参与计算,只参与推理不更新。项目中 QLoRA 脚本一般会把优化器换成 paged_adamw_32bit,这是基于分页内存的优化器变体,显存不足时能把优化器状态换出到 CPU,是 QLoRA 能在 12GB 卡上跑 8B 模型的最后一块拼图。选 8bit 还是 4bit,我的习惯是:单卡 24GB 用 4bit 最稳,追求生成质量且显存仍有富余时上 8bit;推理阶段同样要保持量化方式一致,不然加载 adapter 时权重分布对不上,输出会莫名其妙。

4. 跑通一条完整链路:数据、训练入口与断点续训的落地细节

4.1 指令数据格式与 prompt 模板拼接

项目 data 目录下有 Belle_sampled_qwen.json 和西西嘛呦.json 两个文件,这俩都属于中文指令微调常见格式。前者的字段结构与 Alpaca 风格接近,最后喂进模型时,需要把 instruction、input、output 拼成一个完整文本,而且这个「拼法」直接决定模型学的应答格式。微调这事的玄学成分没想象中高,大部分翻车都发生在模板不一致上。典型的数据样例如下:

{ "instruction": "请把下面的句子翻译成英文", "input": "今天天气很好", "output": "The weather is nice today." }

数据预处理时,常见做法是实现一个统一的格式化函数,把所有样本都转成「指令 + 输入 + 应答」的结构:

def format_example(ex): if ex.get("input") and ex["input"].strip(): prompt = "### Instruction:\n{instruction}\n### Input:\n{input}\n### Response:\n".format( instruction=ex["instruction"], input=ex["input"] ) else: prompt = "### Instruction:\n{instruction}\n### Response:\n".format( instruction=ex["instruction"] ) return prompt + ex["output"]

这里最关键的一步是:把 prompt 部分在 labels 里掩盖掉。模型只需要对 output 部分计算损失,instruction 和 input 只是条件输入。如果 labels 不掩盖 prompt,loss 就会把「复述题目」也算作任务,训练 loss 降得很好,生成却全是乱码。项目多个 finetune 脚本里都会有一段 tokenize 逻辑做这件事,差别只在 prompt 模板字符串不同,换模型时必须连模板一起换。

4.2 训练入口:torchrun 与 DeepSpeed 的搭配方式

项目的 script 目录提供了很多启动脚本,它们的共同特征是通过命令行参数把模型路径、数据路径、输出路径一次传入,不硬编码。常见组合是 torchrun 启动训练脚本,再配合 --deepspeed 指向配置:

torchrun --nproc_per_node=2 finetune_llama3.py \ --model_name_or_path /path/to/Llama-3.1-8B-Instruct \ --data_path data/西西嘛呦.json \ --deepspeed config/ds_config_zero2.json \ --per_device_train_batch_size 1 \ --gradient_accumulation_steps 16 \ --output_dir output/llama3.1_full

逻辑说明:nproc_per_node=2 表示单机两卡并行,每卡处理 batch_size=1,那么等效 batch size 是 1 × 2 卡 × 16 累积 = 32。把 batch 做大对收敛稳定有好处,代价是优化器更新频率降低。项目里多个 shell 脚本的差异主要在模型路径、数据路径和 LoRA 相关参数,训练脚本本身是同一套 Trainer 流程。环境变量方面,多机时还需要设置 MASTER_ADDR 和 MASTER_PORT,单机多卡 torchrun 会自动生成,不需要额外处理。72B 级别配置那个脚本则通常配 ZeRO-3,理由很简单:参数总量太大,ZeRO-2 只分片优化器和梯度,显存依然不够。

4.3 输出目录、adapter 保存与断点续训

训练结束后,output_dir 下生成的目录结构是个容易被忽略的重点。LoRA/QLoRA 训练使用 PEFT 的 save_pretrained 后,只会保存一个很小的 adapter 目录,里面是 adapter_config.json 和 adapter_model.safetensors,不包含完整基座权重。这个特性意味着你可以放心把多个任务的 adapter 各存一份,磁盘开销几乎可忽略。

断点续训上,Trainer 默认会在 output_dir 下生成 checkpoint-xxx 目录,里面包含 optimizer 状态、调度器状态和模型权重,中途中断后可以这样恢复:

trainer.train(resume_from_checkpoint="output/llama3.1_lora/checkpoint-200")

我一般会同时设置 save_total_limit=2,只保留最近两个 checkpoint,避免训练几天后磁盘被 checkpoint 撑爆。加载时切记:PEFT adapter 的 adapter_config.json 里写死了训练时的 base_model_name_or_path,换机器跑预测前一定要核对这个路径,它指向的基座不存在时,加载过程会报错或尝试从头下载,很多「模型乱答」的现场其实是从这里开始的。

5. 微调避坑与常见问题:五个让我反复排查的经验点

5.1 现象:loss 快速下降到 0.2 但生成结果是乱码

这几乎是 LoRA 微调最经典的一个坑。原因基本出在模板拼接和 label 掩盖上:如果把 instruction 和 input 也放进 labels 计算损失,模型只需要学会「背题」就能把 loss 压到很低,根本没去学应答。解决方法是先打印一条 tokenized 样本,人工确认 labels 中 prompt 部分是否为 -100;再跑一次生成,如果输出里出现 Prompt 原文重复,基本就是模板没有正确掩码。项目里各 finetune 脚本的 preprocess 函数就是做这件事的,换数据集时不要轻易删减。

5.2 现象:DeepSpeed stage3 下直接 OOM

全参微调开启 ZeRO-3 后仍然爆显存,通常是两个原因:一是没开 gradient_checkpointing,激活值占掉大头;二是 offload 配置不完整,只 offload 了优化器,参数仍然留在显存。解决:在 TrainingArguments 中开启 gradient_checkpointing=True,并确认 ds_config_zero3.json 里 offload_optimizer 和 offload_param 都指向 cpu;如果还爆,把 per_device_train_batch_size 降到 1,改用 gradient_accumulation_steps 补 batch。注意 gradient checkpointing 会牺牲约 20%~30% 训练速度,这是用时间换显存。

5.3 现象:加载 adapter 后模型回答与目标任务完全无关

adapter_config.json 里的 base_model_name_or_path 指向的是训练机上的绝对路径,换机器或换路径加载时,PEFT 可能尝试去原路径找基座,找不到就用当前加载的模型,导致结构不一致。另一个常见原因是 target_modules 与基座模块名不匹配,比如对 GLM-4 使用 q_proj/v_proj 而非 query_key_value。解决:加载时显式指定正确的基座路径,并在加载后打印 model.peft_config 核对 target_modules;不同系列模型间的脚本不要互抄。

5.4 现象:多卡训练时显存不均衡或进程卡死

torchrun 多卡启动后,如果各卡 batch size 一致但显存占用仍不平衡,多半是 dataset sampler 触发了数据长度扎堆,长样本全分到同一张卡上。另一个隐藏点是没有统一设置 gradient_accumulation_steps,脚本间不一致会导致各 rank 的更新节奏错位。解决:多卡环境只从一份启动脚本拉起全部进程,保证参数完全一致;实在不行关闭 dataset 中的按长度排序逻辑,让样本随机分布到各 rank。

5.5 现象:LoRA 训练却把整个基座 checkpoint 写进磁盘

训练结束后发现 output_dir 里躺着一份好几个 GB 的完整权重,说明保存时没有走 PEFT 的 save_pretrained,Trainer 默认的 save_model 会把模型全量写进去。解决:训练脚本里显式调用 model.save_pretrained(output_dir) 只保存 adapter,并设置 save_only_model=True(新版 transformers 支持),再配 save_total_limit=2 控制数量。这样每个任务只占几百 MB,换模型、换任务、回滚实验都方便得多。

6. 预测脚本与效果验证:把 adapter 用对才算微调完成

6.1 预测脚本的加载方式

项目 predict 目录下脚本命名很规律,llama3.1_peft_lora_predict.py、llama3_peft_qlora_predict.py 这类文件,核心都在做同一件事:加载基座、挂 adapter、统一生成参数。QLoRA 的预测脚本和训练脚本必须保持量化方式一致,否则 4bit 量化后的权重分布和 adapter 期望的输入对不上,生成质量会明显劣化。推荐用 AutoModelForCausalLM 与 PeftModel 组合加载:

from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained(base_model_path, trust_remote_code=True) base_model = AutoModelForCausalLM.from_pretrained( base_model_path, device_map="auto", torch_dtype="auto" ) model = PeftModel.from_pretrained(base_model, adapter_path) inputs = tokenizer("### Instruction:\n请写一段产品介绍\n### Response:\n", return_tensors="pt") outputs = model.generate(**inputs, max_new_tokens=256, do_sample=False) print(tokenizer.decode(outputs[0], skip_special_tokens=True))

注意生成时 do_sample=False 保证结果可复现;验证多样性时再调 temperature=0.7 跑多组。加载后第一件事是打印 model.peft_config 与模型的 dtype,确认没有混合精度错位。

6.2 三个验证习惯

第一个习惯:固定同一批 prompt,在微调前后各生成一次,肉眼对比格式和内容变化。第二个习惯:从测试集抽 30~50 条样本,算格式正确率和关键信息命中率,而不是只看 loss。第三个习惯:分别用 do_sample=False 和 temperature=0.7 跑同一批样本,观察稳定性和多样性是否都在可接受范围。这样能快速暴露「过拟合到训练集格式」和「adapter 没生效」两类问题。

6.3 一次路径没对齐的教训

我第一次跑 Llama-3.1 的 LoRA 微调时,训练和预测分开在两台机器上进行,adapter 加载后生成内容完全不可用。排查半天才发现 adapter_config.json 里的 base_model_name_or_path 还指向训练机的旧路径,PeftModel 加载时被带偏,没有用我当前指定的基座。从那以后我每次换机器、换数据、换 adapter,都强制先跑一遍「加载并生成一句固定 prompt」的冒烟脚本,把模型路径、adapter 路径、tokenizer 路径三个值逐一打印确认,再进入正式预测。这套流程后来帮我省掉了大量无效排查时间,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进