公司动态
8G显存玩转ChatGLM3-6B:LoRA微调实操与避坑指南
简介大语言模型微调是垂直领域应用落地的关键环节但全参数微调对显存的要求往往令人望而却步。以ChatGLM3-6B为例全参微调动辄需要50GB以上显存普通个人开发者难以承受。低秩适配LoRA作为一种参数高效微调技术通过冻结原始权重只训练少量低秩矩阵将显存占用降至约7GB让单卡8G成为可能。本文从LoRA的基本原理出发结合实际工程经验围绕ChatGLM3-6B的结构特性系统讲解数据模板构造、target_modules配置、训练超参与权重合并等核心环节并分享版本兼容、标签偏移、过拟合等高频踩坑解决方案。该方法适用于客服问答、行业知识注入等垂直场景在有限算力下实现模型风格的快速迁移与领域适配。 做这个小项目之前我已经被“可用显存只有一张8G卡”的现实反复锤过。需求其实不难理解有一套内部业务语料涉及产品售后问答和少量多轮对话想把ChatGLM3-6B这个开源底座往目标领域拉一拉让它说话更像我们自己的客服而不是一个什么都懂一点的通用模型。最开始我没打算碰LoRA脑子里第一反应是“把6B全参微调跑起来”。结果算了第一笔账就放弃了一张8G显存的卡连加载FP16权重都勉强更别提优化器状态的巨额开销。后来转向LoRA前后花了一周左右把流程完整跑通包括数据整理、参数调整、训练、合并、部署测试。这篇就把整个实操过程记录下来重点放在那些文档里不会写但实际一定会遇到的坑上给准备用ChatGLM3-6B做LoRA微调的朋友一条能直接走通的路。1. 为什么最后选了ChatGLM3-6B加LoRA显存和效果之间的现实平衡1.1 全参微调和LoRA在显存账本上的差异先算一笔硬账。ChatGLM3-6B的参数量是6B左右也就是60亿参数。如果做全参微调光是把模型权重以FP16格式加载进显存就要占用大约12GB。这还没完反向传播计算梯度需要额外空间梯度同样是FP16再加12GB。优化器如果用AdamW每个参数还要额外保存一阶动量m和二阶动量v这两份都是FP324字节一份合计每个参数吃8字节。也就是说全参微调6B模型的总显存需求大概是模型权重FP1612GB梯度FP1612GBAdamW状态FP3224GB激活值和中间变量视序列长度和batch size而定通常还要再占几个GB加起来稳稳超过50GB。这还没考虑激活值重计算之类的优化手段。所以“一张8G卡全参微调6B”现实里基本走不通除非用多卡张量并行加各种省显存技巧但这已经超出了大多数个人项目的资源范围。LoRA的思路完全不同冻结原始权重只训练注入的低秩矩阵。还是以6B模型为例如果只把attention模块里的query、key、value映射矩阵挂LoRA可训练参数量大约只有总参数的0.1%到1%。我实际用rank8配置训练可训练参数只有600多万不到模型总量的0.1%。这些可训练参数对应的优化器状态和梯度小到可以忽略不计显存大头基本只有模型权重和激活值。算下来LoRA微调ChatGLM3-6B在8G显存上是可以跑的。我实测用了一个固定的基础配置batch size设为1梯度累积8步序列最大长度1024显存占用稳定在7GB出头。如果把量化打开甚至可以在6G显存上跑出相似效果。这让我当时立刻下了判断这个项目就选LoRA。1.2 ChatGLM3-6B这个底座在微调里的特性和脾气ChatGLM3-6B和常见的LLaMA系列有几个明显的差异在实际微调中会影响非常多的细节。首先它的模型结构是prefix LM与causal LM的混合风格。在训练时用户侧输入和模型侧输出通过特殊的token分隔ChatGLM3-6B使用了gmask和sop这样的特殊标记。这个特点决定了我们在构造训练样本时不能像LLaMA那样简单粗暴地“把用户输入拼在前面标签拼在后面”而是需要带上模板和特殊标记。其次它的分词器是自研的不是LlamaTokenizer那种BPE实现。在微调过程中要特别防止在文本拼接时产生“token粘连”。举个例子如果用户输入结尾没有加特殊token模型可能会把最后一个字和回答的第一个字拼成一个token导致训练语料中标签错位。这种错误在loss曲线上不一定看得出来但会直接影响生成质量而且很难排查。另外ChatGLM3-6B的权重要用挂载remote code的方式加载也就是说要信任仓库里附带的模型代码。PEFT库在处理它的时候和LLaMA的路径也有区别target_modules的命名方式不同很多照着LLaMA抄的配置在ChatGLM3上根本匹配不到层。这个我在后面第4节专门展开。2. 环境依赖和工程结构版本组合比想象中更容易翻车2.1 硬件和软件版本组合的选择思路先说我最终使用的环境组合这个组合踩过一遍之后确认是稳定可复现的GPUNVIDIA GeForce RTX 2060 8GBCUDA11.8Python3.10PyTorch2.0.1Transformers4.36.2PEFT0.7.1bitsandbytes0.41.3accelerate0.25.0为什么强调这个组合因为ChatGLM3-6B在transformers里的加载方式和常规CausalLM模型不一样它使用AutoModel而不是AutoModelForCausalLM。如果你用的transformers版本太新可能它的接口和远程代码之间出现不兼容太老又可能缺少对ChatGLM3的映射支持。我一开始装了transformers 4.38之后加载时就报过一个和“CausalLMOutputWithPast”相关的关键参数missing错误后来固定到4.36.2才稳定。还有bitsandbytes这个库的版本跟CUDA版本强绑定。如果CUDA 11.8却用了需要CUDA 12.x的bitsandbytes导入库的瞬间就会报“libbitsandbytes_cuda.so”找不到。千万不要从源码乱编译直接用官方release里对应的wheel最稳妥。我当时花了一个下午在这个问题上后来一查原来只是版本号对不上。2.2 工程项目的目录组织方式拿到项目源码后我习惯先把工程根目录改造成这样的结构方便调试project_root/ ├── data/ │ ├── raw_data.json │ ├── train.jsonl │ └── eval.jsonl ├── scripts/ │ ├── make_dataset.py │ ├── train_lora.py │ └── merge_lora.py ├── output/ │ ├── lora_checkpoint/ │ └── merged_model/ └── configs/ ├── lora_config.json └── train_args.json这个结构不一定每个项目都适用但拆分脚本目录和输出目录是必要的。尤其是LoRA checkpoint和最终合并后的完整模型一定要分开存放不然后面做权重对比、回滚实验时会很混乱。我的做法是每次训练跑一个新的配置就单独建一个时间戳目录避免覆盖。一个值得注意的细节在原始工程里数据处理、训练、推理的代码经常堆在同一个脚本里。我强烈建议拆开。LoRA训练和推理虽然共用同一个模型目录但训练时需要把模型切成训练模式推理时需要关掉dropout和梯度计算逻辑混在一起极容易出错。拆开之后训练脚本只管产出checkpoint推理脚本只管验证效果调试成本直线下降。3. 数据集构造与模板设计微调效果的分水岭3.1 从原始问答到标准训练格式LoRA微调的效果数据占六成参数占两成剩下两成才是训练细节。这个比例我自己的感受是准的。很多人跑完LoRA发现模型“变傻了”大多数时候不是参数问题而是数据构造出了问题。我最初拿到的数据是一批Excel表格形式的问答对包含客户问题、标准答案、问题分类、是否多轮对话等字段。为了转成训练格式我做了几步清洗第一去掉空值和重复样本。看起来很简单但业务数据里重复率常常高得惊人。第二轮去重后样本量直接少了四分之一。第二统一问答风格。同一个问题可能有多条答案我选了其中表达更完整、语气更专业的一条保留。这一步需要业务人员协助纯技术手段很难判断。第三构造多轮对话样本。ChatGLM3-6B的模板本质上能把多轮对话表示成带有history参数的格式但实际训练时我更倾向于把多轮对话按顺序拼进输入文本里让模型在生成任务中端到端学习。处理完的数据格式为JSONL每条数据包含两个字段conversations或input/output取决于你用的是哪种训练脚本。用PEFT写自定义训练脚本时我选择了这种最简单的格式{ instruction: 客户说我的产品开不了机怎么办, output: 您好请先检查电源线是否插紧长按电源键10秒尝试强制重启。如果仍然无法开机请记录设备型号我们会安排专人跟进。 }然后在训练脚本里把instruction和output拼接成模型输入。这个方案的灵活性最高不用依赖某个框架预设的模板。3.2 ChatGLM3-6B的指令模板为什么不能省这里是我踩过的一个大坑。ChatGLM3-6B在预训练时使用了带系统提示词和特殊标记的聊天模板它的推理和训练都应该遵循同一个模板否则模型的注意力分布会被打乱生成质量严重下滑。仅使用PEFT库时我在自定义训练脚本中按下述方式构建输入prompt f[{system_prompt}]\n[用户] {instruction}[助理]\n但后来验证效果时发现即使有[用户]和[助理]这种语义标记因为没带上ChatGLM3官方tokenizer预处理时自动添加的gmask等token生成结果的质量还是不如直接用tokenizer自带的apply_chat_template方法稳定。更稳妥的做法是借助官方tokenizer的chat模板from transformers import AutoModel, AutoTokenizer model_path THUDM/chatglm3-6b tokenizer AutoTokenizer.from_pretrained(model_path, trust_remote_codeTrue) messages [ {role: system, content: system_prompt}, {role: user, content: instruction}, ] input_text tokenizer.apply_chat_template(messages, tokenizeFalse)这样生成出的文本本身就带着ChatGLM3认识的gmask、sop这些token我只需要在训练脚本里把输入tokenize后把标签部分单独mask掉即可。最终我在训练里用了这个方案效果比手写模板有明显提升。如果想从原理角度理解可以简单类比模板是模型的“母语语法”你用LoRA训练模型时是在做“科目迁移”而不是“重新学说话”。如果输入连母语语法都不符合模型一边要理解任务一边还要适应输入格式训练效率自然低下。4. LoRA参数设置与ChatGLM3-6B适配每个数字都有讲究4.1 rank、alpha和dropout怎么定LoRA核心思想是把权重增量ΔW矩阵分解成两个低秩矩阵A和B的乘积ΔW BA。其中A的维度是r×kB的维度是d×rr就是rank。训练时不更新原始权重W只更新A和B最终模型权重变成W BA。rank r决定了低秩空间的容量。r越大能表达的信息越丰富但可训练参数也越多显存占用越大过拟合风险越高。实践中对于对话能力底子比较好的6B级别模型r取8通常表现已经不错r取16或32可以获得更大容量但容易在小型数据集上过拟合。我做过一组对比实验同样一个小型业务数据集rank可训练参数loss收敛情况生成效果4约320万lr5e-4时收敛慢回答泛化不足8约640万收敛稳定效果和稳定性均衡16约1280万收敛快小数据集有震荡回答有个性但偶尔偏离模板32约2560万收敛最快过拟合信号明显开始复述训练数据最终我选定了rank8。alpha的取值一般设置为r的1到2倍也就是8到16之间。从数学角度看最终缩放系数是alpha/r。如果r取8alpha取16那么缩放系数就是2这个值在实践里最常用。dropout要看训练集规模。数据量只有几千条时dropout开0.1能明显抑制过拟合数据量上万时dropout取0.05甚至0都可能更好因为太强的随机丢弃在数据充足时反而拖慢收敛。我最终用0.05。4.2 target_modules指定方式ChatGLM3和LLaMA的最大区别这是很多从LLaMA迁移过来的人最容易翻车的地方。LLaMA的attention层权重通常被命名为q_proj、k_proj、v_proj、o_proj但ChatGLM3-6B沿用了GLM的结构把query、key、value三个矩阵打包在一个名为query_key_value的大矩阵里输出矩阵叫dense。所以PEFT的LoraConfig里target_modules要这样写from peft import LoraConfig, TaskType lora_config LoraConfig( task_typeTaskType.CAUSAL_LM, r8, lora_alpha16, lora_dropout0.05, target_modules[query_key_value, dense], )有一个细节需要特别说明target_modules也可以不写让PEFT自动匹配包含指定关键词的层。但ChatGLM3里如果不加target_modules参数有的PEFT版本会默认全量注入到所有Linear层这会导致可训练参数大幅增加显存飙升。所以项目里一定要显式指定不要偷懒。是否要把dense输出投影也加进去我最初只挂了query_key_value生成内容流畅但感觉有点“模板化”。后来把dense也加进来模型表达能力确实变好了一些显存增幅很小。如果你的显存有余量建议两个都挂。4.3 训练超参学习率、batch size和梯度累积LoRA因为只训练少量参数通常可以使用比全参微调更高的学习率。全参微调6B一般用1e-5到2e-5而LoRA我从5e-4起步效果不错。学习率太高会出现loss震荡甚至NaN太低则收敛缓慢。如果你用的是小型数据集可以尝试1e-4到3e-4更稳。batch size受显存限制我设定为1然后用梯度累积到等效batch size 8。公式是等效batch size 单卡batch size x 梯度累积步数。项目里我设了gradient_accumulation_steps8所以等效batch size8。训练轮数上我对几千条数据只跑了3个epoch。数据集小的时候跑太多轮过拟合很严重模型会把训练语料里的个别表达原封不动背出来回答问题时非常生硬。我建议在训练过程中定期在eval集上测试不要只看train loss。我把一组最终使用的训练参数列在这里training_args dict( learning_rate5e-4, num_train_epochs3, per_device_train_batch_size1, gradient_accumulation_steps8, warmup_ratio0.1, weight_decay0.01, logging_steps10, save_steps200, lr_scheduler_typecosine, bf16False, fp16True, )5. 训练启动与显存监控从准备到跑通的一次完整记录5.1 用PEFT把LoRA挂到ChatGLM3上下面这段代码就是把LoRA注入模型的完整流程可以直接作为训练脚本的核心import torch from transformers import AutoModel, AutoTokenizer, TrainingArguments, Trainer from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training 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.float16, device_mapauto, ) # 量化加载时要用非量化可省略 # model prepare_model_for_kbit_training(model) lora_config LoraConfig( task_typeTaskType.CAUSAL_LM, r8, lora_alpha16, lora_dropout0.05, target_modules[query_key_value, dense], ) model get_peft_model(model, lora_config) model.print_trainable_parameters() training_args TrainingArguments( output_dir./output/lora_checkpoint, per_device_train_batch_size1, gradient_accumulation_steps8, learning_rate5e-4, num_train_epochs3, fp16True, logging_steps10, save_steps200, save_total_limit2, remove_unused_columnsFalse, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, ) trainer.train() trainer.save_model()有两点必须提示一下。第一remove_unused_columns要设为False因为我们的数据集里可能有多个字段默认True会把我们tokenize时需要用到的原始文本列全删掉导致训练直接报错。第二model.print_trainable_parameters()这一行一定要保留它能明确打印出可训练参数数量让你第一时间确认LoRA有没有正确挂载。如果我看到可训练参数数量是模型全参数的一半就说明target_modules有问题。5.2 训练过程的显存和loss观测训练启动后我用nvidia-smi -l 1看显存变化。第一次跑的时候显存占用峰值出现在前几个step大约7.2GB基本把8G卡的余量吃满了。这时要注意别开浏览器或其他图形程序否则会触发OOM。为了避免意外我平时都用SSH远程连接运行训练显卡上不接显示器。Loss变化方面初始loss在1.2左右前200步逐步下降到0.5左右最后稳定在0.2-0.3。如果loss曲线一直下不来先检查学习率是否太低、数据预处理是否有误如果loss骤然跌到0.01以下大概率是过拟合了。训练时长供参考几千条样本3个epoch在RTX 2060上大概跑了7个小时。如果换成A100或4090时间会缩短到1小时内。LoRA训练因为参数少算力瓶颈主要在attention计算本身所以不要指望单卡2060能很快。5.3 断点续训和显存容错训练中途最怕意外中断。好在Trainer默认会保存checkpoint恢复只用在TrainingArguments里加上resume_from_checkpoint参数即可trainer.train(resume_from_checkpointTrue)恢复时要注意数据集和tokenizer必须和之前完全一致否则位置编码和标签错位会导致后续loss异常。我遇到过恢复训练后loss反而上升的情况后来发现是当时为了省时间用了不同的数据切割方式导致恢复时数据顺序变了。从那之后我固定了数据随机种子。6. 推理验证与模型合并导出效果好不好的判定标准6.1 用PEFT adapter直接推理训练完后先不用着急合并权重直接在内存里加载adapter做推理验证这样迭代速度最快from peft import PeftModel base_model AutoModel.from_pretrained( base_model_path, trust_remote_codeTrue, torch_dtypetorch.float16, device_mapauto, ) model PeftModel.from_pretrained(base_model, ./output/lora_checkpoint/checkpoint-600) model model.half().cuda() model.eval() prompt 客户说我买的水杯用了两天就漏水怎么办 messages [ {role: system, content: 你是某品牌的售后客服回答要专业、简练、有同理心。}, {role: user, content: prompt}, ] input_text tokenizer.apply_chat_template(messages, tokenizeFalse) inputs tokenizer(input_text, return_tensorspt).to(cuda) with torch.no_grad(): outputs model.generate(**inputs, max_new_tokens256, temperature0.7) print(tokenizer.decode(outputs[0], skip_special_tokensTrue))生成时注意temperature不要设太高客服场景要稳定我一般设0.6到0.8。如果生成内容重复或空泛把temperature调低必要时提高top_p。LoRA微调后的模型如果效果不理想不要急着加大数据量先检查推理时是否沿用训练时的对话模板。6.2 合并LoRA权重导出完整模型验证效果好就要把LoRA的增量权重合并到原始权重里生成一个独立的完整模型方便部署和后续量化。合并代码from peft import PeftModel import torch base_model AutoModel.from_pretrained( base_model_path, trust_remote_codeTrue, torch_dtypetorch.float16, ) model PeftModel.from_pretrained(base_model, ./output/lora_checkpoint/checkpoint-600) merged_model model.merge_and_unload() merged_model.save_pretrained(./output/merged_model) tokenizer.save_pretrained(./output/merged_model)merge之后务必用6.1的推理脚本再跑一遍确认合并没有造成精度损失。理论上LoRA合并是矩阵相加不会有太大变化但有时因为数值精度问题生成结果会有细微差别务必要回归测试一遍。合并后的模型可以直接用之前的AutoModel.from_pretrained加载不再需要PEFT依赖。6.3 评测方法不能只凭感觉我在这个项目里对微调效果做了三层评价。第一层是业务case抽测准备50条训练里完全没见过的真实客服问题跑一遍模型输出人工打分看回答是否准确、语气是否自然。第二层是相似问题泛化测试。训练数据里只有“开不了机”评测时换成“无法开机”看模型能不能理解这是同义表达并给出类似的标准回答流程。这一步最能反映模型是不是真的学到了能力而不是死记硬背。第三层是通用能力退化检测。跑10条通用常识问题确保模型没有因为领域微调而“忘了”自己的底座能力。LoRA微调因为冻结大部分参数这方面表现通常不错但数据质量差时仍然可能出现退化。我粗测下来微调后在业务case上的达标率从原来的不到40%提升到85%左右通用能力基本没明显下降。这个结果说明LoRA在小规模数据和有限显存条件下确实能把ChatGLM3-6B往目标领域掰动不少。7. 绕坑记录这个项目里我踩过并解决的真实问题7.1 版本兼容性引发的一连串Loading报错前文提到过transformers和bitsandbytes的版本问题这里再补充一类高频报错当你加载ChatGLM3时出现“AttributeError: ChatGLM3Tokenizer object has no attribute chat_template”这一般是transformers版本太旧不支持chat_template属性。升级transformers到4.36以上可以解决但也要注意不要直接升到最新版有些版本会对远程代码做更严格的安全校验导致AutoModel.from_pretrained直接拒绝执行远程代码。我的建议还是锁定一个经过验证的版本组合不要追求最新。7.2 数据预处理里的“标签偏移”陷阱我在构造训练数据时一开始把input和output简单拼接后整个输入给模型但没有对output对应的token做loss mask这导致模型训练时把用户的输入也当成待生成文本进行优化。从loss曲线上看也能收敛但实际生成时模型容易复述用户的问题而不是回答。标准的做法是计算loss时只计算output部分的loss把input部分在labels里设为-100。Trainer默认会忽略labels中值为-100的位置。数据构建时用tokenizer的return_offsets_mapping或者直接文本切割找output起始位置然后mask。这一步是LoRA微调的通用基础操作但出错率极高务必仔细。7.3 训练集太小导致的复读机效应我在第一次完整训练时数据集只有800条问答。训练完发现模型在回答很多问题时会直接复述训练集里的原句甚至带出里面对应的客户名。这就是典型的过拟合。后来我把数据扩展到3000条同时增加了同义改写数据才基本解决。如果你的数据量实在有限另一个有效做法是加入1/4左右的通用指令数据混在训练集里一起训练。这样模型能在保持领域能力的同时保留一定的通用生成能力复读机现象会明显减轻。这个“领域数据通用数据”的混合比例我自己的经验是7:3比较合适。7.4 多轮对话数据的历史拼接如果要在客服场景里支持多轮对话数据格式就不该是简单的instruction-output一对一问答应了。我在项目里把多轮会话构造成一条数据每轮用户问题前补上“用户”模型回答前补上“助理”期间插入对应的特殊token。实际训练下来模型能记住前几轮的上下文但上下文超过三轮后偶尔会混淆身份。解决的办法很简单训练时随机截断历史让模型适应不同长度的上下文窗口同时防止它对长历史的过度依赖。最后再分享一点我的实际操作体会这个项目前前后后改了四版数据调了三组参数真正跑通的第一个稳定版本在效果上已经足够支撑内部试用。我个人最大的体会有两条。第一LoRA微调不是“万能炼丹”。它适合在有限算力下做领域适配和风格迁移但如果业务场景要求模型获取复杂的推理能力或大规模新增知识LoRA的上限是存在的。我做客服问答是因为答案本身高度结构化很多问题用模板就能覆盖LoRA在这里正好能发挥优势。第二数据和模板的权重远比参数大。前两次训练我把大量时间花在调学习率和batch size上效果提升非常有限。后来把精力转回数据清洗、模板统一、补全多轮样本效果立刻上了一个台阶。如果只打算看一篇文章就上手我想请你在开始训练前先花至少一半的时间在数据上这个投资回报率是所有环节里最高的。这套流程完整跑通之后后续换底座模型比如换成Qwen或者LLaMA思路基本一致只要改改target_modules和模板就能复用。希望这篇记录能帮你少走一些我已经走完的弯路。本文还有配套的精品资源点击获取