微调适合教行为(格式、风格、决策策略)而非教事实,事实应走 RAG;最强经济 case 是小模型微调后匹配大模型单一任务效果。
微调教的是行为:一种格式、一种语气、一种决策策略、一个领域的惯用语、一种模型容易漂移的结构。它不擅长教事实,因为事实会变化,而把事实存在模型里是很别扭的。
先做 Prompt 工程。如果一个更长的系统 prompt 加三个示例就能解决问题,那就到此为止了,而且明天你还可以改变主意。
用检索来管理知识。任何需要保持时效性、需要引用或需要审计的内容,都应该放在上下文中,而不是放进权重里。
为了一致性、风格和压缩而微调。当你想要的行为需要两千个 token 的指令来描述,而且仍然会漂移时,把它训练进去反而更可靠,每次调用的成本也更低。
为了降级到更小的模型而微调。最强有力的经济账:一个小模型经过调优后,在某个狭窄任务上能匹配大模型的表现,而服务成本只是零头。
在开始任何操作之前,先检查基模型的许可证。有些许可证对衍生作品有命名或署名要求,有些限制用输出结果来训练其他模型,而微调本身无法赋予其基模型被扣留的权限。
这决定了你的整个方案,而且它跟这个领域里其他所有东西一样是算术。完整微调每个参数需要保存:权重、梯度、优化器状态(对于 Adam,是两个时刻变量加上通常一份高精度的权重副本)。
full fine-tune, Adam, mixed precision
~16 bytes per parameter, before activations
8B model -> 8e9 * 16 = 128 GB (a multi-GPU job)
LoRA on a 16-bit base
base weights frozen 8e9 * 2 = 16 GB
adapters + their optimiser < 1 GB
activations workload dependent
QLoRA: LoRA on a 4-bit quantised base
base weights 8e9 * 0.5 = 4 GB
adapters + optimiser + activations a few GB
-> an 8B tune fits on a single 24 GB card
这就是为什么几乎所有大型实验室以外的微调都是 LoRA。低秩适配冻结基模型,在选定的权重矩阵旁边训练一对小矩阵,因此可训练参数数量下降了几个数量级,优化器状态也随之下降。对于上面描述的行为改变,它通常与完整微调几乎无法区分。
预计在这里花费大部分时间,对任何不这么做的方案保持警惕。几百个优秀的例子经常胜过好几万个平庸的例子,因为模型学到的是你展示给它的分布,包括其中的不一致性。
完全匹配服务格式。系统 prompt、消息角色、聊天模板都要和生产环境一致。一个在一种表述下微调、却在另一种表述下服务的模型,是为一个它永远不会被安排的工作而训练的。
对一致性要毫不留情。如果一半的示例使用一种 JSON key 顺序,另一半用另一种,那你就教会了模型不一致。数据中的矛盾会变成输出中的方差。
properly 留出验证集,按组划分。在查看数据之前就分割好。如果几个示例来自同一个文档或同一个客户,它们必须落在划分的同一侧,否则你的评估测量的就是数据泄露而不是模型能力。
包含边界案例。拒绝的示例、要求澄清的示例、处理畸形输入的示例。只有成功案例的数据集会训练出一个从不拒绝的模型。
注意数据来源。另一个模型的输出可能受该模型条款的限制。这一点会在后期坑到人,而且这是个许可证问题,不是技术问题。
# a representative configuration; the framework varies, the knobs do not
base_model: org/model-8b-instruct
load_in_4bit: true # QLoRA
adapter: lora
lora_r: 16 # rank: capacity of the adaptation
lora_alpha: 32 # scaling, commonly 2x rank
lora_dropout: 0.05
lora_target_modules: # attention projections at minimum;
- q_proj # adding the MLP projections raises
- k_proj # capacity and memory together
- v_proj
- o_proj
sequence_len: 4096
micro_batch_size: 2
gradient_accumulation_steps: 8 # effective batch = 16
num_epochs: 2
learning_rate: 0.0002 # 1e-4 to 2e-4 is the usual band
lr_scheduler: cosine
warmup_ratio: 0.03
val_set_size: 0.05
Rank 决定了适配器能改变多少。8 到 16 覆盖大多数风格和格式工作;如果要真正新能力就提高它,但很快就会遇到收益递减。
目标模块在实践中比 rank 更重要。只针对 attention 是便宜的默认选项;包含 MLP 投影有助于更困难的任务,但会付出真实的内存代价。
Epochs。一到三个。这是过拟合所在的地方——监控验证 loss,在它上升时停止,而不是在 schedule 结束时报。
LoRA 的学习率比完整微调高一个数量级。早期发散通常意味着太高;什么都没变化通常意味着太低或者 rank 太小。
验证 loss 告诉你训练成功了。它不能告诉你模型在任务上变得更好,而这两者经常背离。
在相同的留出集上、用相同的 prompt 和相同的采样设置,与未调优的基模型比较。没有配对的基线,你有的只是一个没有比较对象的数字。
在目标任务之外测试回归。窄范围的调优会损害通用能力——指令遵循、拒绝、多轮连贯性。保留一个小型的通用集,每次都跑它。
先检查机械属性。Schema 有效性、必填字段、长度分布。这些通常会显著改善,而它们正是你做这件事的原因。
要注意你可能在评估中赢了却在部署中输了,如果评估集来自与训练数据相同的窄切片。从不同周采样留出集。
单独服务适配器。批处理服务器可以加载一次基模型,然后挂载多个 LoRA 适配器,按请求路由。当你有多个调优模型时,这是你想要的,因为一份基模型副本服务所有:
vllm serve org/model-8b-instruct \
--enable-lora \
--lora-modules support=./out/support-lora triage=./out/triage-lora
# then send model: "support" or model: "triage" per request
或者合并后发一个工件。合并把适配器折叠进权重里,得到一个正常的 checkpoint,不需要运行时适配器支持——这正是你本地分发需要的:
from peft import AutoPeftModelForCausalLM
m = AutoPeftModelForCausalLM.from_pretrained("./out/support-lora")
m.merge_and_unload().save_pretrained("./merged", safe_serialization=True)
# then, for llama.cpp deployment:
python convert_hf_to_gguf.py ./merged --outfile support.gguf --outtype f16
llama-quantize support.gguf support-Q4_K_M.gguf Q4_K_M
关于合并的一个注意事项:如果用 4 位基模型训练的,合并到 16 位基模型而不是量化后的那个,然后再对合并结果做量化。合并到量化基模型上会让两个近似叠加,输出可能比每一步单独的结果都差。在你要服务的那个精确工件上重新跑评估——合并后量化是对模型的改变,而你已知其质量的版本只有你测试过的那一个。