大模型这个词被炒了两三年,真正动手做过微调的人其实没那么多。很多人卡在同一个地方——拿着ChatGLM的权重不知道从哪儿下手,装完PyTorch就不知道该干嘛了。这次我把自己从零开始折腾ChatGLM微调的全过程整理出来,基于PyTorch框架,从环境搭建、模型加载到真正的LoRA微调训练,每一步都写清楚原理和实操方法,给想入门大模型开发的朋友一条能直接照着走的路。
这篇文章适合两类人:一类是有Python基础、想进入大模型方向但找不到切入点的开发者,另一类是已经在跑推理、但对微调训练流程不熟的算法工程师。我会把我踩过的坑、试错的结果、以及最终稳定的方案全部放出来,不做任何保留。
1. 从模型到微调,先弄清楚我们要做什么
1.1 ChatGLM到底是什么样的模型
ChatGLM是国产开源大模型中非常有代表性的一个系列,它是基于Transformer架构的自回归语言模型。所谓自回归,简单说就是根据前面已经生成的token,预测下一个token是什么,一个一个往外蹦,最终形成完整的回答。
以ChatGLM-6B为例,60亿参数量在今天的标准下不算大,但这个规模刚好是一个分水岭:
- 比它小的模型(比如1B以下),微调后能力天花板明显,复杂指令基本接不住;
- 比它大的模型(比如70B以上),消费级显卡完全跑不动,量化+分布式训练的门槛又太高;
- 6B这个档位刚好能用RTX 3090、4090这类24GB显存的卡做全量微调,或者用消费级显卡配合LoRA做高效微调。
所以ChatGLM-6B成了很多人入门大模型微调的第一个选择。到了ChatGLM2和ChatGLM3时代,模型在推理速度、上下文长度、工具调用能力上都做了优化,但核心的Transformer结构和自回归范式没有变,我们在微调时踩的很多坑也依然是通用的。
这里提醒一句:你不需要在动手前把所有Transformer原理都吃透。我的建议是先跑通流程,再回头补原理,效率会高很多。
1.2 微调在解决什么问题
我们拿到的ChatGLM预训练权重,它本身的知识量是够的,但它的“行为方式”是通用的——你问它什么它都能搭两句,却不一定会按照你想要的口吻、格式和逻辑来回答。
微调要解决的核心问题有三个:
- 领域适配:让模型明白你是做法律咨询、医疗问答还是代码助手,输出的内容要落在特定领域里;
- 格式对齐:让模型学会你的问答格式,比如必须输出JSON、必须带“总结:”前缀、必须按编号列要点;
- 行为校正:纠正模型原有的表达习惯,比如太啰嗦、太官方、容易跑题等。
本质上,微调是在给模型“立规矩”,把预训练阶段学到的泛化能力引导到你想要的方向上。
1.3 全量微调与高效微调怎么选
微调分两条路线:全量微调(Full Fine-tuning)和参数高效微调(PEFT)。
全量微调的意思是把模型所有参数都参与训练。这个方法效果好,但对硬件要求极其苛刻。以ChatGLM-6B为例:
- 模型权重本身占12GB显存(FP16精度);
- 梯度占12GB;
- 优化器状态(AdamW)占24GB左右;
- 加上激活值、中间变量,总显存需求轻松超过50GB。
也就是说,全量微调至少需要几张A100或者一张80GB的A800才行,个人开发者基本想都别想。
高效微调的代表方法是LoRA(Low-Rank Adaptation,低秩适配)。它的核心思路是:冻结原模型的全部参数,在注意力层(Q、K、V、O)旁边插入低秩分解矩阵,训练时只更新这些小矩阵。
LoRA的好处是:
- 可训练参数量从60亿降到几百万,显存占用大幅下降;
- 原模型权重完全不动,保留了通用能力;
- 训练完保存的适配器文件只有几十到几百MB,部署灵活。
后面我整个实操流程用的就是LoRA方案,这是个人开发者和中小团队最现实的路径。
2. 环境准备与传统安装大坑
2.1 PyTorch安装最容易翻车的地方
很多人第一步就摔在PyTorch安装上。PyTorch的安装看似简单,一个conda命令就能搞定,但实际很容易出问题。
先说最典型的坑:选择了不匹配版本的CUDA和PyTorch。你机器上安装的NVIDIA驱动是有对应的CUDA版本的,但PyTorch不依赖系统的CUDA工具包,它依赖的是CUDA运行时库。所以即使你系统里的CUDA版本是12.4,装了CUDA 11.8配套的PyTorch也没问题——只要显卡驱动够新就行。
我建议的安装方式是:
# 先查看自己显卡的驱动支持的最高CUDA版本 nvidia-smi看右上角的CUDA Version,比如显示的是12.4,那说明你的驱动足够新。然后到PyTorch官网选择对应的安装命令:
# CUDA 12.x版本 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # CPU版本(没有NVIDIA显卡时用) pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu另一个常见问题是conda安装PyTorch时把CUDA、cuDNN等一堆依赖打包装进去,体积大不说,有时还会和已有环境冲突。我个人的经验是:用pip装PyTorch,用conda装Python环境和其余依赖,各管各的,问题最少。
2.2 一套开箱即用的环境配置
完整的环境配置我放在这里,照着走基本不会有问题:
# 1. 创建独立的Python环境 conda create -n chatglm python=3.10 -y conda activate chatglm # 2. 安装PyTorch(以CUDA 12.1为例) pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # 3. 安装transformers和accelerate pip install transformers accelerate # 4. 安装Peft(LoRA的核心库) pip install peft # 5. 安装其他依赖 pip install datasets tensorboard sentencepiece protobuf这里解释一下每个库的作用:
- transformers:Hugging Face生态的核心库,负责加载模型、tokenizer、训练器的对接;
- accelerate:简化多卡训练和混合精度训练的库,单卡训练也会隐式用到;
- peft:参数高效微调库,LoRA的实现已经封装好,直接调用;
- datasets:用来加载和预处理数据集。
装完跑一下验证:
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"输出结果显示torch.cuda.is_available()为True,才说明环境OK了。
2.3 为什么我推荐用SFT数据集而不是ChatGPT蒸馏数据
在准备微调数据之前,先明确一个概念:我们用ChatGLM做微调时使用的是**SFT(Supervised Fine-Tuning,监督微调)**方法。数据格式是标准的“指令-回答”对:
{ "instruction": "介绍一下B树", "output": "B树是一种平衡的多路搜索树..." }很多教程会建议用某个框架去调用大模型的API批量生成数据,这个思路本身没错,生成速度快、数据格式统一,但有个隐藏风险:数据质量上限就是生成数据的模型的天花板。你拿着ChatGPT蒸馏的数据微调出来的模型,能力永远不会超过ChatGPT。
我更推荐的做法是:从真实的用户问题、客服对话记录、专业论坛问答里整理数据。虽然清洗成本高,但数据的真实性和多样性是蒸馏数据替代不了的。如果刚开始手头数据不多,几百条高质量数据微调的LoRA效果,可能好过几万条低质量数据,数据不在多而在精。
我在整理微调数据时发现,ChatGLM类模型对指令格式非常敏感。使用ChatGLM3自带的ChatGLM3Processor时,对话格式要求是mult-turn的结构,每个turn带role标记(system/user/assistant),数据格式不一致会导致tokenize后的input_ids错乱,训练出的模型回答会带有奇怪的重复片段。
3. ChatGLM模型加载与LoRA微调实操
3.1 从Hugging Face加载模型的速度优化
加载ChatGLM权重一般用transformers的AutoModel.from_pretrained。但这有一个很现实的痛点:权重文件几个GB到十几个GB,每次从Hugging Face服务器下载慢得离谱。
我的方案是:优先用modelscope下载权重,再把本地路径传给from_pretrained。
from modelscope import snapshot_download # 下载ChatGLM3-6B到本地目录 model_dir = snapshot_download('ZhipuAI/chatglm3-6b') print(model_dir) # 打印出本地路径国内环境下,ModelScope的下载速度比Hugging Face快很多,下载完的权重结构和Hugging Face格式完全兼容,可以直接用transformers加载。
加载时还有一个很重要的参数:torch_dtype。ChatGLM-6B原始权重是FP32,精度高但显存占用翻倍,实际推理时一般用FP16就够了,显存减半,精度损失几乎可以忽略:
from transformers import AutoModel, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True) model = AutoModel.from_pretrained( model_dir, trust_remote_code=True, torch_dtype=torch.float16, device_map='auto' )trust_remote_code=True这个参数必须带上,因为ChatGLM的模型结构代码不在transformers内置库中,需要从权重目录里的.py文件加载。
3.2 LoRA微调的完整代码拆解
我们的微调目标:让ChatGLM学会用简洁、口语化的方式回答问题。这里用的是一个很小的示例数据集,结构是标准的“指令-回答”对。
先加载Base模型并冻结参数:
from transformers import AutoModel, AutoTokenizer model = AutoModel.from_pretrained( model_dir, trust_remote_code=True, torch_dtype=torch.float16, device_map='auto' ) # 冻结所有参数 for param in model.parameters(): param.requires_grad = False注意,device_map='auto'的意思是让transformers自动分配模型到可用的设备上。单卡环境它会全部装进GPU显存,显存不足时会自动做CPU offload——但这里要提醒,LoRA训练时尽量不要用CPU offload,因为CPU和GPU之间反复传数据会让训练速度慢到怀疑人生。
然后配置LoRA:
from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=8, # 低秩矩阵的秩 lora_alpha=32, # LoRA缩放系数 target_modules=['query_key_value'], # ChatGLM的注意力层模块名 lora_dropout=0.1, # Dropout比例 bias='none', task_type='CAUSAL_LM' ) peft_model = get_peft_model(model, lora_config) peft_model.print_trainable_parameters() # 输出:trainable params: 4,194,304 || all params: 6,258,229,248 || trainable%: 0.067关键参数逐个说明:
- r(秩):决定低秩矩阵的维度。r越大,可学习的参数越多,模型表达能力越强,但过拟合风险也越高。r=8是推荐的经验值,简单任务可以降到4,复杂任务可以升到16;
- lora_alpha:LoRA层的缩放因子,最终的权重变化量需要用lora_alpha/r来缩放。一般设置为r的2倍或4倍效果较好;
- target_modules:指定在哪层插入LoRA结构。ChatGLM的自注意力层的模块名是
query_key_value,你可以在模型配置里用find_all_linear_names()函数自动扫描所有Linear层,但手动指定更精准、显存占用更小。
注意peft_model.print_trainable_parameters()的输出——670万参数在60亿参数总量中占比不到0.1%,这就是LoRA高效的原因。
3.3 训练参数设置与损失曲线解读
训练参数我用的是这样一个配置:
from transformers import TrainingArguments from transformers import Trainer training_args = TrainingArguments( output_dir='./chatglm-lora-checkpoints', per_device_train_batch_size=4, gradient_accumulation_steps=8, learning_rate=2e-4, num_train_epochs=3, logging_steps=10, save_steps=200, fp16=True, remove_unused_columns=False, )逐个说下关键考量:
- 批量大小和梯度累积:单卡24GB显存时
batch_size=4可能就到极限了,但一个很小的batch会让梯度估计不准确,所以用gradient_accumulation_steps=8做累积。这相当于实际batch size = 4 × 8 = 32,既保证了训练稳定性,又控制了显存占用; - 学习率:LoRA的适用学习率比全量微调大很多,因为可训练参数少、优化空间小。2e-4到5e-4都是合理范围;
- fp16混合精度:必须开启,否则显存不够。注意ChatGLM的某些层在fp16下可能会溢出,如果训练时loss变成NaN,第一个排查点就是fp16的数值稳定性问题。
训练启动之后,重点看loss曲线的下降趋势。正常情况loss会从1.2左右快速下降到0.4以下,如果loss在某个位置剧烈震荡不下降,大概率是学习率太高了。如果loss下降到0.1以下但验证集效果反而变差,那就是过拟合了,需要减小epoch数量或增加数据量。
训练完成后保存模型:
peft_model.save_pretrained('./chatglm-lora-final') tokenizer.save_pretrained('./chatglm-lora-final')保存下来的文件只有几十MB,这就是我们微调的全部成果。推理时需要把LoRA适配器重新加载到原始模型上。
3.4 合并LoRA权重与推理测试
LoRA推理有两种方式:
方式一:直接加载LoRA进行推理(推荐)
from peft import PeftModel # 先加载原始模型 base_model = AutoModel.from_pretrained( model_dir, trust_remote_code=True, torch_dtype=torch.float16, device_map='auto' ) # 加载LoRA权重 model = PeftModel.from_pretrained(base_model, './chatglm-lora-final') model = model.eval()方式二:合并LoRA权重到原始模型
merged_model = model.merge_and_unload() merged_model.save_pretrained('./chatglm-lora-merged')方式二的优势是合并后的模型是完整的、不依赖Peft库的模型文件,部署时不需要额外引入LoRA相关依赖,推理速度会快一点。缺点是合并后的模型文件会变成完整大小(10GB以上)。
推理测试:
questions = [ "什么是人工智能?", "推荐一本关于Python的书", "写一封请假邮件" ] for q in questions: response, history = model.chat(tokenizer, q, history=[]) print(f"问题: {q}") print(f"回答: {response}") print("---")这里用的model.chat方法是ChatGLM的快捷接口,内部已经封装了对话模板和采样参数。
我在实际测试时发现,微调后的模型回答出现了明显的“风格迁移”——比如我们微调数据里给的是简洁直接的回答,模型会倾向于用短句而不是长篇大论。这就是微调“立规矩”的效果。
4. 不花钱的显存优化方案与推理加速
4.1 显存占用过大时的三个降级策略
很多人没有24GB显存的卡,只有一张12GB甚至8GB的小卡,这确实尴尬,但也不是完全无解。有三种降级方案:
方案一:4-bit量化 + LoRA
ChatGLM4bit的量化加载和前面的方式非常接近,只需要在加载模型时指定bitsandbytes的4-bit配置就行:
from transformers import BitsAndBytesConfig bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16, bnb_4bit_quant_type='nf4' ) model = AutoModel.from_pretrained( model_dir, trust_remote_code=True, quantization_config=bnb_config, device_map='auto' )量化之后模型显存占用会降到8GB左右,加上LoRA参数和激活值,12GB显卡能跑起来。注意,4-bit量化后LoRA训练时,必须把量化后的模型参数也冻结,只训练LoRA分支里的参数,否则会报梯度回传的错误。
方案二:梯度检查点(Gradient Checkpointing)
model.gradient_checkpointing_enable()这行代码用计算换显存——不保存前向传播的所有中间激活值,而是在反向传播时重新计算。效果是显存占用降低约50%,代价是训练时间增加约20%-30%。对于显存紧张的人来说,这20%的训练时间其实换不太在意,能顺利跑通更重要。
方案三:CPU Offload
在device_map='auto'时设置:
model = AutoModel.from_pretrained( model_dir, trust_remote_code=True, torch_dtype=torch.float16, device_map='auto', offload_folder='offload' )部分层会被放置到CPU上,模型能跑起来,但在CPU和GPU之间传输数据会在每个step都拖慢速度。这个方法只建议用来做推理测试,不建议做训练。
4.2 推理加速的实战经验
微调完之后,推理速度也是要重点关注的。ChatGLM-6B在没做任何优化时,生成一个token大约需要40-60ms,如果你要生成512个token,大概要30秒左右,体验很一般。
提升推理速度的常用手段有以下几项:
- 半精度推理:上面已经用了
torch.float16,这能比FP32快一倍左右; - 批处理:多个用户请求合并成一个batch喂给模型,GPU利用率会大幅提升;
- torch.compile:PyTorch 2.0引入的编译特性,直接在模型上调用
model = torch.compile(model)就能生效,实测推理加速30%-50%; - vLLM等推理框架:这个对大模型收益最大,但如果需要动态加载LoRA权重这类操作,vLLM的支持还不算成熟。
另外一个很容易忽略的点:减少输出长度限制。把max_new_tokens从默认的2048改成实际需要的256或512,生成时间会成倍下降。很多人追求长回答,但很多场景下256个token就已经够用了。
5. 微调失效的排查手册与经验之谈
5.1 训练loss不下降的常见排查思路
我在多次微调实验中发现,训练loss不下降是出现频率最高的问题,而没有之一。排查看下面这张表:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| loss在初始值附近震荡,完全不下降 | 学习率太大或太小 | 调到1e-4到5e-4区间重试 |
| loss下降很快,然后骤停 | 数据量太小,模型学完了 | 增加训练数据量 |
| loss变成NaN | fp16精度溢出,或数据中有异常值 | 减小batch size,尝试bf16精度 |
| loss曲线先降后升 | 过拟合 | 降低epoch数或减小r值 |
| 训练正常,但回答乱码 | 数据格式和tokenizer不匹配 | 检查模板的special token是否正确 |
有一个非常隐蔽的坑:ChatGLM的tokenizer在加载时trust_remote_code=True如果没传,tokenizer会退回通用tokenizer,分词结果完全错乱,导致训练loss也在下降但生成的回答是一堆乱码。这个低级错误浪费了我一整天时间,现在先写出来提醒大家。
5.2 微调后效果变差的常见原因
还有一类问题更让人崩溃:微调训练完,loss也下降了,但模型回答质量反而变差了。
这个现象背后的原因往往不是单方面的,而是一系列连锁反应:
- 数据质量不够。如果训练数据里有很多错误答案,模型学到了错误的映射关系;
- 数据分布太单一。比如你全用短问短答的数据训练,模型的回答风格会越缩越窄,变得机械;
- 训练epoch过多,模型发生了灾难性遗忘。LoRA在冻结原始参数的情况下这个问题会轻一些,但依然存在。
我自己的经验是:微调数据覆盖度的重要性,排在数据量前面。宁可要1000条覆盖各种类型的数据,不要10000条全是同一个场景的数据。
预训练模型本身就具有非常强的通用能力,微调的目的是提供风格参考和领域约束。如果训练数据过于单一,模型就被“带偏”了。
5.3 哪里还能继续优化
如果你已经跑通了整个流程,想继续精进,我觉得可以从这几个方向扩展:
- 多轮对话微调:把单轮问答数据改成多轮对话格式,让模型学会上下文理解和多轮一致性;
- DPO(Direct Preference Optimization):这是目前对齐人类偏好的主流方案,比RLHF简单很多,同时LoRA可以和DPO结合起来做对齐微调;
- RAG检索增强:微调解决“风格和行为”,检索解决“知识和实时性”,两者配合效果更好;
- 用更高质量的数据集迭代:微调模型之后,可以用它生成回答,再用人工或更高级的模型打分,筛选排序后作为下一轮训练数据——这是很多实际项目做数据迭代的通用范式。
6. 写在最后的几点实在话
能读到这里,说明你确实想把大模型微调这回事弄明白,而不是只停留在看热闹的阶段。我自己第一次跑通ChatGLM微调时,前后折腾了好几个日夜——下载权重反复中断,显存OOM报错,模型加载时各种不兼容,训练出来后效果还不如原版模型,几乎每个坑都踩了一遍。
但回头再看,这个过程中收获最大的恰恰是那些踩坑的瞬间。你会慢慢建立起一个很重要的能力:面对一堆看不懂的报错信息,不慌,知道该去看哪里、去改什么。
我的建议是,先别急着追求完美的微调效果,用一个几百条数据的小数据集把整个流程完整跑通一遍,记录每一步的操作和结果。当你有了“从零到推理”的完整经验,再往下走就会顺很多。
我坚信模型微调是一项极具实操性的技能,坐在那里看再多的文章,不如亲手打开终端敲下第一行命令。希望这篇分享能帮你缩短从阅读到完成的距离。