都柏林 DCU 商业分析硕士生。我构建小型、真实的数据和机器学习项目,并记录结果以及其中的 bug。
我们的大语言模型基准测试中有一个单元格显示 100% 失败率(42 次运行中 42 次失败,召回率 0.0)。看起来像是模型崩溃。其实是我们的解析器。
在 42 次失败中,有 30 次是因为模型没有重复我们代码已经知道的字段。只有 5 次是我们预期的 JSON 格式问题。
在看到结果之后再修复评分器是危险的。我们只修复格式问题,保留所有内容失败,绝不触碰原始输出,并编写测试将这些规则锁定。
第二个无关问题,即隐藏的“推理” token 在免费层 API(应用程序编程接口) 上消耗输出预算,产生了截断答案,看起来像是模型质量差异。这两个问题都容易犯,也容易漏掉。
我们构建了一个基准测试,用来检验大语言模型能否根据项目规划文档编写项目风险登记册。真实基准是真实的:21 个世界银行和英国政府项目,每个项目都发布了人工编写的风险登记册。我们从模型输入中移除这些登记册,要求三个模型生成自己的登记册,然后通过语义相似度匹配两组结果。
网格是三个模型(Gemini、gpt-oss-120b 和 Llama-3.3-70B-Instruct)、三种提示策略(zero-shot、few-shot,以及一种要求逐步推理并在之后给出 FINAL JSON: 标记的“结构化”提示),每个项目每个单元格运行两次。这产生了 392 次真实生成,全部使用免费层 API,总共约 US$0.18 的付费回退调用。我们在 2026 年 7 月下旬遇到这个问题,当时结果仍在陆续产生。
每个模型输出都必须是符合固定 schema 的有效 JSON,才能被评分。未能解析的运行计为 recall = 0。
当我们第一次对网格进行评分时,有一个单元格看起来是这样的:
| 模型 | 提示 | 运行次数 | 解析失败 | | --- | --- | --- | --- | | Llama-3.3-70B | zero-shot | 43 | 5 | | Llama-3.3-70B | few-shot | 42 | 10 | | Llama-3.3-70B | structured | 42 | 42 |
同一个模型,同样的 21 个项目,它在两个提示上大多数时候都能正常工作,而在第三个提示上从未成功。一个模型在 42 次尝试中全部失败是有可能的,但我们不相信这个结果。在真实实验中,完美的 100% 或 0% 通常意味着问题出在测试框架,而不是模型。
所以我们做了最枯燥的事:不再看结果表格,而是开始阅读原始失败记录。
我们按根本原因拆解了整个网格中全部 88 次解析失败。对于那一个单元格中的 42 次失败,旧解析器自身的错误信息说明了问题:
| 运行次数 | 旧解析器的报错 | 实际发生的情况 | | --- | --- | --- | | 30 | 'project_id' 是必需属性 | 模型没有重新声明项目 ID。我们的代码已经知道每次调用对应哪个项目。 | | 5 | Extra data: line 9 column 2 | 模型将风险列为独立的 JSON 对象,没有用数组包裹。解析器保留了第一个,丢弃了其余的。 | | 7 | 没有 FINAL JSON: 标记 | 模型从未给出答案。这是真正的失败。 |
解析器在两个方面过于严格,而这两方面与模型能否识别风险毫无关系:
我们的 schema 要求模型重复一个我们可以自行填充的字段。
提取正则要求恰好一个 {...} 对象,且锚定在响应的末尾:
_FINAL_JSON_RE = re.compile(r"FINAL JSON:\s*(\{.*\})\s*\Z", re.DOTALL)一个裸数组、一个逗号分隔的对象列表,或者 JSON 后面跟一句友好的“如需其他帮助请告诉我!”,都会导致解析失败。在整个网格中,其他模型也出现了同一类格式问题,只是数量较少。Llama 在 structured 提示上恰好撞上了最严重的情况。
此时我们有一个在看到结果之后才想要修改的评分器。这正是研究人员被警告不要做的事。如果我们放宽解析器直到数字看起来不错,我们就不再是在测量模型了。
因此,在修改任何代码之前,我们先把规则写了下来:
1. 绝不重新生成。绝不编辑原始输出。每次调用的原始响应都已保存到磁盘。我们只从保存的文本中重新推导解析结果,并对每个更改的文件进行 diff,以确认原始文本未被改动。
2. 修复格式,绝不修改内容。只有当模型明显在错误的包装器中产生了正确内容时,我们才允许修复:
从运行元数据中填入 project_id,而不是要求模型重复它。
收集所有连续的 JSON 值,而不仅仅是第一个,使用 Python 的 json.JSONDecoder.raw_decode 在循环中处理,而不是使用正则表达式(下面有精简版)。
接受裸数组,或完整 JSON 值之后的尾随文本。
将 risk_register 视为 risks 的同义词。
接受 "External" 作为 "external",但仅当小写后的值是八个真实类别之一时。
decoder = json.JSONDecoder()
values = []
i = first_json_start(text) # index of the first '{' or '['
while i < len(text):
while i < len(text) and (text[i].isspace() or text[i] == ","):
i += 1 # skip separators between objectstry:
val, i = decoder.raw_decode(text, i)
except json.JSONDecodeError:
break # whatever is left is trailing prose
values.append(val)3. 不要改动 schema。风险类别超出我们的八类分类法("legal"、"market"、"social"、"procurement")是模型错误,而不是格式怪癖。这些仍然会失败,并且我们添加了一个名为 test_invalid_category_still_rejected_not_papered_over 的回归测试,这样未来的更改就不能悄悄开始接受它们。
4. 检查剩余内容。修复后,我们逐一阅读剩余的失败案例,而不是假设它们都是真实的。
5. 披露它。这个 bug、修复以及前后数字都在论文中。
在相同的已保存输出上重新运行解析器:
整个网格中的总解析失败数:88 → 49。即恢复了 39 个。
Llama 在结构化提示上的表现:42 个中 42 个失败 → 42 个中 7 个失败,其召回率从 0.0 提升到约 0.43,与其他两个提示一致。
剩余的 49 个是真实的:25 个超出分类法的无效类别,加上字段长度违规、缺失必填字段和截断响应。
如果我们没有查看,结果表会显示结构化提示使 Llama-3.3-70B 完全崩溃。可能有人会引用它。
在调试该问题的过程中,我们在同一个实验里发现了另一类隐藏的问题。
当前有若干模型会在写出可见答案之前,将一部分输出 token 预算用于隐藏推理。提供商自己也承认这一点。Google 的 Gemini 文档指出,输出上限包含思考 token,并且模型在推理时触及该上限会“以 incomplete 状态停止生成,并返回截断或空输出”(Gemini API: thinking)。OpenAI 的文档警告称,这种情况“可能在任何可见输出 token 产生之前发生”(OpenAI: reasoning models)。
如果你按照旧模型的方式设置 max_tokens,预算会在响应中途耗尽。API 会返回一个截断的答案,finish_reason="length",看起来就像是模型写得很糟糕。
我们当时在免费层上看到的情况,来自我们当时带日期的构建笔记(我们没有为主网格记录 finish_reason 或 completion-token 计数,因此这些具体数字是我们对一次性冒烟测试的同期记录,并非今天能从保存的数据中重新推导出来的内容):
Gemini 在 max_tokens=4096 时:在一次早期的 9 次调用冒烟测试中,三个 Gemini 单元格全部在约 650 到 700 个字符处被截断,且截断发生在字符串中间。九次调用中总共有六次解析失败。
Gemini 在 max_tokens=8192 时使用结构化提示词:仍然失败,仅用了 325 个 completion token。在 24,000 时则干净地成功(4,061 个 completion token)。我们并不完全理解为什么失败案例使用的 token 比 4096 案例更少。我们将其记录为一个未解问题,而不是编造一个解释。
这里有一个数字我们可以独立地为之背书:gpt-oss-120b 在 max_tokens=1024 时返回了空响应,finish_reason="length",可见答案从未开始,它需要 max_tokens=4096 才能清除推理开销并返回真实内容。这一点在我们自己的项目文档中记录了两次,来自一次真实记录的调用,而不仅仅是我们写了一次就略过的评论。
我们将所有模型的上限提高到 24,576。对于本来就会提前停止的模型来说,这不会产生任何成本。
同一个根本原因在 LLM(大语言模型)-as-judge 试点中第三次影响了我们。512 token 的评判预算意味着 5 次评判调用中有 3 次返回截断或空结果。这是 60% 的失败率,与评判质量毫无关系。
在证明并非如此之前,将 0% 和 100% 都视为 bug。在解释分数之前,先阅读原始失败记录。
保存每一个原始响应,并且绝不覆盖它。将生成与解析和评分分开,这样你就可以在不重新调用 API 的情况下重新评分。
不要让模型重述你的代码已经知道的内容。ID、项目名称和运行元数据应放在你的代码中。
在每次调用时记录 finish_reason 和 completion-token 计数。length 是测试框架事件,不是模型答案。要单独计数。
将失败分为格式失败和内容失败。只有格式失败才是你需要修复的。在修改代码之前先把规则写下来。
按根本原因、按单元格细分失败。总体失败率会掩盖那个已经着火的单元格。
将规则放入测试中。如果某个修复不能放松某项约束,就写出当它放松时就会失败的测试。
报告 bug。一个已披露并已修正的测试框架 bug 会让你的数字更可信,而不是更不可信。
语料清单、提示词、解析器、原始输出和回归测试均已公开:github.com/smadhu6364-beep/public-reproducible-benchmark。解析器修复位于提交 4608c8e。
我们是纽约大学的一名学生研究员和都柏林城市大学的一名学生研究员,在攻读学位的同时构建这个项目,我们更愿意展示错误,而不仅仅是打磨后的结果。如果你运行 LLM 评估,我们想听听你遇到的这个 bug 是什么样子的。
AI 研究员和产品构建者,从事网络安全、可信 AI、智能体系统和大语言模型评估方面的工作。我还为 IEEE 审阅研究论文。
转载声明:本文为转载整理,原文版权归原作者及原站点所有。
原文来源:Hacker Noon
原文作者:Kruthik Jadhav;如有侵权,请联系管理员删除。