简介:面向需要落地大模型微调项目的开发者,这套资料围绕LLama3.1给出全参数微调、Lora微调与QLora微调的完整方案,包含可直接运行的源码和分步流程教程。资源包为zip压缩格式,共45个文件,整体仅144KB,主要包含24个Python脚本(覆盖训练、预测、数据下载与依赖生成)、13个Shell启动脚本(内置不同微调策略的命令)、5个JSON配置文件(含DeepSpeed Zero2/Zero3分布式训练设置),另有requirements.txt和README说明。教程从数据集准备、运行环境搭建、微调脚本执行到模型效果评估逐步讲解,并附带Qwen、GLM4、InternLM2.5、Yi等主流模型的微调与预测示例,便于横向对比不同参数高效微调方法。通过实际动手操作,可深入理解全参微调的内存开销、Lora的低资源适配以及QLora在受限环境下的加速收敛思路。目前已有454人学习下载,适合具备一定Python和深度学习基础、希望快速上手大模型微调的开发者。
1. 从全参到QLoRA:LLama3.1微调的三条路线与显存真相
一枚8B量级的LLama3.1微调项目,九成团队第一反应都是全参微调,这也是最容易翻车的决定。8B模型在全参模式下,仅权重、梯度、AdamW优化器状态三份账就要96GB以上,单张80G显卡都未必放得下;换成QLoRA重跑同一份数据,显存可能只要十几GB,效果差距却远没有想象中大。
这个标题里的“附项目源码+流程教程”,本质是把全参、LoRA、QLoRA三条路线整理成可直接改参数的脚本和文档。为什么值得投入时间?因为大多数真实业务只需要对齐输出格式、修正领域语义,并不需要重训一个基座,而这三条路线从16GB单卡到多卡集群都有对应解。
我会把显存账单、最小训练脚本、必须避开的坑、以及微调后如何验证逐条拆开。新手能跟着命令跑通,熟手能直接对照参数边界。
2. 先算显存账再选路线:全参、LoRA、QLoRA的原理与边界
2.1 全参微调到底在“全”什么
“全参”意味着模型每一层线性层的权重和偏置都会进入反向传播并更新。训练一条样本时,显存里有四类东西在同时占空间:权重本身、反向传播产生的梯度、优化器状态、以及前向计算留下的激活值。前三个是固定开销,最后一个随序列长度和batch size暴涨。
以LLama3.1常见的8B量级模型为例,bf16精度下权重占2字节/参数,梯度也是2字节/参数,而AdamW优化器要维护一阶矩和二阶矩两个fp32副本,共8字节/参数。三者加起来就是12字节×80亿=96GB,这还没算激活值。
| 显存组成部分 | 单参数字节数 | 8B量级估算 |
|---|---|---|
| 模型权重(bf16) | 2字节 | 约16GB |
| 梯度(bf16) | 2字节 | 约16GB |
| AdamW优化器状态(fp32×2) | 8字节 | 约64GB |
| 权重+梯度+优化器合计 | 12字节 | 约96GB |
| 激活值 | 随batch和seq长度变化 | 数GB到数十GB |
这就是为什么全参微调几乎总是跑在多卡上,还要配合ZeRO把优化器状态切分到多张卡。项目源码里有多少个batch_size都是浮云,显存账在上一步已经锁死了上限。
我一般这样判断是否选全参:数据量在十万条以上,且任务需要模型学到全新的知识结构,而不是单纯改变回答格式。全参微调的收敛上限确实最高,但跑不动就毫无意义。
2.2 LoRA的低秩假设
LoRA的核心不是“少训一点”,而是假设微调过程只会改动预训练权重的极少数方向。做法是把原基座权重冻结,在旁边挂一组低秩矩阵A和B,前向变成Wx + BAx。训练时只有A、B在更新,基座原权重全程不动。
可训练参数量的公式大约是:2 × 隐藏维度 × r × 模块数。在8B量级模型上,r=16时通常对应几千万到一亿个可训练参数,只占全模型参数的0.1%~1%。这也是LoRA能被广泛接受的根本原因:预训练模型已经有很强的通用能力,微调只需要在很小的子空间里校正方向。
显存账也乐观得多:基座权重和梯度仍然按bf16占用,但优化器状态只对LoRA部分生效。一个8B模型用LoRA训练,总显存需求大致在20~28GB,单张24G显卡能跑,这和全参动辄上百GB完全不是一个量级。
LoRA最适合的场景是:数据量不大,任务和基座能力接近,主目标是约束输出格式和领域术语。它的缺点也很明确,更新空间受限,如果任务和预训练分布差异太大,效果会明显低于全参。
2.3 QLoRA的三个关键让步
QLoRA是在LoRA基础上的进一步压缩,主要是三件事:基座权重降成4bit的NF4类型,把量化常数再做一次量化,以及引入分页优化器把不常访问的优化器状态临时挪到CPU。基座权重从2字节/参数压到0.5字节/参数,8B基座大约只需要4GB。
| 微调路线 | 8B量级典型显存 | 可训练参数 | 适用显卡 |
|---|---|---|---|
| 全参微调 | 110GB以上 | 80亿 | 多卡集群(80G×4以上) |
| LoRA | 20~28GB | 约0.1%~1% | 单张24G可跑 |
| QLoRA | 10~16GB | 约0.1%~1% | 单张16G/24G均可 |
QLoRA让单卡训练大模型从“不可能”变成了“日常操作”,但它也有代价:量化引入的信息损失在极端任务上会让模型学不到足够精细的语义,且训练速度通常比LoRA慢一些。
我的习惯是:QLoRA作为默认路线,先跑通数据流程和效果验证;如果发现效果明显不够,再考虑LoRA或全参升级。这个顺序能在资源有限时保住底线。
3. 全参微调LLama3.1:环境搭建与最小训练脚本
3.1 先把环境固定住:LLama3.1微调的依赖安装
全参微调依赖Transformers、accelerate、DeepSpeed、datasets这几个核心库。拿到标题里这类项目zip包,第一件事不是跑train,而是解压后检查有没有requirements文件;没有就直接按下面的方式装齐。
conda create -n llama_full python=3.11 -y conda activate llama_full pip install transformers accelerate deepspeed datasets装完先跑一句python检查版本和CUDA是否匹配。Transformers和accelerate、DeepSpeed三者版本不匹配,最典型的表现是报找不到某个类,或者启动时提示deepspeed config字段未知。我习惯先全部升级到当前稳定版,再各自锁版本,而不是让pip自己猜。
python -c "import torch, transformers, deepspeed; print(torch.cuda.is_available(), transformers.__version__, deepspeed.__version__)"只有torch.cuda.is_available()为True,后面的显存账才成立。这块最容易翻车的是装了CPU版torch,训练启动才发现CUDA不可用,浪费半天。
3.2 最小训练脚本:从数据到Trainer
全参微调的核心训练脚本用Transformers的Trainer封装最省事。DeepSpeed的接入通过training_args里的deepspeed字段指向配置文件,不需要手写分布式逻辑。
import torch from transformers import ( AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments, DataCollatorForSeq2Seq, ) model = AutoModelForCausalLM.from_pretrained( "你的本地模型路径", torch_dtype=torch.bfloat16, device_map="auto", ) tokenizer = AutoTokenizer.from_pretrained("你的本地模型路径") tokenizer.pad_token = tokenizer.eos_token # 基座模型通常没有pad token training_args = TrainingArguments( output_dir="checkpoints/full", per_device_train_batch_size=1, gradient_accumulation_steps=16, learning_rate=2e-5, num_train_epochs=3, bf16=True, gradient_checkpointing=True, logging_steps=10, save_strategy="steps", save_steps=500, deepspeed="ds_config.json", ) trainer = Trainer( model=model, args=training_args, train_dataset=dataset, data_collator=DataCollatorForSeq2Seq(tokenizer), ) trainer.train()这里有几个参数值得展开说。per_device_train_batch_size=1+gradient_accumulation_steps=16,等效batch size是16,多卡场景下这个值要跟着卡数调整,避免有效batch过大导致收敛异常。bf16=True要求显卡支持bf16计算,不支持就退到fp16,但fp16在8B量级模型上容易出现loss溢出,排除问题更费劲。gradient_checkpointing=True是用约30%的训练速度换显存减半,全参微调必须开。
数据侧要提前把训练集处理成文本。常见做法是给每条样本拼一个带“指令、输入、输出”三段标签的模板,然后tokenize。
from datasets import Dataset import json def encode(example, max_len=2048): text = ( f"### 指令\n{example['instruction']}\n" f"### 输入\n{example['input']}\n" f"### 输出\n{example['output']}" ) return tokenizer(text, truncation=True, max_length=max_len, return_tensors="pt") dataset = Dataset.from_list(json.load(open("train.json"))) dataset = dataset.map(encode)注意标签格式要统一,推理时也要用同样的模板拼prompt,不能训练一种格式、推理另一种格式。这个看似不起眼的点,是后面“微调后变笨”最常见的元凶。
3.3 DeepSpeed配置:多卡全参的启动命令
全参微调在多卡环境下绕不开ZeRO。8B模型用ZeRO Stage 2就够了,Stage 2会把优化器状态和梯度切到各卡;Stage 3更进一步把模型权重也切分,通常给更大模型用。
{ "train_batch_size": "auto", "gradient_accumulation_steps": "auto", "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu" } }, "bf16": { "enabled": true } }offload_optimizer把优化器状态换到CPU内存,能显著降低每卡显存占用,但训练会变慢。如果显存够用,建议先不开offload,跑得慢有时候比爆显存更折磨人。
deepspeed --num_gpus=4 train_full.py --deepspeed ds_config.json启动之后重点观察每张卡的显存峰值。不要只看总显存,要看单卡峰值是否接近上限。全参微调跑通后,checkpoint可以直接用AutoModelForCausalLM加载,也可以继续在此基础上挂LoRA做后续实验。
4. LoRA与QLoRA微调LLama3.1:配置、训练与显存账
4.1 LoRA:冻结基座,只训练低秩旁路
LoRA落地依赖PEFT库。流程是先加载原始bf16基座,再包一层LoraConfig,最后用get_peft_model把可训练参数锁定到LoRA上。
from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_dropout=0.05, bias="none", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()r是低秩矩阵的秩,越小可训练参数越少、拟合能力越弱;lora_alpha是缩放因子,实际生效的缩放比例是alpha/r,这里32/16=2。lora_dropout=0.05是常规值,如果训练数据噪声较大可以调到0.1。target_modules必须跟模型实际模块名完全一致,LLama3.1的注意力层常见模块名就是q_proj、k_proj、v_proj、o_proj,如果名字对不上,PEFT不会报错,但trainable_parameters数量会明显不对,那说明LoRA根本没挂上目标层。
trainable_parameters打印出来后,对比总参数量,确认占比在0.1%~1%之间才算正常。
4.2 QLoRA:4bit基座加载与分页优化器
QLoRA和LoRA的差别全在基座加载这一步。需要先用BitsAndBytesConfig构造4bit量化参数,再传给from_pretrained。量化好之后还要调用prepare_model_for_kbit_training,这一步会冻结基座并处理好反向传播时的dtype问题,漏掉它会在第一轮训练直接炸显存或报梯度错误。
from transformers import BitsAndBytesConfig from peft import prepare_model_for_kbit_training bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) model = AutoModelForCausalLM.from_pretrained( "你的本地模型路径", quantization_config=bnb_config, device_map="auto", ) model = prepare_model_for_kbit_training(model) lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_dropout=0.05, ) model = get_peft_model(model, lora_config)nf4是QLoRA论文里验证过的4bit数据类型,比int4保留更多信息;compute_dtype用bf16表示反量化后计算仍然走bf16,精度损失主要发生在存权重时。双重量化打开后基座体积还能再小一点。
QLoRA训练时建议把优化器换成paged_adamw_8bit,这是bitsandbytes提供的分页优化器,显存吃紧时会把部分状态挪到CPU,临时续命很好用。
training_args = TrainingArguments( output_dir="checkpoints/qlora", per_device_train_batch_size=2, gradient_accumulation_steps=8, learning_rate=2e-4, num_train_epochs=3, bf16=True, gradient_checkpointing=True, logging_steps=10, save_strategy="steps", save_steps=500, optimizer="paged_adamw_8bit", )LoRA和QLoRA的学习率比全参高一个量级,常见范围是1e-4到2e-4。如果loss下降太猛,先降一半再看;如果完全不降,优先检查数据模板而不是调学习率。
4.3 数据模板:统一用apply_chat_template
LLama3.1这类对话模型的训练数据,最稳的做法是用tokenizer自带的chat template来拼文本。自己手拼special token很容易漏,漏一个bos或角色标签,模型在推理时就会把标签当正文输出。
def format_chat(example): messages = [ {"role": "system", "content": example["system"]}, {"role": "user", "content": example["instruction"]}, {"role": "assistant", "content": example["output"]}, ] return tokenizer.apply_chat_template(messages, tokenize=False) dataset = dataset.map(lambda x: {"text": format_chat(x)})训练时用这个text列做tokenize,推理时同样用apply_chat_template构造输入。这样能保证两边看到的是完全一致的token序列,不会出现格式错乱问题。
提示:如果pre-trained模型的tokenizer没有chat_template属性,需要用一段带角色标签的模板手动指定,而不是直接拼接纯文本。
5. 避坑:OOM、loss=nan与微调后变笨的真实原因
5.1 显存OOM:你的显存账漏了激活值
现象:按12字节公式算好96GB,配了4张80G显卡,结果一启动训练就OOM,甚至batch_size降到1还是崩。原因:只算了权重、梯度、优化器状态,没算激活值;LLama3.1在seq_len=4096时,激活值峰值可能再吃数十GB。解决:先开gradient_checkpointing,再用CUDA_LAUNCH_BLOCKING=1启动让报错精确到层,然后观察nvidia-smi峰值。
CUDA_LAUNCH_BLOCKING=1 deepspeed --num_gpus=4 train_full.py --deepspeed ds_config.json这个环境变量让GPU报错立即同步返回,而不是异步卡死在某个晦涩的cuda error里。如果还爆,就把max_seq_len从4096降到2048,这是全参微调里最管用的两个杠杆。
5.2 loss=nan:先降学习率再查数据
现象:前几步loss还在2.x,第20步突然变成nan,之后一直nan。原因:最常见是learning_rate过大;其次是bf16/fp16精度处理不当;还有一个隐藏原因是数据里存在极长样本,tokenizer截断后target部分为空。解决:先把learning_rate降一个量级,比如全参从2e-5降到1e-5,QLoRA从2e-4降到1e-4,然后过滤空输出样本。
def filter_empty(example): enc = tokenizer(example["output"], add_special_tokens=False) return len(enc["input_ids"]) > 0 dataset = dataset.filter(filter_empty)如果使用了fp16且显卡支持bf16,直接切回bf16更省心。nan之后不要硬跑,加载最近一个save checkpoint,降lr重启。
5.3 微调后变笨:训练集loss正常但输出格式错乱
现象:训练loss稳步下降,推理时模型却回答“好的,我了解了”或者把角色标签原样输出。原因:训练数据用了纯文本拼接,推理用了apply_chat_template,两边的special token不一致,模型学到的输出分布和推理输入完全对不上。解决:训练侧和推理侧统一走apply_chat_template,system/user/assistant结构保持一致。
这个坑在项目实战里出现频率极高,尤其当教程把“数据预处理”和“推理”分成两个独立脚本时。两边各写各的,最容易出问题。
5.4 验证集loss比训练集高很多:数据切分有问题
现象:训练集3轮后loss到1.2,验证集一直停在1.8不降。原因:最常见是数据切分没按语义去重,同一条指令的不同回答同时出现在训练集和验证集,模型在背答案而不是学泛化;也可能是验证集和训练集领域分布不同。解决:按指令文本做hash去重,确保验证集里的instruction没有在训练集出现过。
train_instructions = set(ds_train["instruction"]) ds_val = ds_val.filter(lambda x: x["instruction"] not in train_instructions)验证集要的是“没见过的任务”上的表现,而不是记忆力的测试。如果数据量太少,可以按时间或来源领域切分,保证分布有差异。
5.5 QLoRA合并后模型还是4bit
现象:用merge_and_unload合并LoRA后保存的模型文件很小,加载后回答质量明显不对。原因:QLoRA的基座本身就是4bit量化权重,合并结果默认仍然是nf4格式;直接把adapter合并进量化基座,等于把LoRA信号加在了已经损失精度的权重上。解决:不追求单文件合版,推理时用原始bf16基座+adapter加载;如果一定要合并,先把基座转回bf16再做merge。
base = AutoModelForCausalLM.from_pretrained( "你的本地模型路径", torch_dtype=torch.bfloat16, device_map="auto", ) model = PeftModel.from_pretrained(base, "./checkpoints/qlora/step-1500")这个方式的推理质量和训练时最接近。想交付单文件模型,再单独跑一遍反量化合并脚本,不要直接拿量化基座merge。
6. 验证微调效果:训练loss只是热身,固定提示词才是尺子
6.1 用固定提示词做回归测试
训练完先别急着调参,准备一组固定提示词,覆盖通用任务和垂直任务,分别跑微调前后的输出。关键在于do_sample=False和固定seed,保证结果可复现。
from peft import PeftModel base = AutoModelForCausalLM.from_pretrained( "你的本地模型路径", torch_dtype=torch.bfloat16, device_map="auto", ) model = PeftModel.from_pretrained(base, "./checkpoints/lora/step-1500") questions = [ "用一句话说明什么是过拟合。", "把这句话翻译成英文:这家店的招牌菜是酸菜鱼。", "列出三种常见的激活函数并说明用途。", ] for q in questions: inputs = tokenizer.apply_chat_template( [{"role": "user", "content": q}], return_tensors="pt", ).to("cuda") out = model.generate(inputs, max_new_tokens=256, do_sample=False) print(tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True))对比时既看内容正确性,也看格式是否稳定。通用任务集负责检查模型有没有“变笨”,垂直任务集负责确认业务目标是否达成。这个回归集合一旦固定下来,就不要再频繁改动,否则没法横向比较。
6.2 用困惑度做定量对照
困惑度能给出一个客观数字,用于同一个模型在微调前后、以及不同超参版本之间的比较。
import math def compute_ppl(model, tokenizer, texts, max_len=512): total_nll = 0.0 total_tokens = 0 for text in texts: enc = tokenizer(text, truncation=True, max_length=max_len, return_tensors="pt") with torch.no_grad(): out = model(**enc, labels=enc["input_ids"]) total_nll += out.loss.item() * enc["input_ids"].numel() total_tokens += enc["input_ids"].numel() return math.exp(total_nll / total_tokens)困惑度下降说明模型对验证文本的拟合变好了,但它不代表输出格式正确。早期我做微调只盯loss,训到怀疑人生也没发现模型输出格式烂;后来学乖,先立回归提示词集合,每次训练结束先跑一遍再谈优化超参。这个习惯帮我避开了一半以上的无效调参。希望帮到你。
本文还有配套的精品资源,点击获取