ChatGLM3-6B LoRA微调实战:从原理到排坑,一张显卡也能训大模型
简介面向自然语言处理开发者和研究者的实践型资源围绕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和BA的维度是(r, d)B的维度是(d, r)这里d是隐藏层维度r是远小于d的秩。前向计算变成y W0x BAx反向传播只更新A和B原权重全程不动。6B模型、隐藏层4096、28层Transformer如果只在每层的q_proj和v_proj上挂LoRA且r8新增可训练参数大约370万占总参数量0.06%。这意味着显存占用里“可训练参数相关”的部分从6B量级降到几百万量级梯度与优化器状态随之大幅缩小。这也就是为什么LoRA微调能在24G甚至16G显存上跑起来而全量微调做不到。另一个常被忽略的点是低秩修正本身是一种强正则。可训练参数少模型被业务数据带偏的风险就小灾难性遗忘更弱。对ChatGLM3-6B这种通用能力已经不错的基座来说你不需要让它忘掉通用知识只需要给它在垂直场景下“拨正方向”。2.2 ChatGLM3-6B的哪些层适合挂LoRAChatGLM3-6B的线性层命名与LLaMA系列不完全一样这一度让很多照着LLaMA教程抄的人卡住。在Hugging Face的modeling_chatglm.py里attention层投影层叫q_proj、k_proj、v_projMLP层叫dense_h_to_4h和dense_4h_to_h。PEFT的target_modules参数传的就是这些模块名字的子串。我一般第一轮只挂q_proj和v_proj这是最稳的起点。q和v投影对指令语义和输出风格最敏感所需显存增量也最小。如果r8训完效果不够再考虑加k_proj或者把MLP的dense_h_to_4h也带上。target_modules配成全部线性层“all_linear”能让模型学得更灵活但也更容易过拟合而且可训练参数翻倍24G显存仍可以接受只是训练时间明显变长。需要留意的是ChatGLM3-6B的embedding层不参与LoRA改造。有些人不理解为什么embedding不更新会导致行业术语在输出里频繁走样——词向量代表基座对词语的静态理解LoRA改的是它往上走之后的高维语义修正如果你手头数据里大量出现预训练阶段从未见过的生僻概念embedding不更新确实有上限但这不是r8能解决的得靠扩充数据或换更强的基座。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 torch2.1.2 torchvision0.16.2 --index-url https://download.pytorch.org/whl/cu121 pip install transformers4.36.2 peft0.7.1 datasets2.16.1 accelerate0.28.0参数说明torch必须与CUDA版本匹配cu121对应CUDA 12.1transformers锁定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|区分角色。偷懒只拼promptanswer也能训但会让模型学到错误的对话结构常见后遗症是生成内容里夹杂|user|等原始token文本。我常用的转换脚本输入是每行一条JSON字段不定但至少要能切出“问题”和“标准答案”。下面的脚本把问答对转成Alpaca风格并补上对话模板import json with open(raw_qa.jsonl, r, encodingutf-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, encodingutf-8) as f: for item in converted: f.write(json.dumps(item, ensure_asciiFalse) \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, encodingutf-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(fchatglm3_lora/{name}.jsonl, w, encodingutf-8) as f: for item in subset: f.write(json.dumps(item, ensure_asciiFalse) \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_codeTrue让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_codeTrue) model AutoModel.from_pretrained( model_path, trust_remote_codeTrue, torch_dtypetorch.bfloat16, device_mapauto, ) model.enable_input_require_grads() model.gradient_checkpointing_enable() model.config.use_cache False逻辑说明torch_dtypetorch.bfloat16比fp16更稳反向传播时梯度不容易溢出16G显存用户也可以试fp16但bf16在24G卡上是我的默认选择。enable_input_require_grads()是LoRA训练的必要步骤它让输入层保留梯度否则某些低层模块可能被错误冻结。use_cacheFalse关闭KV缓存因为训练时不需要缓存开着反而增加显存开销。参数说明device_mapauto在单卡场景会把全部层放到GPU0。如果你的卡只有16G且加载后剩余显存太少可以改为device_mapcuda:0并减少batch_size不要同时把模型切到CPUCPU offload会让训练速度掉到无法接受的程度。4.2 LoraConfig参数与target_modules选择PEFT库的LoraConfig是整套LoRA配置的核心这里放一组我在ChatGLM3-6B上验证过稳定的参数from peft import LoraConfig, get_peft_model, TaskType lora_config LoraConfig( r8, lora_alpha32, lora_dropout0.1, target_modules[q_proj, v_proj], biasnone, task_typeTaskType.CAUSAL_LM, ) model get_peft_model(model, lora_config) model.print_trainable_parameters()逻辑说明r是低秩矩阵的秩控制可训练参数规模。r8对大多数指令微调任务够用r16能容纳更多任务特有信息但增加显存和过拟合风险。lora_alpha是缩放系数LoRA前向计算会把AB的输出乘上alpha/ralpha32、r8时等效缩放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_len1024): self.tokenizer tokenizer self.max_seq_len max_seq_len self.items [] with open(path, r, encodingutf-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_tokensFalse) input_ids input_ids[: self.max_seq_len] prompt_ids self.tokenizer.encode( f[gMASK]sop|user|\n{item[prompt]}|assistant|\n, add_special_tokensFalse, ) labels [-100] * len(prompt_ids) input_ids[len(prompt_ids):] input_ids torch.tensor(input_ids, dtypetorch.long) labels torch.tensor(labels, dtypetorch.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_tokensFalse很关键因为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_epochs3, per_device_train_batch_size1, gradient_accumulation_steps8, learning_rate2e-5, warmup_ratio0.03, lr_scheduler_typecosine, logging_steps10, save_steps200, save_total_limit2, bf16True, gradient_checkpointingTrue, report_tonone, ) trainer Trainer( modelmodel, argstraining_args, train_datasetSFTDataset(chatglm3_lora/train.jsonl, tokenizer), eval_datasetSFTDataset(chatglm3_lora/val.jsonl, tokenizer), tokenizertokenizer, ) trainer.train()这里batch_size1配合gradient_accumulation_steps8等效batch size是8。LoRA微调不需要大batch调大batch并不会让效果更好反而让显存压力迅速上升。learning_rate2e-5是LoRA场景的常见选择超过5e-5很容易看到loss炸掉或是中文输出开始带乱码。bf16在4090、A100这类卡上没问题老卡不支持bf16就改成fp16True。训练过程里观察日志正常时loss会在前几十步从2.x附近降下来如果一开始就是4.x或5.x先检查数据模板拼接是否出错。每200步存一次checkpointsave_total_limit2保证磁盘不爆。5. LoRA微调排坑笔记从loss不降到显存爆掉的5条实战记录5.1 反向传播前就OOM显存直接报错现象训练脚本跑完第一步还没来得及打印loss就弹出CUDA out of memory。原因最常见的是max_seq_len设太大。很多人把ChatGLM3-6B的8192直接抄进训练代码单条序列编码后超过7000 token即使batch_size1中间激活也轻松吃掉十几G显存。其次是device_mapauto与其他模块同时启用gradient_checkpointing时transformers可能在CPU与GPU间反复调度。解决把max_seq_len压到1024per_device_train_batch_size固定为1先跑通再慢慢往上探。用torch.cuda.max_memory_allocated()/1024**3打印峰值显存如果峰值超过显存的85%说明配置已经到临界值gradient_accumulation_steps不影响显存放心用。5.2 loss在2.0附近震荡生成内容全是重复短句现象训练了两个epochloss能降到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_codeTrue) model PeftModel.from_pretrained(base, ./chatglm3-lora-checkpoint/checkpoint-600)然后推理时用model.chat或model.generate。如果你确认加载路径正确还是没效果检查是不是把lora_alpha设成了lora_alphar等效缩放变成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_codeTrue, torch_dtypetorch.bfloat16, ) tokenizer AutoTokenizer.from_pretrained(THUDM/chatglm3-6b, trust_remote_codeTrue) peft_model PeftModel.from_pretrained(base_model, ./chatglm3-lora-checkpoint) merged_model peft_model.merge_and_unload() merged_model.save_pretrained( ./chatglm3-merged, safe_serializationTrue, ) tokenizer.save_pretrained(./chatglm3-merged)合并之后得到一个完整大小的6B模型之后部署就可以完全脱离PEFT库直接用AutoModel加载。safe_serializationTrue用safetensors格式保存加载速度和安全性都更好。6.2 验证微调效果别信loss信人工对照微调效果的主观性很强我习惯把验证集里抽20条问题分别丢给基座模型和微调模型并排对比输出。评估维度三条是否按要求格式作答、业务事实是否准确、措辞是否接近标准文档风格。loss只能告诉你模型拟合了训练集不能告诉你它是否真的学会了正确路径。我一般会做一个极简评分表逐条打分3分以上算通过。如果20条里合格率不到70%先别急着调参回去翻训练数据——多半是数据里掺杂了大量无关对白。合格率达到90%以上再把合并模型接到现有的推理服务里替换基座。6.3 三个后续调优方向效果差一口优先动数据补足高频追问的变体表达让模型见过足够多“同一个意思的多种问法”。效果中庸时动LoRA配置r从8提到16target_modules加k_proj或dense_h_to_4h微调方向会更灵活。效果好了但想压显存对合并后的模型做4bit量化量化精度损失在对话任务里通常可以忽略。我踩过最大的坑就是过早调参r从8调到32、学习率上蹿下跳最后一查是数据里几百条答案全是复制粘贴重复文本。从那以后我给自己立了条规矩先修数据再动参数最后才动结构。希望帮到你。本文还有配套的精品资源点击获取