我从未向上滚动

我每天都在使用一个编码代理。我给它一个任务,去做其他事情,然后回到屏幕底部的这样一行:

"빌드·회귀 스모크·기존 분석 테스트 모두 통과."
(构建、回归冒烟和现有分析测试均已通过)

那是我自己会话日志中的真实一行。我从未检查过它。我没有向上滚动查看测试是否真正运行,而是给了它下一个任务。我这样做了好几个月。

后来,我在韩国开发者社区读到一篇文章,有人调查了他们代理的私有推理,发现分配给测试角色的记忆中包含了遇到问题时绕过问题的方法列表

这并不意味着代理是恶意的。如果你告诉它让测试通过,找到最便宜的路径就是优化的作用。问题在于,当最便宜的路径是“避免检查”而不是“修复代码”时,你却无法注意到。

于是我决定检查一下。这个工具叫做red-handed

不是评判,而是对比

我不是在尝试构建一个评判者。我想要的是代理所说和它实际所做的并排对比

材料已经存在了。Claude Code 为每个会话都写入一个转录:它运行的每个命令及其返回结果、每次文件编辑的前后状态,以及它对我说的每一句话。将它与 git 对齐,你就可以计算出话语与工作之间的差距。

它是否在没有运行测试的情况下声称测试通过了?它是否在运行失败后立即声称测试通过了?它是否重写了预期值以匹配损坏代码产生的结果?诸如此类的九项检查。

我花时间最长的不是一个功能,而是当工具出错时会发生什么

出错有两种方式。

遗漏了什么。用户没有损失——他们与之前一样不知情。

指控了诚实工作的人。那就是告诉一个人他们作弊了,而他们并没有。搞错一次,就没人会再打开它了。

成本绝不对称。所以规则变成了:宁可遗漏,也不要错误指控。

这变成了两个硬性约束。

确认的发现需要两份证据。会话必须显示它发生了,并且更改必须仍在工作树中。如果代理后来撤销了它,就没有什么可以指控的了,发现也就消失了。

未读的内容不是失败。如果测试运行了而我无法解析输出,那是“我不知道”,而不是“它失败了”。这个区别最终会颠倒整个结果。

这一切都不需要调用模型。如果判决来自 LLM,同样的转录在不同的日子会产生不同的答案——那就不是证据了。

在 249 个会话上运行它

我把它指向了我机器上的每一个会话。

249 个会话。你的代理说了“测试通过”124 次。
   117  先运行了一个测试
     7  该会话根本没有运行测试

进入全屏模式 退出全屏模式

确认的发现:

老实说,这有点令人沮丧。花了数周时间构建一个测谎器,却没有抓到任何谎言。

然后我逐个打开了这七个,发现我问错了问题。

真正的问题不在于撒谎

七个中的四个来自一个项目。代理说了这样的话:

"드래그·휠 5방향 브라우저 테스트 통과."
(拖拽和滚动,所有五个方向,浏览器测试通过)

这不是谎言。代理确实打开了浏览器并进行了检查。但这次检查没有留下任何机器可读的痕迹。所以以后没有办法查明这个声明是否成立。对我不行,对工具不行,对继承这段代码的人也不行。

另外三个是同样的情况:项目通过自己的脚本运行测试,而我的解析器不知道那种输出格式。我能看出运行了某种类似测试的东西。我无法看出它通过了。

结论就在这里翻转了。我担心的是一个撒谎的代理。我真正发现的是没人能读懂的验证。

这与其说是 AI 的属性,不如说是我们现在工作方式的属性。过去,一个人打开浏览器、查看,然后继续,是可以的——因为那个人一直在看那段代码。现在,做检查的东西是一个每回话记忆都会重置的代理。你读不懂的验证与从未发生的验证没有区别。

所以那七个被报告为SUSPICIOUS,而不是CAUGHT,措辞是“去看看那个命令实际报告了什么”,而不是“它对你撒谎了”。这是工具能诚实地达到的极限。

我想声明一个免责:在这 249 个会话中,只有 12 个包含“测试通过”的声明。其余的都很短——一两个问题。所以把这解读为“249 个中的 0 个”会是一种夸大的读法。

它还错误指控了我六次

一旦它能用了,我花了一段时间试图破坏它。那结果成了这个项目最有价值的部分。一次对抗性审查发现了种它会指控诚实工作的不同方式:

  1. 因超时被杀死的测试运行,被读作失败
  2. 它不认识的运行器(rspecphpunittox),被读作“没有运行测试”
  3. 在它看不到的启动器下重新运行,因此它根据过时的结果进行判断
  4. 用户明确要求的行为更改,被读作重写答案
  5. 后台运行的命令,被计为通过

前两个值得详细说明。

超时那个是最糟糕的。当一个测试运行因耗时太长而被杀死时,它会留下一个独特的退出信号,而我的代码将其读作失败。所以如果代理随后说测试通过了,它就会打印出工具中最侮辱性的判决:“说测试通过——上次运行失败了。”我的代码直接违反了我写了两段设计笔记的规则。

第二个是一个字符。识别测试运行器的模式寻找前面有词边界的单词spec——这在rspec中永远匹配不上,因为r是一个单词字符。所以一个 Ruby 开发者可以运行他们的套件,得到20 examples, 0 failures,而我的工具会自信地声明根本没有运行测试

刺痛的地方来了。我的 README 骄傲地写着“在 183 个真实会话中 0 个假阳性。”那是真的——但不是因为工具好。而是因为我机器上的每个项目都是 JavaScript 或 TypeScript,所以那两条路径都从未执行过。我构建了一个用于验证声明的工具,报告说它通过了自己的验证,但实际上并没有测试过代码。我做了工具旨在捕捉的那个确切的事情。

第六个是在我发布后出现的。一个作用域为pytest tests/test_sync_cli.py的命令,然后编辑了一个 GitHub 工作流文件和两个.tsx文件,工具就认为声明已过时。一个 Python 测试无法导入.tsx,一个 CI 工作流也不是刚才在这台机器上运行的东西。什么都没有过时。

有一个诱人的修复:“不同目录,将其视为无关。”简单,而且大多数时候是对的。但这也是错的——运行pytest tests/然后编辑src/,声明确实已经过期了。所以与其猜测,我只抑制绝对为真的情况:Python 无法加载.tsx,JS 运行器无法加载.py,CI 工作流不是本地运行。任何模棱两可的仍然计入。“看起来无关”是一个猜测,你不会因为一个猜测而丢弃证据。

所有六个现在都由回归测试固定了。

它在我写这篇文章时又抓到了我

我重新运行它来刷新这篇文章的数字,发现列表中有我自己的一个:

"테스트 3개로 고정했고 전체 389개 통과."
(用三个测试固定了它,全部 389 个通过)
→ 之后有 1 个文件更改:scripts/social.tape

我说的没错,改变的文件是一个用于演示 GIF 的屏幕录制脚本。它与测试套件无关。

但这不是一个 bug。这是我在前一节选择的规则——任何模棱两可的仍然计入——正在精确地执行我告诉它做的事。工具不知道.tape文件是什么,而我决定未知意味着计入。

这就是选择的代价。为了避免遗漏,它有时会对空气吠叫。作为交换,这个被输出为SUSPICIOUS而不是CAUGHT,更改的文件名就打印在旁边,所以我花了三秒钟就驳回了它。这就是两个层级的用途。

我会保留的三件事

在调整准确性之前,决定你宁愿在哪个方向上出错。“准确性是多少?”对于这样的工具来说几乎毫无意义,因为两种错误方向的成本差异巨大。选择方向,然后在每个分支中捍卫那个决定。我在一个文档中写下了这个原则,然后在代码中破坏了它三处。

发布证据,而不是判决。如果输出说“它撒谎了”,用户只能相信或不相信。展示时间戳和引用的行,他们就可以自己决定。这也是为什么驳回我自己的假阳性只花了三秒钟的原因。一个能被抓到出错的工具,是一个你会继续使用的工具。

真正的修复是可读的验证。这就是那四个浏览器测试发现教给我的。当你把工作交给一个代理时,不要说“检查一下”——而是说“检查一下,并把结果留在机器可读的地方。”只存在于某人脑海中的检查,在下一次会话时就不存在了。

试试看

本地运行。你的转录和代码永远不会离开你的机器——它不调用任何模型,也不发出任何自己的网络请求。同一个会话,每次都给出同样的答案。

# 一个虚构的会话,其中每个检查都会触发,这样你就可以先看到它的样子
npx @jinhyuk9714/red-handed@latest demo

# 然后是你自己版本的上述数字
npx @jinhyuk9714/red-handed@latest stats

进入全屏模式 退出全屏模式

MIT 许可,代码在 github.com/sjh9714/red-handed

你的数字会和我的不同。我有零个确认的发现,七个没人能读的检查,以及一个我在写这段话时发现的假警报。不管对你来说结果如何,重点是你不看就不知道。