简介:面向大模型应用开发者的LLaMA快速微调实战项目,聚焦如何利用预训练语言模型完成问答、文本生成、机器翻译等具体任务的二次训练。内容覆盖环境设置、数据准备、模型加载、微调配置、模型训练、验证与测试、保存部署的完整链路,源码内置训练脚本、数据处理函数与模型配置文件,并有逐步引导的教程说明。资源包共340个文件,以Python脚本、JSONL数据、Markdown教程、Shell脚本为主,同时包含模型权重(pt)、JSON/YAML配置及少量音频图片样例,整体约31.92MB,目录结构清晰,便于按模块检索。目前已有820人学习下载,适合希望快速上手大模型微调的研究人员、算法工程师及高年级学生。通过该项目可掌握数据预处理、超参数调节、训练监控与排错要点,积累实战经验,为后续业务场景中的模型优化提供参考。
1. 快速微调 LLaMA:先想明白“快速”两个字
如果你手里拿到一份“大模型微调-快速微调LLaMA实现”的项目包,第一反应通常是找训练脚本、拉满显存、跑一晚上看 loss。但真正有价值的不是“跑通”,而是你能否在短时间内把一个通用模型变成能回答领域问题的模型。很多初学者以为微调是给模型灌输新知识,其实多数场景只是在修正回答顺序和表达习惯,想明白这点比调参更关键。快速微调 LLaMA 的“快速”,指的就是尽量少的数据、尽量低的算力、尽量短的时间,完成一次能拿到业务效果的方向修正。常见做法是自写一套 PEFT 训练流程,或借助 LlamaFactory 这类集成工具完成环境配置、模型微调、模型部署和效果展示,两条路各有利弊。下面这套流程是我在单卡上反复跑过的顺序,照着走一遍,能让你少踩两三天坑。
2. 微调选型:为什么 LoRA 是快速微调 LLaMA 的默认答案
“微调”这个词听上去简单,但第一次动手的人通常会面临一个选择:全量微调、LoRA、还是 QLoRA。这个选择直接决定你对显存和数据量的诉求,也决定翻车概率。先算账,再选路,是快速微调的第一课。
2.1 全量微调、LoRA 与 QLoRA:算力账与效果账
全量微调的意思是整个 7B 模型的全部权重都参与梯度更新。对 7B 模型来说,即使用 FP16 存储权重,仅模型本身就要 14GB,梯度还要 14GB,Adam 优化器的动量要再吃掉约 28GB,激活值和中间变量另算。也就是说,全量微调一个 7B 模型,40GB 显存都会很紧张,13B 以上基本要依赖多卡或 CPU offload。这也是为什么“单卡快速微调”这条路里,全量微调天然出局。
LoRA 的原则很朴素:不动原始权重,而是在 Transformer 层的 Q、K、V、O 投影边上挂一组低秩矩阵 B 和 A,训练时只更新这组小矩阵。对 7B 模型,可训练参数往往只有 0.1%~1%。这带来的好处不只是训练参数少、优化器状态只剩几十兆,更关键的是 LoRA 天然自带“隔离”作用,不会把原始模型的通用能力搅乱,比全量微调更不容易出现灾难性遗忘。
QLoRA 在 LoRA 基础上再往前一步:把底座模型的权重先量化到 4bit,以 NF4 格式存在显存里,训练时只对 LoRA 部分反量化计算。这样同样一张 24GB 显卡,LoRA 只能勉强装下 7B,QLoRA 可以轻松处理 7B,并留出余量去调序列长度和批大小。从效果上看,QLoRA 和 LoRA 的差距通常小于 1 个百分点,对绝大多数业务场景完全可以忽略。
这里有个比较直观的理解方式:大模型在预训练阶段已经学会了语言、逻辑和大量常识,微调并不需要推倒重来,而是在原有参数附近搜索一个更适合业务方向的局部解。LoRA 相当于限定模型“只能在一个低维子空间里移动”,这个限制乍一听像是约束,实际上却是最好的正则,能有效挡住训练数据里那些噪声样本带来的越界更新。这也是为什么小数据量下 LoRA 比全量微调更稳。
我把三个方向的差别整理成一张表,方便你对照自己的资源做决定。
| 对比维度 | 全量微调 | LoRA | QLoRA |
|---|---|---|---|
| 可训练参数占比 | 100% | 0.1%~1% | 0.1%~1% |
| 7B 最低显存参考 | 约 40GB 以上 | 24GB 左右 | 12GB~16GB |
| 训练速度 | 慢 | 快 | 比 LoRA 慢一点 |
| 最小数据量建议 | 5 万条+ | 1 千条+ | 1 千条+ |
| 通用能力保留 | 容易遗忘 | 较好 | 较好 |
| 典型适用场景 | 数据充足、领域差异大 | 单卡快速迭代 | 消费级显卡/小显存 |
看到这里不要急着无脑选 QLoRA。我一般会先看手里数据量:少于 1 万条,直接 QLoRA 起步;超过 5 万条且预算允许,才值得考虑 LoRA 甚至全量微调。数据量不够的时候,选更重的方案只是把过拟合和灾难性遗忘的风险加倍,并不会让业务指标变好。
2.2 快速微调的硬件与显存估值:24G 显存到底够不够用
显存够不够,不是看显卡标称多少 G,而是要一条一条算。以 QLoRA 训练 7B 模型为例,底座权重用 4bit 量化后大概占 4GB,LoRA 参数和优化器状态加起来不到 0.5GB,剩下的开销集中在激活值上,而激活值和批大小、序列长度强相关。一个粗略经验是:24GB 显卡上,7B 模型配 2048 序列长度、batch size 设为 4,通常能压在 20GB 以内跑起来;如果序列长度拉到 4096,batch size 就得降到 2。
你可以按这个表快速毛估:7B 模型 QLoRA、序列长度 2048、batch size 2 时,显存占用大约在 14~18GB 之间;序列长度 4096 时,占用会跳到 20~24GB。如果加入 gradient checkpointing,显存还会再降 20% 左右,代价是每个 step 多走一次反向传播,训练速度变慢 10%~20%。这是典型的拿时间换显存,适合 12GB 用户。
如果你手里的显卡只有 12GB,也不是完全不能做,但要学会“压缩到极限”:batch size 设成 1,配合 gradient_accumulation_steps 凑够等效批大小;序列长度控制在 1024 以内;训练层数只挂 q_proj 和 v_proj 而不是四个投影层;如果还不行,就在 4bit 量化基础上再加 CPU offload,把不参与计算的权重先放到内存里。当然,offload 的量一旦超过几百 MB,训练速度会明显下降,所以只建议当作兜底方案。
还有一个小细节:很多初学者分不清“模型大小”和“显存占用”。7B 模型在未量化的 FP16 下权重文件是 14GB,这不是显存占用,而是模型体积。量化成 4bit 后大约 4GB,运行时的显存占用还要叠加 KV cache 和激活值。因此,看到别人说“24G 能跑 30B QLoRA”时,那通常是把序列长度调得很短、batch size 压到 1 的结果,并不代表你的业务场景也适用。
2.3 用 LlamaFactory 还是自己写训练脚本
做快速微调时,我见过两类人:一类坚持只用自己写的脚本,结果在 DataLoader 和自定义 loss 上花了两天;另一类只点 LlamaFactory 的 Web UI,数据一换就报错,完全不知道哪里出问题。我的判断是:第一轮做 demo,直接上 LlamaFactory 是性价比最高的选择,它把环境配置、模型微调、模型部署、效果展示整个流程都收在统一的配置里,对新手友好。
但如果你打算把它用在生产环境,或者需要改 prompt 格式和采样策略,我建议至少手写一遍 PEFT 训练脚本。原因很简单:LlamaFactory 把很多细节藏起来了,比如训练数据格式、attention mask 的处理、标签是否对 padding 位置做了掩码,这些一旦出问题,它就成了黑匣子,你只能删掉整个实验重来。反过来,当你自己写过一遍数据整理和训练调用,再去看 LlamaFactory 的配置项,很多参数一眼就能对应上,排错时的感觉完全不一样。
另外,不管用哪种方式,数据的格式和 tokenizer 处理都必须自己先过一遍。我见过直接把中文 csv 喂给 LlamaFactory 然后一顿操作跑完,结果模型输出大量“undefined”的情况,原因是输入列名对不上模板字段,框架把整行数据当成一个字符串塞进了 prompt,模型只能硬编。这种情况和训练参数没关系,纯粹是数据入口没把关。所以下面提供一个按场景选择路径的参考:
| 情况 | 建议路径 |
|---|---|
| 数据少于 5000,快速验证 | QLoRA + LlamaFactory |
| 数据 5000~50000,需要定制逻辑 | QLoRA/LoRA + 自写脚本 |
| 数据大于 50000,算力充足 | LoRA 或全量微调 |
先说明结论:如果你的项目包里带了现成源码,不要盲目替换,先把数据格式看明白再跑;如果包里只有流程教程,按下一章的步骤自己写一份训练流水线,这套功夫不会被浪费。用同一份数据,写脚本和用框架的最终效果不会差太多,但前者会让你具备调优的能力。
3. 从数据到训练脚本:在单卡上跑通 LLaMA 微调的最小闭环
选型定了,接下来就是动手阶段。这个闭环由四段构成:环境准备、数据整理、训练启动、权重合并。按顺序走,一路踩到坑也知道往哪躲。
3.1 准备环境:依赖清单与版本组合
快速微调最怕的不是模型大,而是 Python 依赖互相打架。我自己的习惯是先用 conda 新建一个干净环境,再统一装下面的依赖。以 Python 3.10 为例,一套组合是:
conda create -n llm-finetune python=3.10 -y conda activate llm-finetune pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.38.2 datasets accelerate peft==0.8.2 bitsandbytes第一行把 PyTorch 装成适配 CUDA 11.8 的预编译版本,第二行的 transformers 必须大于等于 4.36,否则对 4bit 量化和 attention mask 的支持会差很多。peft 版本我固定在 0.8.2 附近,bitsandbytes 建议装最新版,因为 4bit 的反量化算子一直在修 bug,旧版本在一些新显卡上直接报 CUDA error 的概率很高。
装完之后,先别急着往下走,用下面这条命令确认 PyTorch 和显卡驱动真的通上了:
python -c "import torch; print(torch.cuda.is_available()); print(torch.__version__)"输出应该看到True和一个带+cu118后缀的版本号。如果看到False,大概率是 torch 装成了 CPU 版,或者 conda 环境里残留了旧版本。先把环境删掉重装,不要强行继续,否则后面训练时每一步都可能出幺蛾子。这一步花五分钟,能帮你省出后面两小时。
3.2 把业务数据整理成指令微调格式
数据格式是 LoRA 微调最容易被低估的环节。LLaMA 家族模型的指令微调,通常把一条样本组织成“指令、输入、输出”三个字段,Alpaca 格式是其中最常见的一种。下面是一条消防设备维保领域的示例:
{ "instruction": "消防泵启动后压力表无读数,可能是什么原因?", "input": "设备型号:XBD7.0/10G-L,工频启动后读数为零", "output": "先检查压力表是否损坏,排除后重点查泵内进水是否充足、叶轮是否堵塞、出口阀门是否打开。" }如果你手里的原始数据是几百行二维表,比如“问题、背景、答案”,可以直接用脚本转成上面的 JSON 格式。我一般会顺手做两件事:一是统一角色标记,给每条样本加系统提示词;二是做训练集和验证集的切分,按 9:1 随机切分,避免模型在验证阶段“背答案”。
import json, random, pandas as pd # 假设原始数据是 Excel 或 CSV,列名为 question, context, answer df = pd.read_csv("domain_data.csv") random.seed(42) train, valid = [], [] for _, row in df.iterrows(): item = { "instruction": row["question"].strip(), "input": row["context"].strip(), "output": row["answer"].strip(), } if random.random() < 0.9: train.append(item) else: valid.append(item) with open("train.json", "w", encoding="utf-8") as f: json.dump(train, f, ensure_ascii=False, indent=2) with open("valid.json", "w", encoding="utf-8") as f: json.dump(valid, f, ensure_ascii=False, indent=2) print(f"train={len(train)}, valid={len(valid)}")这段脚本的逻辑很简单,但有三个细节值得说明。ensure_ascii=False保证中文能被直接读出来,而不是被转成\uXXXX序列;strip()清理首尾空格,能避免模型把换行符当正文学进去;随机种子固定为 42 是为了复现实验结果,否则每次切分不同,模型效果对比就没了基准。
还要注意一个实际经验:如果原始问答里包含表格、图片、超长日志,不要直接塞进input字段。LLaMA 的分词器对超长文本的处理质量有限,序列长度一上去训练速度就断崖式下跌。超过 2048 字符的样本,我通常会拆成多条短样本,或者先做字段精简,把“答案”里的格式模板保留,把无意义的口水话去掉。
3.3 核心训练脚本与关键参数说明
环境有了,数据有了,接着就是训练脚本。下面这段脚本是我在 24GB 显存单卡上跑 7B QLoRA 的最小可运行版本,代码里每一步都标注了用途:
import torch from transformers import ( AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer ) from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training from datasets import load_dataset model_path = "your_local_path/llama-2-7b-hf" # 也可换成 qwen2.5-7b 等开源模型 tokenizer = AutoTokenizer.from_pretrained(model_path) tokenizer.pad_token = tokenizer.eos_token # LLaMA 没有 pad_token,必须手动指定 model = AutoModelForCausalLM.from_pretrained( model_path, load_in_4bit=True, # 4bit 量化,显存占用骤降 torch_dtype=torch.bfloat16, device_map="auto", use_cache=False, # 训练时关闭 KV cache,省显存 ) model = prepare_model_for_kbit_training(model) # 冻结原权重,为量化准备 lora_config = LoraConfig( r=8, # 低秩矩阵的秩 lora_alpha=32, # 缩放系数,一般取 r 的 2~4 倍 lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # 确认可训练参数占比 def format_fn(examples): texts = [] for instruction, input_text, output in zip( examples["instruction"], examples["input"], examples["output"] ): prompt = f"### 指令:{instruction}\n### 输入:{input_text}\n### 回复:" texts.append(prompt + output + tokenizer.eos_token) return {"text": texts} dataset = load_dataset("json", data_files={"train": "train.json", "validation": "valid.json"}) tokenized_dataset = dataset.map( lambda x: tokenizer(format_fn(x)["text"], truncation=True, max_length=2048, padding=False), batched=True, remove_columns=dataset["train"].column_names, ) training_args = TrainingArguments( output_dir="./lora-checkpoint", per_device_train_batch_size=2, gradient_accumulation_steps=8, num_train_epochs=3, learning_rate=2e-4, bf16=True, logging_steps=20, save_steps=200, save_total_limit=2, evaluation_strategy="steps", eval_steps=200, ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset["train"], eval_dataset=tokenized_dataset["validation"], ) trainer.train()先把逻辑说清楚:加载模型时用load_in_4bit=True完成量化,prepare_model_for_kbit_training会冻结模型权重并为低秩适配器腾出训练位置,get_peft_model把可训练矩阵挂进去。数据侧,format_fn把每一条样本拼成完整的输入输出文本,再统一做分词和截断。之后所有逻辑交给 HuggingFace Trainer 处理。
参数选择上,r=8是 LoRA 的秩,秩越高能学到的信息越多,但随之而来过拟合和显存占用也越高。lora_alpha=32是缩放系数,实际生效的缩放比例大致是alpha / r = 4,这个比例不宜调得太大,否则模型权重更新过猛,输出会变得暴躁。learning_rate=2e-4是经验值,对 7B 模型来说,超过 5e-4 很容易在几百步内把 loss 打到 NaN。batch_size=2加gradient_accumulation_steps=8等效出了全局批大小 16,这个数值偏保守,但胜在稳定。
注意:训练前建议先打印两三条 tokenized_dataset 样本,确认 prompt 模板和标签是否拼接正确。这一步能拦住大部分“loss 正常但输出乱码”的问题,不要跳过。
训练过程中的 loss 曲线不用过分在意绝对值,重点看验证集 loss 是否随训练步数同步下降。如果训练 loss 一路降、验证 loss 却突然抬头,说明模型开始死记硬背训练集了,可以提前终止训练。
3.4 训练完合并 LoRA 权重:保存与加载
训练结束后,训练器默认保存的是 LoRA 适配器权重,训练产物体积只有几百 MB。但部署和后续推理时,每次都要先加载底座模型再加载适配器,既麻烦又容易出维度错误。所以我把“合并权重”当成标准动作:
import torch from transformers import AutoModelForCausalLM from peft import PeftModel base_model_path = "your_local_path/llama-2-7b-hf" lora_path = "./lora-checkpoint/checkpoint-xxx" # 按实际保存步数修改 base_model = AutoModelForCausalLM.from_pretrained( base_model_path, torch_dtype=torch.bfloat16, device_map="auto", ) merged_model = PeftModel.from_pretrained(base_model, lora_path).merge_and_unload() merged_model.save_pretrained("./merged-model")这段脚本的关键在merge_and_unload(),它会把 LoRA 的低秩矩阵和底座权重现场做矩阵加法,拼成一个完整的可用模型。保存后,./merged-model的体积和底座模型一样大,可以直接交给后续部署脚本或量化工具处理。注意加载时要用和训练时一致的torch_dtype,否则合并出来的权重在精度上会和训练时有细微偏差。
有几点我得提醒你。合并前一定要确认lora_path指向的那个 checkpoint 是你手动挑选的结果,而不是最后一个自动保存点。自动保存的 checkpoint 偶尔会掉在过拟合段,效果往往不是最好的。另外,不要直接在原目录执行merge_and_unload后又保存回原目录,先把合并结果存成一个新目录,避免覆盖原始权重。
4. 效果评估:微调完的 LLaMA 是变好了还是变傻了
训练跑完,训练日志里显示 loss 降到了 0.8,这不能说明模型合格。LLaMA 这类生成模型在训练集上把 loss 压到很低很容易,真正难的是面对没见过的业务问题时仍然稳定。下面这套评估流程,我每次都会跑一遍。
4.1 面向任务的自动化评估:用留出集算分
自动评估的第一步是先把验证集捡回来。前面切分数据时留出的 10% 验证集,就是用来回答“模型是否学到了规律”的。最省事的办法是复用 Trainer 的 evaluate 方法,看一下预测 loss:
trainer.evaluate(eval_dataset=tokenized_dataset["validation"])这个 evaluate 会输出 eval_loss 和 eval_runtime 两个字段,eval_loss 越低说明模型在验证集上的困惑度越小。但千万别只盯着它看,它反映的是语言建模层面的概率,不能直接代表业务正确率。比如消防维保场景里,模型把“检查出口阀门是否打开”说成“检查进口阀门是否打开”,loss 可能相差无几,但业务上完全不可接受。
对于生成任务,我可以按类别手工定义规则。以“故障排查建议”任务为例,把答案里必须出现的领域关键词抽出来,比如“叶轮”“压力表”“出水阀”“排气阀”,然后让模型生成答案,用关键词命中率来衡量质量:
import json from transformers import AutoModelForCausalLM, AutoTokenizer model_path = "./merged-model" tokenizer = AutoTokenizer.from_pretrained(model_path) model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.bfloat16, device_map="auto") def generate_reply(prompt, max_new_tokens=256): inputs = tokenizer(prompt, return_tensors="pt").to("cuda") outputs = model.generate( **inputs, max_new_tokens=max_new_tokens, do_sample=False, repetition_penalty=1.05, ) return tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True) def keyword_hit_rate(sample, reply): needed = sample["keywords"] # 验证集里预先标注好期望关键词 hit = sum(1 for k in needed if k in reply) return hit / len(needed) with open("valid.json", "r", encoding="utf-8") as f: valid_data = json.load(f) total = 0 for sample in valid_data[:50]: # 先跑 50 条观察分布 prompt = f"### 指令:{sample['instruction']}\n### 输入:{sample['input']}\n### 回复:" reply = generate_reply(prompt) total += keyword_hit_rate(sample, reply) print(f"average keyword hit rate: {total / 50:.2f}")这个脚本的意义不是给你一个终极指标,而是当你改动数据或调整参数后,能拿同一套规则回头对比。关键词命中率的绝对值高低不重要,重要的是它在多次实验之间如何变化。如果模型把所有领域词汇都堆在回答里,这个指标会虚高,所以我会额外人工抽查,而不是只看一个分数。注意do_sample=False是故意关掉采样的,这样同一份 prompt 每次生成都完全一样,评估才可复现。
4.2 面向业务的评审:微调前后对比与坏例收集
自动分数筛掉明显不合格的版本后,剩下的要靠人来验收。我会把微调前的基础模型和微调后的模型放在同一个业务测试集上对比,专门挑 10 个高频真实场景,逐一观察两个模型的回答差异。这步虽然费时,却是快速微调实践里最容易被跳过、也最致命的一环。
在做微调前后对比时,我习惯把两个模型的输出都折叠到标准格式里,再让一个更懂业务的同事盲评。盲评很重要,因为只要你心里有预期,就很容易给微调后的版本打高分。每次盲评的对照表保留下来,下一次调参后直接覆盖同一张表,进步和退步都一目了然。
一个典型的对比表格长这样:
| 场景 | 基础模型回答 | 微调后回答 | 结论 |
|---|---|---|---|
| 消防泵压力表无读数 | 先检查电源是否正常 | 先确认压力表是否损坏,再排查泵内进水和叶轮 | 可用 |
| 喷淋系统末端试水压力偏低 | 建议更换水泵 | 先放水观察压力变化,检查管路是否有气锁 | 需人工复核 |
对比做多了会总结出一个规律:基础模型不是不会说行业术语,而是不知道故障排检的优先级。微调后的提升往往体现在“把正确的检查顺序说出来”,而不是记住更多名词。如果发现微调后模型连基础知识都答错了,那不是数据量的问题,需要回到上一章检查数据里是否有大量噪声或标签错误。
每次评审时,记得把“坏例”单独收集到一个 json 文件里。这些坏例是后续迭代最有用的素材,不要只在脑子里留个印象。整理坏例时记录四个字段:原始 prompt、模型回答、期望回答、你觉得模型错在哪。下一轮微调时,把坏例按一定比例混入训练集,模型很快就能修正同类问题。这比加几千条新数据有效,坏例就是留给自己的后悔药。
4.3 评估失败的定位:从数据、训练参数到模型版本
要是新模型在评估集上的表现不如上一版,别急着改数据,先按“数据 → 训练 → 推理”的顺序排查。数据侧看两处:验证集是不是和训练集有重叠,重叠会让评估结果虚高;prompt 模板在训练和推理时是否一致,比如训练时“### 回复:”后面直接跟答案,推理时却多打了一个换行,效果会肉眼可见地下降。
训练侧主要看可训练参数规模和步数。LoRA 秩从 8 提到 32 后,通常需要同步降低学习率,否则模型在训练后期容易振荡。推理侧则要看生成参数,do_sample=True和temperature=0.9会引入随机性,两次评估结果相差大时,请先固定随机种子和采样策略。
如果以上排查都做了还是退步,还有一个很现实的原因:新版本数据里混入了格式不整齐的样本。比如有些训练样本的“输出”字段里带着“答:”、“回复:”这类前缀,模型会把这种前缀当成回复部分学进去,推理时就会多输出一段无意义引导语。这类问题在自动评分里很难发现,只能靠人工看坏例来定位。
5. 微调 LLaMA 踩坑记录:五个高频问题与排查步骤
做快速微调时踩过的坑,比微调本身教给我的更多。下面这五个问题出现的频率最高,每条都按“现象 → 原因 → 解决”来拆,希望能替你把路铺平一点。
5.1 训练中 loss 变成 NaN 或突然冲上天
现象:训练到第 200 步左右,日志里 loss 从 1.2 变成nan,之后无法恢复。
原因:最常见的是学习率过大。为了让模型快速收敛,把 learning_rate 设成 5e-4 甚至 1e-3,结果 7B 模型的更新步长太大,梯度爆炸后数值直接溢出。另外,旧显卡跑 bf16 时如果底层实现不够稳,也可能在长时间训练后出现精度异常。还有一类隐蔽原因是数据里有超长文本,截断后标签全部落到 padding 上,导致损失计算出现除零。
解决:先把学习率降到 2e-4,并在 TrainingArguments 里加gradient_clip_val=1.0。再把数据里超过 1536 字的样本筛出来人工看一眼,确认output字段非空且不是一句废话。如果问题依旧,跑一版纯 FP16 训练做对照,确认是不是 bf16 的锅。按这个顺序排查,绝大多数 loss 翻车都能被定位到。
5.2 loss 降了但业务指标没涨:LoRA 配置与数据的锅
现象:训练结束时训练 loss 掉到 0.6,但跑到验证集上,领域问题的回答和基础模型差别不大,该给的操作步骤还是错的。
原因:这个现象太典型了。一是 LoRA 的秩设置过低,r=4只能学到表层措辞变化,学不会领域知识;二是训练数据里“指令”的描述太单一,比如所有问题都长一个样,模型只记住了模板;三是数据集规模不足,几百条样本还编不出稳定的映射关系。
解决:把r提到 16 或 32,同时把lora_alpha提到 64,效果会更明显。数据侧,把训练样本里的“输入”字段扩一下,哪怕同一条答案配上 3 种不同的问题表述,也比硬凑 3000 条同质数据有用。还有一个细节:检查训练数据里是否大量重复了同一条业务样例,重复样本权重过大会把 LoRA 的更新方向带偏。
5.3 QLoRA 量化工具在启动时报错
现象:脚本一跑起来,控制台报bitsandbytes相关的 CUDA error,或者在from_pretrained里直接提示 unknown quantization layer。
原因:绝大多数时候是 bitsandbytes 的预编译扩展和本机 CUDA 版本不匹配。比如机器里 CUDA 12.1,但 torch 装的是 cu118,bitsandbytes 加载时找不到对应算子。还有一类是 transformers 版本过旧,对 4bit 反量化层的解析方式还在更新,旧版本不兼容新轮子。
解决:先用pip install -U bitsandbytes升级到最新;然后确认torch.version.cuda输出与 PyTorch 编译时的 CUDA 版本一致。如果不一致,要么把 torch 换成匹配的版本,要么在环境变量里指定兼容模式。这类问题几乎都是环境问题,和模型本身没有关系,别反复删 checkpoint,先把 CUDA 版本对齐。
5.4 推理时输出全是重复文本
现象:模型生成“好的,我可以帮您检查消防泵压力表。可以帮您检查消防泵压力表。可以帮您检查消防泵压力表……”然后死循环。
原因:两个原因都常见。一是训练阶段没有对 padding token 做掩码,模型学到可以用 padding 区域生成无意义内容;二是推理时把max_new_tokens设得很大,而repetition_penalty又没开,模型在编不出内容时就不断复读自己刚输出的片段。前者是训练 bug,后者是推理参数问题。
解决:训练侧在分词后,把labels中 padding 位置设为 -100,让损失函数忽略这些位置;推理侧设置repetition_penalty=1.1,并且把do_sample设为 False,或者把 temperature 调低到 0.3 以下。改掉这两个点之后,复读问题基本能根除。
5.5 合并后的模型和训练时行为不一致
现象:训练时验证集上回答质量不错,但把 LoRA 合并回底座模型后,跑同一个 prompt 得到的回答变得很差,甚至多出前导符号。
原因:合并脚本和训练脚本在精度上不一致。训练时用的是 4bit 量化底座加 LoRA,合并时如果用 FP32 去加载底座权重,低秩矩阵叠加后数值分布发生轻微偏移;还有一类是tokenizer.decode时没有跳过特殊 token,导致输出前面挂着<s>和###这些符号。
解决:合并时用和训练完全相同的加载方式,保持torch_dtype=torch.bfloat16,合并前先打印一遍model.dtype确认。合并后做一次对照测试,用同一批 10 条 prompt 分别跑训练时的 checkpoint 和合并保存后的模型,如果输出不一致再回来查加载参数。这一步能救回你至少一个下午。
6. 把微调成果接进业务:部署验证与量化取舍
微调完成后,业务方要的不是 checkpoint 文件,而是一个能对话的接口或一个能跑在边缘设备上的模型。这最后一段路,重点是验证和取舍。
如果显卡资源允许,最简单的部署是直接加载合并后的模型,用 FastAPI 包一层。脚本逻辑很简单:启动时加载模型,收到请求后拼 prompt 调generate,完成后返回文本。这样做的优点是零转换成本,缺点是 7B 模型在推理时仍要占 14GB 显存,如果有并发请求还得靠排队解决。所以拿 demo 给业务方看时可以这么做,真正要交付时我一般会再做一步量化:用 GGUF 格式把模型压到 4bit,让它在普通 CPU 机器或小显存设备上也能跑,llama.cpp 就原生支持这种格式。这一步会让回答质量有一点损耗,但换来的是人人都能用服务器跑模型。
验证环节别忘了一个细节:量化后的模型一定要跑一遍第 4 章的评估脚本,对比量化前后的关键词命中率。如果量化后指标掉得不多,就直接交付;如果掉得明显,退回 6bit 量化或保留半精度。这种“先量化再验证”的动作,比任何口头承诺都有说服力。
提示:部署前先确认模型目录下是否包含 tokenizer 文件,否则推理时会直接报 tokenizer not found,找半天才反应过来是文件没拷全。
我现在把流程固定成了模板,每次接手新项目都会直接复用:先拿 100 条标杆数据做微调,跑通后再扩展到全量数据;评估脚本里始终保留 10 条固定场景,用来做版本之间的回归对比。这个习惯帮我在很多次模型迭代里及时踩了刹车,避免把一个明明更差的版本当成成果交出去。希望这个流程能帮到你,让你做 LLaMA 微调时少走我走过的弯路。
本文还有配套的精品资源,点击获取