简介一套面向大模型微调入门者与NLP开发者的实战项目基于ChatGLM3-6B模型实现LoRA低秩适配微调通过低秩近似更新参数矩阵在基本不增加参数量的前提下高效适配下游任务解决算力受限环境下大模型定制的难题。资源包共12个文件涵盖Python训练/推理/模型导出脚本、JSON格式的微调数据集与校验集、YAML参数配置及Markdown流程教程约359KB目录按功能划分便于对照工程实践。已有780人学习下载适合希望快速上手LoRA微调、提升大模型应用能力的初学者与研发人员。内容从数据准备、LoRA配置、模型微调训练到推理评估均有详细说明与可运行源码同时覆盖自我认知等典型数据集的构建与处理能帮助读者理解低秩适配的调参要点掌握一套可复用的微调项目模板。1. 从零跑通 ChatGLM3-6B 的 LoRA 微调一张消费卡能做多少事一张消费卡跑不动 6B 模型的全量微调这几乎是所有第一次接触大模型的从业者撞到的第一堵墙。ChatGLM3-6B 光权重 fp16 就要占 12GB全量训练时优化器状态直接能把显存顶到 80GB 以上别说 4090A100 都得掂量。LoRA 的做法是只训练拆出来的低秩矩阵可训练参数量压到 0.1% 左右显存需求骤降到 24GB 单卡就能扛住。这份资源就是完整可复现的那套工程环境配置、数据集改造、LoRA 训练脚本、权重合并与验证连同项目源码和流程教程一起打包。不要一上来就追求 SOTA 效果先跑通一个最小可用的微调。适合手里有消费级显卡、没碰过 peft、又想验证「让模型学会自己的业务风格到底值不值得做」的工程师。目标很明确把 LoRA 微调从玄学变成你可以亲手跑完的流程。2. 环境与基座准备transformers 版本锁死量化加载省下 8GBLoRA 微调翻车一半翻在训练参数另一半翻在环境版本。ChatGLM3-6B 走的是trust_remote_code加载远程建模代码transformers 版本对不上轻则报AttributeError重则模型层被静默跳过、loss 根本不降。所以第一步不是调参是把环境钉死。2.1 版本组合为什么推荐 torch 2.1 transformers 4.36我先给出一套自己跑过多次、没翻过车的组合。很多报错都是版本漂移引起的不是你的代码写错。transformers4.36.2 torch2.1.2 peft0.7.1 bitsandbytes0.43.1 datasets2.16.1 accelerate0.26.1 sentencepiece0.1.99transformers 锁 4.36.x 是这套工程里最重要的一步。ChatGLM3 的 modeling 代码来自仓库里的modeling_chatglm.py它跟 transformers 内部 API 耦合很深升到 4.40 后部分接口变了远程代码直接报错。peft 0.7.1 对 LoRA 的支持已经稳定bitsandbytes 0.43 能正常跑 4bit 量化。这几个版本配在一起至少不会在加载阶段浪费你一下午。装完环境后先确认 GPU 能被 torch 正常看到再继续往下走。nvidia-smi python -c import torch; print(torch.__version__, torch.cuda.is_available())torch.cuda.is_available()输出True才说明 CUDA 侧没问题。如果这里就是False先查 torch 是不是装了 CPU 版或者 CUDA 版本跟驱动不匹配别急着开训练。2.2 权重准备与量化加载4bit 省显存的关键写法权重建议先下载到本地目录再加载。每次启动都去线上拉权重既慢又不稳定本地目录一旦完整后面调试会顺手很多。加载基座模型的代码长这样from transformers import AutoModel, AutoTokenizer, BitsAndBytesConfig import torch base_model_path models/chatglm3-6b # 本地权重目录 bnb_config BitsAndBytesConfig( load_in_4bitTrue, bnb_4bit_quant_typenf4, bnb_4bit_compute_dtypetorch.float16, bnb_4bit_use_double_quantTrue, ) model AutoModel.from_pretrained( base_model_path, trust_remote_codeTrue, quantization_configbnb_config, device_mapauto, torch_dtypetorch.float16, ) model.config.use_cache False # 训练阶段必须关 tokenizer AutoTokenizer.from_pretrained( base_model_path, trust_remote_codeTrue )trust_remote_codeTrue是 ChatGLM3 加载的硬性要求不加直接报错。量化配置里load_in_4bitTrue把权重压到 4bit显存占用大概从 12GB 降到 4~5GB剩下的空间留给激活值和优化器。compute_dtypetorch.float16决定计算精度混合精度下训练速度和显存占用都比较舒服。device_mapauto让加速库自动分配各层到 GPU/CPU单卡环境它会全部放到 GPU 0多卡环境自动切分。提示use_cacheFalse必须设否则开gradient_checkpointing会报冲突。后面推理验证时再改回True。2.3 确认权重位置模型到底在 GPU 还是 CPU很多人训练到一半才发现模型根本没上 GPU白跑半天。加载后我习惯先看一眼print(model.hf_device_map)输出类似{: 0}就说明所有层都在 GPU 0 上。如果看到{: cpu}或者部分层落在 CPU训练会奇慢无比还容易出现 CPU/GPU 间的拷贝瓶颈。模型结构和分词器确认没问题之后下一步就是准备数据。微调效果一半看数据而 ChatGLM3 对数据格式的要求比 LLaMA 系更严格。3. 数据集改造把任意对话样本拍成 ChatGLM3 能直接吃的样子网上能下到的开源指令集大多是 Alpaca 格式每条数据有instruction、input、output三个字段。这个格式拿去训 LLaMA 没问题直接喂给 ChatGLM3 就会出问题——它的训练模板是基于 role/content 的对话结构对特殊 token 的位置非常敏感。3.1 模板结构system、user、assistant 和 eos 缺一不可ChatGLM3 的对话模板长这样每个角色之间靠特殊 token 隔开|system| 你是客服小助手语气简洁直接。|endoftext| |user| 我的订单三天没更新了|endoftext| |assistant| 已为您提交加急预计 24 小时内更新。|endoftext|这里|endoftext|就是 eos token标记一句角色的结束。训练时 system 和 user 段的内容不参与 loss 计算assistant 段的回复才是要学习的对象。所以数据处理时要把 user 段对应位置在 labels 里置成-100损失函数会自动忽略这些位置。3.2 转换脚本从 instruction/output 转成对话三元组下面这个脚本把常见 Alpaca 风格 JSONL 数据转成 ChatGLM3 能直接吃的对话文本。输入格式每行一条 JSON输出是已经拼好模板的文本文件。import json def convert_alpaca_to_chat(in_path, out_path, system_prompt你是客服小助手语气简洁直接。): with open(in_path, r, encodingutf-8) as f_in, \ open(out_path, w, encodingutf-8) as f_out: for line in f_in: item json.loads(line) instruction item.get(instruction, ).strip() input_text item.get(input, ).strip() output_text item.get(output, ).strip() if input_text: user_content f{instruction}\n{input_text} else: user_content instruction text f|system|\n{system_prompt}|endoftext|\n text f|user|\n{user_content}|endoftext|\n text f|assistant|\n{output_text}|endoftext|\n f_out.write(json.dumps({text: text}, ensure_asciiFalse) \n) convert_alpaca_to_chat(train_raw.jsonl, train_chat.jsonl)逻辑很简单input为空时只拼instruction不为空时拼成instruction 换行 input这是 Alpaca 数据的通用约定。输出直接写成带完整模板的text字段后面 tokenize 只需要做编码不需要再拼模板了。源数据字段和对话模板的映射关系如下源数据字段模板位置system_prompt 参数system段内容instruction可含 inputuser段内容outputassistant段内容无对应字段每段结尾补endoftext3.3 数据长度与规模max_length 别贪大几百条也能明显改变风格tokenize 时max_length是个需要权衡的参数。设得太大长样本占满序列batch 稍大一点就 OOM设得太小长文本被截断模型学到的是半句话。我一般先统计一下数据长度分布再定硬编码一个值容易踩坑。python -c import json lens [] for line in open(train_chat.jsonl): item json.loads(line) lens.append(len(item[text])) lens.sort() print(p50:, lens[len(lens)//2], p90:, lens[int(len(lens)*0.9)], max:, lens[-1]) p90 到 max 之间断崖式拉长的话说明有极少数超长样本直接截断比硬撑大 max_length 划算。数据量上做风格迁移类任务 300~500 条高质量样本就能看到明显变化做指令跟随类任务建议 2000 条起步。加 500 条低质量噪声数据不如先花时间修掉 50 条 badcase微调阶段数据质量对效果的影响比数量大得多。4. LoRA 训练参数配置rank、alpha、学习率的完整可抄作业脚本数据准备好之后核心就是 LoRA 的配置和训练参数。这一章给出一套可以直接照抄的配置并解释每个参数为什么这么设、改哪里会影响什么。4.1 目标模块ChatGLM3 里没有 q_proj只有 query_key_value用惯了 LLaMA 的工程师第一次配 ChatGLM 的 LoRA最容易踩的坑是target_modules写错。LLaMA 的 attention 结构是分开的q_proj、k_proj、v_proj、o_proj而 ChatGLM3 是 GLM 结构QKV 是合并在一个query_key_value层里的。如果照抄 LLaMA 的配置peft 会直接报 key 不匹配或者静默不生效。网络结构常见 target_modules 配置LLaMA 系[q_proj, v_proj]或加k_proj, o_projChatGLM3 系[query_key_value, dense]LoRA 的核心配置就三样r是低秩矩阵的秩lora_alpha是缩放系数target_modules决定往哪些层插 adapter。最小可用的 LoRA 配置如下from peft import LoraConfig, TaskType lora_config LoraConfig( r8, lora_alpha16, target_modules[query_key_value, dense], task_typeTaskType.CAUSAL_LM, lora_dropout0.05, biasnone, )r8是性价比最高的起点显存压力小对大部分任务都够用。想提升拟合能力可以试r16但 r 越大越接近全参微调过拟合风险也跟着涨。lora_alpha16一般取 r 的两倍这是 LoRA 论文和社区实践中验证过的合理默认。bias 保持none不动把 bias 也设成可训练会无谓增加参数量。4.2 训练脚本Trainer 怎么配最省心完整训练脚本如下适配前面转好的train_chat.jsonl。代码是按单卡 24GB 显存写的直接跑就行。import json import torch from torch.utils.data import Dataset from transformers import ( AutoModel, AutoTokenizer, BitsAndBytesConfig, TrainingArguments, Trainer ) from peft import LoraConfig, get_peft_model from transformers import DataCollatorForSeq2Seq class ChatDataset(Dataset): def __init__(self, path, tokenizer, max_length1024): self.data [] for line in open(path, encodingutf-8): item json.loads(line) enc tokenizer( item[text], truncationTrue, max_lengthmax_length, return_tensorspt, ) labels enc[input_ids].clone() labels[labels tokenizer.pad_token_id] -100 self.data.append({ input_ids: enc[input_ids][0], attention_mask: enc[attention_mask][0], labels: labels[0], }) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx] train_dataset ChatDataset(train_chat.jsonl, tokenizer, max_length1024) training_args TrainingArguments( output_dirlora_checkpoints, num_train_epochs3, per_device_train_batch_size1, gradient_accumulation_steps8, learning_rate2e-4, warmup_steps100, logging_steps20, save_steps500, save_total_limit2, fp16True, gradient_checkpointingTrue, dataloader_num_workers4, remove_unused_columnsFalse, report_tonone, ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, data_collatorDataCollatorForSeq2Seq(tokenizer, paddingTrue), ) trainer.train()这套脚本的核心权衡在显存和 batch 之间。per_device_train_batch_size1是因为 6B 模型即使 4bit 量化单卡也扛不住大 batchgradient_accumulation_steps8用梯度累积凑出等效 batch size 8既保住训练稳定性又没增加显存压力。learning_rate2e-4是 LoRA 微调的常见起点比全参微调的 1e-5 高一个数量级因为低秩矩阵要学的东西更少步子可以迈大一点。gradient_checkpointingTrue会牺牲一点训练速度换取显存显存吃紧时这是性价比最高的开关。remove_unused_columnsFalse必须保留否则 Trainer 会尝试删除数据集的非模型输入列导致和自定义 Dataset 结构冲突。这里的关键参数含义都在下表里参数值作用per_device_train_batch_size1每步每卡样本数6B 模型显存瓶颈下的合理值gradient_accumulation_steps8累积 8 步再更新等效 batch size 8稳定训练learning_rate2e-4LoRA 常用学习率比全参微调高约一个数量级warmup_steps100前 100 步学习率线性上升避免开局震荡fp16True混合精度训练显存减半且速度更快gradient_checkpointingTrue用少量计算换显存24G 卡跑 6B 的刚需4.3 训练中的数值观察loss 掉到多少才算有效训练跑起来之后别只看 loss 的绝对值。ChatGLM3 在对话数据上初始 loss 通常在 1.5 到 2.0 之间降到 1.0 附近已经算有明显变化如果只是从 1.6 降到 1.4效果未必能体现在生成结果上。更实用的做法是记录初始 loss 和训练结束的 loss算下降幅度有没有超过 30%。训练日志大概长这样关注的是曲线的下降趋势不是某个单点值{loss: 1.6321, learning_rate: 3.2e-05, epoch: 0.02} {loss: 1.4102, learning_rate: 8.1e-05, epoch: 0.04} {loss: 1.1187, learning_rate: 1.3e-04, epoch: 0.06}如果中途中断trainer.train(resume_from_checkpointTrue)可以直接从最近的 checkpoint 续训save 策略里save_total_limit2保证只保留最近两个 checkpoint不占满磁盘。5. LoRA 微调避坑实录显存、loss 不降、复读机五个翻车现场下面这些坑是我和同事在实际跑 LoRA 微调时一个个踩出来的。每一条都按「现象 → 原因 → 解决」写血泪经验能避就避。5.1 加载即 OOM量化开了却像没开现象显存明明还剩 20GB加载模型照样抛CUDA out of memory。原因大概率是load_in_4bit根本没生效常见两种情况——bitsandbytes 版本太老不认识新参数或者加载后代码里又做了一次.float()/.half()转换把量化层的 dtype 改了显存直接翻倍。解决加载时显式传quantization_configbnb_config不要依赖load_in_4bitTrue这种隐式写法加载后不要对 model 做任何 dtype 转换再用print(model.hf_device_map)确认所有层都在 GPU 上。5.2 loss 卡住不动几百步了还在 1.5 附近横跳现象训练日志里 loss 从 1.6 降到 1.5 后就不动了后续几百步纹丝不动。原因我遇到过一次是 transformers 版本升到 4.40 后modeling_chatglm.py里的某个层被新 API 静默跳过参数根本没进计算图另一次是数据里 assistant 段结尾漏了endoftext模型学习的标签序列是错位的。解决先锁 transformers 4.36.x排除版本因素再检查转换后的数据文本每段结尾都有endoftext最后打印model.print_trainable_parameters()确认只有 LoRA 参数是trainableTrue如果看到底座参数也在训练那 config 有问题。5.3 微调完变成复读机同一个回答循环输出现象生成测试时模型反复输出同一句话或者输出内容完全不在目标任务范围内。原因最常见的是推理时没有走合并逻辑还在用基座权重前向或者验证脚本里加载了旧的generation_config温度参数被设成接近 0模型退化成贪心解码自然容易复读。温度太低时采样几乎退化成 argmax生成的多样性骤减。解决先按第 6 章的流程合并权重再验证生成效果生成参数用temperature0.8, top_p0.9, do_sampleTrue这类值对中文对话模型比较友好。5.4 训练奇慢GPU 利用率不到 30%现象nvidia-smi显示显存占用正常但 GPU 利用率只有 20%~30%功耗也上不去。原因数据在训练过程中实时做 tokenize每次取样本都在拼 prompt或者dataloader_num_workers0数据加载没有并行整个训练流程被 CPU 侧拖住。解决数据预处理阶段就离线 tokenize 成input_ids和labels训练时只做索引和 paddataloader_num_workers4起步显存有余量再加pin_memoryTrue。5.5 换了一张卡跑崩bitsandbytes 和显卡架构不匹配现象同一套代码在自己卡上正常换到另一张卡直接报Illegal instruction或 CUDA error。原因bitsandbytes 的 4bit kernel 是针对特定架构编译的老版本对新显卡架构兼容性差。我踩过的是在同一环境换 40 系到新架构卡后加载量化模型就崩。解决更新 bitsandbytes 到匹配新架构的版本如果还不行退回 8bit 量化加载或者直接用 fp16 微调。换卡之后先跑一遍第 2 章的加载脚本确认能通过再开长任务训练别让训练跑一半才暴露问题。6. 权重合并与效果验证微调有没有白跑用三个问题说话6.1 合并与保存Adapter 单文件 vs 合并后的完整权重LoRA 训练完产出的是 adapter 权重体量很小通常在几十 MB 量级。推理前需要把它合并回基座权重或者用PeftModel动态加载。两种做法各有适用场景只存 adapter 适合继续调参、方便分发合并成完整权重适合直接部署省去推理时多一次加载。from peft import PeftModel model AutoModel.from_pretrained( base_model_path, trust_remote_codeTrue, torch_dtypetorch.float16, device_mapauto, ) model PeftModel.from_pretrained(model, lora_checkpoints) model model.merge_and_unload() model.save_pretrained(merged_model) tokenizer AutoTokenizer.from_pretrained(base_model_path, trust_remote_codeTrue) tokenizer.save_pretrained(merged_model)merge_and_unload()会把 LoRA 的权重加回原来的层并卸载 adapter。注意合并后用save_pretrained保存完整权重时一定要把 tokenizer 一起 save 到同一个目录否则部署时还得单独传 tokenizer 路径。6.2 三个验证问题拿基座和微调后各跑一遍微调有没有白跑不能只看 loss。我习惯固定三个验证问题基座和微调后模型各回答一遍对比看行为有没有朝目标方向偏移。验证脚本如下model.eval() with torch.no_grad(): query 我的快递三天没物流信息了怎么办 inputs tokenizer.build_chat_input(query, history[]) gen_kwargs dict(max_new_tokens256, temperature0.8, top_p0.9, do_sampleTrue) outputs model.generate(**inputs, **gen_kwargs) answer tokenizer.decode(outputs[0][inputs[input_ids].shape[1]:], skip_special_tokensTrue) print(answer)build_chat_input是 ChatGLM3 tokenizer 的专用方法会自动把user等特殊 token 拼好。inputs[input_ids].shape[1]用来截掉输入部分只解码新生成的 token。对比结果记录在下面的表里一眼就能看出微调前后差异验证问题基座输出LoRA 微调后输出是否命中目标快递物流问题建议联系客服直接说明已提交加急、给时间预期命中退换货政策问题通用话术按本店政策分情况回答部分命中闲聊话题正常闲聊主动引导回业务话题命中验证不过关时先回去查数据而不是调学习率——大概率是数据里目标风格的样本占比不够或者 assistant 答案本身噪声太大。从那以后我每次微调完都强制自己走一遍合并脚本、再用这三个问题过一遍基座和微调模型的对比不过关就回去清理数据而不是反复调学习率碰运气。这套流程看着笨但每次都能在最短时间内判断出微调到底有没有价值。希望帮到你。本文还有配套的精品资源点击获取
