我在后训练研究项目(Qwen3-0.6B QLoRA SFT/DPO,仓库见文末)里给数据与评测管线写了 13 项单元测试,全部通过。然后在第一次真实运行里,数据准备命令直接崩溃;评测管线则撑了 2.5 小时后死在最后一个环节上。本文是这两个缺陷的完整根因分析,外加一个"锁文件从未真正可安装"的工程债。三个问题有同一个母题:单元测试覆盖的是逻辑正确性,而管线的健壮性只能被真实运行验证。
缺陷一:hendrycks_math 缺少必需的 config,数据准备首跑即崩
现象。 数据准备命令 python -m posttrain.prepare 在下载完 GSM8K 后当场抛出:
arduino
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 当成单配置数据集:
python
dataset: Any = load_dataset("EleutherAI/hendrycks_math", split="train")
但这个仓库把 MATH 数据集按七个学科拆成了七个配置,load_dataset 在没有显式 config 名时无法决定加载哪个,直接拒绝。这类"多配置 hub 数据集"(类似的还有 super_glue、math_qa)的语义坑在于:本地测试时如果恰好在缓存里有某个默认配置的产物,它可能悄悄工作;换一台干净的机器立即崩溃。
修复。 MATH 的训练集语义本来就是七个学科的全集,所以正确写法是遍历拼接:
python
MATH_SUBJECTS = (
"algebra", "counting_and_probability", "geometry", "intermediate_algebra",
"number_theory", "prealgebra", "precalculus",
)
def load_math_train() -> list[dict[str, str]]:
from datasets import load_dataset
records: list[dict[str, str]] = []
for subject in MATH_SUBJECTS:
dataset: Any = load_dataset("EleutherAI/hendrycks_math", subject, split="train")
for item in dataset:
... # 解析 solution、追加记录
return records
修复后还有一个隐藏收益:MATH 解析不出答案的样本会被 extract_answer 的 None 检查自然过滤,最终 8K 训练集里每个样本都带可校验的 #### 答案。
缺陷二:一个以 #### 结尾的输出,报废了 2.5 小时评测
这是代价最大、也最有教学价值的一个。
现象。 Base 模型的第一轮评测在 GSM8K 上跑了两个半小时后,整个进程消失。回看日志,最后的 traceback 是:
scss
File ".../posttrain/answers.py", line 52, in extract_answer
candidate = text.rsplit("####", maxsplit=1)[1].splitlines()[0]
IndexError: list index out of range
根因。 出错的这行是想取模型输出里 #### 标记后面的第一行作为预测答案:
python
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 指标),而不是崩掉整个评测:
python
def _first_line(text: str) -> str:
lines = text.splitlines()
return lines[0] if lines else ""
同时补上针对真实故障形态的回归测试:
python
def test_extract_answer_survives_malformed_endings() -> None:
assert extract_answer("some reasoning ####") is None # 原崩溃点
assert extract_answer("reasoning ####\n") is None
assert extract_answer("reasoning #### 42") is not None # 正常解析不受影响
修复后 Base 模型的全量评测一次跑通,1319 题 GSM8K 的格式无效率为 0------这个 0 本身就是修复正确性的证据:残缺输出被正确地降级为 None,而不是崩溃或误判。
附赠:一份从未真正可安装的锁文件
排查缺陷二时顺藤摸瓜发现了第三个问题。项目里的 requirements.lock 写着:
ini
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),后者是管线的失败(聚合写盘把局部失败放大成了全局损失)。好的管线设计让每个缺陷都在它所属的层爆炸。
预注册实验设计在这里帮了忙:因为所有配置、数据契约、评测协议在跑之前就已固化,每个缺陷的"预期行为"是明确的,定位时不需要先争论"应该是什么行为"。
*完整的数据管线、修复后的代码与回归测试:*github.com/fangjianzhi...