基于LoRA的ChatGLM2-6B微调实战:从文本分类到垂直领域应用
1. 项目概述
最近在折腾大语言模型(LLM)的微调,发现很多朋友对这块既感兴趣又有点发怵,总觉得动辄几百亿参数的模型,没个几块A100根本玩不转。其实不然,开源社区已经涌现出不少“小而美”的模型,比如清华开源的ChatGLM2-6b,它就是一个非常典型的代表。这个模型参数规模适中,只需要一块显存稍大(比如32GB)的消费级显卡就能跑起来推理和微调,对于个人开发者、研究者或者中小团队来说,门槛大大降低。
我这次拿它来做个具体的实验:用一个外卖评论数据集,教ChatGLM2-6b学会区分好评和差评。听起来是个简单的文本分类任务,对吧?但我想通过这个“麻雀虽小,五脏俱全”的例子,把LLM微调的完整链路——从数据准备、模型加载、参数高效微调(PEFT)到训练验证和最终部署——都清晰地走一遍。你会发现,微调后的模型效果,相比单纯用精心设计的提示词(Prompt)去“问”模型,会有明显的提升。更重要的是,这个范式是通用的。今天我们是做文本分类,明天你想做命名实体识别、翻译、甚至构建一个垂直领域的对话机器人,思路都是一样的:把你的任务巧妙地“包装”成一个对话或续写问题,然后准备相应的数据对模型进行微调。
2. 核心思路与方案选型
2.1 为什么选择ChatGLM2-6b与LoRA?
选择ChatGLM2-6b,首要原因就是它的“平民友好”特性。它基于GLM架构,在多项中文理解和生成任务上表现不错,而且完全开源。6B的参数量,使得它在拥有不错能力的同时,对硬件的要求相对亲民。在量化技术的辅助下,甚至能在显存更小的卡上运行。社区活跃度高也意味着遇到问题更容易找到解决方案和讨论。
对于微调方案,我选择了LoRA(Low-Rank Adaptation),而不是全参数微调。这是关键的一步。全量微调一个6B的模型,需要保存和优化所有参数的梯度,对显存和算力都是巨大的挑战。LoRA的核心思想很巧妙:它冻结预训练模型的所有参数,然后在模型的某些层(通常是注意力模块)旁边,插入一系列可训练的“低秩适配器”。在微调时,只更新这些适配器的参数。因为适配器的参数量极少(通常只占原模型的0.1%-3%),所以训练速度极快,显存占用也大幅降低,而且多个不同的适配器可以像插件一样在同一个基础模型上切换,非常灵活。对于我们这种希望快速验证想法、低成本适配特定任务的场景,LoRA几乎是目前的最优解。
2.2 任务形式转换:从分类到“文字接龙”
大语言模型本质上是基于概率的文本生成器,它最擅长的是“给定上文,续写下文”。传统的文本分类任务输出是一个离散的标签(如“好评”),这与LLM的生成式输出不太匹配。因此,我们需要做一个转换:把分类任务“伪装”成一个文本续写任务。
我们的策略是:设计一个固定的提示模板(Prompt),将用户评论作为问题的一部分,而期望的分类结果(“好评”或“差评”)作为模型的回答。在训练时,我们把完整的“提示+评论”作为模型输入的上文(context),把“好评/差评”这个标签作为模型需要生成的下文(target)。模型的学习目标,就是让它在看到这个特定的上文后,生成我们期望的下文。在推理时,我们只需要输入“提示+评论”,模型就会自动续写出“好评”或“差评”,从而完成分类。
注意 :Prompt的设计至关重要。一个清晰、包含少量示例(few-shot)的Prompt,不仅能提升零样本(zero-shot)和少样本(few-shot)下的表现,也能为微调提供一个更好的起点。我们的Prompt明确说明了任务、给出了正负样例,并严格限定了输出格式,这能有效引导模型学习我们想要的模式。
3. 环境准备与数据工程
3.1 模型与依赖安装
首先,需要准备好Python环境(建议3.8以上)和PyTorch。然后安装核心库:
pip install transformers datasets peft torchkeras accelerate pandas numpy tqdm
transformers: Hugging Face的核心库,用于加载模型和分词器。datasets: 同样来自Hugging Face,方便高效地处理和加载数据集。peft: 实现参数高效微调(包括LoRA)的官方库。torchkeras: 一个让PyTorch训练像Keras一样简洁的封装,非必须但能极大提升代码优雅度。accelerate: Hugging Face的分布式训练库,能简化混合精度训练、多GPU训练等流程。pandas,numpy: 数据处理。tqdm: 进度条显示。
接下来是下载ChatGLM2-6b模型。可以从Hugging Face Model Hub直接拉取,国内用户如果网络不稳定,可以考虑从魔搭社区(ModelScope)或清华源下载,或者手动下载所有文件到本地目录。
from transformers import AutoModel, AutoTokenizer
model_name = “./chatglm2-6b” # 使用本地路径,如果从hub下载则改为 “THUDM/chatglm2-6b”
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModel.from_pretrained(model_name, trust_remote_code=True).half().cuda() # 半精度加载到GPU
实操心得 :
trust_remote_code=True参数是必须的,因为ChatGLM2的实现包含自定义代码。首次运行时会从远程拉取这些代码,需要确保网络通畅。如果遇到长时间卡住,可以尝试科学上网或使用国内镜像源。
3.2 数据集准备与预处理
我们使用一个外卖评论数据集,包含评论文本和标签(0为差评,1为好评)。第一步是加载并划分训练集和测试集。
import pandas as pd
import datasets
# 假设数据已下载为 waimai_10k.csv
df = pd.read_csv(“data/waimai_10k.csv”)
df[‘tag’] = df[‘label’].map({0: ‘差评’, 1: ‘好评’})
df = df.rename({‘review’: ‘text’}, axis=1)
# 平衡正负样本(可选,但通常有利于训练)
df_good = df.query(‘tag==“好评”’)
df_bad = df.query(‘tag==“差评”’).head(len(df_good))
df = pd.concat([df_good, df_bad])
# 划分训练集和测试集
ds_dict = datasets.Dataset.from_pandas(df).train_test_split(test_size=2000, shuffle=True, seed=43)
df_train = ds_dict[‘train’].to_pandas()
df_test = ds_dict[‘test’].to_pandas()
接下来是关键步骤:构建符合ChatGLM2对话格式的输入。我们需要模拟 model.chat 方法内部的对话历史构建逻辑。
def build_inputs(query, history):
prompt = “”
for i, (old_query, response) in enumerate(history):
prompt += f“[Round {i+1}]\n问:{old_query}\n答:{response}\n”
prompt += f“[Round {len(history)+1}]\n问:{query}\n答:”
return prompt
# 定义我们的任务Prompt和少量示例(Few-shot)
task_prompt = “”“文本分类任务:将一段用户给外卖服务的评论进行分类,分成好评或者差评。
下面是一些范例:
味道真不错 -> 好评
太辣了,吃不下都 -> 差评
请对下述评论进行分类。返回’好评’或者’差评’,无需其它说明和解释。
{} ->”“”
# 构建一个包含示例的历史记录,作为上下文
his = []
# 先加入任务描述和第一个例子
first_query = task_prompt.format(“味道不错,下次还来”)
his.append((first_query, “好评”))
# 再加入其他几个例子
his.append((“太贵了 ->”, “差评”))
his.append((“非常快,味道好 ->”, “好评”))
his.append((“这么咸真的是醉了 ->”, “差评”))
his.append((“价格感人 优惠多多 ->”, “好评”))
# 应用函数,为每条训练数据构建输入文本(context)和目标输出(target)
df_train[‘context’] = [build_inputs(f”{x} ->”, history=his) for x in df_train[‘text’]]
df_train[‘target’] = df_train[‘tag’].tolist()
df_train = df_train[[‘context’, ‘target’]]
# 测试集同样处理
df_test[‘context’] = [build_inputs(f”{x} ->”, history=his) for x in df_test[‘text’]]
df_test[‘target’] = df_test[‘tag’].tolist()
df_test = df_test[[‘context’, ‘target’]]
这样,每条数据都变成了一个包含多轮对话历史(我们的任务描述和示例)和当前查询(待分类评论)的 context ,以及一个简短的 target (“好评”或“差评”)。
3.3 Tokenization与数据管道构建
文本需要被转换成模型能理解的数字ID(token ids)。同时,我们需要将 context 和 target 拼接起来作为模型的输入,并正确设置标签,让模型只对 target 部分计算损失。
from transformers import AutoTokenizer, AutoConfig
model_name = “./chatglm2-6b”
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
config = AutoConfig.from_pretrained(model_name, trust_remote_code=True, device_map=‘auto’)
max_seq_length = 512 # 根据你的显存调整,越长能处理的文本越长
skip_over_length = True # 是否跳过超长样本
def preprocess(example):
context = example[“context”]
target = example[“target”]
# 分别对上下文和目标进行编码
context_ids = tokenizer.encode(
context,
max_length=max_seq_length,
truncation=True
)
target_ids = tokenizer.encode(
target,
max_length=max_seq_length,
truncation=True,
add_special_tokens=False # 目标部分不加特殊token
)
# 拼接成完整的输入,并在末尾加上结束符
input_ids = context_ids + target_ids + [config.eos_token_id]
return {“input_ids”: input_ids, “context_len”: len(context_ids), “target_len”: len(target_ids)}
# 应用预处理函数
from datasets import Dataset
ds_train = Dataset.from_pandas(df_train)
ds_val = Dataset.from_pandas(df_test)
ds_train_token = ds_train.map(preprocess).select_columns([‘input_ids’, ‘context_len’, ‘target_len’])
ds_val_token = ds_val.map(preprocess).select_columns([‘input_ids’, ‘context_len’, ‘target_len’])
# 过滤掉长度超限的样本
if skip_over_length:
ds_train_token = ds_train_token.filter(lambda example: example[“context_len”] + example[“target_len”] < max_seq_length)
ds_val_token = ds_val_token.filter(lambda example: example[“context_len”] + example[“target_len”] < max_seq_length)
最后,我们需要一个数据整理函数(collator),在组成batch时进行填充(padding)并创建正确的标签掩码。
import torch
def data_collator(features: list):
len_ids = [len(feature[“input_ids”]) for feature in features]
longest = max(len_ids)
input_ids = []
labels_list = []
for length, feature in sorted(zip(len_ids, features), key=lambda x: -x[0]):
ids = feature[“input_ids”]
context_len = feature[“context_len”]
# 创建labels:context部分(除了最后一个token)用-100忽略,target部分保留原id,padding部分也用-100忽略
labels = (
[-100] * (context_len - 1) + ids[(context_len - 1):] + [-100] * (longest - length)
)
# 对input_ids进行padding
ids = ids + [tokenizer.pad_token_id] * (longest - length)
input_ids.append(torch.LongTensor(ids))
labels_list.append(torch.LongTensor(labels))
input_ids = torch.stack(input_ids)
labels = torch.stack(labels_list)
return {
“input_ids”: input_ids,
“labels”: labels,
}
# 创建DataLoader
from torch.utils.data import DataLoader
dl_train = DataLoader(ds_train_token, num_workers=2, batch_size=4, pin_memory=True, shuffle=True, collate_fn=data_collator)
dl_val = DataLoader(ds_val_token, num_workers=2, batch_size=4, pin_memory=True, shuffle=False, collate_fn=data_collator)
注意事项 :
-100在CrossEntropyLoss中默认是被忽略的,这样损失函数就只会计算target部分token的损失,这正是我们想要的。pad_token_id用于将同一个batch中的序列填充到相同长度。batch_size需要根据你的显卡显存进行调整,如果出现OOM(内存溢出),首先尝试减小batch_size或max_seq_length。
4. 模型配置与LoRA微调实现
4.1 加载基础模型与启用梯度检查点
首先加载基础模型,并进行一些优化设置以节省显存。
from transformers import AutoModel
import torch
model = AutoModel.from_pretrained(“./chatglm2-6b”,
load_in_8bit=False, # 如果显存紧张,可以尝试True,但可能需要bitsandbytes库
trust_remote_code=True,
device_map=‘auto’) # 自动分配模型层到多GPU
# 启用梯度检查点,用时间换空间,大幅减少训练期显存占用
model.supports_gradient_checkpointing = True
model.gradient_checkpointing_enable()
model.enable_input_require_grads()
model.config.use_cache = False # 训练时关闭缓存,避免冲突
原理补充 :梯度检查点(Gradient Checkpointing)是一种时间换空间的技术。在反向传播计算梯度时,它不会保存所有中间激活值(这很占显存),而是选择性地保存一部分,在需要时重新计算。这可以显著降低显存消耗,代价是增加约20%-30%的训练时间。对于大模型微调,这通常是值得的。
4.2 注入LoRA适配器
使用 peft 库为模型添加LoRA适配器。这里我们选择对模型的 query , key , value 等注意力层进行适配。
from peft import get_peft_model, LoraConfig, TaskType
peft_config = LoraConfig(
task_type=TaskType.CAUSAL_LM, # 因果语言模型任务
inference_mode=False, # 训练模式
r=8, # LoRA的秩(rank),决定适配器的大小。通常4, 8, 16,越大能力越强,参数量越多。
lora_alpha=32, # 缩放参数,一般设为r的2-4倍。
lora_dropout=0.1, # LoRA层的dropout率,用于防止过拟合。
target_modules=[“query_key_value”, “dense”, “dense_h_to_4h”, “dense_4h_to_h”] # 指定要对哪些模块添加LoRA。对于ChatGLM2,通常是注意力层和FFN层。
)
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()
运行 print_trainable_parameters() 后,你会看到类似输出: trainable params: 36,864,000 || all params: 6,248,000,000 || trainable%: 0.5898 这意味着只有不到1%的参数是可训练的,这正是LoRA的魅力所在。
4.3 训练循环与监控
这里我使用 torchkeras 来组织训练代码,它能让训练循环看起来非常清晰。同时结合 accelerate 库,可以轻松支持混合精度训练。
from torchkeras import KerasModel
from accelerate import Accelerator
# 自定义StepRunner以适配accelerate
class StepRunner:
def __init__(self, net, loss_fn, accelerator=None, stage=“train”, metrics_dict=None,
optimizer=None, lr_scheduler=None):
self.net, self.loss_fn, self.metrics_dict, self.stage = net, loss_fn, metrics_dict, stage
self.optimizer, self.lr_scheduler = optimizer, lr_scheduler
self.accelerator = accelerator if accelerator is not None else Accelerator()
if self.stage == ‘train’:
self.net.train()
else:
self.net.eval()
def __call__(self, batch):
# 前向传播与损失计算
with self.accelerator.autocast(): # 混合精度训练
outputs = self.net(input_ids=batch[“input_ids”], labels=batch[“labels”])
loss = outputs.loss
# 反向传播与优化(仅在训练阶段)
if self.optimizer is not None and self.stage == “train”:
self.accelerator.backward(loss)
if self.accelerator.sync_gradients:
self.accelerator.clip_grad_norm_(self.net.parameters(), 1.0) # 梯度裁剪
self.optimizer.step()
if self.lr_scheduler is not None:
self.lr_scheduler.step()
self.optimizer.zero_grad()
# 收集所有进程的损失
all_loss = self.accelerator.gather(loss).sum()
step_losses = {self.stage + “_loss”: all_loss.item()}
step_metrics = {‘lr’: self.optimizer.param_groups[0][‘lr’]} if self.stage==“train” else {}
return step_losses, step_metrics
# 绑定自定义Runner
KerasModel.StepRunner = StepRunner
# 重写保存和加载方法,只处理LoRA权重
def save_ckpt(self, ckpt_path=‘checkpoint.pt’, accelerator=None):
unwrap_net = accelerator.unwrap_model(self.net)
unwrap_net.save_pretrained(ckpt_path) # peft模型自带的方法,只保存可训练参数
def load_ckpt(self, ckpt_path=‘checkpoint.pt’):
self.net = self.net.from_pretrained(self.net, ckpt_path) # 加载LoRA权重
self.from_scratch = False
KerasModel.save_ckpt = save_ckpt
KerasModel.load_ckpt = load_ckpt
# 初始化KerasModel并开始训练
import torch.optim as optim
keras_model = KerasModel(model,
loss_fn=None, # 模型内部已计算loss
optimizer=optim.AdamW(model.parameters(), lr=2e-5)) # 学习率通常很小
ckpt_path = ‘./chatglm2-6b-waimai-lora’ # 保存路径
keras_model.fit(train_data=dl_train,
val_data=dl_val,
epochs=10, # 根据loss曲线调整
patience=5, # 早停耐心值
monitor=‘val_loss’,
mode=‘min’,
ckpt_path=ckpt_path,
mixed_precision=‘fp16’, # 使用fp16混合精度训练,节省显存并加速
gradient_accumulation_steps=4 # 梯度累积,模拟更大batch_size
)
实操心得 :
- 学习率 :LoRA微调的学习率通常比全量微调小一个数量级,一般在1e-5到5e-5之间。可以从2e-5开始尝试。
- Batch Size :受限于显存,batch size可能很小。可以使用
gradient_accumulation_steps来模拟更大的batch size。例如,真实batch_size=4,accumulation_steps=4,效果上相当于batch_size=16。- 混合精度 :
mixed_precision=‘fp16’能有效减少显存占用并加快训练速度,但需要注意数值稳定性,对于ChatGLM2通常是安全的。- 监控 :关注训练损失和验证损失曲线。如果训练损失下降但验证损失上升,可能是过拟合,需要减少训练轮次或增加dropout。
5. 模型验证、推理与部署
5.1 加载微调后的模型并进行评估
训练完成后,我们需要加载保存的LoRA权重,并与基础模型合并,以便进行推理。
from peft import PeftModel
from transformers import AutoModel, AutoTokenizer
# 重新加载基础模型
base_model = AutoModel.from_pretrained(“./chatglm2-6b”,
load_in_8bit=False,
trust_remote_code=True,
device_map=‘auto’)
# 加载LoRA适配器
model = PeftModel.from_pretrained(base_model, “./chatglm2-6b-waimai-lora”)
# 将LoRA权重合并到基础模型中,这样推理时就是一个完整的模型,无需额外处理
model = model.merge_and_unload()
# 加载分词器
tokenizer = AutoTokenizer.from_pretrained(“./chatglm2-6b”, trust_remote_code=True)
# 复用之前的预测函数
def predict(text, temperature=0.01):
# 使用与训练时一致的对话历史
response, history = model.chat(tokenizer, f”{text} ->”, history=his, temperature=temperature)
return response
# 在测试集上评估
preds = []
for text in tqdm(df_test[‘text’].tolist()):
preds.append(predict(text))
df_test[‘pred’] = preds
acc = (df_test[‘tag’] == df_test[‘pred’]).mean()
print(f’微调后模型准确率: {acc:.3f}’)
在我的实验中,经过约1小时的微调(在单卡RTX 4090上),准确率从微调前的87.8%提升到了90.3%,提升了2.5个百分点。对于这样一个相对简单的任务,这个提升是显著的,证明了微调的有效性。
5.2 推理技巧与参数探索
大语言模型生成文本时, temperature 参数控制着输出的随机性。 temperature=0 时,模型总是选择概率最高的下一个词(贪心搜索),结果确定但可能呆板。 temperature 越高,选择更多样,但也更可能出错。
# 尝试用不同的temperature来预测一个模糊的评论
ambiguous_review = “死鬼,咋弄得这么有滋味呢”
for temp in [0.01, 0.5, 0.8, 1.0]:
print(f”Temperature={temp}: {predict(ambiguous_review, temperature=temp)}”)
对于分类任务,我们通常希望结果稳定,所以设置 temperature=0.01 (接近0)。但对于“死鬼”这种语义模糊的评论,模型本身可能就不确定。提高 temperature 可能会让它在“好评”和“差评”之间摇摆,这恰恰反映了模型对这类样本置信度不高。在实际应用中,可以结合 temperature 和多次采样,通过投票或计算概率分布来获得更鲁棒的结果。
5.3 模型保存与完整部署
为了便于分享和部署,我们可以将合并后的完整模型和分词器保存到新的目录。
# 保存完整模型
model.save_pretrained(“./chatglm2-6b-waimai-full”, max_shard_size=“1GB”)
tokenizer.save_pretrained(“./chatglm2-6b-waimai-full”)
# 注意:ChatGLM2有自定义的模型代码,需要将这些.py文件也复制过去
import shutil
import os
source_dir = “./chatglm2-6b”
target_dir = “./chatglm2-6b-waimai-full”
for file in os.listdir(source_dir):
if file.endswith(“.py”):
shutil.copy(os.path.join(source_dir, file), os.path.join(target_dir, file))
之后,就可以像加载原始ChatGLM2一样加载这个微调后的专属模型了:
from transformers import AutoModel, AutoTokenizer
custom_model_name = “./chatglm2-6b-waimai-full”
tokenizer = AutoTokenizer.from_pretrained(custom_model_name, trust_remote_code=True)
model = AutoModel.from_pretrained(custom_model_name, trust_remote_code=True).half().cuda()
# 直接使用,无需额外加载LoRA
response, _ = model.chat(tokenizer, “狗子,怎么做的这么好吃呀? ->”, history=[])
print(response) # 应该输出 “好评”
6. 常见问题与避坑指南
6.1 显存不足(OOM)问题
这是微调大模型时最常见的问题。
- 首要措施 :减小
batch_size和max_seq_length。序列长度对显存的影响是平方级的。 - 启用梯度检查点 :如前面所述,在加载模型后立即设置
model.gradient_checkpointing_enable()。 - 使用混合精度训练 :在
KerasModel.fit中设置mixed_precision=‘fp16’。 - 使用梯度累积 :通过
gradient_accumulation_steps模拟大batch,同时保持实际batch较小。 - 尝试量化加载 :使用
load_in_8bit=True参数加载模型,但这需要安装bitsandbytes库,且可能对训练稳定性有轻微影响。 - 使用CPU卸载 :对于非常大的模型,可以考虑将部分层卸载到CPU,但会极大降低速度。
accelerate库的device_map=‘auto’会自动尝试优化。
6.2 训练不收敛或效果差
- 学习率不当 :LoRA微调的学习率需要调小。尝试
1e-5,2e-5,5e-5。 - 数据格式错误 :检查
context和target的构建是否正确,是否与推理时的Prompt格式完全一致。一个字符的差异都可能导致模型困惑。 - LoRA配置问题 :尝试调整
r(秩)和alpha。r太小可能欠拟合,太大可能过拟合。也可以尝试修改target_modules,例如只加到query_key_value上。 - 过拟合 :如果训练集准确率很高但验证集不升反降,说明过拟合。可以尝试:1) 增加LoRA的
dropout;2) 收集更多数据;3) 减少训练轮次(Epochs);4) 对基础模型进行更全面的冻结(如果之前解冻了部分层)。
6.3 模型生成结果不符合预期
- Temperature设置 :分类任务应将
temperature设低(如0.01)。如果生成内容杂乱,检查此参数。 - Prompt设计 :模型严重依赖Prompt。确保你的微调数据Prompt和推理时使用的Prompt在格式、示例上高度一致。可以尝试在Prompt中更加强调输出格式,例如:“请只输出一个词:好评 或 差评”。
- 解码策略 :
model.chat方法内部通常使用贪心搜索。对于分类,这通常是合适的。如果需要多样性,可以改用model.generate并配置do_sample=True,top_p=0.9等参数。
6.4 微调后模型“遗忘”通用能力
这是灾难性遗忘问题。由于LoRA只更新极少参数,且我们通常冻结了绝大部分模型权重,这个问题相比全量微调已经大大缓解。但为了进一步防止,可以在数据中混入少量通用问答数据(如alpaca格式的数据),让模型在学习新任务的同时,保留原有知识。在我们的例子中,最后测试了一个通用问题(跑步比赛名次),模型回答正确,说明其通用能力保持得不错。
整个过程走下来,你会发现用LoRA微调一个像ChatGLM2-6b这样的开源大模型,并没有想象中那么复杂和昂贵。它为我们提供了一个强大的工具,能够以极低的成本,让通用大模型快速适应我们的专属领域和任务。无论是情感分析、实体识别、内容审核,还是智能客服,这套方法论都为你打开了大门。剩下的,就是去收集和构建你的高质量数据了。
更多推荐

所有评论(0)