调试即不变式发现:一次Kafka会话对AI代理的启示
调试即不变量发现:一次 Kafka 会话给我们的 AI 智能体启示
每个 Java 团队在某处都会有这样一句话:
对 TID 的任何操作都必须经过同一条异步路径,绝不能让 TMS 和 EMV 失去同步。
这类规则一旦说出来就显而易见,而在此之前却隐而不现。支付系统中的终端 ID(TID)、更新两个下游系统的异步管道,以及“两者绝不能出现分歧”这一要求,都属于这种规则。类型系统中没有任何机制强制这样的规则,也没有哪个单独的类拥有它——这类规则存在于消费端、服务和存储之间。
一位使用我们智能体的开发者,在一次会话中发现他们的代码有多少种不同方式违反了这句话。我是 Explyt 的产品经理;这个智能体是我们的产品,它运行在普通聊天模式中,并没有启用调试器集成。事后我查看了日志。以下就是日志显示的内容,以及我认为它对“有 AI 智能体在身旁调试分布式 JVM 系统”这件事意味着什么。
这次会话的形态
我不会描述那个症状或具体缺陷,因为细节会暴露开发者的项目。这里重要的是整体形态。
会话从一个可见的问题开始。开发者和智能体修复了一个缺陷,系统又以另一种方式损坏;他们修复了那个问题,系统又出现第三种故障。被修复的内容中,大约有一半与最初的症状毫无关联,但全部都违反了上面加粗的那句话——开发者正是这样概括问题范围的。
任何调试过事件驱动系统的人,都会对这种模式感到熟悉。最初的症状很少是真正值得关注的事实;真正值得关注的是所有症状背后的共同不变量。
智能体做对了什么
在批评之前,我想先说明:智能体在局部小任务上确实有用。
它会在对两个下游系统进行推测之前,先给出 SQL 查看其中的实际数据行;它会标出自己将要改动的代码里的风险;它解释陌现代码时,说法也经得住推敲。另外,它没有在代码库中到处添加新日志,而是阅读代码中已有的日志。
如果只有开发者一个人,也可以手动完成同样的事情,只是会更慢。
它错在哪里,为什么这对 Java 团队很重要
智能体从未真正主导调试进程。它只是回应开发者带来的证据,然后等待更多证据。它从不会提出一个假设,也不提出能证伪该假设的实验。每一步选择都由开发者来做。
经过验证的陈述与未经证实的猜测,说出来的语气却是一样的笃定。在一个漫长且涉及许多缺陷的会话中,最重的认知负担就是记住哪些说法已经被检验过;而智能体把这种负担完全留给了人。
此外,它在故障尚未定位时就提出了修复方案。每次修复都能编译并通过 lint 检查,然后智能体就不再继续。至于修复是否真的让症状消失,却没有人去验证;事实证明,其中好几个并没有修复问题。在日志里读到这些让我很不自在,因为我也曾在某个疲惫的夜晚,恰恰因为“它能编译”这个同样的非理由,合并过这样的补丁。
我反复回想到的失败是最后一个:智能体从未建立关于实体的模型。TID 是什么,两个下游系统各自拥有哪些字段,它们之间保持一致到底意味着什么,它都没有建立概念。没有这个模型,每个缺陷看起来都像各自独立的 bug;有了这个模型,这些缺陷就会归结为对同一条规则的反复违反,排查也变得系统化:找出每一条绕过异步路径的 TID 状态写入。
智能体唯一的传感器是开发者
这里有一个细节可以解释其余所有问题。智能体对运行中系统的全部了解,都来自开发者粘贴到聊天中的内容:查询结果、应用和 broker 日志、HTTP 响应、容器与集群消息、配置。它自己执行的唯一终端命令是 git。
处于这种位置的智能体无法运行实验。它只能对人类选择运行并选择展示给它的实验作出反应。被动性就是从这里来的,任何提示词都解决不了这个问题。解决办法是给它工具:一个它可以自己使用的终端、一个调试器,以及一种让它自己查询状态的方法。
对这次会话的内部复盘建议,给智能体一个可以自行使用的浏览器和终端,同时按照 Superpowers 的 systematic-debugging 技能的精神,记录一份书面调试日志:每一个假设、检验该假设的实验、实验结果,以及一份持续更新的实体及实体间规则模型。这两个建议都假设智能体能够在无需人类中转的情况下观察和行动。没有工具,方法就只是清单。
Java 启示:把不变量写下来,让测试能看到它
先把智能体放到一边。这个故事里的不变量是一条跨服务、跨存储的规则,而这类规则恰恰是 Java 代码库最不擅长显式表达的。下面三个位置可以让这样一句话存在,使测试或智能体能够找到它。
第一个位置是集成测试中运行的领域层断言。下面的示意代码仅作说明,并非本次会话中的代码:
如果一个集成测试把某个场景涉及到的每个 TID 都交给 violations 去检查,就能把这句话从“口口相传的规则”变成一条会失败的断言。当智能体修复了一个缺陷、但断言仍因为另一个不同的 TID 而失败时,它就找到了同一条不变量的第二次违反;此时两个 bug 也就不再显得毫无关联。
第二个位置是写入路径本身。如果规则是“必须经过同一条异步路径”,那么编译器也能帮上忙:将直接写入的类设为包私有(package-private),只暴露一个发布到 topic 的入口,并用架构测试强制这条边界。在 JVM 上,ArchUnit 是常用的工具:
第三个位置是消费端边界。在 Spring for Apache Kafka 中,一个更新两个存储的 listener 无论有没有人写出相关说明,都天然有排序与幂等性的问题。把它显式化:使用一个幂等消费者,每个存储对应一个 saga 步骤,幂等键由 TID 和事件版本派生;再写一个测试,重放同一事件两次,并断言只产生一次效果。一旦有了这样的测试,任何“悄悄假设消息只会投递一次”的修复都会变成一条失败的红条。
这些建议都不新鲜。关键是,这些产物同样也是 AI 智能体能够阅读和运行的东西。只存在于资深工程师脑中的不变量,对智能体来说是不可见的;而写成测试的不变量,智能体就能运行它。
在调试循环中加入调试器会是什么样子
Explyt 就是本故事中的智能体,它带有调试器集成,但本次会话没有使用。我想准确说明:这会改变什么,又不会改变什么。
它文档化的 Debug 工作流适用于可通过测试、应用程序或 IDE 运行配置复现的故障:你可以让智能体在 JetBrains 调试器下确认根因,查看断点、变量值和调用栈;确认后它再进行最小修复,并重新运行场景和相关测试。构建和测试通过 IDE 运行配置执行,并以结构化结果返回,因此智能体能读到测试结果;“它能编译”就不再是最后一项检查。
这覆盖了调试循环中段的环节:观察状态、修复、验证。如果本次会话中的症状可以在本地测试下复现,智能体本可以在 TID 状态被写入时检查它,并在每一次修复后重复运行场景。
但它并没有覆盖外围环节。文档没有描述它能独立读取 Kafka 主题、查询你的数据库或抓取集群日志;这些证据仍然需要开发者提供,或由你附加的 MCP 服务器提供。而且它不会自己注意到最新缺陷和第一个缺陷共享同一个原因。这仍然需要人类来完成,或者交由上面那个不变量测试来完成。
在分布式系统中接受任何智能体修复前的检查清单
- 故障是否已经在智能体能运行的环境中复现,还是仅仅向它描述过?
- 智能体自己观察到的运行时事实是什么:变量、数据行、消息,还是堆栈?
- 修复成功的标准是症状消失,还是构建通过?
- 在同一个区域出现第二个缺陷之后,是否有人问过两者有什么共同点?
- 这两个缺陷共同违反的不变量,是否已经写成了测试?
如果答案是“只描述过”“没有”“构建通过”“否”和“否”,你看到的就是本文中这样的会话。
声明与后续建议
我在 Explyt 工作,所以阅读本节时请记住这一点。Explyt 是面向 JetBrains IDE 的 AI 智能体。上文中的会话是在它的普通聊天模式中运行的;我列出的那些不足,正是我们需要解决的问题。这个产品目前已经闭环的环节,是调试模式(Debug mode)文档中描述的调试器步骤;但一次调试器运行只能确认某个特定场景下的修复,不能替代相关测试,文档中也是这样写的。
如果你想在自己的代码上尝试这种思路:选一个你的团队心知肚明、但从未写下来的跨存储不变量,把它变成类似上面的断言;然后让你正在使用的任意智能体在调试器下修复一个失败用例,并在每次修改后运行这个不变量测试。
相关文章
- Did Your AI Agent Ever Run a Debugger? One JVM Bug, Two Agent Runs
- Event-Driven Architecture in Java and Kafka
- ArchUnit: Testing Your Architecture
来源
- Superpowers 的 systematic-debugging 技能:https://www.skills.sh/obra/superpowers/systematic-debugging
- Explyt 文档,Debug mode 调试:https://explyt.ai/docs/explyt-test/tools/debugger
- Explyt 文档,运行配置:https://explyt.ai/docs/explyt-test/tools/run-configurations
- Explyt 文档,工具与集成(MCP):https://explyt.ai/docs/explyt-test/tools
- Explyt 博客上对本次会话更完整的记述:https://explyt.ai/en/blog/one-symptom-ten-problems-kafka-invariant