ChatGLM3-6B LoRA 微调实战:基于 PEFT 构建甄嬛风格个性化对话模型
【免费下载链接】self-llm《开源大模型食用指南》针对中国宝宝量身打造的基于Linux环境快速微调(全参数/Lora)、部署国内外开源大模型(LLM)/多模态大模型(MLLM)教程项目地址: https://gitcode.com/datawhalechina/self-llm
本文是《开源大模型食用指南》(self-llm 项目)中 ChatGLM3 系列的第六篇实战教程,完整演示如何基于 transformers、peft 等框架,对智谱 ChatGLM3-6B 对话模型进行 LoRA 高效微调,以甄嬛台词数据集为训练语料,构建一个能够模拟甄嬛对话风格的个性化大语言模型。读完本文,你将掌握 ChatGLM3 特化的指令数据格式化方法、LoraConfig 与 TrainingArguments 的关键参数配置、Trainer 训练全流程,以及微调后模型的推理与 PeftModel 重新加载技巧。
概述
LoRA(Low-Rank Adaptation)是一种高效微调方法,其核心思想是在冻结预训练模型原始参数的前提下,通过在权重矩阵旁注入低秩分解矩阵(A、B)来实现参数的高效更新,训练完成后只需保存占比极小的增量参数,即可实现对模型的领域化改造,显著降低微调的显存与存储开销。
本节所讲述的代码脚本位于 models/ChatGLM/06-ChatGLM3-6B-Lora微调.py,可直接运行该脚本来执行微调过程。需要特别注意的是:
- 本文代码未使用分布式框架,微调 ChatGLM3-6B-Chat 模型至少需要21G 及以上显存;
- 运行前必须修改脚本文件中的模型路径(默认
/root/autodl-tmp/ZhipuAI/chatglm3-6b)和数据集路径(默认读取仓库根目录dataset/huanhuan.json); - 同目录下还提供了逐 Cell 讲解的 models/ChatGLM/06-ChatGLM3-6B-Lora微调.ipynb 教学 Notebook,便于逐步学习调试。
环境配置
在完成基本环境配置(含 CUDA、PyTorch)与 ChatGLM3-6B 本地模型部署的前提下,还需要安装以下第三方库:
pip install transformers==4.37.2 pip install peft==0.4.0.dev0 pip install datasets==2.10.1 pip install accelerate==0.21.0注:若使用仓库 models/ChatGLM/requirements.txt 中整理的全套依赖环境,其中对应版本为
peft==0.4.0、datasets==2.14.0、accelerate==0.24.0等,实际运行时只要 peft / transformers / datasets / accelerate 版本兼容即可,无需与本文指定版本完全一致。
本节教程将微调数据集放置在仓库根目录的 dataset/huanhuan.jsonl(JSONL 格式,共 3729 条甄嬛台词对话)。脚本中则通过pd.read_json('../../dataset/huanhuan.json')读取同一份数据的 JSON 数组版本 dataset/huanhuan.json,两者内容一致、格式不同,均为甄嬛台词构造的指令数据。
指令集构建
LLM 的微调一般指指令微调(Instruction Tuning)过程。所谓指令微调,是指我们使用的微调数据形如:
{ "instrution":"回答以下用户问题,仅输出答案。", "input":"1+1等于几?", "output":"2" }其中:
instruction:用户指令,告知模型其需要完成的任务;input:用户输入,是完成用户指令所必须的输入内容;output:模型应该给出的输出。
核心训练目标是让模型具有理解并遵循用户指令的能力。因此,在指令集构建时,应针对目标任务针对性构建任务指令集。例如,本项目以甄嬛对话风格建模为目标(参考 examples/Chat-嬛嬛/readme.md 中利用《甄嬛传》剧本台词打造个性化 AI 的思路),构造的指令形如:
{ "instruction": "", "input":"你是谁?", "output":"家父是大理寺少卿甄远道。" }QA 与 Instruction 的区别与联系
QA 是一问一答的形式,通常是用户提问、模型给出回答;而 Instruction 源自 Prompt Engineering,将问题拆分为两个部分:Instruction 用于描述任务,Input 用于描述待处理的对象。
例如,对于问题"请解释 VC 银翘片和双黄连口服液之间的区别":
问答(QA)格式:
指令(Instruction): 输入(Input):VC银翘片和双黄连口服液之间的区别是什么?指令(Instruction)格式:
指令(Instruction):请解释下面两个药品之间的区别。 输入(Input):VC银翘片和双黄连口服液。
问答格式的训练数据通常用于训练模型回答基于知识的问题,而指令格式的训练数据更适用于训练模型执行具体任务。指令的形式可能使模型具有更好的泛化能力,因为它强调了任务的性质,而不仅仅是特定的输入。通常指令格式和问答格式可以相互转化。
数据格式化
LoRA 训练的数据需要经过格式化、编码之后再输入给模型。熟悉 PyTorch 训练流程的同学知道,我们一般需要将输入文本编码为input_ids,将输出文本编码为labels,编码之后的结果都是多维向量。
首先定义预处理函数,对每一个样本编码其输入、输出文本并返回编码后的字典:
def process_func(example): MAX_LENGTH = 512 input_ids, labels = [], [] instruction = tokenizer.encode(text="\n".join(["<|system|>", "现在你要扮演皇帝身边的女人--甄嬛", "<|user|>", example["instruction"] + example["input"] + "<|assistant|>"]).strip() + "\n", add_special_tokens=True, truncation=True, max_length=MAX_LENGTH) response = tokenizer.encode(text=example["output"], add_special_tokens=False, truncation=True, max_length=MAX_LENGTH) input_ids = instruction + response + [tokenizer.eos_token_id] labels = [tokenizer.pad_token_id] * len(instruction) + response + [tokenizer.eos_token_id] pad_len = MAX_LENGTH - len(input_ids) input_ids += [tokenizer.pad_token_id] * pad_len labels += [tokenizer.pad_token_id] * pad_len labels = [(l if l != tokenizer.pad_token_id else -100) for l in labels] return { "input_ids": input_ids, "labels": labels }经过格式化后,送入模型的每一条数据都是一个包含input_ids、labels两个键值对的字典,其中input_ids是输入文本的编码,labels是输出文本的编码。decode 之后应该是这样的形态:
[gMASK]sop <|system|> 现在你要扮演皇帝身边的女人--甄嬛 <|user|> 这个温太医啊,也是古怪,谁不知太医不得皇命不能为皇族以外的人请脉诊病,他倒好,十天半月便往咱们府里跑。<|assistant|> 你们俩话太多了,我该和温太医要一剂药,好好治治你们。为什么会是这个形态?不同模型所对应的格式化输入都不一样,需要深度阅读模型的训练源码来确认——因为按照原本模型指令微调的形式进行 LoRA 微调效果最好,所以我们依然遵循 ChatGLM3 原始模型的输入格式。其格式化的核心要点是:
[gMASK]、sop是 ChatGLM3 的特殊前缀 token(get_command("[gMASK]")对应 id 64790);- 使用
<|system|>、<|user|>、<|assistant|>角色分隔符拼接 system / 用户 / 模型回复; labels中指令部分的 token 用pad_token_id占位并在最后统一替换为-100,从而在损失计算时屏蔽指令与填充部分,只让模型学习output回复的生成。
Notebook models/ChatGLM/06-ChatGLM3-6B-Lora微调.ipynb 中还展示了两种等价的拆解思路以帮助理解:
- 调用 API 处理:使用
tokenizer.build_chat_input(instruction, history=[], role="user")构造输入,tokenizer("\n" + output, add_special_tokens=False)编码回复; - 手动拆解:用
tokenizer.get_command("<|system|>")、tokenizer.get_command("<|user|>")、tokenizer.get_command("<|assistant|>")手动拼接角色标记与内容 token。
两种方式得到的 token 序列本质一致。ds.map(process_func, remove_columns=ds.column_names)会将原始 3729 条数据整体映射为仅含input_ids、labels两个特征的Dataset,供后续 Trainer 使用。
加载 tokenizer 与半精度模型
模型以半精度形式加载,如果你的显卡比较新,也可以使用torch.bfloat16形式加载。对于 ChatGLM3 这类自定义实现的模型,必须指定trust_remote_code=True以信任并执行其远程代码(模型结构定义与 tokenizer 实现均来自模型仓库):
tokenizer = AutoTokenizer.from_pretrained('./model/chatglm3-6b', use_fast=False, trust_remote_code=True) # 模型以半精度形式加载,如果你的显卡比较新的话,可以用torch.bfloat16形式加载 model = AutoModelForCausalLM.from_pretrained('./model/chatglm3-6b', trust_remote_code=True, torch_dtype=torch.half, device_map="auto")注意:ChatGLM3 的 tokenizer 属于慢速 tokenizer(use_fast=False),其padding_side='left'、truncation_side='right';add_special_tokens=True时会自动带上[gMASK]、sop等特殊前缀 token,这与上文格式化后看到的文本形态严格对应。
定义 LoraConfig
LoraConfig中可以设置很多参数,但主要参数并不多,逐个说明如下:
| 参数 | 含义 | 说明 |
|---|---|---|
task_type | 模型类型 | 因果语言模型填TaskType.CAUSAL_LM |
target_modules | 需要训练的模型层名字 | 主要就是 attention 部分的层,不同模型层的名字不同;可传数组、字符串或正则表达式 |
r | LoRA 的秩 | 低秩矩阵的秩,越小参数量越少、表达能力越受限 |
lora_alpha | LoRA alpha | 与缩放比例相关,具体作用参见 LoRA 原理 |
lora_dropout | Dropout 比例 | 对注入的低秩分支施加的随机失活比例,用于缓解过拟合 |
modules_to_save | 额外完整训练模块 | 除拆成 LoRA 的模块外,其他需要完整训练(不拆分)的模块 |
LoRA 的缩放是什么?当然不是r(秩),这个缩放是lora_alpha / r。在本节的配置中缩放为32 / 8 = 4倍。这个缩放的本质并没有改变 LoRA 的参数量大小,本质在于将里面的参数数值做广播乘法,进行线性的缩放。
config = LoraConfig( task_type=TaskType.CAUSAL_LM, target_modules=["query_key_value"], inference_mode=False, # 训练模式 r=8, # Lora 秩 lora_alpha=32, # Lora alaph,具体作用参见 Lora 原理 lora_dropout=0.1 # Dropout 比例 )针对 ChatGLM3,target_modules指向其注意力模块中的query_key_value层(ChatGLM 系列将 Q、K、V 投影合并为一个线性层)。Notebook 中提示target_modules也可以传入正则表达式,例如仅针对第 1 层 attention 的".*\\.1.*query_key_value"。
通过get_peft_model(model, config)完成 LoRA 注入后,Notebook 中的实测输出为:
trainable params: 1,949,696 || all params: 6,245,533,696 || trainable%: 0.031217444255383614也就是说,6.2B 参数的 ChatGLM3-6B 在 LoRA 微调中仅需训练约 195 万参数(约占 0.031%),这正是 LoRA 能够大幅降低显存与算力门槛的直接证据。
自定义 TrainingArguments 参数
TrainingArguments的源码对每个参数都有说明,这里说明几个常用的:
| 参数 | 含义 |
|---|---|
output_dir | 模型的输出路径(checkpoint 保存目录) |
per_device_train_batch_size | 单卡 batch size |
gradient_accumulation_steps | 梯度累加步数;显存较小可调小 batch_size、调大梯度累加 |
logging_steps | 每多少步输出一次 log |
num_train_epochs | 训练轮数(epoch) |
gradient_checkpointing | 梯度检查点,开启后模型必须执行model.enable_input_require_grads() |
save_steps | 每多少步保存一次 checkpoint |
learning_rate | 学习率 |
同时需要为 ChatGLM3 沿用其源仓库封装好的DataCollatorForSeq2Seq(支持以 -100 填充标签、对 batch 内样本做 padding 对齐):
# Data collator GLM源仓库从新封装了自己的data_collator,在这里进行沿用。 data_collator = DataCollatorForSeq2Seq( tokenizer, model=model, label_pad_token_id=-100, pad_to_multiple_of=None, padding=False ) args = TrainingArguments( output_dir="./output/ChatGLM", per_device_train_batch_size=4, gradient_accumulation_steps=2, logging_steps=10, num_train_epochs=3, gradient_checkpointing=True, save_steps=100, learning_rate=1e-4, )脚本 models/ChatGLM/06-ChatGLM3-6B-Lora微调.py 中为适配单卡显存,采用了更保守的组合:
per_device_train_batch_size=1、gradient_accumulation_steps=8、logging_steps=20、num_train_epochs=1,实际训练时可结合显存大小灵活调整。
使用 Trainer 训练
把 model、训练参数、数据集全部传入Trainer,开始训练:
trainer = Trainer( model=model, args=args, train_dataset=tokenized_id, data_collator=data_collator, ) trainer.train()训练过程中,模型权重本身被冻结,仅更新注入的 LoRA 低秩矩阵(query_key_value层内的 A、B 矩阵),checkpoint 默认保存在output_dir下,每隔save_steps步生成一个 checkpoint 子目录(如checkpoint-1000)。
模型推理
训练完成后,可以用经典方式直接基于当前(已注入 LoRA 的)模型进行推理:
model.eval() model = model.cuda() ipt = tokenizer("<|system|>\n现在你要扮演皇帝身边的女人--甄嬛\n<|user|>\n {}\n{}".format("你是谁?", "").strip() + "<|assistant|>\n", return_tensors="pt").to(model.device) tokenizer.decode(model.generate(**ipt, max_length=128, do_sample=True)[0], skip_special_tokens=True)注意推理时的 prompt 格式必须与训练时的数据格式化保持一致(<|system|>系统角色提示 →<|user|>用户提问 →<|assistant|>触发模型回复),否则模型可能无法正确遵循指令。Notebook 中的实测输出为:
'[gMASK]sop <|system|>\n现在你要扮演皇帝身边的女人--甄嬛\n<|user|>\n 你是谁?<|assistant|>\n 我是甄嬛,家父是大理寺少卿甄远道。'可以看到,微调后的模型已经学会了以甄嬛的口吻进行回答,成功达到了个性化对话建模的目标。
重新加载微调模型
通过 PEFT 微调的模型,都可以使用下面的方法进行重新加载并推理:
- 加载源 model 与 tokenizer;
- 使用
PeftModel将源 model 与 PEFT 微调后的 LoRA 参数合并。
from peft import PeftModel model = AutoModelForCausalLM.from_pretrained("./model/chatglm3-6b", trust_remote_code=True, low_cpu_mem_usage=True) tokenizer = AutoTokenizer.from_pretrained("./model/chatglm3-6b", use_fast=False, trust_remote_code=True) p_model = PeftModel.from_pretrained(model, model_id="./output/ChatGLM/checkpoint-1000/") # 将训练所得的LoRA权重加载起来 ipt = tokenizer("<|system|>\n现在你要扮演皇帝身边的女人--甄嬛\n<|user|>\n {}\n{}".format("你是谁?", "").strip() + "<|assistant|>\n", return_tensors="pt").to(model.device) tokenizer.decode(p_model.generate(**ipt, max_length=128, do_sample=True)[0], skip_special_tokens=True)这种加载方式下,源模型权重与 LoRA 增量权重分开存放:PeftModel.from_pretrained(model, model_id="./output/ChatGLM/checkpoint-1000/")会将 checkpoint 目录下保存的adapter_model(LoRA 适配器)重新挂载到源模型上,随后即可正常推理。若需要部署为独立完整模型,还可以调用p_model.merge_and_unload()将 LoRA 权重合并回主模型再保存,此方式在推理部署时可避免额外的适配器加载开销。
小结
至此,我们完整走通了"指令数据构造 → ChatGLM3 特化格式化 → LoRA 参数注入 → Trainer 微调 → 推理验证 → PeftModel 重新加载"的 ChatGLM3-6B 高效微调全流程。整个过程仅需训练约 0.031% 的参数(约 195 万),却能让 6B 级大模型获得甄嬛式对话风格,充分体现了 LoRA 在低成本、个性化大模型定制上的实战价值。读者只需替换模型路径、数据集路径与角色设定文本(现在你要扮演皇帝身边的女人--甄嬛),即可将该流程复用到任意角色扮演、风格迁移或领域指令微调场景中。
【免费下载链接】self-llm《开源大模型食用指南》针对中国宝宝量身打造的基于Linux环境快速微调(全参数/Lora)、部署国内外开源大模型(LLM)/多模态大模型(MLLM)教程项目地址: https://gitcode.com/datawhalechina/self-llm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考