AI 指令微调:这次怎么落地的
一开始没有直接上 SFT,是因为几个现实的限制:
- 算力问题:单卡 3090Ti,显存 24GB,训练大模型比较吃力
- 数据问题:没有现成的指令数据集,自己标注成本高
- 时间成本:从零训练一个模型可能要几天,还得不断调参
- 效果不确定性:训练完也不一定比原来好,可能还得重来
这些限制导致前期一直在用 RAG + Prompt Engineering 的方式凑合用,效果只能说"能用但不够好"。
为什么要折腾指令微调?
去年底开始做内部知识库问答,场景很明确:用户问技术问题,模型要给出简洁的、带代码片段的答案,不要一大段理论铺陈。试了几次主流的开源模型,问题全出来了:
- 明明问"怎么实现一个 WebSocket 服务器",模型开始从 WebSocket 的历史背景讲起
- 让它"给个 Python 脚本示例",它先给你上三段概念解释,最后才出现
def main() - 要求"写个 API 文档示例",它能写出两页理论分析,而不是你想要的 YAML 或 JSON 格式
这些都是指令理解问题。基础模型预训练的时候,看到的是海量文本,它学会了续写和补全,但没学会"完成用户指令"这件事。
SFT(Supervised Fine-Tuning,监督微调)的核心就是这件事:用"指令-响应"对子,训练模型理解"我让你做什么"和"你应该怎么答"之间的映射。
之前绕不开的限制条件
一开始没有直接上 SFT,是因为几个现实的限制:
- 算力问题:单卡 3090Ti,显存 24GB,训练大模型比较吃力
- 数据问题:没有现成的指令数据集,自己标注成本高
- 时间成本:从零训练一个模型可能要几天,还得不断调参
- 效果不确定性:训练完也不一定比原来好,可能还得重来
这些限制导致前期一直在用 RAG + Prompt Engineering 的方式凑合用,效果只能说"能用但不够好"。
确定的实施路径
今年初尝试了 LoRA(Low-Rank Adaptation)之后,情况改观了。LoRA 不需要训练整个模型,只训练参数量很小的适配器,在 3090Ti 上可以跑起来。加上 QLoRA(Quantized LoRA)支持,甚至可以把 7B 模型量化到 4bit 再微调,显存占用直接降到 12GB 左右。
这次选的技术栈:
- 基础模型:Llama-3-8B(中文支持尚可,架构清晰)
- 微调方法:QLoRA(4bit 量化 + LoRA)
- 训练框架:PEFT + Transformers + bitsandbytes
- 训练数据:自己构建的 2000 条中文指令-响应对
整体流程长这样:
这张图简单说明了整个流程:从原始模型开始,通过量化降低显存占用,加上 LoRA 适配器,用指令数据进行微调,最后合并权重得到可用模型。每个环节都有对应的参数和工具选择,后面会细说。
数据准备:最耗时的环节
数据准备花了整个周期 60% 的时间,这个比例不算夸张。指令微调的数据质量直接决定模型效果,质量差的指令对子会让模型学到错误的模式。
数据来源
最开始想用公开数据集,但试了几个之后发现都不太合适:
- Alibaba’s Instruction Tuning:以英文为主,中文质量一般
- COIG-CQIA:中文覆盖不错,但任务类型跟我们场景差异大
- Self-Instruct 数据集:自动生成的,有些指令描述不够自然
最后决定自建数据集,结构上模仿 Stanford Alpaca,但内容和风格完全贴合我们的场景:
{
"instruction": "请用 Python 写一个简单的 WebSocket 服务器,要求支持消息广播功能",
"input": "",
"output": "以下是一个使用 websockets 库实现的简单 WebSocket 服务器,支持消息广播功能:\n\n```python\nimport asyncio\nimport websockets\n\nconnected = set()\n\nasync def handler(websocket):\n connected.add(websocket)\n try:\n async for message in websocket:\n # 广播消息给所有连接的客户端\n for conn in connected:\n if conn != websocket:\n await conn.send(message)\n finally:\n connected.remove(websocket)\n\nasync def main():\n async with websockets.serve(handler, \"localhost\", 8765):\n await asyncio.Future() # 永久运行\n\nif __name__ == \"__main__\":\n asyncio.run(main())\n```\n\n这个实现简单直接:\n\n1. 使用 `connected` 集合跟踪所有连接的客户端\n2. 收到消息后,广播给除发送者外的所有连接\n3. 客户端断开时自动从集合中移除\n\n运行方式:`python server.py`,然后可以用浏览器或客户端工具连接 `ws://localhost:8765` 测试。"
}
注意这个结构:instruction 是用户指令,input 可以为空或提供额外上下文,output 是期望的响应。响应不是简单的代码,而是带解释的完整回答——这才是我们想要的输出风格。
数据清洗与去重
自建数据的时候,从几个地方收集:
- 历史问答记录(脱敏后)
- 文档片段对应的 Q&A
- 手工撰写的示例指令-响应对
收集过程中发现几个问题:
- 指令重复:同一个问题换了不同的说法,本质是同一个任务
- 响应风格不一:有些带详细解释,有些只有代码,有些很简短
- 数据质量不均:有些是早期写的,质量一般
针对重复问题,用了一个简单的去重策略:对指令做清洗(去标点、转小写、去掉停用词),然后用文本相似度(cosine similarity)聚类,相似度 > 0.85 的只保留一条。
响应风格的问题,花了两天时间手工统一风格——每个响应都遵循这个模式:
- 直接回答问题(1-2 句)
- 给出代码或示例(如果有)
- 补充必要解释(不超过 200 字)
- 最后给一个使用提示(如果有)
最终数据集:2000 条高质量中文指令-响应对,覆盖:

- 代码生成与调试(40%)
- 系统设计与架构(25%)
- 工具使用与配置(20%)
- 概念解释与对比(15%)
从这张图能看出,数据集以代码类任务为主,但也覆盖了设计、工具使用和概念解释,避免模型过度偏向某一种输出模式。
训练踩坑记录
训练过程不是一帆风顺的,踩的坑基本集中在显存和训练稳定性上。
显存不足
一开始用 8bit 量化 + LoRA,结果还是显存不够。换了 QLoRA 之后才正常跑起来。关键参数:
# 4bit 量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4"
)
# LoRA 配置
lora_config = LoraConfig(
r=8, # rank,越小显存越省
lora_alpha=16,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
r=8 是个平衡点:太小效果差,太大显存扛不住。target_modules 指定要对哪些模块应用 LoRA,这里只对注意力机制的 projection 层做微调。
训练不收敛
第一次训练的时候 loss 一直不降,甚至在上升。检查了几个点:
- 学习率:初始用的 2e-5,太大导致训练不稳定。降到 5e-6 后 loss 开始正常下降
- Batch size:原本是 4,梯度累积 8 步,实际相当于 batch=32。太大导致更新太粗,改回 batch=16 后收敛变稳
- 数据顺序:数据本身没 shuffle,导致某个任务连续出现,模型过拟合这个任务。加上
shuffle=True后情况改善
最终用的训练参数:
training_args = TrainingArguments(
output_dir="./results",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # 实际 batch=16
learning_rate=5e-6,
warmup_steps=100,
logging_steps=10,
save_steps=500,
fp16=True,
evaluation_strategy="no",
save_total_limit=2,
load_best_model_at_end=False
)
过拟合问题
训练到第 2 个 epoch 的时候,发现训练 loss 降得很低,但测试集上的表现开始变差——典型过拟合。
加了几个正则手段:
- Dropout:LoRA 配置里
lora_dropout=0.05,适度随机丢弃防止过拟合 - Early stopping:虽然这次没用到,但如果连续多个 step loss 不降就停止
- 数据增强:对部分指令做了同义词替换和句式改写,扩充到 2200 条

最终第 3 个 epoch 完成后,loss 在 0.8 左右,算是收敛了。这条曲线在第二个 epoch 开始明显放缓,说明模型已经学会了主要模式,剩下的训练更多是在细节上优化。
效果评估与对比
训练完成后的模型,做了一轮人工评估,对比了三个版本:
- 原始 Llama-3-8B:未微调的基座
- Prompt-tuned 版本:只用系统提示词引导的版本
- SFT 版本:这次训练出来的模型
评估用了 50 个测试问题,从几个维度打分:
- 指令理解:模型是否明白用户要做什么
- 输出质量:回答是否准确、简洁、可执行
- 风格一致性:是否符合预期的"代码+简要解释"风格
- 幻觉程度:是否产生错误或不存在的信息
整体结果:
| 维度 | 原始模型 | Prompt-tuned | SFT 版本 |
|---|---|---|---|
| 指令理解 | 2.8/5 | 3.5/5 | 4.2/5 |
| 输出质量 | 3.1/5 | 3.8/5 | 4.5/5 |
| 风格一致性 | 2.5/5 | 3.2/5 | 4.6/5 |
| 幻觉程度 | 3.0/5 | 3.4/5 | 4.1/5 |
具体例子
问:“给一个 Go 语言的 HTTP 服务器示例,支持静态文件服务”
原始模型输出:(省略了 300 字的 HTTP 历史和 Go 语言介绍,最后才给代码)
SFT 模型输出:
package main
import (
"net/http"
)
func main() {
// 配置静态文件目录
fs := http.FileServer(http.Dir("./static"))
http.Handle("/", fs)
// 启动服务器
http.ListenAndServe(":8080", nil)
}
这是一个最简单的静态文件服务器实现:
http.FileServer创建静态文件处理器,指向./static目录http.Handle将根路径/映射到文件处理器ListenAndServe在 8080 端口启动服务
运行后,访问 http://localhost:8080/index.html 会返回 ./static/index.html 的内容。如果需要自定义 404 页面,可以添加额外的处理逻辑。
风格完全对上了:先给代码,再补必要的解释,最后加一个使用提示。
收获与边界
这次折腾下来,对指令微调有了更实的理解:
- 数据比模型重要:用 7B 模型 + 高质量数据,效果可能比 13B 模型 + 垃圾数据好
- SFT 不是万能药:只能让模型"学会听话",不能提升它的基础能力。如果模型本身就不懂某个领域,SFT 也救不了
- 算力瓶颈可以用技巧绕过:QLoRA + 合理配置,可以在消费级显卡上训练 7B 模型
- 时间成本主要在数据:训练本身只要几小时,数据准备要几天甚至更久
但也有明确的边界:
- 领域迁移能力有限:训练数据主要集中在技术问答,让它写营销文案还是不行
- 指令覆盖范围有限:2000 条数据能覆盖常见任务,但遇到新任务还是吃力
- 持续维护成本:新的业务场景出现时,需要补充数据和重新训练
下一步考虑
后续可能做的几件事:
- 扩充数据集:从 2000 条扩展到 5000 条,覆盖更多任务类型
- 多轮对话支持:当前只支持单轮指令,后续考虑加入对话历史
- Reward Model:如果数据量够,可以加一步 RLHF,进一步优化响应质量
但目前的版本已经能满足内部知识库问答的基本需求了。至少现在,它不会再从 WebSocket 的历史背景讲起了。
指令微调这件事,本质上是让大模型从"百科全书"变成"实用工具"。这个过程不神秘,但确实需要不少细节上的耐心。如果你也在考虑做 SFT,建议先把数据准备好,其他的都可以慢慢调。
版权声明: 本文首发于 指尖魔法屋-AI 指令微调:这次怎么落地的(https://blog.thinkmoon.cn/post/394-ai-instruction-finetuning-model-instruction-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。