背景
我们日常所认识的大模型通常都是经过微调之后得到的。大模型本质上是一个「文本续写的模型」。在未经过任何微调或者基于人类反馈的强化学习(RLHF)之前,模型只能实现基本的文本接龙,并没有直接理解用户指令或意图的能力。
下面是开源大模型 Yi-34B 未经过微调之前的输出示例:
![预训练模型输出示例]
上面的输出从语法结构角度来说都是对的,但是它的输出并不是「对话」形式,而是像继续编写某个文本的片段。
经过 Chat 数据微调训练之后,模型才学会如何和人类进行正常交流:
![微调后模型输出示例]
用来微调 Chat 对话模型使用的训练数据由符合预期的「一问一答」数据组成。
社区上无论是 GPT 还是开源大模型,基本都采用先预训练后微调的方式,从而得到一个对话或者其他用途的模型。
什么是预训练?
在预训练阶段,模型通过学习大量的数据来提取特征、理解语义和推理能力,从而对通识知识、代码知识获得一般性认识。质量较高的预训练数据可以增强模型的泛化能力,减少在后续特定任务上的训练调优时间和资源消耗。
- 无监督学习:预训练是一种无监督学习方式,是指使用随机文本片段在大规模数据集上对神经网络模型进行初始训练,以便模型能够学习广泛的特征和知识。
- 数据格式:预训练使用的训练数据格式为「只有输出」,没有输入的标签。大模型使用大量这种没有标记的训练数据来自己学习数据中的规律(中英文语法、代码语法、通识知识等)。
预训练适合什么场景?
- 从 0 开始训练一个新的通用大模型。
- 基座大模型 LLaMA2 中文数据非常少,只占 0.13%,有必要使用更多比例中文数据继续预训练使大模型学会中文表达。
- 基座大模型 LLaMA2 模型代码内容的占比同样也不高(4%),使用预训练添加更多比例的代码数据,强化大模型代码能力(Code LLaMA 代码模型)。
- 出了一门新的编程语言,为了让模型能够学到最新的编程语言结构,可以考虑预训练。
什么是微调?
- 微调使用「一问一答」的格式,即有标注的训练数据,在基于原有模型参数上进行有监督学习,来让模型更清楚地知道什么样的输入下他应该给予什么样的输出,按照训练数据里的模式进行学习。
大模型先通过预训练学习基础知识,再通过微调让模型学会基于它通过预训练已经学会的知识,学习如何去根据用户指令输出正确适当的内容。
我能用微调定制什么?
- 设置风格、语气、格式等定性方面:
- 场景举例:创建一个语音对话机器人,不通过提示词的方式,让模型输出的内容尽可能精简(如 50 字以内)。
- 大模型智能 Code Review,通过打标过的优质数据使得大模型 Review 输出更加有效和高质量的结果。
- 提高生成预期输出的可靠性:
- 将天气查询的需求转换为 JSON 请求参数,通过大量示例作为微调训练数据提升输出 JSON 的信息抽取的效果,并降低输出非法 JSON 内容的概率。
- 要求大模型只输出代码块,即使通过提示词告诉模型只能一次性输出代码块且不添加任何解释,但大模型偶尔也会输出解释和多个代码块。此时用一些少量微调数据可以改善这些问题。
- 提升效果:
- 使用大模型生成 Pandas 数据分析和可视化代码。大模型本身理解 Pandas 代码的编写,但是编写准确率在一些场景下可能不是特别高,通过一系列经过打标正确的训练数据提升大模型理解用户需求编写 Pandas 代码的效果和正确率。
- 比较复杂的,有大量任务说明的提示词。比如将用户的一段描述转换为一个甚至多个接口请求参数,需要在提示词里添加大量说明文档和样例。
- 减少 Token 占用:
- 就像上面的例子,在提示词里添加大量文档说明,使用按 Token 计费的模型会显得很贵。同时较少的 Token 能获得更快的推理速度。
可以使用微调来让大模型新增知识吗?
不推荐。在需要有可信依据的场景上,比如构建智能客服机器人,通常会使用 RAG 的方式配合向量搜索等方式从文档库搜寻与用户询问问题最为相关的内容,并将匹配到的文档段落作为知识内容添加到提示词中,大模型使用提示词中的知识来回答用户的问题。
微调改善的是大模型在某种模式下的表现(如风格、准确度、幻觉问题等)。虽然微调也能一定程度上记忆新增的知识,但由于微调是改变模型的参数结构,使得模型在生成新 token 时输出与训练数据模式更相似的内容。从输出准确度上来说是不如在提示词中添加的知识内容。
微调方式
在大模型预训练参数上进行参数微调训练,主要有三种方式:
- 全参数微调:即完全监督微调,在所有参数层上根据标注数据去调整原始预训练模型中的 QKV 参数层。这种方式效果最好,但显存消耗巨大,训练时间长。
- LoRA:即 LLM 的低秩适配(Low-Rank Adaptation),通过两个较小的矩阵来拟合调整后的参数层。这个过程可以理解为 $X + Z = Y$,其中 X 为原始参数,Y 为训练之后的参数,训练过程中就是寻找可以将 X 拟合为 Y 的 Z 矩阵。Z 矩阵由两个较小的 Wa 矩阵和 Wb 矩阵组成。
- QLoRA:与 LoRA 方式类似,也是训练两个拟合参数层来达到对原始模型的调整。区别在于为了节省训练硬件资源,QLoRA 会先将原始模型参数量化至 4-bit 并冻结,然后添加一小组可学习的低秩适配器权重。这些权重通过量化权重的反向传播梯度进行调优,在量化之后的参数上进行 LoRA 训练,这将大幅下降显存的占用(33B 的模型以 FP16 全量加载需消耗 80GB 显存,量化至 4 bit 之后模型加载仅需要 20 GB 左右显存的占用)。
除了量化并冻结原始参数,QLoRA 还支持分页优化器:使用 NVIDIA 统一内存特性,将部分显存溢出的部分 offload 到内存中实现分页,来进一步避免 OOM 的问题。
关于全量参数微调和 LoRA 方式效果的对比,以下以 SQL 生成场景举例。在 SQL 数据集上,根据模型大小和微调方法预测准确率,LoRA 微调模型的表现可与全参数微调模型相当。需要注意的是,LoRA 微调的 13B 模型的表现略优于全参数微调的 7B 模型。
关于 QLoRA 训练的效果:在 5-shot MMLU 场景下,QLoRA 效果与常规 LoRA 效果接近,甚至能反超常规 LoRA 微调效果。
关于资源消耗:
| 训练方式 | Full 全参数微调 | LoRA | 4bit-QLoRa |
|---|---|---|---|
| 占用 (6B 模型) | 68450 MB | 15226 MB | 8422 MB |
从上表中可以看到:
- 全参数训练显存占用约为 LoRA 的 4.5 倍。
- 使用 QLoRA 方式显存占用相对于 LoRA 又可以省一倍。
- 6B 参数的微调,一张 24G 显存的显卡运行 LoRA 微调绰绰有余。
因此,结合训练效果看,LoRA 或者 QLoRA 的高效微调的方式成本较低效果相对也较好,因此以 LoRA 的方式来对基座模型进行微调是最为合适的。
微调训练框架的选择
2023 年各家推出的大模型浩如烟海,如 GPT4、Llama、ChatGLM、Baichuan、RWKV、Stable-Diffusion 等。每个模型的训练方法可能都有略微区别,且业界提出了众多高效微调的方法,例如 Adapter-Tuning、Prompt-Tuning、LoRA、QLoRA 等。不同的基座模型,不同的训练方法需要不同的代码去适配,造成了较高的上手门槛。
目前业界推出了许多轻量级训练和推理工具,通过框架代码、CLI 的封装,可以使得普通开发者以几行代码就能启动模型训练、推理。
本地微调开源大模型推荐配置一张 24G 显存的显卡。如果没有的话,那么试试云厂商提供的微调接口。
还有其他可以调整的参数:如训练模式、训练几个 epoch、checkpoint 记录步长、lora_rank 等,不清楚的话可以先使用默认参数。
CLI 启动训练示例代码,最主要的部分就是设置 custom_train_dataset_path 和 custom_val_dataset_path 配置训练数据:
PYTHONPATH=../../.. \
CUDA_VISIBLE_DEVICES=0 \
python llm_sft.py \
--model_id_or_path 01ai/Yi-6B \
--model_revision master \
--sft_type lora \
--tuner_backend swift \
--template_type default-generation \
--dtype fp16 \
--output_dir output \
--train_dataset_sample -1 \
--num_train_epochs 5 \
--max_length 2048 \
--max_new_tokens 2048 \
--check_dataset_strategy warning \
--lora_rank 8 \
--lora_alpha 32 \
--lora_dropout_p 0.05 \
--lora_target_modules ALL \
--gradient_checkpointing true \
--batch_size 1 \
--weight_decay 0.01 \
--learning_rate 1e-4 \
--gradient_accumulation_steps 16 \
--max_grad_norm 0.5 \
--warmup_ratio 0.03 \
--eval_steps 100 \
--save_steps 100 \
--save_total_limit 2 \
--logging_steps 10 \
--quantization_bit 4 \
--bnb_4bit_comp_dtype fp16 \
--custom_train_dataset_path /root/train.jsonl \
--custom_val_dataset_path /root/train_eval.jsonl
以下是魔搭有标注的训练数据格式,使用 query 和 response 两部分组成:
{"query": "11111", "response": "22222"}
{"query": "aaaaa", "response": "bbbbb"}
{"query": "AAAAA", "response": "BBBBB"}
数据准备最佳实践
高质量的训练数据是微调成功的关键。建议遵循以下步骤进行数据清洗:
- 去重:去除重复的样本,防止模型过拟合。
- 过滤:移除包含敏感信息、乱码或长度异常的样本。
- 格式化:确保所有样本符合
query和response的标准格式。 - 多样性:保证数据覆盖多种场景和难度,避免单一分布导致模型能力退化。
数据可视化场景的微调
数据分析中可视化是一个非常常见的场景。在各类可视化系统中,一般先将数据源分类为一系列指标(度量)或者维度(类别)。通过指标和维度之间的组合加上适当的可视化类型,如折线图、柱图、饼图等,来生成可视化图表。
以 BI 系统举例,如果需求为「展示每个产品类型的单价和利润情况」,那么需要从已知的维度列表中选择 product_type,度量选择「利润金额」和「单价」,最后再选择柱图或者线图进行可视化。对于不那么熟悉指标维度概念的非专业数据分析人员,或者移动端场景来说,快速选择合适的指标和维度操作不是那么容易。
而利用大语言模型,可以实现理解用户数据分析的意图并结合已知的指标维度列表,抽取其中的指标维度信息,并将抽取生成的配置映射为真实的可视化展示,通过自然语言的方式大大降低了用户操作的门槛。
设计以下的提示词:
![提示词设计示例]
上面提示词中,绿色部分为固定说明,橙色部分为根据已知可使用的可视化字段列表动态进行替换的地方,而蓝色部分则替换为用户实际的输入问题。
来测试一下,将以上提示词直接带入某开源大模型,得到以下输出:
{
"chartType": "CHART_LINE",
"chartFields": {
"dimensions": ["时间"],
"metrics": ["利润率"]
},
"chartTimeFilter": {
"granularity": "day",
"dayjsScript": ["subtract", 6, "months"]
},
"dimensionFilters": [
{
"field": "时间",
"values": [
"2022-01-01",
"2022-01-02",
"2022-01-03"
]
}
],
"metricFilters": [],
"mainAxis": "时间"
}
可以看出提取字段基本没问题,但是有一点问题。首先我们要求将时间范围输出为 dayjsScript 语句,从而通过 JS 运行并解析获取准确的近半年时间,但是大模型没有按照要求生成。其次 dimensionFilters 字段错误地列出所有近半年具体日期,且今年也不是 2022 年。
这样的效果肯定是没有办法接入到我们的应用流程中的。要优化大模型输出,我们只需要给大模型一个例子即可:
![Few-Shot 示例]
上面紫色框添加了一个示例,这样大模型可以基于已有描述和输出范例,更好地输出正确的内容。添加了示例之后的输出:
{
"chartType": "CHART_LINE",
"chartView": {
"dimensions": [
{
"name": "时间"
}
],
"metrics": [
{
"name": "利润率"
}
]
},
"mainAxis": "时间",
"chartTimeFilter": {
"granularity": "month",
"dayjsScript": ["dayjs().subtract(6, 'month').startOf('month')", "dayjs().subtract(0, 'month').endOf('month')"]
}
}
添加了示例后,可以看到大模型正确解析了近半年的时间范围对应的 Dayjs 代码,且没有输出多余的 dimensionFilters 属性。
添加示例的限制
添加示例的确可以快速大幅提升模型的输出效果,但是示例本身也是会占用 prompt 窗口长度的,越长的 prompt 调用成本越高,同时也会挤占后续多轮对话提示词的空间。
且示例的方式可能无法覆盖所有的边缘 case。在实际测试过程中发现,会出现一些异常情况:比如大模型时不时自己添加了大于 0 的指标筛选、模型根据自身理解添加了不在用户描述范围内的维度值,取了不在说明里的枚举值等等。虽然可以进一步添加示例来解决这些问题,但显然不能添加太多的示例,这样会大幅提高调用成本且降低推理速度。
使用微调解决
微调实际上有点类似于添加大量的「示例」数据,不通过提示词的方式而是直接训练模型,从而调整其原本的参数来更好地适应我们的任务。这里所说的「示例」数据,其实就是大量有标注的数据,一个问题输入和问题输入对应的正确的输出,让模型自己学习什么样的输入应该有什么样的输出。
训练数据怎么来?
以下介绍两种方法:
- 从已有系统里尝试捞取含有图表描述和具体图表配置的图表,最后清洗整理得到训练数据。
- 给 GPT4 或者其他效果较优的模型不同场景和 case 下的正确范例,让大模型继续扩写示例。
扩写示例的方式,其实就是使用「种子任务」构造指令池的过程。
下面是生成可视化配置转 JSON 的具体提示词示例,将以下这段 prompt 输入给 GPT4,GPT4 就能按照示例的编写模式源源不断地生成更多的标注数据。
训练超参数调优指南
微调过程中的超参数选择直接影响最终效果。以下是一些关键参数的建议:
- Learning Rate(学习率):通常设置在 1e-5 到 1e-4 之间。LoRA 微调可以使用稍高的学习率,如 2e-4。
- Batch Size(批次大小):受限于显存大小。使用梯度累积(Gradient Accumulation)可以在小显存下模拟大 Batch Size。
- Epochs(轮数):通常 3-5 轮足够。过多的 Epoch 会导致过拟合。
- Warmup Ratio:建议设置为 0.03 到 0.1,帮助模型稳定初期训练。
- Weight Decay:正则化参数,通常设为 0.01 以防止过拟合。
常见问题与解决方案
- 灾难性遗忘:微调后模型丢失了通用能力。
- 解决:混合通用数据和任务数据;使用更小的学习率;限制微调层数。
- 显存不足 (OOM):
- 解决:使用 QLoRA 量化;开启梯度检查点;减小 Batch Size。
- Loss 震荡不下降:
- 解决:检查数据质量;降低学习率;增加 Warmup 步数。
部署与推理优化
训练完成后,模型通常需要部署到生产环境。以下是一些优化建议:
- 量化推理:使用 GGUF 或 AWQ 格式将模型量化至 4-bit 或 8-bit,显著降低显存占用。
- 推理引擎:推荐使用 vLLM 或 TensorRT-LLM 进行加速,支持连续批处理(Continuous Batching)和高吞吐量。
- 服务化:将模型封装为 API 服务,使用 FastAPI 或 Flask 提供 HTTP 接口。
总结
- 只经过预训练的大模型只是一个单纯的「文本续写」模型,通过微调训练可以让大模型更好地执行与训练数据模式类似的任务。
- 使用「示例」的方式在提示词中添加有标注数据也能改善模型效果,但是会存在 token 占用多,响应速度变慢的问题。
- 使用 LoRA 高效微调的方式可以有效降低模型训练使用硬件资源。
- 通过「种子任务」的方式让 GPT4 或其他模型编写和提示词类似的数据可以更加高效地生成训练数据。
- 数据质量决定上限,合理的超参数调优和部署策略决定下限。
- 在实际项目中,应优先考虑 RAG 方案处理知识更新,仅在风格、格式、逻辑强相关场景下使用微调。

