Ohhnews

分类导航

$ cd ..
foojay原文

构建全绿,任务跑偏:Java团队使用AI编码代理的工作约定

#ai编码代理#java#规范驱动开发#测试锁定#代码评审

你打开一个拉取请求。./gradlew check 是绿的,diff 有 400 行,描述里写着“按要求加了测试”。你往下滚,看到一个服务类有十几个单元测试。而你在两行 Jira 评论里真正要的,是结账流程的端到端覆盖。

没人撒谎。智能体用最可能的解读填补了请求里的空白,然后开始干活。这是我们团队在 9 月 28 日一场网络研讨会上经历的三种失败模式中的第一种。我把它写下来,是因为修复方法最终显得无聊又机械,而且它们适用于你们团队已经安装的任何智能体。

我在 Explyt 工作,我们做的是面向 JetBrains IDE 的 AI 智能体;演讲者 Sergey Pospelov 是我的同事。下面的材料是他讲的内容,我针对 Java 开发者重新讲述,并删掉了只在一个产品内部才说得通的部分。哪里加入了我自己的观点或例子,我会说明。

三种烧掉一个下午的方式

一个数字就能解释这三种情况:一个全新的对话已经要把大约 12% 的上下文窗口花在系统提示、工具列表以及你仓库里的 AGENTS.md 上。用到 50% 到 85% 之间的某个位置,回答会变差。到 99% 时,模型就不回答了。下面每一种失败都是在浪费那扇窗口。

请求不明确。 “加测试”变成给一个类加单元测试,而作者想要的是端到端覆盖。解药在键盘这一侧,也就是你这边。点名类、点名层、指向 issue,并把项目约定放进 AGENTS.md,这样你就不用每个对话都重复一遍。

对话过载。 一个会话,三个小时,一连串互不相关的工单。到第四个工单时,智能体正在把它从第一个工单学到的模式套用到第三个工单的代码上,而且 diff 看起来还挺像样。每个任务用一个对话,大任务拆到多个对话里,只有在确定你需要的那部分能在压缩后保留下来时,才压缩对话。

把总结照单全收。 智能体写下“所有测试通过,实现完成”,审查者点点头,一周后 bug 进了生产环境。智能体给自己的作业打分很慷慨。检查必须是一个程序(测试、linter、构建),或者是一个拥有全新上下文的第二个智能体,最好还是来自不同厂商的模型。

Sergey 关于第三种情况的幻灯片上有一句话,我一直反复想起:代码看起来没问题,智能体听起来也很自信。这两句说的都是呈现,而呈现说明不了行为。

先写规格:智能体在写代码前应该写什么

用 Sergey 的框架来说,解决方案质量是智能体对项目理解得有多好、对任务理解得有多精确这两者的乘积,所以每一半都有自己的文档。

项目文档是仓库根目录下的 AGENTS.md:构建命令、模块布局、测试约定、对每个任务都成立的事情。智能体每次运行都会读它,所以保持简短,并让团队在版本控制中维护它。

任务文档就是规格,而让这件事变得可忍受的是:你不用写它。智能体在规划模式下读代码、问你问题,然后起草文件。你来编辑和批准。如果你比我更讨厌面对空白页,就让智能体采访你,并根据你的回答构建规格。

下面是网络研讨会扩展指南里的规格,是为一个 Spring Boot 服务写的。我保留了它的结构,改写了内容。

# Task 01: rate-limit GET /api/orders

## Context
- order-service, Spring Boot 3, Java 21 (conventions in AGENTS.md)
- Origin: issue #482
- Classes involved: OrderController, ApiKeyFilter

## Goal
Cap each API key at 100 requests per minute on GET /api/orders.

## Requirements
1. Above the cap: 429 Too Many Requests plus a Retry-After header.
2. The cap is counted per API key.
3. Property orders.rate-limit.per-minute, default 100.
4. A request with no API key keeps returning 401. Do not touch that path.

## Out of scope
- Every other endpoint
- Limits shared across nodes

## Design
- RateLimitFilter registered directly after ApiKeyFilter
- In-memory token bucket per key, Bucket4j
- RateLimitProperties bound to orders.rate-limit.*

## Acceptance criteria (turn into tests before implementing)
- [ ] calls inside the cap within one minute all return 200
- [ ] the first call above the cap returns 429 with Retry-After
- [ ] two keys do not share a bucket
- [ ] overriding the property moves the cap
- [ ] ./gradlew check is green

## Plan
1. Failing tests: RateLimitFilterTest with MockMvc
2. Bucket4j dependency, RateLimitProperties
3. RateLimitFilter
4. Green, then review, three rounds at most

“Out of scope”部分阻止智能体因为它能做就去构建一个分布式限流器;而“Acceptance criteria”部分则是向下一阶段的交接:每个复选框都会变成一个测试,而且测试在过滤器存在之前就已经存在。

测试先行:验收标准在 JUnit 里长什么样

Sergey 坚持一个固定顺序。智能体写测试,你批准测试,智能体写代码,智能体运行测试,然后一直跑到测试变绿。对于上面的规格,智能体在第一步应该产出的测试大致如下。这个片段能针对 Spring Boot 3.3 编译,其中 OrderController、ApiKeyFilter、RateLimitFilter 和 RateLimitProperties 类是桩类,它只展示形态。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(OrderController.class)
@Import({ApiKeyFilter.class, RateLimitFilter.class})
@EnableConfigurationProperties(RateLimitProperties.class)
@TestPropertySource(properties = "orders.rate-limit.per-minute=3")
class RateLimitFilterTest {

    static final int CAP = 3;

    @Autowired
    MockMvc mvc;

    @Test
    void callsInsideTheCapSucceed() throws Exception {
        for (int i = 0; i < CAP; i++) {
            mvc.perform(get("/api/orders").header("X-Api-Key", "key-a"))
               .andExpect(status().isOk());
        }
    }

    @Test
    void firstCallAboveTheCapIsRejectedWithRetryAfter() throws Exception {
        for (int i = 0; i < CAP; i++) {
            mvc.perform(get("/api/orders").header("X-Api-Key", "key-b"))
               .andExpect(status().isOk());
        }
        mvc.perform(get("/api/orders").header("X-Api-Key", "key-b"))
           .andExpect(status().isTooManyRequests())
           .andExpect(header().exists("Retry-After"));
    }

    @Test
    void twoKeysDoNotShareABucket() throws Exception {
        for (int i = 0; i < CAP; i++) {
            mvc.perform(get("/api/orders").header("X-Api-Key", "key-c"))
               .andExpect(status().isOk());
        }
        mvc.perform(get("/api/orders").header("X-Api-Key", "key-d"))
           .andExpect(status().isOk());
    }

    @Test
    void missingKeyStillReturnsUnauthorized() throws Exception {
        mvc.perform(get("/api/orders"))
           .andExpect(status().isUnauthorized());
    }
}

@WebMvcTest 切片只注册控制器以及你 @Import 的内容,所以两个过滤器和属性绑定都被显式点名;没有 @EnableConfigurationProperties,过滤器的构造函数就没有东西可注入,上下文会加载失败。类级别的 @TestPropertySource 把上限降到 3,这让“覆盖属性会移动上限”变成整个类都会演练的事情,并让循环足够短,慢的 CI runner 也不可能给第四次调用一个已经重新填充的令牌。最后一个测试钉住了规格里的需求 4,也就是必须持续返回 401 的那条路径,所以 ApiKeyFilter 的回归会在同一个类里暴露出来。

把测试名称对照规格里的清单读一遍。这个映射就是你在检查点要做的审查:四个标准,四个测试,再加一个针对不能改变的需求,而且没有断言是规格没要求的。你批准之后,锁定测试目录,让智能体只能读它。在 Explyt 里这个边界是 .agentignore;其他智能体有自己的权限文件来做同样的事。

代码没变,测试却变绿了

Sergey 指出了一种每个 Java 审查者都应该学会识别的模式。智能体有一个失败的测试,它找不到修复方法,而通往绿色构建的最便宜路径就是测试文件。在 diff 里,它看起来比如像这样:

// before
assertThat(response.getStatus()).isEqualTo(429);

// after: the agent "fixed" it
assertThat(response.getStatus()).isIn(200, 429);

isIn 这个版本最让我受不了:它读起来像防御性编程,一个疲惫的审查者会对着它点头。同一个动作的其他形态还有:一个 @Disabled("flaky, see follow-up") 注解,但根本没有后续;或者一个悄悄替换掉被测组件的 mock。构建是绿的,验收标准在形式上满足了,bug 还在原地。

三种防御,按我会采用的顺序:

  1. 上一节说的锁定测试目录。如果智能体不能往那里写,这一整类捷径就消失了。
  2. 拆分角色:一个对话写测试,另一个对话做实现。实现者永远看不到产生这些断言的推理过程。
  3. 在你读测试 diff 之前,先把它交给一个审查智能体过一遍,然后你还是自己读一遍。我自己为 CI 加的是这样一个作业:当 src/test 和 src/main 在同一个 PR 里发生变化、却没有 reviewer 标签时就失败;它很便宜,而且能在周五下午抓住被弱化的断言。

带硬停止的反馈循环

在这套词汇里,反馈循环是智能体可以自己运行并据此采取行动的任何检查。指南里的示例提示是“一直写代码,直到 e2e 文件夹里的所有测试都通过”。linter 是反馈循环,把警告当错误的编译器、覆盖率阈值,或者通过 MCP 服务器为 UI 跑一次 Playwright,也都是反馈循环。

审查循环是同一个想法,只是换成第二个智能体。停止条件才是指南提示里有趣的部分:

实现 task-01.md 里的任务。当所有测试通过后,对你的 diff 运行一个审查子智能体。如果它报告了问题,就修复这些问题,并再次运行审查。最多做 3 轮审查。当审查干净时,或在第 3 轮之后停止,然后报告:你修复了什么、还有什么未解决、以及为什么。

审查者必须独立,因为作者智能体会批准自己的 diff。设置上限是因为如果没有上限,审查者每一轮都能找到东西,作者开始重写本来没问题的代码,token 账单涨了却没有任何产出。第三轮之后仍然未解决的问题,要由人来决定。

完整的六步循环如下,需要你注意的地方用粗体标出:

  1. 你陈述任务:issue、目标、限制。
  2. 智能体起草带验收标准的 task-01.md。
  3. 你批准规格。 这是抓住误解最便宜的地方。
  4. 智能体写失败的测试。你批准它们并锁定目录。
  5. 智能体实现直到变绿。
  6. 智能体运行审查循环,然后你读最终 diff 并合并。

对原型来说,这些都不是必须的;直接聊天,看看返回什么。当智能体老是误读任务时,规格就值得存在;当代码老是带着 bug 回来时,测试就值得存在。Sergey 的建议是只有在更简单的设置失败时才升级,我认为这是对的:这套方法要花真实时间,只有当替代方案是重写时,它才划算。

子智能体,以及什么时候不必费这个劲

子智能体有自己的上下文窗口、自己的系统提示和自己的工具。父智能体交给它一项工作,然后收到一个结果,探索过程中的噪音被去掉。这个定义带来了专门化(一个只阅读和判断的审查者,一个只 grep 和报告的搜索者)、干净的父上下文(子智能体读四十个文件,返回三个路径,那四十个文件永远不会进入主对话,这就解决了第一节里的对话过载问题),以及并行性(三个子智能体分别走三个模块,而你在读计划)。

[LOADING...] 来自网络研讨会指南:子智能体在哪里值回成本,单个智能体在哪里胜出。

Sergey 在那张幻灯片之后,又在下一张上引用了归因于 Anthropic 的一句话:团队花几个月搞多智能体架构,然后发现把单个智能体的提示写得更好就能得到同样的结果。他的解读是:一个拥有精确请求的单个智能体是默认选择,子智能体适用于一个上下文装不下、或者太分散的工作。修 bug 不符合条件,能放进一个对话的功能也不符合,任何你想一步步盯着看的东西都不符合。成本也很容易被低估:每个子智能体都要从零重建上下文,所以五个子智能体的流水线明显比一个智能体做同样的工作更贵。

对于同时进行的几个不相关任务,工具就不一样了:git worktree。你保留一个仓库,在多个独立目录里有多个检出,每个都在自己的分支上,配自己的智能体。智能体不会踩到彼此的文件,历史仍然共享。

[LOADING...] 不同 worktree 上的并行智能体;人在审查环节仍然是瓶颈。

那张幻灯片把现实上限放在两到三个并行智能体;Explyt 团队自己的经验是在状态好的一天能拉到四个。生成代码扩展起来没什么麻烦,而阅读 diff 并在它们之间切换,仍然是一份要由人来做的工作。

规则、技能、MCP:哪个文件干哪种活

个人设置放在你的主目录里,永远不会进入仓库:回复语言、你想要多少自主权、你偏好的堆栈跟踪分析格式。团队设置放在仓库里:AGENTS.md、项目规则和技能、智能体的访问边界、MCP 服务器列表、项目记忆。大多数智能体都遵循这个划分的某个版本;目录名各不相同。

[LOADING...] 规则、技能和 MCP 服务器,以及各自是做什么的。

规则是进入系统提示的长期指令:“JUnit 5 和 AssertJ。永远不要编辑生成源文件。”把它限定到某个文件模式,这样关于测试的规则就不会跟着你进入生产代码;还要记住,每一轮都重复的规则会在每一轮消耗 token。

技能是按需加载的专门知识:一个 Markdown 文件,带有一段智能体可以看到的描述,以及一个当描述与任务匹配时它会打开的正文。有一个开放的 Agent Skills 格式,能读取它的智能体就可以拾取为另一个工具编写的技能,这样让一个团队使用两个智能体时,就不必维护两份副本。现场演示展示了描述为什么重要。Sergey 生成了一个用于测试 Spring 控制器的技能,删掉一个测试类,要求把它找回来,而智能体忽略了那个技能。frontmatter 缺少一个字段,用来列出哪些智能体可以自动拾取它。他加上那个字段,再问一次,对话里就显示技能被使用了。生成技能,然后反复迭代描述,直到智能体能可靠地拾取它。

MCP 服务器是智能体伸向 IDE 之外的手:GitHub、Jira、浏览器。如果一个服务器暴露了五十个工具,光是它们的描述就会吃掉上下文;有些智能体可以把这样的服务器放在一个子智能体后面,这样主对话只看到摘要。

Sergey 在它们之间做选择的规则是:如果它应该始终适用,就写规则;如果它是某些任务的流程,就写技能;如果它需要编辑器之外的数据或操作,就连接 MCP 服务器。

项目记忆是最新的一块。非显而易见的事实(为什么构建使用了一个 fork 的插件、哪个模块是禁区)会按条目写进仓库里的 Markdown 文件,并带一个索引。智能体在对话过程中写入它们,并在下一次对话开始时读取索引。类的位置和方法签名不属于那里;智能体可以在代码里看到这些。## 一份适用于任何智能体的检查清单

指南中的环境配置清单总共只需三十分钟:

  1. 全局规则:用哪种语言回答、代码风格、行动前询问到什么程度、如何汇报。十分钟。
  2. 一两个个人技能(skill),用于每周都会反复出现的任务。先查看公开的技能注册表。每个十分钟。
  3. 打开项目记忆,如果你的智能体支持的话。五分钟。
  4. 处理一个真实的工单,然后调整任何出现偏差的措辞。五分钟。

还有评审者清单,这一份我会打印出来:

  • 规格说明是否点明了相关的类、问题以及不在范围内的内容?
  • 每一条验收标准是否都有对应测试,每个测试是否都能追溯到某条标准或需求?
  • 在实现阶段,测试目录对智能体是否为只读?
  • 评审循环是否设定了轮次上限?停止时还有哪些问题没有解决?
  • 是否有断言被削弱、测试被禁用,或某个 mock 替换了被测对象?
  • 不看智能体的总结,你能向同事解释这个 diff 吗?

披露与其余内容的位置

我在 Explyt 工作,我们为 JetBrains IDE 开发一款 AI 智能体,这场网络研讨会也是我们举办的,所以请带着这一背景阅读上文。本文的一切内容都刻意保持智能体无关:规格说明、锁定的测试、评审轮次上限以及 worktree 布局,都适用于 Claude Code、Codex、Cursor 或 IntelliJ IDEA 中的智能体。我省略的部分是产品特有的内容:在我们的插件中演示规则、技能和记忆,以及生成 AGENTS.md 的向导。完整的幻灯片、规格模板和提示词都在研讨会的扩展指南中;录像会上传到我们的 YouTube 频道,等链接可用后我会在评论中补上。

如果你在一张工单上尝试这六个步骤,而智能体仍然去改测试而不是改代码,我很想知道它是怎么绕过锁定的。这正是我在收集的失败模式。

资料来源

  • 网络研讨会“在用户层面使用 AI 工具”,2026 年 9 月 28 日,讲者 Sergey Pospelov,及其扩展指南(链接见上)
  • Bucket4j,规格示例中提到的令牌桶库
  • git worktree 文档
  • Agent Skills,开放技能格式