简介:针对大模型微调需求的实战型资源,面向算法工程师与人工智能学习者,围绕ChatGLM3-Base模型提供可直接运行的有监督微调完整实现,覆盖数据准备、模型加载、参数训练到结果推理的闭环,帮助使用者解决微调落地过程中的数据准备、训练配置与评估改进等常见难题。压缩包共十一个文件,大小仅为七百九十七KB,包含六个脚本(分别承担数据预处理、模型引擎封装、训练器构建、工具函数与结果发送等核心模块)、一个推理演示笔记本、一份已标注样本数据、一篇教程文档以及两张训练曲线截图,结构紧凑便于按需复用。已有二百七十二人学习下载。项目不局限于跑通代码,而是按照有监督微调的典型流程拆解出样本构造、模型引擎封装、训练执行与推理验证等关键步骤,并穿插超参数调优、学习率策略、正则化技术等进阶话题,帮助学习者建立系统方法论。通过实际动手操作,学习者能独立完成ChatGLM3-Base模型的监督微调任务,并可将这套方法迁移至其他预训练语言模型,沉淀可复用的工程代码与排错经验,适合希望快速进入大模型微调实战的开发者。
1. 先跑通一条 SFT 最小链路再谈调优
一个反直觉的结论:很多人微调 ChatGLM3 第一反应是拿 Chat 版继续训练,但真正做行业私有化模型,正确的起点是 Base 版。Chat 版里一切会聊天的能力,都是别人用某套 SFT 数据配比训练出来的;你拿它当底座再灌自己的数据,等于在一个不可见的分布上叠 buff,效果会卡在一个说不清的天花板上。反过来,把 ChatGLM3-Base 当白纸,用整理好的指令-回答对从头做有监督微调,你能完全控制对话风格、回复格式和知识边界。
这套链路的核心资产是数据和配置。模型加载参数错一个,loss 直接不降;对话模板错一个标记,模型学到的全是格式噪声;超参不合适,12GB 卡和 24GB 卡完全是两种体验。这就是为什么实战项目习惯把源码和流程一起打包——缺经验时,一份能跑的代码比读十篇文档有用得多。这篇沿着「环境基座→数据处理→训练配置→推理评估」展开,最后落到几条能直接带进生产的排错经验上。适合有一定 transformers 基础、正准备在业务数据上跑第一个 SFT 模型的人。
2. 环境与基座:怎么把 ChatGLM3-Base 加载成可训练状态
2.1 最小环境清单与加载代码
ChatGLM3 系列依赖自定义模型代码,transformers 在加载时必须显式允许远程代码执行,否则会直接报错。依赖版本上,transformers>=4.37.0、torch>=2.1、peft>=0.9、accelerate>=0.28是一套经过验证的组合。先把环境装好,再跑下面这段最小加载。
import torch from transformers import AutoModel, AutoTokenizer model_id = "THUDM/chatglm3-6b-base" tokenizer = AutoTokenizer.from_pretrained( model_id, trust_remote_code=True, padding_side="right", ) model = AutoModel.from_pretrained( model_id, trust_remote_code=True, torch_dtype=torch.bfloat16, # 30 系及以上显卡用 bf16,V100 换 fp16 device_map="auto", # 让 accelerate 自动分配显存 ) model.gradient_checkpointing_enable() model.train() print(model.dtype, model.device)trust_remote_code=True是 ChatGLM 系列绕不开的参数,它会执行仓库里的自定义 Python 代码来构建模型结构。padding_side="right"设置为右侧填充,SFT 阶段如果混用左右填充,attention mask 的位置偏置会干扰训练。torch_dtype建议优先 bf16,它的数值范围比 fp16 大,训练更稳;V100 这类老卡不支持 bf16,退到 fp16。device_map="auto"会按显存把层分到多张卡,单卡场景下它等价于直接把模型放到cuda:0。
2.2 显存策略:全参、LoRA 与 4bit 量化三挡
6B 模型 fp16 权重约 12GB,看起来 24GB 卡能装下,但训练还要算梯度和优化器状态,直接全参微调需要大约 4 倍权重内存。
| 方案 | 显存需求 | 适用场景 |
|---|---|---|
| 全参微调 | 40GB 以上 | 有多卡 A100/GH200 的团队 |
| LoRA | 约 16-18GB | 单卡 24GB 的标准选择,最推荐 |
| QLoRA(4bit 量化) | 约 9-12GB | 单卡 16GB 或以下,体验型项目 |
LoRA 只训练注入的低秩矩阵,冻结原权重;QLoRA 在此基础上把基座量化到 4bit,进一步压显存。对大多数业务场景,LoRA 的效果已经足够,量化主要解决「卡不够」的硬约束。QLoRA 的加载代码需要在from_pretrained里增加量化配置:
from transformers import BitsAndBytesConfig 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_id, trust_remote_code=True, quantization_config=bnb_config, device_map="auto", )bnb_4bit_quant_type用 nf4,它对正态分布权重有更好的量化精度;bnb_4bit_compute_dtype决定反量化后的计算精度,保持 bf16 即可;use_double_quant再省一点显存但会稍微增加耗时。量化后训练前要调用prepare_model_for_kbit_training(model),它会把需要训练的层转为 fp32,否则 LoRA 层可能出现精度问题。
2.3 加载后先做一次冒烟验证
训练跑十几个小时才发现模型加载有问题,成本太高。加载后立刻做一次 forward 验证,确认 tokenizer 和模型能正确协作。
input_text = tokenizer.apply_chat_template( [{"role": "user", "content": "你好"}], tokenize=True, return_tensors="pt", add_generation_prompt=False, ) with torch.no_grad(): out = model(input_ids=input_text.to(model.device)) print("logits shape:", out.logits.shape)apply_chat_template是这条链路里最重要的一个 API:它把对话结构按照模型预设的模板转成 token 序列。ChatGLM3-Base 本身没有对话能力,但它的 tokenizer 依然带有模板能力,SFT 训练的数据必须经过它处理。add_generation_prompt=False表示只编码对话本身,不追加模型回复的引导标记,这个参数到了推理阶段会反过来用。如果 logits shape 第一维是序列长度、最后一维是词表大小,就说明模型链路通了。
3. 数据工程:不解决好对话格式与 token 截断,SFT 就是白练
3.1 指令数据集的三种常见形态
SFT 训练数据本质上是一条条「指令/上下文 + 标准回答」的配对。项目里常见的格式有两种:Alpaca 式的单轮instruction/input/output,和 ShareGPT 式的多轮conversation数组。实际业务数据以多轮居多,下面以 JSON Lines 为例:
{"system": "你是某保险公司的客服助手,回答须包含条款编号。", "conversation": [ {"role": "user", "content": "重疾险等待期是多久?"}, {"role": "assistant", "content": "根据条款 2.3 条,等待期为 90 天。"} ]}system字段放角色设定和输出约束,ChatGLM3 模板支持把它放在对话最前面。conversation里的 role 只用user和assistant两种,多轮就继续往后追加。这部分工作的重点不是写解析代码,而是确保每条对话的语义闭环:上下文完整、回答不依赖外部记忆。数据里的system如果每一条都不同,模型会把它当成对话内容的一部分去拟合,所以相同业务域建议统一系统指令。
3.2 tokenize、标签掩码与截断策略
数据解析成统一结构后,需要转换成模型接受的input_ids和labels。最直接的做法是把整条对话通过apply_chat_template转成 token,然后让模型对整段序列做语言建模。这里有一个关键选择:labels是否要掩码掉用户输入部分。
import json from torch.utils.data import Dataset import torch class SFTDataset(Dataset): def __init__(self, path, tokenizer, max_len=2048): self.tokenizer = tokenizer self.examples = [] with open(path, encoding="utf-8") as f: for line in f: row = json.loads(line) sys_prompt = row.get("system", "你是一个可靠的助手。") conversation = [{"role": "system", "content": sys_prompt}] conversation.extend(row["conversation"]) encoded = tokenizer.apply_chat_template( conversation, tokenize=True, return_tensors="pt", truncation=True, max_length=max_len, add_generation_prompt=False, ) input_ids = encoded[0] # 简化方案:全部 token 参与 loss 计算 labels = input_ids.clone() self.examples.append({"input_ids": input_ids, "labels": labels}) def __len__(self): return len(self.examples) def __getitem__(self, i): return self.examples[i]这段代码的labels等于input_ids,是最省事的做法,模型对整个对话序列做预测。更严格的做法是把labels中用户输入、系统提示和模板标记替换为-100,只让 assistant 轮次的回答参与 loss 计算,这样模型不会浪费建模能力去「背」用户的问法。ChatGLM3 官方微调脚本里有现成的掩码实现,原理是扫描模板中 assistant 轮次的起止 token 位置,把区间之外的位置全部置-100。如果数据里用户输入长度占比高,掩码方案收敛更稳;如果只做格式对齐,全体计算 loss 也能用。
截断策略是另一个坑。max_length截断是硬切,多轮对话一旦超过长度,后面最新的问答会被直接丢掉。实际项目中更稳妥的做法是:保留 system 和最后两轮对话,中间部分舍得删。SFT 数据里「最新指令」的权重远高于历史上下文,开头两轮没被模型记住不致命,但最后一轮被截掉,这条样本就废了。
3.3 数据量级与配比参考
不是所有任务都需要几万条数据。SFT 的下限比多数人想象的低,关键是任务类型和数据质量。
| 任务类型 | 建议规模 | 数据侧重点 |
|---|---|---|
| 回复风格/格式对齐 | 1k-5k 条 | 覆盖全部要求格式,正例即可 |
| 垂直领域问答 | 10k-50k 条 | 知识覆盖面、难例比例 |
| 工具调用/结构化输出 | 2k-10k 条 | 严格校验输出 schema 的样本 |
| 通用能力保持 | 业务数据的 10%-20% | 混合通用指令,防止灾难性遗忘 |
loss不降时先别调超参,回头检查数据:是不是 system 指令每一条都不同、是不是 assistant 回答里夹杂了大量噪音、是不是截断把答案切没了。数据里若有 5% 的坏样本,模型会用 20% 的容量去拟合这 5% 的噪声。
4. LoRA 与超参:ChatGLM3-Base 有监督微调的核心配置
4.1 用 peft 配置 LoRA 目标模块
数据准备好后进入训练配置。LoRA 的做法是冻结原模型,在目标线性层旁路插入低秩矩阵。ChatGLM3-6B 的自注意力和前馈网络里都有可注入的线性层,target_modules直接决定哪些层被训练。
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training model = prepare_model_for_kbit_training(model) # 4bit 量化场景必加 lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["query_key_value", "dense"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()target_modules里的模块名不是拍脑袋写的。ChatGLM3-6B 的 SelfAttention 把 Q、K、V 合并在一个query_key_value矩阵里,输出投影叫dense。如果你用的包版本有命名差异,先打印模块名核对:
print([n for n, _ in model.named_modules() if "query" in n or "dense" in n])r是低秩矩阵的秩,业务任务 8 够用,跨领域泛化需求高可以加到 32;lora_alpha是放缩系数,通常设成r的 2 倍,过大等于把 LoRA 权重放大,基座能力容易漂移;lora_dropout防过拟合,数据量少于 5k 时可加到 0.1。bias="none"表示不训练任何偏置项,这是 LoRA 的默认推荐,全参微调才需要额外考虑。
4.2 用 Trainer 拉起一个有监督微调训练
数据已经是 token 化后的Dataset,用 Hugging Face Trainer 最顺。不用 SFTTrainer 的原因是后者期待原始文本字段,拿 token 化好的数据进去还要再过一遍打包逻辑,容易重复截断。训练前必须实现一个 collator,把 batch 内样本垫到相同长度。
def sft_collator(features): input_ids = [f["input_ids"] for f in features] labels = [f["labels"] for f in features] max_len = max(len(x) for x in input_ids) batch_ids, batch_labels = [], [] for ids, lbs in zip(input_ids, labels): pad_len = max_len - len(ids) batch_ids.append(ids + [tokenizer.pad_token_id] * pad_len) batch_labels.append(lbs + [-100] * pad_len) return { "input_ids": torch.tensor(batch_ids), "attention_mask": (torch.tensor(batch_ids) != tokenizer.pad_token_id).long(), "labels": torch.tensor(batch_labels), }attention_mask让模型忽略 padding 位置;labels里的-100是 PyTorch 交叉熵的忽略索引,自动排除掉这些位置。padding 一律放右侧,配合加载时设置的padding_side="right"。
训练参数按 LoRA 的常见配置来:
from transformers import Trainer, TrainingArguments training_args = TrainingArguments( output_dir="sft_ckpt", per_device_train_batch_size=2, gradient_accumulation_steps=8, # 等效 batch size = 2*8 = 16 learning_rate=2e-4, num_train_epochs=3, lr_scheduler_type="cosine", warmup_ratio=0.03, logging_steps=10, save_strategy="epoch", save_total_limit=2, bf16=True, gradient_checkpointing=True, max_grad_norm=1.0, report_to="none", ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, data_collator=sft_collator, ) trainer.train()per_device_train_batch_size=2配gradient_accumulation_steps=8,是 24GB 单卡下比较稳的组合。等效 batch size 16-32 是 SFT 的常见区间,太小收敛不稳,太大容易过拟合。学习率 2e-4 是 LoRA 的常见起点,这个值比全参微调的 1e-5 高一个数量级,因为只更新低秩矩阵。gradient_checkpointing=True用计算换显存,如果卡足够大可以关掉提速。save_total_limit=2防止 checkpoints 把磁盘写满。
如果不习惯手写训练循环,LLaMA-Factory 这类高效微调平台把 ChatGLM3 的 LoRA 配置模板化,界面里选 Base 模型、填数据路径就能训练,但超参逻辑和 Trainer 是一样的,理解上面的参数含义后再去用它,排查问题会顺手得多。
4.3 断点续训与损失异常判定
训练中断是常态,不要每次从零开始。Trainer 天然支持断点续训:
python train.py --resume_from_checkpoint sft_ckpt/checkpoint-1000对应的代码里需要判断 checkpoint 目录是否存在,或者直接作为命令行参数传入trainer.train(resume_from_checkpoint=...)。续训时会自动恢复优化器状态和学习率调度器位置,只有数据顺序会重置。
| 现象 | 原因 | 处理 |
|---|---|---|
| loss 恒定为常数 | 模板输出为空、labels 全 -100 | 打印一条 tokenize 结果人工核对 |
| loss 前几步直接下降但立刻停滞 | 数据里有大量重复样本 | 检查去重、调低 lr |
| loss 正常下降但 eval loss 上升 | 过拟合 | 减少 epoch、增大 dropout、加通用数据 |
5. 推理与评估:验证 SFT 生效的三个关键信号
5.1 加载 adapter 并做生成推理
训练产物不是完整模型,而是 LoRA adapter。推理时先加载 Base 模型,再挂 adapter。注意生成阶段的模板处理和训练不同:add_generation_prompt=True,让模板在最后补上模型回复的引导标记。
from peft import PeftModel base_model = AutoModel.from_pretrained( model_id, trust_remote_code=True, torch_dtype=torch.float16, device_map="auto", ) model = PeftModel.from_pretrained(base_model, "./sft_ckpt/final") model.eval() conversation = [ {"role": "system", "content": "你是保险客服助手,回答必须引用条款编号。"}, {"role": "user", "content": "重疾险等待期多久?"}, ] prompt = tokenizer.apply_chat_template( conversation, tokenize=False, add_generation_prompt=True ) inputs = tokenizer(prompt, return_tensors="pt").to(model.device) out = model.generate( **inputs, max_new_tokens=512, do_sample=True, temperature=0.7, top_p=0.9, repetition_penalty=1.05, ) answer = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True) print(answer)max_new_tokens限制新增生成长度而不是总长度;temperature=0.7保留多样性又不至于发散;repetition_penalty=1.05对长回复场景很有用。decode 时通过inputs["input_ids"].shape[1]切掉输入部分,只保留模型新生成的 token。
5.2 用三个信号判断 SFT 是否真的生效
第一个信号是格式约束力。SFT 最直接的效果是「按格式说话」,评估时看模型是否稳定输出训练集定义的格式,比如是否带条款编号、是否遵守角色设定。第二个信号是知识边界。Base 模型对垂直领域的问题只会泛泛而谈,SFT 后应该能引用训练数据里的实体和规则。第三个信号是反事实能力。把训练样本里的实体换掉,比如把「重疾险」换成「医疗险」,模型应该基于规则重新作答,而不是复读训练数据原文。如果换实体后仍然一字不差输出训练答案,说明过拟合严重,需要加正则或减 epoch。
SFT 和 RL 在这里有一个明确分工:SFT 负责教会模型格式和能力,RLHF 负责对齐人类偏好。项目标题写明 SFT,评估时就不应该期待模型学会「拒绝回答」或「承认不知道」,那是偏好对齐的范畴。
5.3 合并权重与交付部署
上线服务时,把 adapter 合并回 Base 模型,得到一个完整的独立权重,省去每次加载时的 PeftModel 包装。
merged = model.merge_and_unload() merged.save_pretrained("./chatglm3-sft-merged") tokenizer.save_pretrained("./chatglm3-sft-merged")合并后的权重可以用 vLLM 这类推理框架直接加载部署,也可以转成 GGUF 格式跑在更低配置的 CPU 环境上。私有化部署的常见坑是:合并前用merge_and_unload()而不是model = model.merge(),后者会把 LoRA 权重残留在模型里,后续推理状态不对。
6. 从训练到私有化部署,值得记住的 5 条实战排错经验
6.1 高频失败模式的处置
| 现象 | 根因 | 处置 |
|---|---|---|
| 训练 loss 第 1 步就是 nan | bf16 在旧卡上不可用 | 换 fp16 重跑 |
| loss 不降 | 模板或数据问题 | 抽 50 条数据冒烟训练 1 步,打印 loss 和样本 |
| 生成结果重复 | repetition_penalty 太小或温度过低 | 调高到 1.1 左右 |
| 复读训练原文 | 过拟合或数据泄漏 | 减 epoch、降 lora_alpha、检查验证集是否混入训练数据 |
| 部署后效果与验证时不一致 | 加载流程少了 add_generation_prompt | 检查推理模板是否和训练一致 |
每一类都可以在 30 分钟内定位。loss 异常先跑冒烟训练,把max_steps=50配logging_steps=1,观察第一步 loss 是否接近log(vocab_size)附近,偏差过大说明模板把序列拼错了。
6.2 写一个可复用的验证剧本
评估 SFT 效果不建议人肉敲 prompt 一个个试。维护一份独立的验证集,每轮训练后批量生成结果再人工抽审:
#!/bin/bash DECODED_CKPT="sft_ckpt/final" python eval_generate.py \ --base_model THUDM/chatglm3-6b-base \ --adapter_path "$DECODED_CKPT" \ --eval_file data/valid.jsonl \ --max_new_tokens 512 \ --output_file eval_results/$(date +%Y%m%d_%H%M).jsonleval_generate.py里循环读取valid.jsonl,逐条走 5.1 的生成流程,把输入、标准答案、模型输出写进结果文件。评估指标不需要上复杂框架,先统计三点:格式违规率、回答平均长度、以及「输出与标准答案完全相同」的比例——最后一点恰好对应过拟合。演示场景建议挑一个格式约束明显的任务,比如 JSON 输出或固定话术模板,视觉差异比闲聊任务更直观,学生或业务方一眼能看出模型变化。
跑完验证剧本,把效果稳定的 checkpoint 合并、导出、记录训练参数,这一轮 SFT 才算真正收尾。下一轮迭代时,用同样的剧本对比新旧模型输出,比任何 loss 曲线都可信。
本文还有配套的精品资源,点击获取