junyi.isEN

三个模型,一个分数

我微调了两版 8B,又换了一个商用模型。三个都打了 0.923。问题不在模型,在那套评测上。

我花了几周把一个旅行产品的对话层蒸馏进一个 8B 模型,为发布做准备。然后我用六倍的数据训了第二版,分数一模一样。再后来我把整个角色换成另一家厂商的商用模型,分数还是一模一样。

三个毫无共同点的系统,一个数字。我隔了很久才把这件事读对。

做了什么

产品里一轮对话会经过好几个模型角色。其中一个角色要接住用户刚说的这句话,然后吐出一个后面程序能直接用的结构:该问的信息归好位、还差什么算出来、而且不能把自己的推理过程漏进给用户看的那段话里。这个角色原本用的是别人托管的大模型,我想把它换成自己的、跑在本地的小模型,于是做了蒸馏。

Qwen3-8B 上跑 QLoRA 4bit,LLaMA-Factory,单张 RTX 3090。训练数据来自在真实对话路径上捕获托管模型的行为。

v1  = checkpoint-891
      3 epoch / 891 步,cutoff 1536
      eval_loss 0.12487

merge 成 fp16 -> llama.cpp convert_hf_to_gguf -> f16 GGUF
-> ollama create --quantize q4_K_M   (5.0 GB)

发布之前要过一道验收:13 条从真实使用中摘出来、没参与训练的对话。我看的指标是 parseRate——模型的回复里,有多少比例能被程序按约定的格式解析出来。v1 是 13 中 12,也就是 0.923,而线画在 0.9。推理过程零泄漏,响应中位数 637 毫秒。它通过了发布门槛。

第二版

v2 是顺理成章的下一步:加数据、加多样性。种子由另一个模型做多样化,再经两个独立评审软过滤,训练集做到 28,810 条,大约是 v1 的六倍。

v2  = checkpoint-3400
      2 epoch,cutoff 1600
      28,810 条训练样本  (约 6x v1)
      eval_loss 0.2133

parseRate  0.923   <- 与 v1 完全相同

不是接近,是一模一样。还是 13 中 12——但不是原来那 12 条。v2 修好了一条,又弄坏了另一条,一进一出,净变化为零。我给自己定的规矩是「必须超过 0.923 才算过」,它没超,于是我把它搁下了。

我当时的结论是:这个任务饱和了,加数据没用。这个结论是错的,而且从我盯着的那个数字里,我根本没有办法知道它是错的。

第三个数据点

一个月后,我把这个角色换成了一个托管的商用模型——不同厂商、不同架构、不是我微调的,和前两者没有任何关系。我跑了同样那 13 条。

0.923。

到这一步,信号已经不含蓄了。当三个互相独立的系统给出的数字精确到小数位都一致,你看到的不是三个结果,你看到的是同一台仪器。

这套 eval 的分辨率是 7.7 分

13 条,每条只判对错。一条就占总分的 1/13,也就是 7.7 分。比 7.7 分更小的差别,这套题根本显示不出来——一个真的好了 5% 的模型,给出的数字和原来一模一样;一个「赢一条又输一条」的模型,给出的还是那个数字。它其实不是在给模型排名次,它只是把模型归进 14 个档位里的一档,然后告诉你是哪一档。

更糟的是,当时手上还有第二个读数在提醒我,我没听。eval_loss 是往反方向走的,从 0.12487 涨到了 0.2133——在一个大得多、也杂得多的数据集上少训了几轮,本来就会是这样。两个测量结果互相打架,我选了信粗的那个,只因为它是写进验收标准里的那个。

这条判据就写在我自己的运维手册里,而且是为另一件完全无关的事故写的:当多个互相独立的东西偏差量完全一致,偏差在尺子上,不在东西上。我在一个五小时的时区差上正确地用了它,却在这里连着几周都没想起来——因为在这里,相同的数字长得像成功,不像故障。

换我现在会怎么做

先定评测集有多大,再去训练,而不是反过来。n 条题、每条只判对错,能分辨的最小差别就是 1/n。如果你想判断的是「这一版有没有好三个点」,13 条题永远给不出答案,跑得再认真也没用。

打分要给部分分,别非对即错——五个信息对了四个、错一个,和压根吐不出程序能读的东西,不该算成同一件事。要比就在同一批输入上一条一条配对着比,并且把每条的差异都列出来:「赢一条又输一条,净变化为零」和「十三条完全没变」是两种完全不同的处境,而一个总分会把你到底在哪一种里藏起来。

还有一件事:把这套验收「能回答什么问题」明明白白写下来。我那套是为了回答「这一版上线安不安全」建的,这个问题它一直老老实实地答着。而我拿它去问「这一版是不是更好」——这个问题它从头到尾就答不了。

训练侧的几个坑

花过时间、而且文档里看不出来的:

  • ollama 无法从 safetensors 转 Qwen3ForCausalLM,必须走 llama.cpp 的 convert_hf_to_gguf。
  • ollama 自带模板会在提示词尾部追加 /no_think,这对契约类任务是致命的——模型会不再吐结构。必须自定义 TEMPLATE 删掉它。
  • 契约类任务要 temperature=0。调高一点,拿解析率换来的东西你一样都不想要。
  • preprocessing_num_workers 必须为 1;cutoff 按实测 token 分布定,别拍一个整数——这批数据上 1536 是零截断。
  • CUDA 轮子必须从 download.pytorch.org/whl/cu124 装;直接用 PyPI 会静默装成 CPU 版,你会在第一个训练步才发现。

尾声

这件事能被重新审视之前,训练机已经在显卡持续满载时反复失稳。供电是首要嫌疑,但我没有完成足以锁定故障元件的测试。机器已经不在了,生产跑的是托管模型,我自己的权重从来没有真正放到用户面前。

硬件坏掉是这里面最不值一提的部分。真正发生的事情是:我造了一台测量装置,拿它做了一个它支撑不了的判断,而我察觉到这一点,是因为一个完全不相干的模型走进来,交出了同一个数字。