我把幻觉修复标记为已验证。现在五个虚构响应中有零个,运行干净,完成。
我在更严苛的条件下再次运行,得到 66.7%。我曾足够信任这个数字,以至于在它旁边写下了 "HUMAN-VERIFIED",结果却错了。这并不是我说谎,而是一次干净运行和 "验证" 并不等同。我还构建了一个工具,它的核心目的就是不允许这种区别被忽视,但我差点还是让它被忽视了。
这种讽刺值得拥有自己的规范。
该工具名为 spec-verify:一种 Claude Code 技能,它接受 spec-writer 已经生成的 Given/When/Then 验收标准,并将其转化为用于检查是否真的在测试某些内容的测试。
本教程将展示如何安装 spec-verify、如何在真实代码上运行它,以及如何读取它所设计用来捕获的两种失败模式。它们互为相反,若把它们当作同一种情况处理,就失去了最初构建它的意义。
spec-writer 是一种 Claude Code 技能,它将模糊的功能请求转化为结构化的规格,并将其在未被明确告知的情况下做出的每个决策标记为 [ASSUMPTION: ...]。给它一个十二词的会话捕获功能请求,除了其他内容外,它会呈现如下内容:
3. Session ID from .jsonl filename is the deduplication key
Impact: MEDIUM
Correct this if: session IDs are stored differently in your schema
这是一个隐藏在十二词提示中的真实决定:通过文件名来识别会话,同一会话的两个副本在其中一个被重命名的瞬间看起来就像两个不同的会话。(想了解 spec-writer 如何得到这个假设的完整演练:如何阻止 AI 代理猜测你的需求。)
spec-writer 的全部价值在于在代码编写之前捕捉到这个假设。但假设你捕获并纠正了它。你告诉代理“不,哈希内容而不是文件名。”代理写出了修复。它也写了一个测试,因为你要求了,或者因为这就是代理现在会做的事情。
这里是没有任何检查的部分:该测试实际上是否验证了修复?还是它调用了去重函数,得到一个列表,断言该列表是一个列表,然后通过,不管底层错误是否仍然存在?
Given/When/Then 是人类可读的散文。它本身并不是机器运行的测试。这两者之间的差距正是被纠正的假设悄然恢复为真实错误的地方,直到生产环境中会话被重命名时才会有人注意到。
明确的解决办法是让代理根据每个 Given/When/Then 块生成一个测试。许多工具正是这么做的。陷阱在于“代理编写了一个测试”和“代理编写了一个有意义的测试”并不是同一个说法。不幸的是,LLM 生成的测试往往比你预期的更频繁地归入第一类:一个运行、断言一些琐碎为真的事情,并且无论代码实际上做什么都会通过的测试。它看起来像是覆盖率。一个绿色的勾号所说的并没有比红色的勾号多说什么,因为两者本来就不可能出现。
更糟的是,这些测试不仅无法捕捉原始错误。在它们本应守护的行为因第二个、无关的原因而损坏后,它们仍然会继续通过。没有任何东西得到验证。测试仅仅看起来就像套件中其他所有通过的测试一样。
因此,修复不能停留在“生成一个测试”上。它必须检查测试是否能够发现其所谓守护的对象实际上是否出现了故障。
对于每个 Given/When/Then 块,spec-verify 会做四件事:
生成测试: Then 子句成为对返回值、状态或副作用的具体断言。永不“未抛出异常即运行”。
生成一个有针对性的突变: 不是一般的突变测试扫描,而是一次 deliberate、特定的断裂,由标准配对的 [ASSUMPTION: ...] 标签提供信息。如果假设命名了风险,则该突变会重新引入恰好该风险。
对突变体运行测试:如果它仍然通过,则该测试实际上从未检查它声称要检查的内容。这是一个无效的测试,会被标记出来,而不是被信任。
失败关闭:此方式无法验证其测试的标准会以名称、原因明确地大声阻止“完成”,而不是悄悄通过审查。
采用上面规范编写者示例中的确切假设:去重键来源于文件名而非文件内容。看起来正确的测试如下:
def test_dedup_sessions_runs(tmp_path):
result = dedup.dedup_sessions([str(f)])
assert result is not None
它调用该函数,得到一个列表并通过。它也会通过使用文件名作为键的 dedup_sessions 版本(假设标记的确切错误),因为它从不检查 哪些 会话在去重后幸存,仅仅检查是否有东西返回。
这是实际检查该标准的那个:
def test_renamed_session_still_deduped(tmp_path):
original = tmp_path / "session_abc123.jsonl"
original.write_bytes(content)
renamed = tmp_path / "session_abc123_renamed_by_sync_tool.jsonl"
renamed.write_bytes(content) # same content, different name
result = dedup.dedup_sessions([str(original), str(renamed)])
assert result == [str(original)]
先对正确实现运行两遍,然后对一个突变体运行,其中去重键被切换回文件名(即恢复为之前的修正假设):
test baseline mutant verdict
test_dedup_verified.py pass FAIL VERIFIED
test_dedup_vacuous.py pass pass VACUOUS
PROOF PASSED: mutation check correctly told VERIFIED from VACUOUS.
一个测试在错误重现的瞬间失败。另一个则毫无察觉。在你仔细查看之前,它们共享同样的绿色勾选标准。但防护程度完全不同。
VACUOUS and UNVALIDATABLE 是对立的,而非变体并非所有标准都能进行突变测试。我在文章开头提到的修复(一个实体 grounding 规则,告诉模型不要在另一个提供商的名义下编造某付款提供商的文档)首次发布时,它完全存在于系统提示中。对大语言模型的纯自然语言指令。没有可调用并断言的函数边界。检查它的唯一方法是向模型提出陷阱问题并阅读其回答。突变测试无法触及这一点。
spec-verify 将此称为 UNVALIDATABLE,人们容易把它当作 vacuous 测试来对待:一种不够好、需要修复的东西。其实不然。vacuous 测试是一种缺陷:测试本身有问题,修复办法始终是一样的——编写一个更好的测试。UNVALIDATABLE 表示该 标准 完全超出了此技术能够检查的范围,通常是因为行为具有非确定性,而不是因为有人做错了什么。
如果把它们当作同一种情况处理,就会导致两种不良结果之一:要么你因为“某些东西根本无法测试”而放过 vacuous 测试(其实它们可以被测试,只是这个测试没有写好),要么你对所有非确定性的东西永久阻塞,而在实际代码库中这种情况经常出现。两种做法都不对。
因此,该门禁有两条轨道:
VACUOUS 和 BROKEN-TEST:永不豁免。通过有缺陷测试的唯一方法是编写一个更好的测试。
UNVALIDATABLE:仅能通过明确的、有记录的人工签署来通过,其中一个人需要书面说明他们实际是如何检查的。
这让我想回到开头。我在实体 grounding 标准上签署时,附上了一条注明实际数字的注释:五次编造的响应中有零次。该注释通过了 spec-verify 的结构检查:它既不是空的,也不是单词橡皮图章,并且指出了一个实际的方法。
结果表明,这其实是一次乐观的单次运行。在固定温度且没有固定种子的独立重测中,干净下降率为 66.7%,而非 100%。某些提供商对检索到的近乎相同的文本块,欺骗自检的频率高于首次运行所显示的程度。
诚实的修复并不是更好的签 off,而是替换掉被签 off 的对象:一个用于已知问题情况的确定性代码级门禁,这种门禁可以通过常规方式进行测试,并且与用于其他一切情况的基于提示的检查并存。UNVALIDATABLE 并不是永久状态:它是一个标志,表明某件事需要人工介入,或者理想情况下,不再需要人工介入。
这是 exact vacuous-test 问题的较温和版本:一个 HUMAN-VERIFIED 的注释可能诚实但仍然错误,就像我的情况一样。spec-verify 对签离注释的结构检查(拒绝空注释,拒绝少于一句话的文本,拒绝诸如 "looks fine" 或 "lgtm" 之类的股票短语黑名单)无法验证一个人确实完成了他们声称的操作。它只是提高了最懒惰的橡皮图章的成本。一个有决心的人仍然可以在其后填充虚假的叙述。
在团队环境中,有一种更强的做法:直接从 Git 提交的实际作者和时间戳中确定谁签署了以及何时签署,而不是依赖 JSON 文件中的自由文本字段。这样一来,伪造的签署就必须以某人的真实身份提交一次实际的提交:这在历史记录中可见,而不仅仅是没人关注的文件中的一次编辑。对于个人项目来说这有些过度。但只要有多于一人可能有动机伪造签署,这就成为正确的选择。
与 spec-writer 类似,spec-verify 是一个 Claude Code 技能:一个 Markdown 文件加上几个可运行的示例目录,无需安装包,也不需要 API 密钥。
mkdir -p ~/.claude/skills/spec-verify
git clone https://github.com/dannwaneri/spec-verify.git ~/.claude/skills/spec-verify
在 Windows PowerShell 中:
New-Item -ItemType Directory -Force -Path "$HOME\.claude\skills"
git clone https://github.com/dannwaneri/spec-verify.git "$HOME\.claude\skills\spec-verify"
不要轻信上面的 VERIFIED/VACUOUS 表格:该仓库提供了本文中两个示例的可运行代码:
cd ~/.claude/skills/spec-verify/example
python run_proof.py
这可以精确复现去重表。以及更严格的签核层级:
cd git_attributed_signoff
python build_demo_repo.py
python check_signoff.py demo_repo entity_grounding
python tamper_demo.py
tamper_demo.py 值得亲自运行,而不仅仅是阅读了解:它在工作树中未提交的情况下编辑签署的注释,然后再次检查。该检查在甚至读取被篡改的注释之前,就完全拒绝信任该文件,因为工作树已不再匹配任何提交。
安装后,在您实现了经过 spec-writer 的功能之后,在您声明任务完成之前调用它。它需要 spec-writer 的输出(Given/When/Then 块及其 [ASSUMPTION: ...] 标签)以及用于对照检查的实现。它不会凭空编造用于验证的验收标准。如果没有可供其使用的 spec-writer 输出,那么它就没有事情可做。
阅读报告的方式就像阅读 spec-writer 的假设摘要一样:首先扫描所有不是 VERIFIED 的内容。VACUOUS 或 BROKEN-TEST 表示需要去修复测试。这样的发现没有任何版本适合直接发布。UNVALIDATABLE 意味着需要明确决定:是由人工手动检查并记录下来,还是底层行为需要移动到可测试的地方,就像实体基础修复最终所做的那样。
spec-writer 能捕捉到您因错误原因而构建的功能。spec-verify 能捕捉到本应告诉您却未能告诉您的测试。在这两者之间:一个假设会被标记出来,然后在代码发布前得到修正;如今,如果有人在六个月后未读原始规格就撤销了此修正,则会有一个实际失败的测试。
这些都不能替代判断。一个规格可能格式正确,但仍然关于要构建什么是错误的。一个签署可能诚实,但仍可能遗漏更严格重新测试所能发现的问题。这两个工具所做的就是确保“做起来像完成了”和“真正完成”之间的差距必须有意跨越,并把原因写下来,而不是因为没有检查而跳过。
spec-verify 仓库位于 github.com/dannwaneri/spec-verify。在您标记任务完成之前,请用它来检验 spec-writer 为您生成的下一个功能。如果其中的测试是虚假的,您希望通过突变来发现,而不是在生产环境中发现。
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。