我在后训练研究项目(Qwen3-0.6B QLoRA SFT/DPO,仓库见文末)里给数据与评测管线写了 13 项单元测试,全部通过。然后在第一次真实运行里,数据准备命令直接崩溃;评测管线则撑了 2.5 小时后死在最后一个环节上。本文是这两个缺陷的完整根因分析,外加一个"锁文件从未真正可安装"的工程债。三个问题有同一个母题:单元测试覆盖的是逻辑正确性,而管线的健壮性只能被真实运行验证。
缺陷一:hendrycks_math缺少必需的 config,数据准备首跑即崩
现象。数据准备命令python -m posttrain.prepare在下载完 GSM8K 后当场抛出:
ValueError: Config name is missing. Please pick one among the available configs: ['algebra', 'counting_and_probability', 'geometry', 'intermediate_algebra', 'number_theory', 'prealgebra', 'precalculus']原因。原实现想当然地把EleutherAI/hendrycks_math当成单配置数据集:
dataset:Any=load_dataset("EleutherAI/hendrycks_math",split="train")但这个仓库把 MATH 数据集按七个学科拆成了七个配置,load_dataset在没有显式 config 名时无法决定加载哪个,直接拒绝。这类"多配置 hub 数据集"(类似的还有 super_glue、math_qa)的语义坑在于:本地测试时如果恰好在缓存里有某个默认配置的产物,它可能悄悄工作;换一台干净的机器立即崩溃。
修复。MATH 的训练集语义本来就是七个学科的全集,所以正确写法是遍历拼接:
MATH_SUBJECTS=("algebra","counting_and_probability","geometry","intermediate_algebra","number_theory","prealgebra","precalculus",)defload_math_train()->list[dict[str,str]]:fromdatasetsimportload_dataset records:list[dict[str,str]]=[]forsubjectinMATH_SUBJECTS:dataset:Any=load_dataset("EleutherAI/hendrycks_math",subject,split="train")foritemindataset:...# 解析 solution、追加记录returnrecords修复后还有一个隐藏收益:MATH 解析不出答案的样本会被extract_answer的 None 检查自然过滤,最终 8K 训练集里每个样本都带可校验的####答案。
缺陷二:一个以####结尾的输出,报废了 2.5 小时评测
这是代价最大、也最有教学价值的一个。
现象。Base 模型的第一轮评测在 GSM8K 上跑了两个半小时后,整个进程消失。回看日志,最后的 traceback 是:
File ".../posttrain/answers.py", line 52, in extract_answer candidate = text.rsplit("####", maxsplit=1)[1].splitlines()[0] IndexError: list index out of range根因。出错的这行是想取模型输出里####标记后面的第一行作为预测答案:
candidate=text.rsplit("####",maxsplit=1)[1].splitlines()[0]# 崩溃点考虑两种输入的差异:
"reasoning ####\n":rsplit后剩余"\n",splitlines()返回[""],取[0]得空串,被后面的if candidate.strip()兜住——正常降级;"reasoning ####"(####恰好是输出结尾):rsplit后剩余"",而"".splitlines()返回空列表[],[0]直接越界——崩溃。
一个字符的差别,走的是完全不同的分支。而 Base 模型(没经过任何####格式微调)恰恰大量产生残缺、截断、以####收尾的输出——这个缺陷只在"未微调模型 + 真实生成分布"的组合下触发。
为什么 13 项单测没拦住?因为单测里的输入是我手写的、格式规整的字符串。测试覆盖了"解析逻辑对不对",没有覆盖"生产环境的输入长什么样"。这是所有 LLM 评测管线的共性盲区:传统软件的输入来自用户,边界可以枚举;LLM 管线的输入来自另一个模型的采样分布,边界只能实测。
代价放大器。更疼的是后果:评测循环逐题推理,但结果文件是整个基准跑完才写盘。第 N 题崩溃 = 前 N-1 题全部作废,2.5 小时归零。这个"聚合写盘"策略本身也是个缺陷(应该增量落盘),属于同一类"没被真实运行检验过的设计"。
修复。加一个安全的取行助手,让残缺输出走设计好的降级路径(返回 None,计入 invalid_rate 指标),而不是崩掉整个评测:
def_first_line(text:str)->str:lines=text.splitlines()returnlines[0]iflineselse""同时补上针对真实故障形态的回归测试:
deftest_extract_answer_survives_malformed_endings()->None:assertextract_answer("some reasoning ####")isNone# 原崩溃点assertextract_answer("reasoning ####\n")isNoneassertextract_answer("reasoning #### 42")isnotNone# 正常解析不受影响修复后 Base 模型的全量评测一次跑通,1319 题 GSM8K 的格式无效率为 0——这个 0 本身就是修复正确性的证据:残缺输出被正确地降级为 None,而不是崩溃或误判。
附赠:一份从未真正可安装的锁文件
排查缺陷二时顺藤摸瓜发现了第三个问题。项目里的requirements.lock写着:
datasets==4.4.1 trl==1.7.0 # 而 trl 1.7.0 声明依赖 datasets>=4.7.0这两行逻辑上不可能同时满足——pip 直接报ResolutionImpossible。也就是说这份锁文件是手写 aspirational 的,从未被pip freeze类工具从真实环境生成、也没被干净环境验证过。而如果绕过锁文件按pyproject.toml的宽松版本范围安装,就会装到 trl 1.13,然后撞上另一个 API 漂移:SFTConfig(warmup_ratio=...)直接 TypeError。最终的对齐方案是 trl 1.7.0 + transformers 5.6.1 + datasets 4.8.5——代码是对着这三个版本写的,但这个事实只存在于作者的脑子里,直到实跑才被逼出来。
教训:锁文件要么由工具生成并被一次干净安装验证,要么别叫 lock。
方法论收尾
把三个问题排在一起看,共性很清楚:
- 单元测试验证逻辑正确性(答案解析的规则对不对),但管线的健壮性来自真实运行(真实模型的输出分布、真实 hub 数据集的元数据、真实 pip 解析器)。两者不可互相替代。
- 每次真实运行暴露的缺陷,都值得一条回归测试。缺陷二的回归测试现在永远跑在 CI 里,这个故障形态不会再出现第二次。
- 失败要发生在正确的层。数据准备崩溃发生在下载层,五分钟定位;评测崩溃发生在 2.5 小时推理之后——前者是管线的功劳(fail fast),后者是管线的失败(聚合写盘把局部失败放大成了全局损失)。好的管线设计让每个缺陷都在它所属的层爆炸。
预注册实验设计在这里帮了忙:因为所有配置、数据契约、评测协议在跑之前就已固化,每个缺陷的"预期行为"是明确的,定位时不需要先争论"应该是什么行为"。
完整的数据管线、修复后的代码与回归测试:https://github.com/fangjianzhi/small-llm-post-training(src/posttrain/prepare.py、src/posttrain/answers.py、tests/test_core.py)
系列文章:①《Qwen3-0.6B 后训练实践:一次被数据否定的预注册假设》②《LLM 评测吞吐优化实践:从 18s/题 到 2.5s/题》