2.1 从零跑通一次 LoRA/QLoRA 训练:配置、接线与假装在学的识别


文档摘要

2.1 从零跑通一次 LoRA / QLoRA 训练:配置、接线与「假装在学」的识别 读者读完这一节,应该能拿走一份可以直接复制运行的训练脚本骨架,并且学会一招最值钱的本事:从 loss 曲线和生成结果,判断训练真的在学,还是 GPU 在空转「假装在学」。前者是成果,后者是绝大多数人第一次跑微调的真实状态。 2.1.1 先说结论:一次完整的训练 = 四块拼图 我见过太多人从网上复制一段代码直接 ,跑完一脸茫然:loss 在动,但模型一点没变聪明。问题不在于代码错,而在于他没有把训练拆成可独立验证的模块,任何一步出错都只能对着一长串 traceback 发懵。 我把一次完整的 PEFT 微调拆成四块,每一块都能单独跑通、单独验证: 块一 · 模型加载:用 把 GLM-5.

2.1 从零跑通一次 LoRA / QLoRA 训练:配置、接线与「假装在学」的识别

读者读完这一节,应该能拿走一份可以直接复制运行的训练脚本骨架,并且学会一招最值钱的本事:从 loss 曲线和生成结果,判断训练真的在学,还是 GPU 在空转「假装在学」。前者是成果,后者是绝大多数人第一次跑微调的真实状态。

2.1.1 先说结论:一次完整的训练 = 四块拼图

我见过太多人从网上复制一段代码直接 python train.py,跑完一脸茫然:loss 在动,但模型一点没变聪明。问题不在于代码错,而在于他没有把训练拆成可独立验证的模块,任何一步出错都只能对着一长串 traceback 发懵。

我把一次完整的 PEFT 微调拆成四块,每一块都能单独跑通、单独验证:

  • 块一 · 模型加载:用 from_pretrained 把 GLM-5.2 权重读进来,QLoRA 还要在这里挂上 4-bit 量化。
  • 块二 · 适配器注入:用 LoraConfig + get_peft_model 把可训练参数「插」进冻结的底座里。
  • 块三 · 数据管线:把你的 SFT 数据变成 input_ids / labels,并正确掩码。
  • 块四 · Trainer 接线:用 TrainingArguments + Trainer 把上面三块串起来开跑。

这四块的顺序就是「地基 → 墙体 → 水电 → 通电」。下面我带你在每一块都停一下,确认它没问题,再进下一块。

```mermaid graph TD A[块一: 模型加载
from_pretrained + 量化] --> B[块二: 适配器注入
LoraConfig / get_peft_model] B --> C[块三: 数据管线
tokenize + labels 掩码] C --> D[块四: Trainer 接线
TrainingArguments + Trainer] D --> E[开跑 + 监控 loss / 生成] ```

2.1.2 块一:模型加载,QLoRA 的命门在 BitsAndBytesConfig

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,第一反应就应该是量化数据类型或学习率出了问题。

2.1.3 块二:适配器注入,LoraConfig 每个字段都要懂

加载完底座,下一步是「往里插适配器」。核心是 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。

实战技巧:在最终训练前,可先用 LoraConfigvalidate_only=True(部分 PEFT 版本支持)做一次 dry-run,确认 target_modules 真的命中了模型里存在的层名,避免跑了一小时才发现适配器压根没接上。

2.1.4 块三:数据管线,labels 掩码是隐性 bug 重灾区

数据是微调的「食材」,而 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_idslabels:prompt 段(灰色)在 labels 里是 -100,模型不对其算损失;只有回答段(彩色)才是真正的学习目标。

```mermaid graph LR S[原始样本] --> P[拼接: 指令+输入+回答] P --> I[input_ids: 整段都参与] P --> L[labels: 指令/输入=-100, 回答=真值] L --> T[模型只学回答部分] ```

这是最常见的「假装在学」原因之一:如果 labels 没掩码,模型会同时去拟合问题和回答,结果往往是过拟合 prompt、生成时原样复述问题。下次看到模型输出把问题又抄一遍,先检查这里。

2.1.5 块四:Trainer 接线,TrainingArguments 给保守初值

四块拼图的最后一块,是把模型、数据、配置交给 TrainerTrainingArguments 字段很多,我给你一份最小可用且稳健的模板,并标注每个字段为什么这么设:

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,结果你盯着屏幕半小时什么都看不到,还以为没在跑。监控的前提是有日志,先把步长调小,确认曲线在动,再考虑调大。

2.1.6 最关键的判断:怎样看出「看着在跑,其实没学」

这是本节最值钱的内容。训练真的有信号,我给你四个「自检开关」,开跑后逐项核对:

  1. loss 是否在下降并收敛? 健康曲线是平滑下行后趋平;如果出现 NaN/Inf,多半是学习率过大或量化 dtype 不对;如果纹丝不动,说明模型根本没在更新(检查 labels 掩码、学习率是否过小、适配器是否真接上)。
  2. 训练 loss 和 eval loss 是否一起降? 只降训练 loss、eval loss 反而升 = 过拟合,模型在背训练集。
  3. 生成结果像不像人话? 跑几个样本做推理,看回答是否真的「领域化」了。如果输出乱码、空回复、或把 prompt 抄一遍,就是没学进去。
  4. 可训练参数占比是否合理?print_trainable_parameters() 确认在 0.1%~3% 区间。

下面这张图对比了「健康的训练」与「假装在学」两种 loss 命运:

```mermaid graph TD A[开跑] --> B{loss 下降并收敛?} B -- 否: NaN/不动 --> C[假装在学: 查 lr/掩码/量化] B -- 是 --> D{eval loss 也降?} D -- 否: 训练降 eval 升 --> E[过拟合: 减 epoch/加正则] D -- 是 --> F[真在学: 跑样本验证生成] F --> G{生成像人话?} G -- 否 --> C G -- 是 --> H[成功] ```

据我观察,90% 的新手第一次跑出来都是「假装在学」。三个高频原因按出现概率排:① labels 没掩码(模型在学噪声);② 学习率过大导致 loss 直接爆炸变 NaN;③ 数据格式错了(字段名对不上、JSON 解析静默失败),模型实际在训练一堆空样本。把上面四个开关当 checklist,能省掉你至少一周的瞎调。

2.1.7 一个能直接跑的最小可复现骨架

把四块整合起来,就是下面这份骨架。把它存成 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 的具体权重路径与最优超参请以智谱官方文档为准,不要凭记忆填。

2.1.8 现场排错清单:把红色报错翻译成「下一步动作」

跑训练时你一定会撞上红色报错。我整理一份「报错 → 原因 → 下一步」的对照表,按出现频率排序,建议你边跑边对照——这比每次去搜半天更高效。

  • CUDA out of memory(显存溢出):这是最高频的拦路虎。下一步依次是:把 per_device_train_batch_size 降到 1;把 gradient_accumulation_steps 调大维持等效 batch;确认开了 4-bit 量化(QLoRA);再不行就上梯度检查点(见 2.1.10)。
  • KeyError: 'labels' / 'input_ids':说明你的数据集字段名和代码对不上,或 preprocess 没正确返回。下一步:打印一条样本,确认字段名;检查 map 是否真的把新字段写回了数据集。
  • bitsandbytes 未编译 / CUDA 版本不匹配Could not load bitsandbytes。下一步:重装与你的 CUDA 驱动版本对应的 bitsandbytes(常见 CUDA 11.8 / 12.1 两个轮子),别盲目 pip install 最新版。
  • 需要 trust_remote_code:部分 GLM 权重在加载时会提示需要实现自定义模块。下一步:以智谱官方文档为准决定是否设 trust_remote_code=True,不要为了跑通就盲目开启未知远程代码。
  • loss 一直是 None / 看不到曲线:多半是 logging_steps 太大。下一步:调到 10,确认有日志输出再考虑调大。
  • 生成全是重复词 / 乱码:通常不是学习率问题,而是数据质量或掩码错了。下一步:回到 2.1.4 复查 labels 掩码,并抽查训练数据是否本身就有噪声。

2.1.9 肉眼确认:适配器真的挂上去了吗

「假装在学」的另一种隐蔽形态是:代码跑通了,但 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_Alora_B 这类适配器相关张量,底座的 weight 全是 requires_grad=False。如果你看到 model.layers.0.self_attn.q_proj.weight 这种底座参数 requires_grad=True,说明冻结失败——通常是先 get_peft_model 之后又对底座做了会解除梯度的操作,检查调用顺序。

2.1.10 再省显存:梯度检查点(差一点就 OOM 的救命稻草)

如果你的显存离「刚好够」只差一小截,量化也开了、batch 也降到 1 了,还有一个标准解法:梯度检查点(gradient checkpointing)。它的原理是不在显存里保留每一层的中间激活,而是反向传播时重新计算,用约 20%~30% 的额外时间换掉一大块激活显存:

model.gradient_checkpointing_enable() try: model.enable_input_require_grads() # 配合 PEFT 时需要,确保输入梯度可回传 except AttributeError: pass

注意:开了之后单步更慢,但往往能把「必崩」变成「能跑」。在 GLM-5.2 这种大模型上,这一招常是消费级显卡能否跑通 QLoRA 的分水岭。

2.1.11 训练后:适配器怎么用、怎么验,要不要合并

训练跑完,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 的人审抽测一起上——训练集之外的领域样本,微调后能不能一句话答上来?能,才算这轮训练毕业。

2.1.12 今日小结

一句话带走:跑通训练不难,难的是确认它真在学。下次打开日志,先问自己三个问题——loss 降了吗?eval 一起降了吗?生成像人话吗?三个都答「是」,你才算真正跑通了一次 LoRA / QLoRA 微调。


发布者: 作者: 渗透测试失败者的小龙虾 转发
评论区 (0)
U