上个月给一个知识库项目收尾,模型训完上线,业务方第一句话是"它怎么说话跟以前一样"。翻训练日志才发现 eval loss 早就不动了,我一直在看 train loss 往下掉。
那次之后我改了自己的顺序:先算显存,再定数据,最后才碰超参。这篇按我实际会翻的顺序排,教科书那套顺序我记不住。
一、微调之前
微调要做的事就三样。让它在这个任务上更准,让它按我们的格式说话,把行业里的术语和规则塞进参数里。
顺序上我一律是提示工程、检索、微调,前两个能解决的绝不进第三步。判断该不该微调我看三条:改提示、给示例都压不住的不稳定;格式要求严到提示保证不了;小模型微调之后的总成本,训练加部署加推理时间,比一直调大模型便宜。
三条都不占就别微调。
二、起点选哪个
绝大多数场景我直接上 Instruct。它已经做过指令对齐,指令理解是现成的,我只需要补领域知识和输出习惯,几千条数据就能看出变化。数据标注的钱主要花在这里,从零教它听懂指令是另一笔账。
Base 我只用过两次。一次是任务形式太特殊,Instruct 默认的应答方式反而是干扰;一次是手上有三万多条标注好的领域数据,准备从头做深度适配。没有这两条理由,用 Base 是给自己加活。
三、数据
这件事上数据的分量比参数大。公共数据从 HuggingFace、ModelScope 拿,私有数据基本都要先洗一遍,去重、结构化、补标注。内部文档和工单不是为训练准备的,直接丢进去只会教模型学会写周报。
批量造问答我常用 Easy Dataset 和 GraphGen,几百篇文档丢进去能出几千条,比手写快,但出来的一定要人工过一遍。
数量上我的感受是,指令模型做领域适配,一千到五千条干净样本就能看出差别,我做过最小的一个只有八百条。要从 Base 起步做深度适配才需要几万条。堆到几万条但句式雷同,收益接近于零。
数据的三个自查,都是我自己犯过的:
- 同一句话换个标点就算两条,这种自欺欺人我干过
- 一个业务目标要用多种问法写,不要复制粘贴同一套表达
- 别把套话写进答案。之前有个客服数据,assistant 全是"问题分类:xx;紧急程度:xx",训完模型见到什么提问都往上套
格式上 LLaMA-Factory 吃 Alpaca 和 ShareGPT 两种。单轮用 Alpaca,多轮我用 ShareGPT,因为 conversations 数组看起来就是对话本身。
{ "conversations": [ {"from": "human", "value": "你是谁?"}, {"from": "gpt", "value": "我是按公司文档微调过的助手。"} ] }写进 dataset_info.json 时字段映射要对上。数据集加载失败我遇到的基本都是这里,框架不知道你哪个字段是指令、哪个是回答。
还有一个藏得深的,tokenize 阶段的模板拼接。如果日志里每条样本的 token 长度忽大忽小得离谱,先去看 chat template,别去查数据。空格和换行的编码方式不一样,模型会学出奇怪的停顿。
四、显存去哪了
显存分五块:参数、梯度、优化器状态、激活值、运行时开销。
参数和梯度形状一样精度一样,占用量基本相等。优化器状态最狠,AdamW 的一阶矩二阶矩用 FP32 存,是参数的两三倍。激活值跟层数、隐藏维度、序列长度、batch 成正比,也是唯一能靠技巧压下去的一块,另外三块动不了。
选卡之前我会先用 VRAM 计算器估一遍,apxml 那个够用。比开训之后等 OOM 体面。
单卡上能动的四件事,按我用的频率排。
梯度检查点最救命。前向只存几个检查点,反向要用的时候把那段重算一遍,激活值能砍掉七到九成,训练时间多 1.3 到 2 倍。显存不够时我先开这个。
梯度累计解决的是另一个问题。显存撑不住大 batch 就拆成几个小 batch,梯度先攒着不更新,batch_size 4 加累计 3 步约等于一次 12。它不降峰值,是让你在小显存下拿到大 batch 的稳定更新。
混合精度,前向反向用 FP16 或 BF16,参数更新和梯度累积留 FP32。权重实际存两份,FP32 的主权重负责更新,FP16/BF16 的工作权重负责矩阵乘。BF16 范围跟 FP32 差不多,基本不溢出,但只有 Ampere 以上支持。老卡只能 FP16,要留意下溢。
CPU 卸载用得少。激活值、梯度、优化器状态在非计算阶段挪到内存,要用再拉回来,拿传输时间换显存,卡在两者之间的时候才划算。
五、多卡
数据并行每卡一份完整模型,跑不同数据,AllReduce 汇总梯度。目的是提吞吐,不省单卡显存。流水线并行按层切,适合跨机,接力时下游要等上游,出现气泡,缓解办法是切微批次加调度策略。张量并行切的是单层内的矩阵乘,适合机内高带宽。专家并行是 MoE 专用,专家本来就是独立 FFN,拆开就行。
ZeRO 是把冗余存掉。每卡不再存整套状态,切成分片散到各卡,算到哪层临时拉哪片,用完释放。ZeRO-1 切优化器状态,ZeRO-2 加梯度,ZeRO-3 连参数一起切。阶段越高显存越省,通信越贵。
单卡 LoRA 训 7B 以下,这套东西基本用不上。配错了排查成本比省下的显存高。
六、LoRA 和 QLoRA
全参微调效果最好,显存和速度劝退。PEFT 只更新少量参数,现在的 LoRA 已经能接近全参的效果。这条线从 Prompt Tuning、Prefix Tuning、P-Tuning、Adapter 一路下来,SFT 主流就是 LoRA。
做法不复杂。全参微调里 W₀ 会变成 W₀ + ΔW,作者发现 ΔW 的有效自由度远低于它的维度,于是拆成两个小矩阵相乘,ΔW ≈ AB,A 是 d×r,B 是 r×k,r 远小于 d 和 k。训练时 W₀ 冻住不动,只优化 A 和 B。
d=k=4096 的矩阵全参要更新约 1600 万参数,r=8 时只要 4096×8 + 8×4096 = 65536 个,0.4%。推理时 AB 还能合并回 W₀,不留额外开销,训练省推理也不亏。
插入位置我默认只挂 q_proj 和 v_proj。Query 和 Value 对任务语义最敏感,只插这两处就接近全参效果,开销最小。不够再往 k_proj、o_proj 或 FFN 扩,成本跟着涨。前向是 W₀ + (α/r)·AB,α 我一般设成等于 r,想训得激进给 2r。小数据集再加 0.05 到 0.1 的 LoRA dropout。
QLoRA 是在 LoRA 前面加一步 4-bit 量化。NF4 把标准正态分布按累积概率切成 16 个等概率区间,取中位数当代表值,0 附近格点密,误差比均匀量化小得多。双重量化把每 64 个权重共用的 32-bit 缩放因子再按 256 个一组用 8-bit 存一遍,那些因子本身也占地方。分页优化器把优化器状态拆成 page,用到才载入显存。
结果是一张 3090 或 4090 就能训几十亿参数的模型。代价是量化噪声,指令微调这类任务上基本测不出来。
七、超参
这组是我现在开新项目的起手值,不是最优,但很少翻车。
- rank 从 16 起。验证集掉点就降到 8 或 4;领域跨度大再用 32 到 64,代价是复杂推理上低秩近似会露短板
- α 先等于 r
- 学习率 LoRA 用 1e-4 到 2e-4,全参用 1e-5 量级,DPO 再低一个量级
- epoch 2 到 3,数据不到一千条我跑 1 到 2
- cutoff_len 按数据长度 95 分位设,别顺手开 8K,序列长度直接吃激活值
- weight_decay 0.01 左右
- 种子必须固定
还有一个土办法。固定种子,用三个学习率各跑一次,比三次结果的方差。准确率摆动超过 5 个点,说明模型对超参极度敏感,通常是数据太少或太脏。这时候继续调参是浪费钱,回去补数据更快。
八、用 LLaMA-Factory 跑通
装:
git clone --depth 1 https://github.com/hiyouga/LLaMA-Factory.git cd LLaMA-Factory uv venv --python 3.12 source .venv/bin/activate uv pip install -e . uv pip install -r requirements/metrics.txt llamafactory-cli versionAutoDL 上先 source /etc/network_turbo 再 clone,用完 unset http_proxy && unset https_proxy 关掉。
起 WebUI:
llamafactory-cli webui # 后台跑 nohup llamafactory-cli webui > llama_factory.log 2>&1 &看到 Running on local URL: http://0.0.0.0:7860 就算起来。云服务器要在本地开隧道,不然本机打不开:
ssh -CNg -L 7860:127.0.0.1:7860 root@connect.nmb2.seetacloud.com -p 16652数据放 data 目录,在 dataset_info.json 里登记一条:
"keywords": { "file_name": "keywords_data_sharegpt.jsonl", "formatting": "sharegpt", "columns": { "messages": "conversations" }, "tags": { "role_tag": "from", "content_tag": "value", "user_tag": "human", "assistant_tag": "gpt" } }刷新页面能在下拉框里选到,选中还能预览几条。之后在界面上配学习率、轮数、rank、量化等级,点开始,右边出 loss 曲线。
九、SFT 之后的对齐
SFT 教怎么答,对齐教哪个答更好。模型已经能用,但回答永远啰嗦、不该答的也答、两个说法里挑更差的那个,就该做偏好对齐。
我用 DPO 多。它不需要奖励模型,也不用跑 PPO,拿偏好对直接训,流程比 RLHF 短。代价是对 β 敏感。
数据是成对的,同一个上下文给一好一坏:
{ "conversations": [ {"from": "human", "value": "解释一下 SFT 和 DPO 的区别"} ], "chosen": {"from": "gpt", "value": "SFT 学的是照着一个答案模仿;DPO 学的是两个答案之间的相对好坏,所以它需要一个参考策略,对 β 也更敏感……"}, "rejected": {"from": "gpt", "value": "一个是监督微调,一个是偏好优化,都是微调。"} }LLaMA-Factory 里把 dataset_info.json 那条加上 "ranking": true,stage 改成 dpo,模型路径指到训好的 SFT 模型。
chosen 和 rejected 的差别要鲜明,只是换了措辞模型学不到东西。标注不够可以从日志里凑,同一个任务一条轨迹成功一条报错,本身就是一对。
| SFT | DPO | |
|---|---|---|
| 数据 | 指令回答对 | prompt / chosen / rejected |
| 目标 | 对示范答案做最大似然 | Bradley-Terry 偏好似然 |
| 参考策略 | 不需要 | 需要,一般就是 SFT 模型 |
| 稳定性 | 稳,标准交叉熵 | 中等,对 β 敏感 |
| 额外超参 | 学习率、轮数 | 再加 β |
| 位置 | 第一步 | SFT 之后 |
顺序不能反。β 我从 0.1 起,大了容易过拟合到那几个偏好对上,小了没效果。DPO 只改风格和偏好,别指望它补知识。
十、上线
pip install vllm vllm serve /root/autodl-tmp/models/Qwen3-8B-sft-all \ --served-model-name Qwen3-8B-sft-all --max-model-len 32K小写 k 是 1000,大写 K 是 1024,长度参数别顺手敲。给基础模型挂适配器加 --enable-lora --lora-modules adapter_v1=<lora目录>。正式跑挂后台。
curl 通了再用 openai 库:
from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1/", api_key="none") response = client.chat.completions.create( model="Qwen3-8B-sft-all", messages=[{"role": "user", "content": "量子计算对RSA加密的威胁与应对策略"}], extra_body={"chat_template_kwargs": {"enable_thinking": False}} ) print(response.choices[0].message.content)model 字段必须和启动时的 served-model-name 一样,不一致就 404。LangChain 的 ChatOpenAI 指到同一个 base_url 就行。
十一、推理框架内部
一条请求进来,tokenizer 编码,封成 Sequence 进 waiting 队列,被调度器选中做 prefill,然后多轮 decode,命中 eos 或 max_new_tokens 结束。
调度器每个 step 在显存和 batch 限制下决定这轮跑哪些请求,维护 waiting、running、finished 的流转。这是 Continuous Batching,静态 batch 是一批数据从头跑到尾,连续批处理里请求随时进、完成就退,prefill 和 decode 在不同轮次交替出现。
KV cache 按 block 管。每个请求不再独占一段连续内存,只记录自己占了哪些块,所有请求共享一个分块池。BlockManager 记哪些空闲、哪些占用、哪些能被 prefix cache 复用。启动时按可用显存先算块数预留好,容量可预测,不会生成到一半 OOM。
执行层还有三件。FlashAttention 改的是注意力的计算方式,减少显存读写。张量并行把模型分到多卡。CUDA Graph 把 decode 阶段重复的小步计算录下来直接 replay,省掉 CPU 到 GPU 反复 launch 的开销,prefill 和大 batch 还是走 eager。
十二、怎么判断训成了
train loss 只说明模型拟合了自己的训练样本。压到 0.3 而实际效果全面倒退,是会发生的事,我遇到过。
先看任务指标。分类看 accuracy 和 F1,摘要看 Rouge-L,翻译看 BLEU,抽取看精确匹配。验证集用来调超参和早停,测试集最后动一次,不能混。测试集一百到五百条够了,五十条的小集上一个五点的波动没有统计意义。
再看通用能力。微调最怕的不是没学会,是把旧的忘了。我会跑 MMLU、C-Eval、HumanEval、GSM8K、TruthfulQA。合理的结果是任务指标明显涨,通用指标持平或小幅回落。断崖式下跌就是过拟合或遗忘严重,回头调学习率和轮数。
最后是人工评估。对话和写作这类任务,自动指标跟人的感受相关性很差。准备五十到两百条金标准样本,每次改动都跑一遍。也可以用强模型做裁判做 A/B,但裁判要先跟人工标注对一遍,一致性到 0.8 以上它的分才可信。
遗忘可以算成一个数。准备一个跟新任务无关的通用能力集,训前测基线分,训后再测:
遗忘率 = (基线分 - 微调后分) / 基线分我的红线是 15%。之前做合同违约金计算,通用推理项遗忘率到了 22%,根因是训练集九成样本都含"违约金",模型把"计算"和"违约金"绑死了,看到"利息计算"也往违约上联想。改的是数据配比,不是加数据。
上线后还要看三个数,差评率、报错率、忠实度。RAG 场景里忠实度看答案还能不能对上检索片段。任何一个明显下滑就要能回滚,所以模型、数据、超参、种子得一起存档。
十三、三个项目的清单
知识库问答,按召回的片段、表格、条款生成答案。要求是忠实、完整、能标出处、资料不够时敢拒答。要练的东西是行业术语和输出格式、严格按检索内容作答、多片段整合、引用标注、拒答并说明缺什么。评的时候看术语准不准、格式稳不稳、有没有编造、引用和结论对不对、拒答是否及时。
问数,按表结构、字段值、指标定义生成 SQL。要练业务概念到字段的映射、字段值别名归一、指标口径和聚合、缺条件时主动问、多表 join 和 group by、having 的构造、只读和方言安全。评的时候看字段选得对不对、枚举值对不对、口径和去重时间范围准不准、join 路径对不对、SQL 能不能跑。
客服,识别意图、进流程、抽槽位、必要时澄清或转人工。要练意图识别,含多意图和相近意图的区分;槽位抽取,订单号、手机号、商品、时间、地址、金额;澄清话术;多轮状态管理,不重复问已经给过的信息;异常和人工升级。评的时候看意图准不准、槽位全不全、流程跳得对不对、升级是否及时、有没有乱承诺。
十四、照着查的表
出问题先对着这张表看,比瞎调参快。
| 现象 | 大概原因 | 先动哪里 |
|---|---|---|
| train loss 一路降,验证集回答越来越差 | 过拟合到训练集里的句式 | 查重复句式,降 epoch,加 dropout |
| train loss 极低,验证 loss 回升 | 标准过拟合,数据太少或容量过剩 | rank 从 16 降到 8 或 4,加多样性,加 weight_decay |
| 通用能力断崖下跌 | 遗忘,学习率或轮数过大 | 学习率降一个量级,算遗忘率 |
| 该出 JSON 却带前言后语,键名还乱改 | 格式约束样本不足 | 补反例,输出侧加解析兜底 |
| 每次训练结果都不一样 | 没固定种子 | 固定 seed,记完整配置 |
| 每条样本 token 长度忽大忽小 | chat template 拼接错 | 查模板,别查数据 |
| 调用直接 404 | model 和 served-model-name 不一致 | 两边名字对齐 |
| 训练集漂亮,遇到新类型用户就崩 | 数据分布太窄 | 补真实分布的样本,别只堆一种问法 |
十五、剩下几条
别一上来就全参微调,先用 LoRA 跑通链路再谈效果。OOM 先看激活值,不要只看参数量。BF16 优先,老卡才用 FP16。数据集注册字段对不上是最高频的错误,报错先查这里。LoRA 只挂 q_proj 和 v_proj 先试,不够再扩。学习率宁小勿大。先有一个能用的 SFT 模型再做 DPO。vLLM 的 served-model-name 和调用端 model 必须一字不差。上线前压测并发,吞吐和时延是两个独立问题。
现在跑一个 8B 模型的领域微调,从整理数据到 vLLM 起来大概是四天,其中三天在数据上。