2.1 从零跑通一次 LoRA / QLoRA 训练:配置、接线与「假装在学」的识别 读者读完这一节,应该能拿走一份可以直接复制运行的训练脚本骨架,并且学会一招最值钱的本事:从 loss 曲线和生成结果,判断训练真的在学,还是 GPU 在空转「假装在学」。前者是成果,后者是绝大多数人第一次跑微调的真实状态。 2.1.1 先说结论:一次完整的训练 = 四块拼图 我见过太多人从网上复制一段代码直接 ,跑完一脸茫然:loss 在动,但模型一点没变聪明。问题不在于代码错,而在于他没有把训练拆成可独立验证的模块,任何一步出错都只能对着一长串 traceback 发懵。 我把一次完整的 PEFT 微调拆成四块,每一块都能单独跑通、单独验证: 块一 · 模型加载:用 把 GLM-5.
读者读完这一节,应该能拿走一份可以直接复制运行的训练脚本骨架,并且学会一招最值钱的本事:从 loss 曲线和生成结果,判断训练真的在学,还是 GPU 在空转「假装在学」。前者是成果,后者是绝大多数人第一次跑微调的真实状态。
我见过太多人从网上复制一段代码直接 python train.py,跑完一脸茫然:loss 在动,但模型一点没变聪明。问题不在于代码错,而在于他没有把训练拆成可独立验证的模块,任何一步出错都只能对着一长串 traceback 发懵。
我把一次完整的 PEFT 微调拆成四块,每一块都能单独跑通、单独验证:
from_pretrained 把 GLM-5.2 权重读进来,QLoRA 还要在这里挂上 4-bit 量化。LoraConfig + get_peft_model 把可训练参数「插」进冻结的底座里。input_ids / labels,并正确掩码。TrainingArguments + Trainer 把上面三块串起来开跑。这四块的顺序就是「地基 → 墙体 → 水电 → 通电」。下面我带你在每一块都停一下,确认它没问题,再进下一块。
LoRA 和 QLoRA 在「模型加载」这一步就分道扬镳了,这是两者成本差异的源头。
纯 LoRA(不量化) 很简单,正常加载就行,显存占用来自 fp16/bf16 的底座:
from transformers import AutoModelForCausalLM, AutoTokenizer model_id = "这里填 GLM-5.2 的 HuggingFace 仓库名" # 以官方文档公布的权重路径为准 tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=False) model = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype="auto", # 自动选 bf16/fp16,优先 bf16 device_map="auto", # 多卡/显存不足时自动分片 trust_remote_code=False, # GLM 官方权重通常不需要远程代码,别盲目开 )
QLoRA(4-bit 量化) 的关键在于 BitsAndBytesConfig。注意:如果你不用 4-bit 加载,就只是普通的 LoRA,谈不上「QLoRA」——这一步偷懒,显存账就全错了:
import torch from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", # NF4 是 QLoRA 论文推荐的 4-bit 分布,比普通 int4 更贴合权重 bnb_4bit_compute_dtype=torch.bfloat16, # 计算时用 bf16,省显存又稳 bnb_4bit_use_double_quant=True, # 对量化常数再做一次量化,再省一点 ) tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=False) model = AutoModelForCausalLM.from_pretrained( model_id, quantization_config=bnb_config, device_map="auto", trust_remote_code=False, )
这里有个坑要提醒:bnb_4bit_compute_dtype 建议用 bf16。在 Ampere 及更新的显卡(A100 / A800 / 4090 等)上 bf16 有原生支持,又快又稳;老卡没有 bf16 时退而用 fp16。若你看到 loss 直接变 NaN,第一反应就应该是量化数据类型或学习率出了问题。
加载完底座,下一步是「往里插适配器」。核心是 LoraConfig,它的字段一个都不能当成黑盒:
from peft import LoraConfig, get_peft_model config = LoraConfig( r=16, # 低秩矩阵的秩,决定适配器容量 lora_alpha=32, # 缩放系数,有效更新幅度 = (alpha / r) * B@A target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], # 注入到哪些层 lora_dropout=0.05, # 防过拟合 bias="none", # 通常不加偏置 task_type="CAUSAL_LM", # 因果语言模型,GLM 这类自回归模型用这个 ) model = get_peft_model(model, config) model.print_trainable_parameters() # 关键:打印可训练参数占比
target_modules 是最常被问的问题。注意力里的 q/k/v/o_proj 是默认首选,覆盖了「模型怎么组织信息」的核心;若任务复杂、数据量大,可以把 gate_proj / up_proj / down_proj(MLP 部分)也加进来,容量更大但显存和过拟合风险也更高。print_trainable_parameters() 的输出一般会是「trainable params: 0.1%~3%」,这是正常区间——如果显示 100%,说明你没冻结底座,那是在做全量微调,不是 LoRA。
实战技巧:在最终训练前,可先用
LoraConfig的validate_only=True(部分 PEFT 版本支持)做一次 dry-run,确认target_modules真的命中了模型里存在的层名,避免跑了一小时才发现适配器压根没接上。
数据是微调的「食材」,而 SFT 数据最常见的写法是一条样本包含「指令 / 输入 / 回答」。问题出在:模型训练时要计算 loss,但我们只想让它学「回答」部分,不想让它学「复述问题」。
正确做法是把整条样本拼成一段文本,然后在 labels 里把「指令+输入」对应的位置填 -100,PyTorch 会自动忽略这些位置的损失:
def preprocess(example): # prompt 部分:指令 + 输入 prompt = f"### 指令:\n{example['instruction']}\n\n### 输入:\n{example['input']}\n\n### 回答:\n" # 完整文本 = prompt + 回答 full = prompt + example["output"] tokenized = tokenizer(full, truncation=True, max_length=2048) # 重新算一遍 prompt 的长度,把对应 label 全部打成 -100 prompt_only = tokenizer(prompt, truncation=True, max_length=2048) labels = [-100] * len(prompt_only["input_ids"]) + tokenized["input_ids"][len(prompt_only["input_ids"]):] tokenized["labels"] = labels return tokenized tokenized_dataset = raw_dataset.map(preprocess) data_collator = DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True)
下面这张图说清了一条样本如何变成 input_ids 与 labels:prompt 段(灰色)在 labels 里是 -100,模型不对其算损失;只有回答段(彩色)才是真正的学习目标。
这是最常见的「假装在学」原因之一:如果 labels 没掩码,模型会同时去拟合问题和回答,结果往往是过拟合 prompt、生成时原样复述问题。下次看到模型输出把问题又抄一遍,先检查这里。
四块拼图的最后一块,是把模型、数据、配置交给 Trainer。TrainingArguments 字段很多,我给你一份最小可用且稳健的模板,并标注每个字段为什么这么设:
from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir="./glm52-lora-checkpoints", per_device_train_batch_size=2, # 受显存限制,不够就调小 gradient_accumulation_steps=8, # 等效 batch = 2*8 = 16,显存不够靠它顶 num_train_epochs=3, # LoRA 通常 1~3 轮足够 learning_rate=2e-4, # LoRA 常用 1e-4~3e-4,比全量大一个量级 fp16=False, bf16=True, # 有 bf16 支持就开 bf16 logging_steps=10, # 一定要小!否则你根本看不到曲线 eval_strategy="steps", eval_steps=50, # 有验证集就开,盯 eval loss save_strategy="steps", save_steps=200, warmup_ratio=0.03, report_to="none", # 先别接 wandb,本地看日志最直观 save_total_limit=2, ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset.get("validation"), tokenizer=tokenizer, data_collator=data_collator, ) trainer.train()
注意 logging_steps=10 这一行——很多教程用默认 500,结果你盯着屏幕半小时什么都看不到,还以为没在跑。监控的前提是有日志,先把步长调小,确认曲线在动,再考虑调大。
这是本节最值钱的内容。训练真的有信号,我给你四个「自检开关」,开跑后逐项核对:
NaN/Inf,多半是学习率过大或量化 dtype 不对;如果纹丝不动,说明模型根本没在更新(检查 labels 掩码、学习率是否过小、适配器是否真接上)。print_trainable_parameters() 确认在 0.1%~3% 区间。下面这张图对比了「健康的训练」与「假装在学」两种 loss 命运:
据我观察,90% 的新手第一次跑出来都是「假装在学」。三个高频原因按出现概率排:① labels 没掩码(模型在学噪声);② 学习率过大导致 loss 直接爆炸变 NaN;③ 数据格式错了(字段名对不上、JSON 解析静默失败),模型实际在训练一堆空样本。把上面四个开关当 checklist,能省掉你至少一周的瞎调。
把四块整合起来,就是下面这份骨架。把它存成 train.py,把数据路径和模型仓库名替换成你自己的即可开跑:
# train.py —— GLM-5.2 LoRA / QLoRA 最小骨架(按需替换为你的数据与模型) import torch from datasets import load_from_disk from transformers import (AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments, Trainer, DataCollatorForSeq2Seq) from peft import LoraConfig, get_peft_model MODEL_ID = "填你的 GLM-5.2 权重路径" # 具体路径以官方文档公布为准 DATA_DIR = "填你的 tokenized 数据集目录" # 先按 2.1.4 预处理好 # 块一:QLoRA 量化加载(纯 LoRA 则去掉 quantization_config) bnb = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True) tok = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=False) model = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb, device_map="auto", trust_remote_code=False) # 块二:注入 LoRA model = get_peft_model(model, LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj","k_proj","v_proj","o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")) model.print_trainable_parameters() # 块三 + 四 ds = load_from_disk(DATA_DIR) args = TrainingArguments(output_dir="./ckpt", per_device_train_batch_size=2, gradient_accumulation_steps=8, num_train_epochs=3, learning_rate=2e-4, bf16=True, logging_steps=10, eval_strategy="steps", eval_steps=50, save_strategy="steps", save_steps=200, warmup_ratio=0.03, report_to="none") trainer = Trainer(model=model, args=args, train_dataset=ds["train"], eval_dataset=ds.get("validation"), tokenizer=tok, data_collator=DataCollatorForSeq2Seq(tok, padding=True)) trainer.train() model.save_pretrained("./glm52-lora-adapter") # 只存适配器,体积小
提醒:这是骨架,真实跑通还需结合你的数据格式与硬件显存调整 batch / 梯度累积;GLM-5.2 的具体权重路径与最优超参请以智谱官方文档为准,不要凭记忆填。
跑训练时你一定会撞上红色报错。我整理一份「报错 → 原因 → 下一步」的对照表,按出现频率排序,建议你边跑边对照——这比每次去搜半天更高效。
per_device_train_batch_size 降到 1;把 gradient_accumulation_steps 调大维持等效 batch;确认开了 4-bit 量化(QLoRA);再不行就上梯度检查点(见 2.1.10)。preprocess 没正确返回。下一步:打印一条样本,确认字段名;检查 map 是否真的把新字段写回了数据集。Could not load bitsandbytes。下一步:重装与你的 CUDA 驱动版本对应的 bitsandbytes(常见 CUDA 11.8 / 12.1 两个轮子),别盲目 pip install 最新版。trust_remote_code:部分 GLM 权重在加载时会提示需要实现自定义模块。下一步:以智谱官方文档为准决定是否设 trust_remote_code=True,不要为了跑通就盲目开启未知远程代码。logging_steps 太大。下一步:调到 10,确认有日志输出再考虑调大。「假装在学」的另一种隐蔽形态是:代码跑通了,但 LoRA 适配器其实没接上,模型在做一个「什么都不学的全量前向」。开跑前用两行代码确认冻结与注入都生效:
trainable = [n for n, p in model.named_parameters() if p.requires_grad] print("可训练参数名(应只含 lora_A / lora_B):") for n in trainable[:10]: print(" ", n)
健康状态是:可训练参数名里只出现 lora_A、lora_B 这类适配器相关张量,底座的 weight 全是 requires_grad=False。如果你看到 model.layers.0.self_attn.q_proj.weight 这种底座参数 requires_grad=True,说明冻结失败——通常是先 get_peft_model 之后又对底座做了会解除梯度的操作,检查调用顺序。
如果你的显存离「刚好够」只差一小截,量化也开了、batch 也降到 1 了,还有一个标准解法:梯度检查点(gradient checkpointing)。它的原理是不在显存里保留每一层的中间激活,而是反向传播时重新计算,用约 20%~30% 的额外时间换掉一大块激活显存:
model.gradient_checkpointing_enable() try: model.enable_input_require_grads() # 配合 PEFT 时需要,确保输入梯度可回传 except AttributeError: pass
注意:开了之后单步更慢,但往往能把「必崩」变成「能跑」。在 GLM-5.2 这种大模型上,这一招常是消费级显卡能否跑通 QLoRA 的分水岭。
训练跑完,save_pretrained("./glm52-lora-adapter") 落地的只是一个几十到几百 MB 的适配器目录,不包含底座。这是 LoRA 的核心优势:一份底座 + N 个适配器,互不干扰。推理时有两种用法:
用法一:底座 + 适配器一起加载(最常见)。QLoRA 场景下尤其推荐,因为底座本来就是量化加载的,不需要还原全精度:
from peft import PeftModel base = AutoModelForCausalLM.from_pretrained(MODEL_ID, quantization_config=bnb, device_map="auto", trust_remote_code=False) model = PeftModel.from_pretrained(base, "./glm52-lora-adapter") model.eval()
用法二:合并后导出。如果你要把模型交给别人部署、或希望单文件推理更省事,可以把适配器权重加回底座,得到一个「看起来就像全量微调过」的模型:
merged = model.merge_and_unload() # 把 lora 增量并回底座 merged.save_pretrained("./glm52-merged")
提醒:合并后的模型体积等于底座本身(GLM-5.2 这种大模型会非常大),且合并是不可逆的——一旦合并,就丢失了「小适配器灵活切换」的优势。我的建议是:开发期用用法一反复试错,确定效果后再合并出一份交付版。
怎么验(验收标准):别只看 loss。正确验收是拿 2.1.6 的那套开关 + 2.2 的人审抽测一起上——训练集之外的领域样本,微调后能不能一句话答上来?能,才算这轮训练毕业。
一句话带走:跑通训练不难,难的是确认它真在学。下次打开日志,先问自己三个问题——loss 降了吗?eval 一起降了吗?生成像人话吗?三个都答「是」,你才算真正跑通了一次 LoRA / QLoRA 微调。