AI文章智能速读 (TL;DR)
收起

作者背景:后端开发,业余自学大模型微调。本文记录一个个人练手项目(RoboThink)从选型、训练、量化到本地部署的完整过程,重点放在我踩过的坑、事后才看明白的问题,以及还没验证的假设。项目结果并不漂亮,但过程里的东西对我很有价值,所以整理出来。


0. 先说结论

  1. 用 LoRA 在单卡 A100 上微调 7B 模型,工程上的门槛比想象中低:1,296 条样本、162 步、约 5 分钟,加上 GGUF 量化后可以在 16GB 内存的 Mac 上离线运行。
  2. 但**"能跑通"和"效果好"之间隔着一整个数据工程**。我的第一版模型在日语测试题上出现了明显的失败:思考过程用中文输出,并且在 <think> 里反复假设、始终不收敛,直到触发长度截断。
  3. 事后复盘发现,问题大概率不是单一原因,而是数据语言单一、训练/推理格式不一致、评测样本太少这几件事叠加。其中"格式不一致"是我在写这篇文章、回头逐个核对文件时才发现的。
  4. 这个项目最大的收获不是那个 LoRA 权重,而是对"微调到底在改变模型什么"有了更具体的体感。

1. 项目目标与约束

我的目标很具体:

  • 模型能对仓储/AGV 调度类问题(通道避让、充电优先级、简单运筹计算)给出带推理过程、结论明确的回答;
  • 支持中、日、英三种语言提问;
  • 全程个人预算内完成:训练按小时租云 GPU,推理只用我手头一台 16GB 统一内存的 Apple Silicon Mac。

这三条约束基本决定了后面所有的技术选择。

整体流水线如下:

text 复制代码
  [通用 R1 蒸馏数据]                       [Colab A100 80GB]
   筛选 <think> 完整样本  ─────────►  Unsloth + TRL SFTTrainer
   拼装成 ChatML 文本                 LoRA r=32, bf16, 1 epoch
                                              │
                                              ▼
                                  robothink_7b_lora (约 323MB 适配器)
                                              │
                           ┌──────────────────┴──────────────────┐
                           ▼                                     ▼
              合并权重 → GGUF q4_k_m                    直接 PEFT 挂载 (备用)
                           │                                     │
                           ▼                                     ▼
              llama-cpp-python + Metal             Transformers + MPS
                           └──────────────┬──────────────────────┘
                                          ▼
                                  chat.py 命令行交互

2. 选型:用算术而不是直觉

2.1 为什么是 7B

对个人设备来说,模型大小首先是一道内存算术题。

  • 7.6B 参数,以 bf16 存储,每个参数 2 字节,仅权重就约 15.4 GB,已经超过 16GB 内存机器的实际可用空间;
  • 用 q4_k_m 量化后,平均约 4.8 bit/参数,权重约 4.5~5 GB,再加上 KV Cache 和系统开销,16GB 机器可以舒适运行。

所以"7B + 4-bit 量化"几乎是这类设备的甜点区间。再往上(14B)量化后也要 8~9GB,并发或长上下文时会很紧张。

基座选择 DeepSeek-R1-Distill-Qwen-7B 的理由:它本身已经具备 <think> 先思考再回答的行为,Qwen 底座的中日英多语言能力也不错。我要做的是"领域适配",而不是从头教它推理。

2.2 LoRA 的参数量是怎么来的

训练日志里显示可训练参数为 80,740,352 / 7,696,356,864(1.05%)。我对着模型结构手算了一遍,确认自己理解了这个数字:

Qwen2-7B 的关键维度:隐藏层 3584,MLP 中间层 18944,GQA 下 K/V 的投影维度为 512,共 28 层。LoRA 对一个 in → out 的线性层增加 r × (in + out) 个参数,r = 32,于是每层:

模块 计算 参数量
q_proj 32 × (3584 + 3584) 229,376
k_proj 32 × (3584 + 512) 131,072
v_proj 32 × (3584 + 512) 131,072
o_proj 32 × (3584 + 3584) 229,376
gate_proj 32 × (3584 + 18944) 720,896
up_proj 32 × (3584 + 18944) 720,896
down_proj 32 × (18944 + 3584) 720,896
每层合计 2,883,584

2,883,584 × 28 层 = 80,740,352,与日志完全吻合。

这个练习的价值在于:我清楚地知道 rank 和 target_modules 每多选一项,参数量和显存会怎么变,而不是把 r=32 当成魔法数字。


3. 训练流水线的细节

3.1 数据:通用开源蒸馏集 + 简单过滤

第一版我没有自己造数据,而是直接取 Congliu/Chinese-DeepSeek-R1-Distill-data-110k-SFT 的前 3,000 条,保留包含完整 <think>...</think> 且长度在 80~1800 字符之间的样本,最终留下 1,296 条。

python 复制代码
def is_valid_cot(sample):
    out = sample.get("output", "")
    return "<think>" in out and "</think>" in out and 80 <= len(out) <= 1800

这里有一个当时没有深想的点:这份数据集是中文的。它和仓储场景也没有直接关系,本质上只是让模型"继续保持 R1 风格的输出"。后面会看到,这是个伏笔。

3.2 格式:手写 ChatML

我把每条样本手工拼成了 ChatML 格式的 text 字段:

python 复制代码
text = (f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
        f"<|im_start|>user\n{query}<|im_end|>\n"
        f"<|im_start|>assistant\n{out}<|im_end|>")

当时的想法是"Qwen 系列用的就是 ChatML,所以应该没问题"。这个假设后面被证明有问题,见第 6 节。

3.3 超参数

参数 取值 说明
精度 bf16,不量化加载 A100 80GB 显存充裕,避免量化误差进入训练
LoRA r=32, alpha=32, dropout=0 alpha/r = 1,缩放系数为 1
Batch 4 × 梯度累积 2 = 8 1,296 / 8 = 162 步,对应日志
学习率 2e-4,cosine,warmup 10 步 LoRA 的常见区间
优化器 adamw_8bit 省显存
Epoch 1 总耗时约 5 分 06 秒

Loss 从第 10 步的 1.71 降到 1.2~1.3 之间并在后半程基本持平。

需要谨慎解读这条 loss 曲线:它只说明模型在拟合这批数据的输出风格,并不能说明它在仓储调度问题上变聪明了。训练 loss 下降,和"我关心的任务变好"之间没有必然联系,这一点在下面的评测里体现得很直接。

3.4 一个小的工程教训:配置文件没有真正生效

项目里有一份 configs/training_config.yaml,写着 num_samples: 1500、logging_steps: 5。但 Notebook 里的训练代码是硬编码的(logging_steps = 10,实际样本 1,296 条),根本没有读这份配置。

这属于典型的"文档和代码漂移":配置文件看起来很规范,实际上没人使用,反而会误导以后的自己。后续我打算让训练脚本真正从 YAML 加载参数,或者干脆删掉这份配置。


4. 量化导出与本地部署

4.1 合并 + GGUF 量化

训练得到的是约 323MB 的 LoRA 适配器。为了在 Mac 上获得好的推理性能,我用 Unsloth 把适配器合并进底座,再转换成 q4_k_m 的 GGUF:

python 复制代码
model.save_pretrained_gguf("robothink_7b_q4", tokenizer, quantization_method="q4_k_m")

日志显示流程是:先合并成 16bit 权重 → 转 BF16 GGUF → 再量化成 Q4_K_M,整体在云端大约 16 分钟(官方预估值,我没有精确计时)。

4.2 chat.py 的两个设计

(1) 双引擎自动选择

启动时先在 models/、项目根目录、~/Downloads 里找 .gguf:

  • 找到:使用 llama-cpp-python,n_gpu_layers=-1,全部层卸载到 Metal GPU;
  • 找不到:回退到 PyTorch(mps → cuda → cpu)加载原始底座,再用 PEFT 挂载 LoRA。

这样即便没导出 GGUF,项目也能跑起来,只是更慢更吃内存。

(2) 历史记录里剥离思考过程

推理模型每轮回答都带一大段 <think>。如果把它们全部放回多轮对话的上下文,既浪费 KV Cache,又会让后续轮次的注意力被旧的推理过程干扰。所以存历史时只保留结论:

python 复制代码
clean_reply = re.sub(r"<think>.*?</think>", "", full_reply, flags=re.DOTALL)
clean_reply = clean_reply.replace("<|im_end|>", "").strip()
if clean_reply:
    history.append({"role": "assistant", "content": clean_reply})

后来我核对 DeepSeek 官方聊天模板时发现,模板本身也是这样做的(对 assistant 历史内容取 </think> 之后的部分)。这算是殊途同归,让我对这个设计更有信心。


5. 评测:一次失败的实验

训练完我只在 Notebook 里跑了一道测试题(日语 AGV 充电优先级):

AGV-1 电量 82%,距充电站 8m;AGV-2 电量 11%,距充电站 45m;只有一个空闲充电桩,FMS 应该把优先权给谁?

期望的答案很明确:AGV-2。电量 11% 已经逼近"趴窝堵路"的风险线,优先级高于距离因素。

模型的实际表现:

  • 题目是日语,<think> 里却整段用中文自言自语;
  • 反复在"优先电量低的"和"优先电量高的"之间摇摆;
  • 自行假设"充电速度每小时 10%""每小时 5%",推出一堆题目里根本没有的数字;
  • 在 1024 个新 token 的上限处被截断,没有给出任何最终结论。

这是一个很典型的过度思考(overthinking)现象:模型没有收敛的"出口"。

需要强调的是:这只是一个样本。我在此基础上不应该得出"模型整体不行"的结论,也不应该相信任何"全部通过"的说法。事实上,项目 README 里原先写的几个"100% Pass",并没有对应的自动化评测支撑,我已经意识到这类说法不该留在文档里。


6. 复盘:失败可能由哪些因素造成

下面是我对这次失败的分析。这些都是有依据的假设,而不是已被消融实验证实的结论。

6.1 数据是单一语言(中文)

训练集全部来自中文蒸馏数据。LoRA 微调会把模型的"思考语言"往训练数据的分布上拉。日语提问 + 中文思考,是这一假设下非常自然的结果:模型学到的是"R1 风格的思考 = 中文"。

可验证的做法:在英文、中文、日语三种提问下分别统计 <think> 的主要语言,看是否在中文提问下表现正常、在日语/英语提问下出现语言漂移。

6.2 训练格式与推理格式不一致(最重要的发现)

这是我回头逐个文件核对时发现的。

  • 训练时:我手写了 ChatML(<|im_start|>...<|im_end|>);
  • 导出的聊天模板(chat_template.jinja):是 DeepSeek 风格 —— 用户轮是 <|User|>,助手轮是 <|Assistant|>,生成提示末尾是 <|Assistant|><think>\n,结束符是 <|end▁of▁sentence|>。

也就是说,模型在训练时见到的是一种"方言",而在 chat.py(无论是 GGUF 内置模板,还是 apply_chat_template)里推理时遇到的是另一种"方言"。我手工在 Notebook 评测里用的是 ChatML,所以评测和训练是对齐的,但真正上线路径并不是。

这种不一致不一定会让模型完全失效,但它会削弱微调效果,并让"训练时看起来没问题,部署后表现不稳"这种现象变得很难排查。

更稳妥的做法:训练数据统一使用 tokenizer 自带的模板来渲染,让训练、评测、推理三处共用同一份模板:

python 复制代码
def format_with_template(batch):
    texts = []
    for inst, inp, out in zip(batch["instruction"], batch["input"], batch["output"]):
        user = f"{inst}\n{inp}".strip() if inp else inst
        messages = [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": user},
            {"role": "assistant", "content": out},
        ]
        texts.append(tokenizer.apply_chat_template(messages, tokenize=False))
    return {"text": texts}

需要留意的一个细节:这份模板在渲染 assistant 历史内容时会丢弃 </think> 之前的内容。如果训练样本的 assistant 部分要保留思考过程,就不能直接用这个模板渲染,要么自己拼接,要么修改模板,这点需要在实现时想清楚。

6.3 System Prompt 不一致

训练时的 System Prompt 只有"请在 <think> 内逐步推理"这半句;而 chat.py 里的 System Prompt 多了一句:"如果你的初步推导在数学和逻辑上是严谨的,就直接收敛到最终结论。"

我加这句话的初衷是缓解过度思考,但模型在训练时从没见过这句话,指望它在推理时靠一句提示就改变行为,并不现实。如果希望这句约束起作用,应该让它出现在训练数据里,并且配套的样本确实体现了"直接收敛"的行为。

6.4 Loss 覆盖了整条序列

目前训练是对整段文本(包括 system、user 和 assistant)一起算 loss,模型也在"学习复述题目"。更常见、也更合理的做法是只对助手回复部分计算 loss(response-only training)。Unsloth 提供了对应的工具:

python 复制代码
from unsloth.chat_templates import train_on_responses_only

trainer = train_on_responses_only(
    trainer,
    instruction_part="<|User|>",
    response_part="<|Assistant|>",
)

分隔标记要与实际使用的模板严格一致,所以这一步需要在 6.2 修复之后再做。

6.5 评测过于单薄

只有一道题,没有自动化评测,没有固定随机种子,也没有"对比基座模型"的参照。这意味着我无法回答最基本的问题:微调相比不微调,到底有没有变好?


7. 下一步:构造领域数据集

7.1 为什么必须有领域数据

通用蒸馏数据能保持模型的"表达风格",但教不会它"在仓储场景里,什么条件下该优先什么"。这些判据来自业务规则,比如:

  • 电量低于某个安全阈值,说明存在中途停机并堵塞通道的风险,优先级应高于距离因素;
  • 单行通道对向相遇,应优先让退让代价最小的一方后退(离避让点近、载荷轻、任务优先级低);
  • 紧急任务优先级高于普通任务,但要权衡其对全局拥堵的影响。

这类规则最好以确定性的方式写进数据里:由代码按规则算出标准答案,再包装成自然语言问答,而不是让模型"自由发挥"后再人工检查。

7.2 收敛的思考模板

针对"想不完"的问题,我设计了一个固定的四步结构,作为训练数据里 <think> 的写法:

  1. 状态提取:列出题目给定的事实(各车位置、电量、任务级别、约束);
  2. 规则匹配:指出适用的调度规则或安全红线;
  3. 对比排除:量化比较各选项,排除不满足条件的;
  4. 明确结论:直接给出指令,不再展开新的假设。

关键点是禁止在思考里引入题目中不存在的假设数值(如"假设充电速度是每小时 10%")。这种习惯恰恰是第 5 节里失败案例的根源。

7.3 三语一致性

每条样本保证"提问语言 = 思考语言 = 回答语言",并且用代码做校验:

python 复制代码
import re

CJK  = re.compile(r"[\u4e00-\u9fff]")
KANA = re.compile(r"[\u3040-\u30ff]")

def language_consistent(lang: str, think: str) -> bool:
    if lang == "en":
        return not CJK.search(think)          # 英文思考中不应出现中日文字符
    if lang == "ja":
        return bool(KANA.search(think))       # 日文思考中必须出现假名
    if lang == "zh":
        return bool(CJK.search(think)) and not KANA.search(think)
    return False

这个检查很粗糙(比如日文里也大量用汉字),但足以拦住"日语题却全是中文思考"这类最严重的错误。

7.4 质量门控

写入数据集前统一过滤:

  • <think> 与 </think> 各出现且仅出现一次,顺序正确;
  • 思考正文长度限制在一个较短的区间(我目前设的是 30~1000 字符),让模型形成"逻辑够了就停"的习惯;
  • </think> 之后必须有非空的结论;
  • 通过上面的语言一致性校验。

7.5 关于私有数据的原则

如果将来要用公司内部资料做数据源,我给自己定了几条底线:

  • 原始资料不离开本地、不上传任何外部服务,清洗和转写全部离线完成;
  • 在转写前先做脱敏(公司名、客户名、内网地址等替换为通用词),并把原始资料目录加入 .gitignore,防止误提交;
  • 对非结构化文档,使用本地模型做提炼,不依赖云端 API。

我为此写了一个离线脚本,目前只是框架性质,还没有用真实数据完整跑过,更谈不上效果验证,这点要如实说明。


8. 评测体系:先于第二轮训练

在做第二轮训练之前,我打算先把评测补上,否则又会陷入"凭感觉判断好坏"。计划如下:

  1. 固定评测集:覆盖三种语言、几类场景(避让、充电、运筹计算、故障排查),每类若干条,题目和标准答案由规则代码生成,确保答案客观;
  2. 自动指标:
    • 结论正确率(答案是否与标准答案一致);
    • <think> 闭合率;
    • 平均思考长度与触发截断的比例;
    • 语言一致率;
  3. 对照组:同一批题目,分别在未微调的基座、第一版 LoRA、第二版 LoRA 上跑,用同样的解码参数;
  4. 解码设置固定:低温度或贪心解码,固定随机种子,保证结果可复现。

有了这套东西,第 6 节里的每个假设都能通过单变量消融(只改语言/只改模板/只改 loss 掩码)来逐个验证,而不是一次改很多然后猜哪个有用。


9. 关于"大模型在业务里能做什么"的一点思考

做这个项目的过程中,我时常在想:假设真有几百上千台机器人在同一个仓库里运行,一个 7B 的模型在系统里应该放在哪里?

我目前的看法是:大模型不应该放在实时控制回路里。

  • 路径规划、激光避障、正常路口通行,都有成熟的确定性算法,延迟在毫秒级,行为可预测、可证明、可回放。大模型在这里既慢又不稳定,没有任何优势;
  • 大模型更适合处理低频、模糊、需要综合上下文的事情:比如多种告警同时出现时帮人整理排查思路、用自然语言查询系统状态、对一个复杂冲突区域给出"建议"。

如果真的要接入控制系统,我认为需要几层保护:

  1. 事件触发 + 区域聚合:不要让每台车单独请求模型。把一个冲突区域内的多台车状态打包成一个快照,一次性决策,避免局部各自为战,也大幅降低请求量;
  2. 结构化输出 + 校验:模型只输出受约束的结构化指令(如 JSON schema),由确定性的代码做合法性检查和安全边界校验,不通过就拒绝或回退到传统调度;
  3. 人在回路:涉及强制停车、修改库存等不可逆操作,必须有人工确认;
  4. 全量记录:每次模型输入、输出和最终执行结果都落盘,用于事后复盘和持续改进数据集。

这也呼应了第 7 节的思路:现场出现的"坏案例",应该回流成下一轮训练和评测的数据,形成闭环。


10. 小结与下一步

回头看这个项目,我最大的体会有三点:

  1. 工具链已经非常成熟。从 LoRA 微调、量化到本地推理,单人在业余时间完全可以走通,成本也很低;
  2. 难点转移到了数据和评测。训练只花了 5 分钟,而"该喂什么数据、怎么判断好坏"才是真正占据时间的部分;
  3. 一致性是大量隐蔽问题的来源。训练和推理的模板、System Prompt、语言分布,只要有一处对不上,现象就会表现为"模型莫名其妙变笨",排查起来非常困难。

接下来的计划,按优先级:

  • 统一训练/评测/推理的聊天模板,修复格式不一致;
  • 建立带标准答案的三语评测集和自动化评测脚本;
  • 在基座模型上跑一遍基线,量化"微调前后"的差距;
  • 构造规则驱动的三语领域数据集,加上语言一致性和长度质量门控;
  • 尝试 response-only loss,并通过单变量消融验证各项改动的实际贡献;
  • 让训练脚本真正读取配置文件,并清理文档中无法验证的数字。

项目还很粗糙,写出来一方面是为了整理思路,另一方面也希望能帮到同样从后端转向大模型方向的朋友:先把评测和数据的一致性做好,再去追求更大的模型和更多的算力。

如果你对文中任何一处判断有不同看法,或者发现我哪里理解错了,欢迎指正。

评论交流

发表你的评论...
0 / 500
评论交流 (0)
暂无评论,快来抢占第一条沙发吧~
2
1
AIAI