LLM 数据管线缺陷实录:两个单元测试无法覆盖的运行时问题

文章来源声明: 原文作者:Glitch404; 来源站点:掘金; 原文链接:https://juejin.cn/post/7688528701484073001; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

三个缺陷指向同一盲区:测试输入靠手写,生产输入来自模型采样分布。适合 LLM 训练与评测管线、数据工程团队阅读,增量落盘与 fail fast 的设计尤其值得借鉴。

> 我在后训练研究项目(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: [<span>'algebra'</span>, <span>'counting_and_probability'</span>,
<span>'geometry'</span>, <span>'intermediate_algebra'</span>, <span>'number_theory'</span>, <span>'prealgebra'</span>, <span>'precalculus'</span>]

原因。 原实现想当然地把 EleutherAI/hendrycks_math 当成单配置数据集:

dataset: <span>Any</span> = load_dataset(<span>"EleutherAI/hendrycks_math"</span>, split=<span>"train"</span>)

但这个仓库把 MATH 数据集按七个学科拆成了七个配置,load_dataset 在没有显式 config 名时无法决定加载哪个,直接拒绝。这类“多配置 hub 数据集”(类似的还有 super_glue、math_qa)的语义坑在于:本地测试时如果恰好在缓存里有某个默认配置的产物,它可能悄悄工作;换一台干净的机器立即崩溃。

修复。 MATH 的训练集语义本来就是七个学科的全集,所以正确写法是遍历拼接:

MATH_SUBJECTS = (
    <span>"algebra"</span>, <span>"counting_and_probability"</span>, <span>"geometry"</span>, <span>"intermediate_algebra"</span>,
    <span>"number_theory"</span>, <span>"prealgebra"</span>, <span>"precalculus"</span>,
)

<span>def</span> <span>load_math_train</span>() -> <span>list</span>[<span>dict</span>[<span>str</span>, <span>str</span>]]:
    <span>from</span> datasets <span>import</span> load_dataset
    records: <span>list</span>[<span>dict</span>[<span>str</span>, <span>str</span>]] = []
    <span>for</span> subject <span>in</span> MATH_SUBJECTS:
        dataset: <span>Any</span> = load_dataset(<span>"EleutherAI/hendrycks_math"</span>, subject, split=<span>"train"</span>)
        <span>for</span> item <span>in</span> dataset:
            ...  <span># 解析 solution、追加记录</span>
    <span>return</span> records

修复后还有一个隐藏收益:MATH 解析不出答案的样本会被 extract_answer 的 None 检查自然过滤,最终 8K 训练集里每个样本都带可校验的 #### 答案。

缺陷二:一个以 #### 结尾的输出,报废了 2.5 小时评测

这是代价最大、也最有教学价值的一个。

现象。 Base 模型的第一轮评测在 GSM8K 上跑了两个半小时后,整个进程消失。回看日志,最后的 traceback 是:

File ".../posttrain/answers<span>.py</span>", line <span>52</span>, in extract_answer
    candidate = text<span>.rsplit</span>("####", maxsplit=<span>1</span>)<span>[1]</span><span>.splitlines</span>()<span>[0]</span>
IndexError: list index out of range

根因。 出错的这行是想取模型输出里 #### 标记后面的第一行作为预测答案:

candidate = text.rsplit(<span>"####"</span>, maxsplit=<span>1</span>)[<span>1</span>].splitlines()[<span>0</span>]   <span># 崩溃点</span>

考虑两种输入的差异:

  • "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 指标),而不是崩掉整个评测:

<span>def</span> <span>_first_line</span>(<span>text: <span>str</span></span>) -> <span>str</span>:
    lines = text.splitlines()
    <span>return</span> lines[<span>0</span>] <span>if</span> lines <span>else</span> <span>""</span>

同时补上针对真实故障形态的回归测试:

<span>def</span> <span>test_extract_answer_survives_malformed_endings</span>() -> <span>None</span>:
    <span>assert</span> extract_answer(<span>"some reasoning ####"</span>) <span>is</span> <span>None</span>      <span># 原崩溃点</span>
    <span>assert</span> extract_answer(<span>"reasoning ####\n"</span>) <span>is</span> <span>None</span>
    <span>assert</span> extract_answer(<span>"reasoning #### 42"</span>) <span>is</span> <span>not</span> <span>None</span>    <span># 正常解析不受影响</span>

修复后 Base 模型的全量评测一次跑通,1319 题 GSM8K 的格式无效率为 0——这个 0 本身就是修复正确性的证据:残缺输出被正确地降级为 None,而不是崩溃或误判。

附赠:一份从未真正可安装的锁文件

排查缺陷二时顺藤摸瓜发现了第三个问题。项目里的 requirements.lock 写着:

<span>datasets</span>==<span>4.4</span>.<span>1</span>
<span>trl</span>==<span>1.7</span>.<span>0</span>        <span># 而 trl 1.7.0 声明依赖 datasets>=4.7.0</span>

这两行逻辑上不可能同时满足——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。

方法论收尾

把三个问题排在一起看,共性很清楚:

  1. 单元测试验证逻辑正确性(答案解析的规则对不对),但管线的健壮性来自真实运行(真实模型的输出分布、真实 hub 数据集的元数据、真实 pip 解析器)。两者不可互相替代。
  2. 每次真实运行暴露的缺陷,都值得一条回归测试。缺陷二的回归测试现在永远跑在 CI 里,这个故障形态不会再出现第二次。
  3. 失败要发生在正确的层。数据准备崩溃发生在下载层,五分钟定位;评测崩溃发生在 2.5 小时推理之后——前者是管线的功劳(fail fast),后者是管线的失败(聚合写盘把局部失败放大成了全局损失)。好的管线设计让每个缺陷都在它所属的层爆炸。

预注册实验设计在这里帮了忙:因为所有配置、数据契约、评测协议在跑之前就已固化,每个缺陷的“预期行为”是明确的,定位时不需要先争论“应该是什么行为”。


*完整的数据管线、修复后的代码与回归测试:*github.com/fangjianzhi…