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 条中文指令-响应对

整体流程长这样:

graph LR A[基础模型<br/>Llama-3-8B] --> B[量化加载<br/>4bit] B --> C[LoRA 适配器<br/>rank=8, alpha=16] C --> D[指令数据<br/>2000 条] D --> E[微调训练<br/>3 epochs] E --> F[合并权重<br/>base + adapter] F --> G[评估效果<br/>对比测试]

这张图简单说明了整个流程:从原始模型开始,通过量化降低显存占用,加上 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
  • 手工撰写的示例指令-响应对

收集过程中发现几个问题:

  1. 指令重复:同一个问题换了不同的说法,本质是同一个任务
  2. 响应风格不一:有些带详细解释,有些只有代码,有些很简短
  3. 数据质量不均:有些是早期写的,质量一般

针对重复问题,用了一个简单的去重策略:对指令做清洗(去标点、转小写、去掉停用词),然后用文本相似度(cosine similarity)聚类,相似度 > 0.85 的只保留一条。

响应风格的问题,花了两天时间手工统一风格——每个响应都遵循这个模式:

  1. 直接回答问题(1-2 句)
  2. 给出代码或示例(如果有)
  3. 补充必要解释(不超过 200 字)
  4. 最后给一个使用提示(如果有)

最终数据集:2000 条高质量中文指令-响应对,覆盖:

指令微调数据集的类别分布占比,代码生成类任务占 40%

  • 代码生成与调试(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 一直不降,甚至在上升。检查了几个点:

  1. 学习率:初始用的 2e-5,太大导致训练不稳定。降到 5e-6 后 loss 开始正常下降
  2. Batch size:原本是 4,梯度累积 8 步,实际相当于 batch=32。太大导致更新太粗,改回 batch=16 后收敛变稳
  3. 数据顺序:数据本身没 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 降得很低,但测试集上的表现开始变差——典型过拟合。

加了几个正则手段:

  1. Dropout:LoRA 配置里 lora_dropout=0.05,适度随机丢弃防止过拟合
  2. Early stopping:虽然这次没用到,但如果连续多个 step loss 不降就停止
  3. 数据增强:对部分指令做了同义词替换和句式改写,扩充到 2200 条

QLoRA 微调过程中的训练 Loss 曲线,展示 3 个 epoch 的收敛情况

最终第 3 个 epoch 完成后,loss 在 0.8 左右,算是收敛了。这条曲线在第二个 epoch 开始明显放缓,说明模型已经学会了主要模式,剩下的训练更多是在细节上优化。

效果评估与对比

训练完成后的模型,做了一轮人工评估,对比了三个版本:

  1. 原始 Llama-3-8B:未微调的基座
  2. Prompt-tuned 版本:只用系统提示词引导的版本
  3. SFT 版本:这次训练出来的模型

评估用了 50 个测试问题,从几个维度打分:

  • 指令理解:模型是否明白用户要做什么
  • 输出质量:回答是否准确、简洁、可执行
  • 风格一致性:是否符合预期的"代码+简要解释"风格
  • 幻觉程度:是否产生错误或不存在的信息

整体结果:

维度原始模型Prompt-tunedSFT 版本
指令理解2.8/53.5/54.2/5
输出质量3.1/53.8/54.5/5
风格一致性2.5/53.2/54.6/5
幻觉程度3.0/53.4/54.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)
}

这是一个最简单的静态文件服务器实现:

  1. http.FileServer 创建静态文件处理器,指向 ./static 目录
  2. http.Handle 将根路径 / 映射到文件处理器
  3. ListenAndServe 在 8080 端口启动服务

运行后,访问 http://localhost:8080/index.html 会返回 ./static/index.html 的内容。如果需要自定义 404 页面,可以添加额外的处理逻辑。

风格完全对上了:先给代码,再补必要的解释,最后加一个使用提示。

收获与边界

这次折腾下来,对指令微调有了更实的理解:

  1. 数据比模型重要:用 7B 模型 + 高质量数据,效果可能比 13B 模型 + 垃圾数据好
  2. SFT 不是万能药:只能让模型"学会听话",不能提升它的基础能力。如果模型本身就不懂某个领域,SFT 也救不了
  3. 算力瓶颈可以用技巧绕过:QLoRA + 合理配置,可以在消费级显卡上训练 7B 模型
  4. 时间成本主要在数据:训练本身只要几小时,数据准备要几天甚至更久

但也有明确的边界:

  • 领域迁移能力有限:训练数据主要集中在技术问答,让它写营销文案还是不行
  • 指令覆盖范围有限:2000 条数据能覆盖常见任务,但遇到新任务还是吃力
  • 持续维护成本:新的业务场景出现时,需要补充数据和重新训练

下一步考虑

后续可能做的几件事:

  1. 扩充数据集:从 2000 条扩展到 5000 条,覆盖更多任务类型
  2. 多轮对话支持:当前只支持单轮指令,后续考虑加入对话历史
  3. Reward Model:如果数据量够,可以加一步 RLHF,进一步优化响应质量

但目前的版本已经能满足内部知识库问答的基本需求了。至少现在,它不会再从 WebSocket 的历史背景讲起了。


指令微调这件事,本质上是让大模型从"百科全书"变成"实用工具"。这个过程不神秘,但确实需要不少细节上的耐心。如果你也在考虑做 SFT,建议先把数据准备好,其他的都可以慢慢调。

版权声明: 本文首发于 指尖魔法屋-AI 指令微调:这次怎么落地的https://blog.thinkmoon.cn/post/394-ai-instruction-finetuning-model-instruction-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!