你的AI编程代理运行调试器了吗?一个JVM Bug与两次代理运行的实证
Java 开发者会不假思索地使用调试器:设置断点,运行失败的测试,查看变量,然后再决定改什么。
我见过的大多数 AI 编程智能体都会跳过这一步。它们阅读堆栈跟踪、阅读源码,然后提出补丁。即便推理看起来非常出色,也仍然是在猜测一个从未真正运行过的程序。
我想知道这种猜测的代价。于是,我取了一个真实的 JVM bug、一个模型、一个提示词,用两种调试工作流各运行了一次。下面是两份运行记录所展示的内容。
Bug:对无法读取的目录调用 Files.list
该故障源自 Windows 上 JetBrains IDE 中运行的一个 Kotlin 项目。如果你写 Java,这条堆栈的每一行都会很眼熟,因为那就是普通的 java.nio.file:
[LOADING...]
C:\Config.Msi 是一个受保护的 Windows Installer 目录。代码先检查 Files.isDirectory(path),得到 true,然后调用 Files.list(path)。结果这个列表操作抛出了异常。
该函数属于自动补全功能:在聊天中输入 @ 可让用户提及文件或文件夹,而建议列表是通过从某个根目录(如 C:)遍历文件系统构建的。该流程边界处没有任何捕获异常的地方,因此它以未处理协程异常的形式暴露出来,IDE 将责任归咎于插件。
一个很小的函数,一个未受保护的 NIO 调用,却有多种失败方式。这是一个很好的测试用例。
被测方法
系统化调试方法包含四个步骤:收集事实、形成假设、运行实验、修改代码。顺序很重要,而第三步正是我观察到智能体经常跳过的。
Superpowers 是一个由 Jesse Vincent 和 Prime Radiant 团队创建、并由 Matt Pocock 推广的技能集合;其中提供了一个 systematic-debugging 技能,将上述步骤精确地编码为模型的指令。
我参与的 JetBrains 插件 Explyt 内置了 Debug 技能,可以把 IDE 调试器作为工具提供给模型:它可以设置断点、在调试器下运行测试,并读取变量和调用栈。
两次运行的设置:
模型:Opus 5。
相同的仓库、相同的 IDE 日志、相同的提示词。
唯一区别:第一次运行使用 Superpowers 技能,第二次运行使用 Explyt Debug 技能。
一个 bug,两次运行。请把它看作案例研究。真正的基准测试需要更多 bug 和更多重复;我之后会再讨论单一案例能说明什么、不能说明什么。
运行一:没有实验的检查清单
Superpowers 的这次运行在阅读代码方面做得很好。它用 grep 而不是把日志全部倒出来,顺着堆栈跟踪进入 FacadeMentionCandidates,找到了未受保护的 Files.list,并写下一个看似合理的根因:能够通过 isDirectory 检查的受保护目录。
然后它就直接去写代码了。它从未设置断点,也没有运行任何东西,因此从未看到失败执行中的任何一个值。
补丁把列表操作包在 try/catch 里,记录异常,然后返回 emptyList()。
这消除了堆栈跟踪,但也改变了函数的契约。空文件夹和进程无权读取的文件夹现在返回相同的值,UI 无法告诉用户实际发生的是哪一种情况。
回归测试继承了同样的假设:“不可读目录没有子项。”在 Windows 上,测试无法设置 POSIX 权限,因此第一版测试被跳过。为了强制触发异常,智能体向生产构造函数添加了一个 readDirectory 函数参数,并在测试中注入了一个会抛异常的假实现。
最后有七个测试通过。说实话,在忙碌的日子里,我可能会合并它。但这些测试证明的只是回退路径会返回空列表。它们没有说明用户看到的行为是否正确,而且在整个运行中从未接触过一个暂停的 JVM。
我弃用了这个补丁。它掩盖了该功能需要的区分,并在公共 API 中留下了一条仅为测试而设的接缝。
运行二:先断点,再修复
Explyt 的这次运行从相同的日志和源码开始。区别在于它在形成假设之后做了什么。
它编写了一个最小复现程序,在 Files.list 调用上设置行断点,并在 JetBrains 调试器下启动测试。当 JVM 暂停时,模型进行了以下检查:
[LOADING...]
调试器中的调用栈与生产环境的堆栈逐帧匹配:listDir → listChildren → MentionSuggestionMapper.suggestionsFor,位于构建建议的协程内部。
在那一刻,假设不再是假设。目录检查通过、进程无法读取该目录、枚举操作中断了流程——这三个事实都是从暂停的 JVM 上读取的。
修复仍限于文件系统边界,保持正常列表路径不变,并附带了一个有针对性的回归测试。它不需要 println 调用,不需要新的构造函数参数,也不改变提及协议。它通过了评审,并已上线。
这个修复同样使用了 try/catch。区别在于时机。catch 是在智能体亲眼看到失败、知道应该捕获哪个异常,以及调用方需要什么结果之后才写下的。
隐藏在堆栈中的 Java 经验教训
暂且把智能体放在一边。这个 bug 本身就是一个经典的 NIO 陷阱,适用于任何 JVM 语言。
Files.isDirectory 回答的是“这是目录吗?”,而不是“我可以列出它吗?”。在 Windows 上,ACL 让这两个问题相互独立,而 Files.list 正是提出第二个问题的地方。
有两种可能的处理形式。下面的 Java 示意代码仅用于说明,并不是生产补丁:
[LOADING...]
哪种形式正确取决于调用方。第一次运行是在失败状态没有显示的情况下盲目作出决定;第二次运行则是在屏幕上呈现失败状态时作出决定。
顺便提两个小提醒。Files.list 返回的 Stream 持有一个目录句柄,所以要用 try-with-resources 关闭它。另外,AccessDeniedException 继承自 FileSystemException,后者又继承自 IOException;因此,除非你安排好 catch 子句的顺序,否则一个裸的 catch (IOException e) 会把它和其他所有异常一起吞掉。
Token 数量能说明什么,又不能说明什么
Explyt 完成这个案例用了大约 67k token,而 Superpowers 的这次运行用了大约 132k token。
这两份记录说明了差距所在。第二次运行中,决定性事实很早就从调试器那里获得;第一次运行中,同样的预算花费在了猜测性代码、一个被跳过的测试、第二个测试和构造函数重构上。
请把它视为对一个 bug 的一次测量。想从中概括出对任一工具的结论,都有些牵强。Superpowers 可以搭配调试器使用;Explyt 能获得的调试器深度则取决于 IDE、语言和运行配置。
JetBrains 一直在发布关于“节省 token”技能的配对 A/B 测试;在你相信任何单一数字之前,值得先了解其中的规律:
Caveman 测试:README 声称可减少 65% token,但在真实代理任务中,强制激活后输出 token 仅减少 8.5%。
rtk 测试:启用 rtk 的实验组在低推理强度下,每个任务的中位成本反而高出 7.6%;在高推理强度下则没有变化。
Ponytail 测试:在其自有基准上成本降低了 10.3%。
他们的结论也是我的结论:在你自己的 bug 上衡量完整的智能体运行过程;在此之前,把技能自己报告的数据当作营销话术。
可应用于任何智能体的检查清单
任何智能体都可以被要求达到这一标准。必须坚持的是实验这一步。在接受智能体提出的运行时修复之前,请问:
它是否复现了失败,还是仅仅读到了失败?
它观察到了哪个运行时事实:某个变量值、调用栈,还是某条分支被执行?
修复是否改变了返回值的含义?调用方知道吗?
是否存在仅为了让测试可行而做出的生产 API 改动?
新测试证明的是行为本身,还是仅仅证明这个规避方案能运行?
如果答案是“只读到过”“没有任何观察”,并且另外有两处“是”,那你看到的就是运行一。
Explyt 的定位,以及一则声明
我在 Explyt 工作,所以请对这一段酌情考量。Explyt 是一个运行在 JetBrains IDE 内部的 AI 智能体。它的调试工作流会在 IDE 调试器下启动代码,并在编辑前读取断点、变量、调用栈和执行路径。IDE 提供证据;你仍然需要审查假设和 diff。
支持的 IDE、语言和运行配置组合已在功能矩阵中列出。如果你想复现这种对比,拿一个已有的失败测试,要求智能体在修改任何代码之前先在断点处确认原因,然后用上面的检查清单审阅 diff。
本文最初发布在 foojay 上:你的 AI 智能体运行过调试器吗?一个 JVM Bug,两次智能体运行。