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
                )

实操心得

  1. 学习率 :LoRA微调的学习率通常比全量微调小一个数量级,一般在1e-5到5e-5之间。可以从2e-5开始尝试。
  2. Batch Size :受限于显存,batch size可能很小。可以使用 gradient_accumulation_steps 来模拟更大的batch size。例如,真实batch_size=4,accumulation_steps=4,效果上相当于batch_size=16。
  3. 混合精度 mixed_precision=‘fp16’ 能有效减少显存占用并加快训练速度,但需要注意数值稳定性,对于ChatGLM2通常是安全的。
  4. 监控 :关注训练损失和验证损失曲线。如果训练损失下降但验证损失上升,可能是过拟合,需要减少训练轮次或增加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这样的开源大模型,并没有想象中那么复杂和昂贵。它为我们提供了一个强大的工具,能够以极低的成本,让通用大模型快速适应我们的专属领域和任务。无论是情感分析、实体识别、内容审核,还是智能客服,这套方法论都为你打开了大门。剩下的,就是去收集和构建你的高质量数据了。

更多推荐