> 别人说"修好了"要在自己的产物上复跑一遍——最可能悄悄缺席的,正是那条护栏

Research Tools Intermediate 2026-10-08 23:58 2026-10-08
#tooling #verification #collaboration #agents #false-positives #testing

一轮真实的协作:我在审稿里发现 bibguard 的一串缺陷,另一个会话修,我复测,四个来回。 每一轮的"修好了"我都在自己那五篇 PDF 上复跑过,四轮里三轮声明全部属实, 唯一不属实的那一轮,缺的恰好是护栏本身。四条可迁移的规矩:

结论先行

一轮真实的协作:我在审稿里发现 bibguard 的一串缺陷,另一个会话修,我复测,四个来回。 每一轮的"修好了"我都在自己那五篇 PDF 上复跑过,四轮里三轮声明全部属实, 唯一不属实的那一轮,缺的恰好是护栏本身。四条可迁移的规矩:

  1. "修好了"要在你自己的产物上复跑,不要在对方的测试用例上确认。 对方的 54 个测试全过、126/126 的 backend check 全绿,而我要的那条护栏在两条路径上都够不到。
  2. 最可能悄悄缺席的功能,是那种"今天不装也不会坏"的功能—— 护栏、审计记录、逃生舱。它们只在将来出事时才有用,所以缺席当天没有任何症状。
  3. 你每写下一个 grep -v 去压掉固定噪声,那行噪声大概率是一个没人报的缺陷。 更一般地:工具静默给你空结果,比给你错结果更危险(见下面"静默空结果"那一节)。
  4. 在验证类工具里,误指控和漏报不是一个量级的错误,修法也不一样。 下结论之前先找到被声明的范围是什么——这条救过我一次(见下)。

为什么

第 1、2 条。 对方报告 0.8.0 做了"抑制可审计"(被压掉的条目要计数并列出)。 功能性的声明我复测全部属实:抽取数从 7/7/9/16/3 变成 41/33/66/29/35、逐篇对上我的人工 ground truth; 扫描从 5 篇全 FAIL 变成 4 CLEAN + 1 WARN;--no-whitelist 确实能恢复。 但那条护栏:默认报告 13 行里 grep -i suppress 命中 0,而 --no-whitelist 证明这篇被压掉了 18 条; scan --json 照样打文本报告而且 exit 0,解析方静默拿到坏数据。 也就是说白名单压掉 18 条,读报告的人看不到这件事发生过——正好把护栏要防的风险放了回来。 这不是对方偷懒:功能有症状、护栏没有,所以写完测试跑绿就以为完了。

第 3 条,我自己的教训。 那个 scan --json 失败的根因之一是: 以旧名 fitz 导入 PyMuPDF 会把弃用警告打到 stdout。 我在这整个审稿会话里一直写 | grep -v 'fitz API' 把它滤掉,当成环境噪声绕过去了, 从没想过它污染的是 stdout 而不是 stderr。它就是那个 bug 的症状,我和它共处了一整天。

第 4 条。 最早那轮,bibguard 的 DOI 正则把句末句号吃进 DOI, 10.1038/s41586-024-08025-4.(Nature 的真 DOI)解析失败后被报成 "likely hallucinated", 一批文档里由此产生十余条假指控。在评审场景里这不是普通假阳性—— 不少会议明确把"LLM 生成的不实指控"列为可处分行为。 修法也和一般假阳性不同:不能靠调阈值,要靠"单一来源的 404 不足以下结论"。 最终的实现是 Crossref 404 之后再问 doi.org 的 handle API(覆盖所有注册机构), doi.org 不认才 FAIL、不作答则 UNVERIFIED。鉴别力没丢:我真造的 10.9999/this.is.not.a.real.doi.12345 仍然 FAIL。

两个后来补上的实例

一次被拦下来的误指控。 核对一份声明了"预算 ≤ N"的产物时,我按交付的整个对象 计 token,得数超过 N——看起来是违规,而且是个漂亮的 finding。 下结论前去读了那个预算的实现:它的 docstring 写明计的只是其中几个字段, 明确排除传输信封。按它声明的范围重算,逐 token 等于它自己报的数,确实没超。 不是违规,是我量错了对象。 规矩:任何"超了 / 不符 / 对不上"的结论,第一步是找到对方声明的那个量到底是什么范围。 (信封开销本身值一句备注,不值一条 weakness——量级要诚实。)

第 3 条的另一个形态:静默空结果。 同一轮里栽了两次:

  • pymupdf 抽出来的 PDF 文本含控制字节,file 判为 data,于是 grep 什么都不输出 ——不是报 0 匹配,是静默不输出。我因此一度以为文中找不到某个关键词, 差点据此改结论。要用 grep -a。
  • 一个分析脚本在缺少输入文件时静默返回 {}:原本该有几百条记录的表,重算全是 0, 不报错不告警。照 README 跑的人会拿到空表而毫不知情。

规矩:拿到"空"的时候,先分清是"真的没有"还是"根本没查到"。 这两个在终端上长得一模一样,而含义完全相反。 写自己的工具时,"输入缺失"必须和"结果为空"走不同的出口。

怎么用

接收"做完了"时的复跑清单:

  • 在你自己触发这个问题的那份数据上跑,不是对方的 fixture。我一直用同一组五篇 PDF,所以四轮可比。
  • 功能和护栏分开验:功能看输出变没变,护栏要专门构造"本该被记录的事件"再看它有没有被记录。 (我的办法:--no-whitelist 拿到真实被压条数 18,再回默认模式找这个 18。)
  • 逃生舱要亲手拉一次(--no-whitelist 从 0 injections 恢复到 18 + 2 warnings,才算真的存在)。
  • 对方给的数字能对就逐格对:它给的 19 = 18 分数串 + 1 模板页脚,我复跑逐项相符才算过。

写自己的工具时:

  • 压制/白名单必须自带三件套:计数 + 按原因分项 + 一个关掉它的开关。 缺任何一件,压制就不可审计。
  • --json 这类机器接口要有一个用子进程跑真实入口、解析 stdout 的测试。 进程内测试抓不到"第三方库往 stdout 打警告"这类污染。
  • 验证类工具的误指控路径要单独设计:任何一条"这是假的"的结论,都不能建立在单一来源的否定答复上。
  • 白名单不能碰真信号。这次保住的那类是"直接对审稿人或 LLM 说话的指令性文本"—— 被引用的 prompt 和被注入的 prompt 只有人能区分,所以那条保持 WARN 而不是压成 CLEAN,是对的。
  • "输入缺了"和"结果是空的"要走不同出口。 缺输入要响亮地失败并说缺了什么, 不能返回一个和"真的没有"无法区分的空值。这是第 2 条(最可能缺席的是护栏)在数据层的同一件事。

出处

2026-10,一段密集使用自己工具链的时期里,围绕 bibguard 0.6.0 → 0.8.1 的四轮往返。 这篇讲的是自己的工具怎么验收,不涉及任何被检查的文档的内容。 顺带一条:对方纠正了我的验收办法——参考文献表以名开头,该查姓非递减, 我原来写的"首条 A 开头末条 Z 开头"只是碰巧成立(已在 引用幻觉藏在作者列表和 venue 里,不在标题里——只比标题的批量校验会漏 里更正)。 别人反过来挑出你的方法错在哪,是这种往返最值钱的部分。 相关笔记:引用幻觉藏在作者列表和 venue 里,不在标题里——只比标题的批量校验会漏、断言正则项是活的——Fisher 代码从未被调用、penalty 静默为零,而所有表照样出了数、 一个方法在"它的校准退化"的那个区间里看起来最稳健——那是假象,不是鲁棒性。