☰
LoRA微调ChatGLM3-6B实战:消费级显卡跑通领域微调的完整指南
2026/10/23 15:12:02 网站建设 项目流程

简介:本资源是一套面向大模型初学者与NLP工程师的LoRA微调实战项目,聚焦ChatGLM3-6B(3.6B参数)在中文场景下的轻量化适配,解决大模型全参数微调显存高、耗时长、部署难等核心痛点。压缩包共12个文件(359KB),含4个核心Python脚本(如finetune_hf.py、inference_hf.py实现训练与推理)、5个JSON格式数据集(含self_cognition.json等SFT指令微调样本)、1个YAML配置文件(lora.yaml定义秩、alpha、target_modules等关键LoRA超参)、1个README.md说明文档及1个my类型辅助文件,结构精简、模块职责明确,便于快速复现与二次开发。已有775人学习下载,提供从数据预处理(dataset2glm3.py)、LoRA注入训练、模型导出到本地推理的全流程可运行代码,配套清晰注释与分步逻辑,无需额外调试即可上手实践,是掌握大模型高效微调技术的优质入门范例。 开年折腾大模型应用的时候,我干过一件蠢事:把一张 24G 显存的卡拿去跑 ChatGLM3-6B 的全参微调,结果训练刚开始两分钟,显存直接爆掉,报错日志长得像一篇小作文。后来换成了 LoRA 方案,同样的卡,同样的模型,不仅跑起来了,训练完的效果还完全够用。这篇文章就是把当时踩过的所有坑、整理过的思路、最后跑通的完整流程重新捋一遍,从显存账、LoRA 原理、数据格式、训练代码,到权重合并和部署,全部摊开讲。如果你手里只有一张消费级显卡,又想用 ChatGLM3-6B 做领域微调,可以参考这套流程直接上手。

1. 为什么选择 ChatGLM3-6B 配 LoRA:先算清显存这笔账

1.1 全参微调为什么这么吃显存

很多人第一次跑微调,以为只要权重放得下就万事大吉,实际上完全不是。全参微调时显存要同时装四样东西:模型权重、梯度、优化器状态、激活值。以 ChatGLM3-6B 为例,权重用 bf16 存储,大约是 6B 参数乘以 2 字节,等于 12GB。梯度即使也用半精度,至少还得 12GB。优化器如果用的是 AdamW,每个参数要保存 fp32 的一阶动量、二阶动量,再加上 fp32 的权重副本,每参数平均 12 字节,算下来是 72GB 级别的额外开销——当然实际没有这么夸张,因为混合精度下部分能够优化,但对 24G 显存来说,光是这几项大头加起来就已经超了。最后还有激活值,它和 batch size、序列长度直接挂钩,序列越长、批越大,这一块就越离谱。

这就解释了为什么很多人兴冲冲下好代码,一跑就 OOM。不是代码有问题,是这道数学题一开始就无解。全参微调不是消费级显卡该碰的东西,除非你有 A100 或者多卡并联。

1.2 LoRA 把显存开支降到了哪一级

LoRA 的思路很直接:冻结原始权重,只训练一小部分注入的低秩矩阵。这样模型权重还是要完整放在显存里,但梯度、优化器状态只需要为那极小部分参数服务,可训练参数通常只占全部参数的 1% 到 2%。我把常见 LoRA 配置和全参微调的需求放在一起算过一笔账:

项目全参微调(bf16)LoRA(bf16)
模型权重约 12GB约 12GB(冻结)
梯度约 6-12GB只算 LoRA 参数,几十 MB
优化器状态24GB 级别几百 MB 到 1GB
激活值取决于 batch size开启 gradient checkpointing 后可压缩到几个 GB
AdamW 显存合计48GB 以上16GB 以内(不含激活)

LoRA 方案的显存占用,在单卡 16G 或者 24G 的机器上是真正可跑的。如果你再叠加 4bit 量化,把冻结权重也压缩到 6GB 以内,那次一点儿的显卡也能带得动。

1.3 什么场景适合这套组合

如果你要做的任务是给模型注入领域知识、调整回答风格、固定输出格式,或者让模型学会某个特定工具链的调用习惯,LoRA 是性价比最高的选择。反过来,如果你想让模型学会全新的语言、彻底改变底层能力,纯 LoRA 是不够的,那种需求得考虑全参微调或者更大规模的专业训练。我个人的判断标准是:需要改变的是模型“说话的内容和方式”,用 LoRA;需要改变的是模型“思考的底层能力”,别折腾 LoRA,直接换更大模型。

2. LoRA 微调的核心原理:低秩变换到底改变了什么

2.1 低秩分解:把一个大方阵拆成两个瘦子

LoRA 基于一个假设:大模型微调时,权重的变化量 ΔW 其实是一个低秩矩阵。换句话说,我们不需要直接更新一个 4096x4096 的大矩阵,只需要用两个小矩阵 A 和 B 相乘来近似表示这个变化量。假设原始层权重是 W0,维度是 d×d,LoRA 的做法是训练一个 d×r 的小矩阵 A 和一个 r×d 的小矩阵 B,前向计算变成:

h = W0x + (α / r) * BAx

其中 r 是我们设定的秩,通常取 8、16、32。r 越小,可训练参数越少,欠拟合风险越高;r 越大,表达能力越强,显存开销也跟着涨。这个 α 是缩放系数,一般设成 r 的 1 到 2 倍,常见组合是 r=16、alpha=32,或者 r=8、alpha=16。

2.2 训练时不更新原始权重,为什么效果还能跟上

关键点在初始化策略。A 矩阵用随机高斯分布初始化,B 矩阵初始化为全零。这样训练刚开始时,BA 的结果是 0,整个模型的输出和原始预训练权重完全一致,不会在第一步就把模型带偏。随着训练推进,B 矩阵逐渐学习到有意义的数值,BA 才慢慢产生作用。这等于是在原始模型旁边搭了一条很小的旁路,训练过程只是在微调这条旁路,原始主干模型被安全冻结。

实际训练时只会更新 B 和 A 的参数,反向传播的时候梯度也只流经这些低秩矩阵。这就是为什么显存里不需要给 6B 参数各自准备一份优化器状态。

2.3 关键超参的选择逻辑

LoRA 里有几个超参直接影响效果:

  • target_modules:指定往哪些层注入 LoRA。ChatGLM3-6B 的注意力层用的是 query_key_value 这个合并后的线性层,所以目标模块要填 “query_key_value”。我之前见过有人照抄 LLaMA 的 q_proj、k_proj、v_proj,结果训练时发现参数根本没注入,因为模块名对不上。
  • r:秩的大小。做一般领域微调,r=8 到 16 够用;如果任务复杂,可以尝试 32。r 太大训练更慢,而且收益不明显。
  • alpha:缩放系数。调高 alpha 相当于放大新增的旁路权重,会让微调的信号更强,但太大会导致灾难性遗忘。
  • dropout:LoRA 层里的 dropout,一般设 0.05 到 0.1,防止过拟合。

另外还有两个很多人容易忽略的点:一是 LoRA 默认不会把所有 bias 变成可训练,如果你的任务对位置偏移很敏感,可以考虑把 bias 也设成 “all”;二是如果你同时开启了 gradient checkpointing,记得在保存模型后用 merge_and_unload 合并权重,否则推理时依然带着原模型加 LoRA 的叠加结构。

3. 环境准备与依赖版本:这一环节最容易翻车

3.1 硬件与操作系统建议

我自己用的是单张 RTX 4090 24G,跑 ChatGLM3-6B 的 LoRA 微调非常顺畅。如果你是 16G 显存,可以通过 4bit 量化把训练跑起来,只是速度会慢一些。内存建议 32G 以上,尤其是做长文本样本时,数据预处理的峰值内存很容易超过 20G。

操作系统方面,Windows 也能跑,但 bitsandbytes 在 Windows 上的兼容性一直不算好。如果你打算做 4bit 量化训练,我个人更推荐 WSL2 或者直接上 Linux。不是不能跑,是没必要跟编译错误较劲。

3.2 依赖安装清单与版本坑

我跑通这套流程时用的关键依赖版本如下:

pip install torch==2.1.2 pip install transformers==4.37.2 pip install peft==0.8.2 pip install datasets==2.16.1 pip install bitsandbytes==0.43.0 pip install accelerate==0.26.1

这几个版本组合是我实际验证过能正常加载 ChatGLM3-6B 的。特别要注意 transformers 的版本不能太老,也不能太新到破坏了 ChatGLM3 的 trust_remote_code 兼容性。版本太老,AutoModel 加载时可能缺参数;版本太新,某些字段会被弃用,报 warning 后行为发生变化。我第一次跑的时候用了一个很新的 transformers,结果加载 tokenizer 时直接报AttributeError,后来换成 4.37.2 就好了。

3.3 下载模型与验证加载

ChatGLM3-6B 可以从 ModelScope 或 HuggingFace 下载。国内网络环境下,ModelScope 的体验明显更稳。下载方式:

pip install modelscope modelscope download --model ZhipuAI/chatglm3-6b --local_dir ./chatglm3-6b

下载完成后,先用一小段代码验证模型能不能正常加载:

from transformers import AutoModel, AutoTokenizer model_path = "./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).cuda() response, history = model.chat(tokenizer, "你好,请介绍一下你自己", history=[]) print(response)

这里注意trust_remote_code=True不能省略,因为 ChatGLM3-6B 的模型定义代码是放在仓库里的,需要远程加载。如果你在加载阶段就报错,先检查这一步是否正常。

4. 数据准备:指令微调最容易被低估的环节

4.1 ChatGLM3 的 conversations 数据格式

数据格式直接决定模型能不能学会。ChatGLM3 的官方微调数据一般长这样:

{ "conversations": [ { "role": "system", "content": "你是一个专业的客服助手,只回答与公司业务相关的问题。" }, { "role": "user", "content": "我想查询本月账单。" }, { "role": "assistant", "content": "好的,请问您的用户编号是?" }, { "role": "user", "content": "A12345" }, { "role": "assistant", "content": "您好,您本月账单总额为 128.5 元,可在个人中心下载电子发票。" } ] }

注意 system 角色不是必须的,但加了系统提示后,模型会更容易理解整体任务场景。很多开源微调数据用的是 alpaca 格式,包含 instruction、input、output 三个字段,如果你想直接复用那类数据,建议先转换成 conversations 格式。转换脚本不复杂,把 instruction 和 input 拼成一个 user 轮次,output 作为 assistant 轮次即可。

4.2 数据清洗与去重的几个原则

训练数据不是越多越好,而是越干净越好。我踩过的坑是:第一批数据里混了大量未清洗的网页文本,模型训练完以后,回答里带着明显的 HTML 标签味。做领域微调之前,至少要过这几道清洗逻辑:

  • 去重:把语义重复的样本去掉,尤其是 FAQ 类数据,重复率极高。完全相同的样本直接用哈希去重,语义接近的样本可以用 embedding 相似度粗筛。
  • 过滤噪声:删除带乱码、HTML 标签、多余空行的样本。
  • 检查答案质量:如果数据里 assistant 回复本身是错的,模型只会越学越错。
  • 控制 instruction 多样性:要让用户提问的句式变化多一些,不要所有样本都是同一套问法,否则模型容易过拟合到固定句式。

4.3 样本长度分布分析与截断策略

训练前还要统计一下 token 长度分布。做法很简单,把每条 conversations 拼成文本,用 tokenizer 编码后看长度。有些样本会特别长,超过模型的最大序列长度,默认 2048 甚至 8192。

我个人的经验是:如果样本长度普遍超过 2048,先考虑缩短答案,而不是加大 max_length。max_length 设得越长,显存占用呈线性上涨,训练速度也成比例变慢。LoRA 微调的目标是让模型学会业务表达,不是在长文本上做阅读理解。一组干净、长度适中、质量过关的数据,比十万条粗制滥造的数据有价值得多。

5. 训练代码逐段拆解:从加载模型到保存权重

5.1 加载模型并开启梯度检查点

完整的训练脚本可以直接从项目源码中拿,这里我挑核心段落拆开解释。第一步是加载模型,并根据显存条件决定是否做 4bit 量化:

import torch from transformers import AutoModel, AutoTokenizer, BitsAndBytesConfig model_path = "./chatglm3-6b" tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) model = AutoModel.from_pretrained( model_path, trust_remote_code=True, quantization_config=bnb_config, torch_dtype=torch.bfloat16, device_map="auto", ) model.gradient_checkpointing_enable()

如果你的显存是 24G,不做 4bit 量化也可以,直接把quantization_config去掉,模型用 bf16 加载。gradient_checkpointing_enable()一定要开,它通过重新计算而不是存储激活值来节省显存,代价是训练速度稍微变慢,但换来的显存收益很可观。

5.2 配置 LoRA 并确认可训练参数

加载完基础模型,用 PEFT 库注入 LoRA:

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training if model.config.quantization_config is not None: model = prepare_model_for_kbit_training(model) lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["query_key_value"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()

print_trainable_parameters()会输出可训练参数的数量和占比,正常情况下应该看到几百 MB 级别的可训练参数,占模型总参数的 1% 到 2%。如果这里显示的可训练参数数量异常大,或者显示为 0,先检查 target_modules 是不是拼错了。ChatGLM3-6B 的注意力层模块名是query_key_value,不是 LLaMA 系列的q_proj。

5.3 数据预处理与标签掩码

数据加载后需要转换成模型输入的格式,并给标签做掩码。掩码的意思是:只有 assistant 回复部分的 token 参与 loss 计算,system 和 user 部分不计算损失。这样模型只学习怎么回答问题,而不是学习重复用户的提问。

from datasets import load_dataset max_length = 2048 def process_func(example): conversations = example["conversations"] input_ids = [] labels = [] for turn in conversations: role = turn["role"] content = turn["content"] if role == "system": text = f"[系统]\n{content}\n" ids = tokenizer.encode(text, add_special_tokens=False) input_ids.extend(ids) labels.extend([-100] * len(ids)) elif role == "user": text = f"[用户]\n{content}\n" ids = tokenizer.encode(text, add_special_tokens=False) input_ids.extend(ids) labels.extend([-100] * len(ids)) elif role == "assistant": text = f"[助手]\n{content}\n" ids = tokenizer.encode(text, add_special_tokens=False) input_ids.extend(ids) labels.extend(ids) eos_ids = tokenizer.encode(tokenizer.eos_token, add_special_tokens=False) input_ids.extend(eos_ids) labels.extend(eos_ids) if len(input_ids) > max_length: input_ids = input_ids[:max_length] labels = labels[:max_length] return {"input_ids": input_ids, "labels": labels} dataset = load_dataset("json", data_files="train.jsonl", split="train") dataset = dataset.map(process_func, remove_columns=dataset.column_names)

这里把整个数据直接编码成序列,-100 表示该位置不参与 loss 计算。如果你发现训练 loss 异常高,先检查 labels 里是不是有大量位置没有正确掩码,导致模型迟迟学不进去。

5.4 训练参数与 Trainer 配置

训练环节用 transformers 的 Trainer。参数配置是我反复调整后相对稳定的版本:

from transformers import TrainingArguments, Trainer, DataCollatorForSeq2Seq training_args = TrainingArguments( output_dir="./chatglm3-lora-checkpoints", per_device_train_batch_size=1, gradient_accumulation_steps=8, learning_rate=2e-5, num_train_epochs=3, logging_steps=10, save_steps=200, warmup_ratio=0.03, lr_scheduler_type="cosine", bf16=True, gradient_checkpointing=True, optim="adamw_torch", ) data_collator = DataCollatorForSeq2Seq(tokenizer, padding=True, label_pad_token_id=-100) trainer = Trainer( model=model, args=training_args, train_dataset=dataset, data_collator=data_collator, tokenizer=tokenizer, ) trainer.train()

这里有个常见的误区是per_device_train_batch_size和gradient_accumulation_steps的关系。显存不够时,优先把per_device_train_batch_size降到 1,然后用梯度累积补偿 batch size。但要注意,梯度累积并不会减少单步显存占用,它只是把梯度攒起来再更新,所以显存不够该降还是得降。bf16=True对 ChatGLM3-6B 是关键,fp16 在部分算子上有精度溢出风险,bf16 在同样的显存下更稳。

5.5 训练启动与日志观察

启动训练后,日志中每个 logging step 都会打印 loss。正常的场景下,loss 应该从 1 到 2 缓慢下降,到后几个 epoch 会降到 0.5 以下。如果 loss 一开始就处于 5 以上的高位,或者根本不降,先停掉,检查数据预处理和标签掩码是不是出了问题。

训练完成后,LoRA 权重会保存在 output_dir 下的 checkpoint 目录里。训练中途如果中断,Trainer 默认会保留最近的 checkpoint,重新运行时会自动从中断处恢复,这也是save_steps要设得合理的原因。

6. 显存不足的三级排查方案:从量化到梯度累积

6.1 第一级:开 gradient checkpointing 与 bf16

如果你的显存不够初始化训练,第一件事是确认两件事:gradient_checkpointing_enable()有没有调用,bf16有没有设置。我见过很多代码,前面步骤全对,就是忘了开 gradient checkpointing,导致显存占用直接翻倍。开了之后,激活值会重新计算而不是全部保存,大概能省 30% 到 40% 的显存。

6.2 第二级:4bit 量化与 prepare_model_for_kbit_training

如果 bf16 + gradient checkpointing 还是放不下,那就上 4bit 量化。量化后的模型权重只有 3GB 左右,整卡占用能压到 10GB 以内。使用 4bit 量化时,记得调用prepare_model_for_kbit_training,否则训练时会报梯度相关的错误。

我实测过一组显存对比,可以给你一个直观参考:

方案模型加载方式训练时峰值显存
bf16 + 梯度检查点原模型约 16-18GB
4bit 量化 + 梯度检查点BitsAndBytes约 8-10GB
加上更小 max_length额外降低序列长度5-8GB

如果你的卡是 8GB,那就只能走 4bit 量化,并且 max_length 控制在 1024 以内,batch size 固定为 1。

6.3 第三级:PagedAdamW 与序列长度控制

如果量化后还是不够,可以再往两个方向压:一是把 AdamW 换成分页优化器,bitsandbytes 提供了paged_adamw_8bit和paged_adamw_32bit,它在显存不足时会借助 CPU 内存暂存优化器状态,等于把显存压力转移到内存。二是砍 max_length,从 2048 降到 1024,同一时间进入显存的激活值规模会小很多。

这个方案组合起来,我用一张 8G 的 3070 也把 ChatGLM3-6B 的 LoRA 训练跑通过,只是速度比较感人。所以如果你的显卡在 8G 以下,建议先换更大显存,或者直接用 API 服务做领域适配,别为难自己。

6.4 实测显存占用参考

我最后一次跑的数据集是 5000 条客服对话样本,max_length 2048,r=16,batch size 1,梯度累积 8。显存峰值在 15GB 左右。这个数字可以作为你调整参数时的参照。

7. 训练过程中的常见问题:loss 不降、模型学不会、评估跑不出

7.1 loss 恒定的排查链路

训练时最让人崩溃的,就是 loss 从第一步开始纹丝不动。按这个顺序排查:

  • 检查标签掩码:如果所有 labels 全是 -100,loss 会直接变成 0,看似很好实际模型啥也没学到。
  • 检查学习率:2e-5是 ChatGLM3-6B 的安全起点,太高会导致发散,太低会导致收敛慢。loss 完全不动时,可以尝试调到5e-5或1e-4看有没有变化。
  • 检查 batch size 和梯度累积:梯度累积不够会让每一次参数更新都很小,看起来 loss 在缓慢爬行。适当加大学习率或调低累积步数。
  • 检查数据内容:如果训练数据里 assistant 回复全部为空字符串,模型自然会学到输出空。

7.2 模型“学不会”但 loss 正常,可能数据格式有问题

有一种情况很迷惑:loss 在正常下降,但拿模型测试时,回答完全不像训练数据里的内容。遇到这种情况,我首先检查的是数据格式。ChatGLM3 的 tokenizer 对角色标记敏感,训练时如果用了[用户]、[助手]这类标记,推理时也要用同样的标记结构。

另外,如果训练数据里 user 部分的文本本身就带着大量噪声,模型会学到把噪声也原样复制。可以抽样打印几条预处理后的 input_ids 对应的文本,肉眼确认一下格式有没有问题。这一步虽土,但非常有效。

7.3 评估阶段的坑

如果你在 Trainer 里传了 eval_dataset,要注意评估时的数据格式必须和训练数据完全一致,包括角色标记、标点、字段名。我踩过的一个坑是训练数据用conversations字段,评估数据用了messages字段,导致评估时 model 的输入格式错乱,指标看起来一团糟。

另外一个常见问题是:训练完成后,直接调用model.chat()测试,但忘了把模型从训练状态切到 eval 状态。model.eval()一定要记得,尤其在 dropout 没有关闭的情况下,推理结果是随机的,看起来就像模型没学会。

8. 权重合并与部署:让 LoRA 真正可用

8.1 合并 LoRA 权重

训练完成后,我们得到的是基础模型加 LoRA 权重。如果你的推理环境支持 PEFT 的加载方式,可以不合并直接用:

from peft import PeftModel from transformers import AutoModel, AutoTokenizer base_model = AutoModel.from_pretrained("./chatglm3-6b", trust_remote_code=True, torch_dtype=torch.bfloat16) model = PeftModel.from_pretrained(base_model, "./chatglm3-lora-checkpoints/checkpoint-500")

但更推荐的做法是合并成一个独立模型,方便后续推理部署:

model = model.merge_and_unload() model.save_pretrained("./chatglm3-lora-merged") tokenizer.save_pretrained("./chatglm3-lora-merged")

merge_and_unload()会把 LoRA 权重融合进基础模型,然后卸载掉 PEFT 结构。合并后的模型就是一份完整的 ChatGLM3-6B,不再依赖额外权重文件。

8.2 vLLM 部署 API

合并完的模型可以直接用 vLLM 部署成 OpenAI 兼容的 API。这样后续接应用,只需要改base_url,不需要重新写对接逻辑。

pip install vllm python -m vllm.entrypoints.openai.api_server \ --model ./chatglm3-lora-merged \ --served-model-name chatglm3-lora \ --port 8000 \ --max-model-len 4096

启动成功后,测试接口:

curl http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "chatglm3-lora", "messages": [ {"role": "user", "content": "你好"} ] }'

vLLM 部署的并发能力远超裸模型推理,适合直接挂在业务后面用。

8.3 实测调用效果与后续优化思路

融合部署后,我拿当时训练的客服场景测试,模型在回答格式、业务口径上确实比原版模型贴切了很多。不过也要提醒一句:LoRA 微调本质上是在调整模型的输出分布,不是凭空造出新的能力。如果你的训练数据里用户问题很宽泛、答案很窄,模型在测试时只要换个问法就可能水土不服。解决思路是数据里增加更多问法变体,或者在不同 epoch 中留出验证集观察是否过拟合。

另外,训练数据量比较小时,建议把num_train_epochs控制在 2 到 3 个 epoch,再多容易过拟合。模型在训练集上效果很好、测试集上泛化差,基本就是数据量撑不起太多 epoch。

我自己走完一遍流程后最大的体会是:LoRA 微调的门槛没有想象中高,但每一步都需要严谨对待。显存计算、数据格式、标签掩码、部署方式,每一个环节都有默认值之外的细节。把这套流程跑通之后,再换 Qwen、Llama 等模型,只需要改目标模块名和数据模板即可,思路完全通用。希望这个实战拆解能帮你少走一些弯路。

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

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

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

立即咨询