☰
ChatGLM3-6B LoRA微调实战:从原理到排坑,一张显卡也能训大模型
2026/9/25 1:53:48 网站建设 项目流程

简介:面向自然语言处理开发者和研究者的实践型资源,围绕ChatGLM3-6B大模型,提供基于LoRA低秩适配方法的完整微调实现。资源涵盖项目源码与配套流程教程,重点解决资源受限场景下高效微调大模型的问题,帮助读者在保持模型泛化能力的同时提升特定任务适应性,适合具备一定深度学习基础、希望上手大模型微调的初学者和工程师。压缩包共12个文件,以Python脚本、JSON数据与配置文件为主,另有YAML参数配置、Markdown说明文档及辅助文件,整体约359KB。其中脚本覆盖数据转换、LoRA微调、推理及模型导出等环节,JSON文件提供微调所需的数据集样例与验证集,配合教程可完成从数据集准备到效果评估的完整链路。已有780人学习下载。通过阅读和运行源码,可直观理解LoRA低秩更新的实现思路,掌握ChatGLM3-6B微调的数据处理、训练配置和推理导出方法;教程步骤清晰,降低了上手门槛,也为后续在自有任务上开展大模型高效微调提供了可复用的参考模板。

1. 从“一张显卡也能训大模型”说起:ChatGLM3-6B与LoRA微调到底在做什么

把一通业务问答丢给ChatGLM3-6B通用模型,得到的多半是公文腔十足、但关键事实全错的长篇大论。这时候你不需要重训一个基座模型,只需要用几十条到几千条业务数据做一次LoRA微调——冻结6B参数,只训练不到0.1%的附加矩阵,一张消费级显卡就能在几小时内跑完。这正是“大模型LoRA微调”这个组合的实用价值。这篇笔记会围绕基于ChatGLM3-6B的LoRA微调实现来讲:先把低秩适配的原理说清,再给出一套能直接跑通的数据处理和PEFT训练流程,最后把显存、loss、合并权重上的坑逐条铺开。适合手里有可用显卡、被垂类效果逼到墙角的人。

2. 低秩适配的核心原理:为什么LoRA能把显存占用打下来

2.1 从全量微调看LoRA省显存的本质

全量微调ChatGLM3-6B时,优化器状态、梯度、模型权重三样东西同时驻留显存,单卡24G根本装不下,就算勉强塞进去,训出来的结果也经常是一轮之后就把基座能力忘得七七八八。LoRA的做法是冻结原模型权重W0,在每一层插入两个低秩矩阵A和B:A的维度是(r, d),B的维度是(d, r),这里d是隐藏层维度,r是远小于d的秩。前向计算变成y = W0x + BAx,反向传播只更新A和B,原权重全程不动。

6B模型、隐藏层4096、28层Transformer,如果只在每层的q_proj和v_proj上挂LoRA且r=8,新增可训练参数大约370万,占总参数量0.06%。这意味着显存占用里“可训练参数相关”的部分从6B量级降到几百万量级,梯度与优化器状态随之大幅缩小。这也就是为什么LoRA微调能在24G甚至16G显存上跑起来,而全量微调做不到。

另一个常被忽略的点是:低秩修正本身是一种强正则。可训练参数少,模型被业务数据带偏的风险就小,灾难性遗忘更弱。对ChatGLM3-6B这种通用能力已经不错的基座来说,你不需要让它忘掉通用知识,只需要给它在垂直场景下“拨正方向”。

2.2 ChatGLM3-6B的哪些层适合挂LoRA

ChatGLM3-6B的线性层命名与LLaMA系列不完全一样,这一度让很多照着LLaMA教程抄的人卡住。在Hugging Face的modeling_chatglm.py里,attention层投影层叫q_proj、k_proj、v_proj,MLP层叫dense_h_to_4h和dense_4h_to_h。PEFT的target_modules参数传的就是这些模块名字的子串。

我一般第一轮只挂q_proj和v_proj,这是最稳的起点。q和v投影对指令语义和输出风格最敏感,所需显存增量也最小。如果r=8训完效果不够,再考虑加k_proj或者把MLP的dense_h_to_4h也带上。target_modules配成全部线性层“all_linear”能让模型学得更灵活,但也更容易过拟合,而且可训练参数翻倍,24G显存仍可以接受,只是训练时间明显变长。

需要留意的是,ChatGLM3-6B的embedding层不参与LoRA改造。有些人不理解为什么embedding不更新会导致行业术语在输出里频繁走样——词向量代表基座对词语的静态理解,LoRA改的是它往上走之后的高维语义修正,如果你手头数据里大量出现预训练阶段从未见过的生僻概念,embedding不更新确实有上限,但这不是r=8能解决的,得靠扩充数据或换更强的基座。

2.3 什么时候该选LoRA而不是全量微调或Adapter

方案可训练参数量单卡24G可行性灾难性遗忘实现复杂度
全量微调100%不可行,需多卡+重则高中等
Adapter(串行/并行适配器)视配置约2%~8%可行中等低,但推理增耗时
LoRA0.1%以内可行低低,训练后权重可合并

我的判断标准是:数据量在几百到几万条、任务形态是“指令问答风格对齐”或“特定领域知识灌输”时,LoRA就是首选。它不改变基座推理延迟,训练后把LoRA权重合并回原权重里等价于一个完整模型,不需要在推理管线里额外挂一个适配器。Adapter则要保留额外参数并修改推理代码,除非你有极其特殊的逐任务切换需求,否则LoRA操作上更省事。

数据处理阶段最常见的误区是拿通用对话语料直接喂给微调脚本,导致LoRA学到的只是“更流畅的废话”。真正常用的做法是整理成指令、输入、输出三段式,指令写明角色与约束,输出给出你希望模型复刻的标准口径。这一块我在第3章展开。

3. 数据准备与运行环境:把业务问答整理成ChatGLM3-6B能吃的格式

3.1 版本矩阵与显存门槛

先确认你手上的GPU。ChatGLM3-6B在fp16下基座权重约12G,再加LoRA训练时的梯度、优化器状态和中间激活,16G显存属于“勉强能跑”,24G才是舒适区。如果你只有8G显存,不要直接上这一步,先去把基座量化为4bit再挂LoRA,那是另一个分支,本文不展开。

Python库版本我建议锁定在这个组合:Python 3.10、torch 2.1.2、transformers 4.36.2、peft 0.7.1、datasets 2.16.1。ChatGLM3-6B使用trust_remote_code加载,transformers版本太高或太低都会跟ChatGLM3本地建模代码冲突,比如“rotary_pos_emb不存在”这类报错,多半就是transformers接口变动引发的。

pip install torch==2.1.2 torchvision==0.16.2 --index-url https://download.pytorch.org/whl/cu121 pip install transformers==4.36.2 peft==0.7.1 datasets==2.16.1 accelerate==0.28.0

参数说明:torch必须与CUDA版本匹配,cu121对应CUDA 12.1;transformers锁定4.36.2是ChatGLM3官方示例验证过的版本;accelerate负责device_map和梯度累积,版本不建议追新。装完之后用nvidia-smi确认驱动和CUDA可用,再跑一句python -c "import torch; print(torch.cuda.is_available())"验证PyTorch能看见显卡。

3.2 把业务问答转成SFT数据:Alpaca格式与对话模板

LoRA微调ChatGLM3-6B,数据格式跟基座对话模板强相关。ChatGLM3的tokenizer在编码对话时需要特殊token:文本开头带[gMASK]sop,然后按<|user|>、<|assistant|>区分角色。偷懒只拼prompt+answer也能训,但会让模型学到错误的对话结构,常见后遗症是生成内容里夹杂<|user|>等原始token文本。

我常用的转换脚本输入是每行一条JSON,字段不定,但至少要能切出“问题”和“标准答案”。下面的脚本把问答对转成Alpaca风格并补上对话模板:

import json with open("raw_qa.jsonl", "r", encoding="utf-8") as f: lines = [json.loads(line) for line in f] converted = [] for item in lines: prompt = item["question"].strip() output = item["answer"].strip() if not prompt or not output: continue text = f"[gMASK]sop<|user|>\n{prompt}<|assistant|>\n{output}" converted.append({"prompt": prompt, "output": output, "text": text}) with open("sft_data.jsonl", "w", encoding="utf-8") as f: for item in converted: f.write(json.dumps(item, ensure_ascii=False) + "\n") print(f"共转换 {len(converted)} 条")

逻辑说明:把原始问答对序列化成一个完整对话文本,训练时模型直接学习输出这段文本的条件概率。[gMASK]sop是ChatGLM3-6B的强制前缀,不能说丢就丢。脚本里对空字段做了过滤,这类脏数据是微调loss异常的隐形杀手。

参数说明:如果你的任务有“输入”字段(例如“阅读下面文本后回答问题”),可以把输入拼在prompt后面,例如prompt + "\n" + item["input"];output字段就是标准答案,请人工清洗过再灌入,模型会一字不差地模仿你的回答风格,包括错别字和口语习惯。

3.3 训练集/验证集划分与长度控制

数据比例我习惯按9:1划分训练集与验证集,而且要保证同主题内容不进两边,否则验证集loss参考价值很低。小数据场景下不需要追求严格随机,按业务来源分组后按组切分更可靠。

import json, random random.seed(42) with open("sft_data.jsonl", "r", encoding="utf-8") as f: data = [json.loads(line) for line in f] random.shuffle(data) split_idx = int(len(data) * 0.9) train_data, val_data = data[:split_idx], data[split_idx:] for name, subset in [("train", train_data), ("val", val_data)]: with open(f"chatglm3_lora/{name}.jsonl", "w", encoding="utf-8") as f: for item in subset: f.write(json.dumps(item, ensure_ascii=False) + "\n")

显存占用与序列长度强相关。ChatGLM3-6B的max_length是8192,但训练时大多数个人项目根本用不到。指令问答场景我把max_seq_len设为1024,超过部分直接截断;如果你的业务答案很长,再小幅上调到1536或2048。每涨512个token,训练显存增加约1.5G,别拿基座的最大长度去设训练长度,这是新手翻车高发区。

有一个值得重复的数据检查技巧:转完格式之后,随机抽10条打印出完整text字段,确认[gMASK]sop位置正确、角色标记没被转义符破坏。这个步骤花不了两分钟,但能省掉后续好几个小时的排查。

4. 用PEFT在本地跑通ChatGLM3-6B的LoRA训练:最小代码与参数策略

4.1 加载基座模型与分词器的正确姿势

训练脚本第一步是加载ChatGLM3-6B基座。由于该模型的建模代码不在transformers仓库里,必须设置trust_remote_code=True,让transformers从远端仓库拉取modeling_chatglm.py。这一步经常被防火墙拦,也可以先把repo克隆到本地再指定路径加载。

import torch from transformers import AutoModel, AutoTokenizer model_path = "THUDM/chatglm3-6b" tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) model = AutoModel.from_pretrained( model_path, trust_remote_code=True, torch_dtype=torch.bfloat16, device_map="auto", ) model.enable_input_require_grads() model.gradient_checkpointing_enable() model.config.use_cache = False

逻辑说明:torch_dtype=torch.bfloat16比fp16更稳,反向传播时梯度不容易溢出;16G显存用户也可以试fp16,但bf16在24G卡上是我的默认选择。enable_input_require_grads()是LoRA训练的必要步骤,它让输入层保留梯度,否则某些低层模块可能被错误冻结。use_cache=False关闭KV缓存,因为训练时不需要缓存,开着反而增加显存开销。

参数说明:device_map="auto"在单卡场景会把全部层放到GPU0。如果你的卡只有16G,且加载后剩余显存太少,可以改为device_map="cuda:0"并减少batch_size,不要同时把模型切到CPU,CPU offload会让训练速度掉到无法接受的程度。

4.2 LoraConfig参数与target_modules选择

PEFT库的LoraConfig是整套LoRA配置的核心,这里放一组我在ChatGLM3-6B上验证过稳定的参数:

from peft import LoraConfig, get_peft_model, TaskType lora_config = LoraConfig( r=8, lora_alpha=32, lora_dropout=0.1, target_modules=["q_proj", "v_proj"], bias="none", task_type=TaskType.CAUSAL_LM, ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()

逻辑说明:r是低秩矩阵的秩,控制可训练参数规模。r=8对大多数指令微调任务够用,r=16能容纳更多任务特有信息但增加显存和过拟合风险。lora_alpha是缩放系数,LoRA前向计算会把AB的输出乘上alpha/r,alpha=32、r=8时等效缩放4倍。这个比例影响新学参数对原权重的修正强度,不宜乱改。

target_modules传["q_proj", "v_proj"],PEFT会去模型里找名字包含这两个子串的Linear层。ChatGLM3-6B的建模代码里q_proj和v_proj各28个,全部被挂上LoRA。g_proj这类命名不在本项目中出现,不必理会。

运行脚本后看到类似“trainable params: 3,625,984 || all params: 6,283,699,176”即可确认可训练参数比正确,如果打印出来的trainable params数量级是亿级,说明target_modules写错或r设太大,先停下检查。

4.3 训练超参设置与显存分配策略

训练数据加载我习惯直接写一个torch Dataset,比datasets库的map更可控,尤其是处理labels掩码的时候。下面是能直接放进训练脚本的版本:

import json import torch from torch.utils.data import Dataset class SFTDataset(Dataset): def __init__(self, path, tokenizer, max_seq_len=1024): self.tokenizer = tokenizer self.max_seq_len = max_seq_len self.items = [] with open(path, "r", encoding="utf-8") as f: for line in f: self.items.append(json.loads(line)) def __len__(self): return len(self.items) def __getitem__(self, idx): item = self.items[idx] text = item["text"] input_ids = self.tokenizer.encode(text, add_special_tokens=False) input_ids = input_ids[: self.max_seq_len] prompt_ids = self.tokenizer.encode( f"[gMASK]sop<|user|>\n{item['prompt']}<|assistant|>\n", add_special_tokens=False, ) labels = [-100] * len(prompt_ids) + input_ids[len(prompt_ids):] input_ids = torch.tensor(input_ids, dtype=torch.long) labels = torch.tensor(labels, dtype=torch.long) attention_mask = torch.ones_like(input_ids) return {"input_ids": input_ids, "labels": labels, "attention_mask": attention_mask}

逻辑说明:先把整段对话编码成input_ids,再单独编码prompt部分,把prompt对应的label位置设为-100。-100是PyTorch CrossEntropyLoss的默认忽略值,loss只计算assistant回答部分,模型不会去拟合“怎么把问题说清楚”,只学“怎么把答案写对”。这比整段文本无差别计算loss稳定得多。

参数说明:add_special_tokens=False很关键,因为ChatGLM3的special token已经在text字符串里手动加了,重复添加会出现双份[gMASK]。max_seq_len截断放在编码之后,意味着prompt特别长时可能把answer头截掉,遇到这种情况优先调整数据,不要硬扩max_seq_len。

训练循环用transformers的Trainer包起来,省去手写梯度累积和日志:

from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir="./chatglm3-lora-checkpoint", num_train_epochs=3, per_device_train_batch_size=1, gradient_accumulation_steps=8, learning_rate=2e-5, warmup_ratio=0.03, lr_scheduler_type="cosine", logging_steps=10, save_steps=200, save_total_limit=2, bf16=True, gradient_checkpointing=True, report_to="none", ) trainer = Trainer( model=model, args=training_args, train_dataset=SFTDataset("chatglm3_lora/train.jsonl", tokenizer), eval_dataset=SFTDataset("chatglm3_lora/val.jsonl", tokenizer), tokenizer=tokenizer, ) trainer.train()

这里batch_size=1配合gradient_accumulation_steps=8,等效batch size是8。LoRA微调不需要大batch,调大batch并不会让效果更好,反而让显存压力迅速上升。learning_rate=2e-5是LoRA场景的常见选择,超过5e-5很容易看到loss炸掉或是中文输出开始带乱码。bf16在4090、A100这类卡上没问题,老卡不支持bf16就改成fp16=True。

训练过程里观察日志:正常时loss会在前几十步从2.x附近降下来,如果一开始就是4.x或5.x,先检查数据模板拼接是否出错。每200步存一次checkpoint,save_total_limit=2保证磁盘不爆。

5. LoRA微调排坑笔记:从loss不降到显存爆掉的5条实战记录

5.1 反向传播前就OOM,显存直接报错

现象:训练脚本跑完第一步,还没来得及打印loss就弹出CUDA out of memory。

原因:最常见的是max_seq_len设太大。很多人把ChatGLM3-6B的8192直接抄进训练代码,单条序列编码后超过7000 token,即使batch_size=1,中间激活也轻松吃掉十几G显存。其次是device_map="auto"与其他模块同时启用gradient_checkpointing时,transformers可能在CPU与GPU间反复调度。

解决:把max_seq_len压到1024,per_device_train_batch_size固定为1,先跑通再慢慢往上探。用torch.cuda.max_memory_allocated()/1024**3打印峰值显存,如果峰值超过显存的85%,说明配置已经到临界值,gradient_accumulation_steps不影响显存,放心用。

5.2 loss在2.0附近震荡,生成内容全是重复短句

现象:训练了两个epoch,loss能降到1.x,但推理时模型只回重复的“好的”“明白了”或不停循环同一句话。

原因:这一条排在所有质量坑的最前面——学习率过大导致LoRA权重震荡,模型还没收敛就被推到了局部坏点。另一个常见诱因是数据里answer字段过短(比如一两句话),模型学到的映射退化成“接到问题就敷衍回应”。

解决:先把learning_rate降到1e-5或8e-6重训,LoRA微调不是越大步长越快。同时检查数据分布,answer低于20个字的样本要么扩充要么删掉,让它学有信息量的回答。如果你用的封装框架里默认学习率是5e-5,也建议改成2e-5以下。

5.3 加载模型就报错:transformers接口变动引发“rotary_pos_emb”报错

现象:执行AutoModel.from_pretrained时抛AttributeError,提示没有某个rope相关属性,或者提示“loading file出现问题”。

原因:ChatGLM3-6B的建模代码是随模型仓库发布的,它依赖transformers里某些C级别attention接口。transformers升到4.40之后,layers的入参格式变了,旧版modeling_chatglm.py里apply_rotary_pos_emb的调用就会失效。这不是LoRA的问题,是基座代码与库版本错配。

解决:把transformers锁回4.36.2并重建虚拟环境,这是最省事的路。如果你必须用新版本,只能手动编辑本地的modeling_chatglm.py里对应call方法,把旋转位置编码的传入参数改成新接口,改完跑一个简单前向确认不报错再开始训练。

5.4 训完推理发现模型输出跟微调前没有区别

现象:loss明显下降,checkpoint也保存了,但单独跑推理时模型回答还是通用套话,一点业务口吻都没有。

原因:推理代码只加载了基座模型,没有加载LoRA权重。这种情况在PEFT和Trainer混用时特别容易发生:Trainer保存的是“基座+LoRA”的完整状态还是只保存adapter,取决于你的保存方式。很多人从model目录下直接load,把adapter文件落下了。

解决:用PeftModel.from_pretrained加载,一行代码把LoRA权重挂回基座:

from peft import PeftModel base = AutoModel.from_pretrained("THUDM/chatglm3-6b", trust_remote_code=True) model = PeftModel.from_pretrained(base, "./chatglm3-lora-checkpoint/checkpoint-600")

然后推理时用model.chat或model.generate。如果你确认加载路径正确还是没效果,检查是不是把lora_alpha设成了lora_alpha=r,等效缩放变成1,新学权重对输出影响被压没了。

5.5 显存没爆但训练速度慢到怀疑人生

现象:GPU利用率只有20%上下,训练一个step要好几秒,日志半天刷一条。

原因:数据里大量padding让模型在空白token上浪费计算,尤其是batch里序列长度差距大时,padding占了80%计算量。另一个原因是gradient_checkpointing开关没有生效,中间激活重新计算的省显存机制没触发。

解决:先把sequence按长度分组再做batch,常见做法是按长度排序后用bucket切batch。更推荐的做法是把padding补到数据打包阶段,用语言模型的packing方式让每条序列紧密排列。对个人项目来说,把每批数据按长度sort之后再batch就够用了。同时确认训练日志里能看到“gradient checkpointing enabled”字样。

6. 合并权重与效果验证:微调产出能不能真正上线

6.1 用merge_and_unload导出完整模型

训练完的checkpoint只是LoRA适配器,部署时不能裸奔,必须合并回基座权重。merge_and_unload是PEFT里最省事的入口:

from peft import PeftModel from transformers import AutoModel, AutoTokenizer base_model = AutoModel.from_pretrained( "THUDM/chatglm3-6b", trust_remote_code=True, torch_dtype=torch.bfloat16, ) tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm3-6b", trust_remote_code=True) peft_model = PeftModel.from_pretrained(base_model, "./chatglm3-lora-checkpoint") merged_model = peft_model.merge_and_unload() merged_model.save_pretrained( "./chatglm3-merged", safe_serialization=True, ) tokenizer.save_pretrained("./chatglm3-merged")

合并之后得到一个完整大小的6B模型,之后部署就可以完全脱离PEFT库,直接用AutoModel加载。safe_serialization=True用safetensors格式保存,加载速度和安全性都更好。

6.2 验证微调效果:别信loss,信人工对照

微调效果的主观性很强,我习惯把验证集里抽20条问题分别丢给基座模型和微调模型,并排对比输出。评估维度三条:是否按要求格式作答、业务事实是否准确、措辞是否接近标准文档风格。loss只能告诉你模型拟合了训练集,不能告诉你它是否真的学会了正确路径。

我一般会做一个极简评分表,逐条打分,3分以上算通过。如果20条里合格率不到70%,先别急着调参,回去翻训练数据——多半是数据里掺杂了大量无关对白。合格率达到90%以上,再把合并模型接到现有的推理服务里替换基座。

6.3 三个后续调优方向

效果差一口,优先动数据:补足高频追问的变体表达,让模型见过足够多“同一个意思的多种问法”。效果中庸时动LoRA配置:r从8提到16,target_modules加k_proj或dense_h_to_4h,微调方向会更灵活。效果好了但想压显存:对合并后的模型做4bit量化,量化精度损失在对话任务里通常可以忽略。

我踩过最大的坑就是过早调参,r从8调到32、学习率上蹿下跳,最后一查是数据里几百条答案全是复制粘贴重复文本。从那以后我给自己立了条规矩:先修数据,再动参数,最后才动结构。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询