Agent 需要好的文档而不是记忆:为人和 agent 编写 repo

1. 概述

编码 agent 正逐渐成为团队中的一个“成员”:它读代码、修复 bug、编写测试、打开 pull request。问题是 agent 在各个会话之间不记得任何东西。有些工具提供“内存”功能,但这些内存通常与每个人绑定,难以审查,不能跟随代码的版本。

本文解释为什么文档放在 repo 中比 agent 的内存更可靠。本文还指导如何编写 Spring Boot repo,使新加入团队的人和 agent 从第一次开始就能正确工作。读完本文后,你将了解如何编写指导文件 AGENTS.md、将检查工作集中在一条命令中、将约定转化为测试,以及记录架构决策。

2. 为什么内存不够

比较一下 agent 以这两种方式“知道” domain 层不应该依赖 Spring:

  • 内存:上次你提醒了它,它把笔记保存在某个地方。
  • Repo:规则在 AGENTS.md 中,有一个测试会在有人违反它时失败。

第一种方法有明显的弱点:

  • 笔记只存在于某个人的机器或账户。使用其他 agent 的同事不会有它。
  • 没人审查笔记。如果 agent“记错了”,你很难发现。
  • 笔记不随分支变化。当 main 上的规则改变时,旧内存仍然存在。

第二种方法被版本控制、通过 pull request 审查,并对应每个 commit。更重要的是,它也帮助人类。Agent 需要的东西与新开发者第一周需要的东西几乎完全重合。

这是这个主题受关注的原因。Agent 没有暴露出新问题。它暴露了糟糕文档的代价,因为 agent 无法像新人一样去问“嘿,这个怎么跑?”

3. Agent 指导文件:AGENTS.md

许多 agent 工具读取 repo 根目录中的 Markdown 文件以获取指示。文件名取决于工具,但 AGENTS.md 是常见的约定。如果你的工具用不同的名字,你可以创建符号链接而不是复制内容。

3.1. 应该包含的内容

# AGENTS.md

## 项目
订单服务(Spring Boot 3、Java 21)。主要模块:`order`、`payment`、`customer`。

## 命令
- 构建 + 全部检查:`./scripts/verify.sh`
- 运行单个测试:`./mvnw -q test -Dtest=DiscountPolicyTest`
- 本地运行应用:`./mvnw spring-boot:run -Dspring-boot.run.profiles=local`

## 必须遵循的约定
- `..domain..` 不导入 `org.springframework..`(有测试:`ArchitectureTest`)。
- 货币使用 `BigDecimal`,不用 `double`。
- 每个业务行为的变化必须在 `src/test/java/.../domain` 中有测试。

## 禁止做的事
- 不要修改已合并的 `src/main/resources/db/migration` 中的文件;创建新 migration。
- 不要提交 `application-local.yml`。

## 进一步阅读
- 架构决策:`docs/adr/`
- 支付流程:`docs/payment-flow.md`

每一部分都有自己的目的:

  • 命令:agent 运行命令很频繁。一个错误的命令会让它浪费很多轮尝试。写下准确的、可复制粘贴的命令。
  • 必须遵循的约定:每一行应该指出验证方式(测试名、lint 规则)。没有检查机制的约定很容易被忽视。
  • 禁止做的事:列出会导致难以撤销的后果的操作。
  • 进一步阅读:指向详细文档,而不是将其复制到这里。

3.2. 保持简短,指向真理来源

AGENTS.md 应该简短,可以在一分钟内读完。Agent 在每个会话中将此文件加载到上下文中。文件越长,消耗的上下文越多,包含过时信息的可能性就越大。

一般原则:不要重述代码或构建已经明确说明的内容。不要列出每个依赖,因为 pom.xml 已有。不要解释每个端点,因为 OpenAPI spec 已有。只记录无法从代码推导的东西。

注意:绝对不要在 AGENTS.md 中放入密钥、令牌或敏感的内部 URL。此文件由外部工具读取,永远存在于 git 历史中。

4. 单一验证命令

Agent 在有明确反馈循环时工作最好:修改代码、运行命令、看是否通过或失败。如果完整的检查需要按顺序运行四个不同的命令,agent 会跳过其中一些。人也是一样。

#!/usr/bin/env bash
# scripts/verify.sh — 与 CI 完全相同。非零退出码意味着还不能合并。
set -euo pipefail

echo ">> 编译 + 单元测试 + 架构测试"
./mvnw -B -q verify

echo ">> 检查没有遗留的 TODO-AGENT"
if grep -rn "TODO-AGENT" src/; then
  echo "还有未处理的 TODO-AGENT" >&2
  exit 1
fi

echo "OK: 准备创建 pull request"

解释:

  • set -euo pipefail 在任何命令失败时停止脚本。这样 agent 会收到非零退出码,而不是一长串输出末尾有误导的“OK”。
  • -B(批处理模式)关闭彩色输出和进度条。日志更简洁,agent 更容易看到错误。
  • grep 步骤是团队特有约定的例子:agent 被允许用 TODO-AGENT 标记未完成的地方,但在打开 PR 时不能留下遗漏。

成功运行时,你会看到:

>> 编译 + 单元测试 + 架构测试
>> 检查没有遗留的 TODO-AGENT
OK: 准备创建 pull request

最重要的是:CI 必须调用这个脚本。如果 CI 运行不同的命令集,verify.sh 会逐渐与现实偏离,变成错误的文档。

5. 用 ArchUnit 将约定变成测试

在文档中说“domain 不依赖 Spring”只是建议。如果把它变成测试,它就变成了法律。ArchUnit 允许你用 JUnit 测试的形式编写架构规则。

<dependency>
    <groupId>com.tngtech.archunit</groupId>
    <artifactId>archunit-junit5</artifactId>
    <version>1.3.0</version>
    <scope>test</scope>
</dependency>
package com.nonla.shop;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;

@AnalyzeClasses(packages = "com.nonla.shop", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {

    @ArchTest
    static final ArchRule domainKhongPhuThuocSpring = noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat().resideInAPackage("org.springframework..")
            .because("domain 必须能在没有 Spring context 的情况下测试,见 docs/adr/0003-domain-thuan.md");

    @ArchTest
    static final ArchRule webKhongGoiThangRepository = noClasses()
            .that().resideInAPackage("..web..")
            .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
            .because("controller 通过 service 调用以在一个地方保持 transaction");
}

最有价值的是 .because(...) 部分。当规则被违反时,错误消息会包含原因和 ADR 的路径。假设有人在 domain 包中的 DiscountPolicy 上添加 @Component,输出会是这样:

Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package
'..domain..' should depend on classes that reside in a package 'org.springframework..',
because domain 必须能在没有 Spring context 的情况下测试,见 docs/adr/0003-domain-thuan.md'
was violated (1 times):
Class <com.nonla.shop.order.domain.DiscountPolicy> is annotated with <org.springframework.stereotype.Component> ...

Agent 读到这条消息会立即知道该做什么:移除注解,然后在 infra 层的 @Configuration 类中注册 bean。一个好的错误消息就是在读者需要它时出现的文档。

6. 测试作为业务文档

业务规则是 agent 最容易出错的地方。它无法猜测“第一份订单最多折扣 50,000 元”。让代码和测试来说明这一点。

package com.nonla.shop.order.domain;

import java.math.BigDecimal;
import java.math.RoundingMode;

public record Order(String customerId, BigDecimal subtotal, boolean firstOrder) {}

class DiscountPolicy {

    static final BigDecimal FIRST_ORDER_RATE = new BigDecimal("0.10");
    static final BigDecimal FIRST_ORDER_CAP = new BigDecimal("50000");

    BigDecimal discountFor(Order order) {
        if (!order.firstOrder()) {
            return BigDecimal.ZERO;
        }
        BigDecimal raw = order.subtotal().multiply(FIRST_ORDER_RATE)
                .setScale(0, RoundingMode.DOWN);
        return raw.min(FIRST_ORDER_CAP);
    }
}

附带的测试应该按行为命名,而不是按方法名:

package com.nonla.shop.order.domain;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;

@DisplayName("订单折扣")
class DiscountPolicyTest {

    private final DiscountPolicy policy = new DiscountPolicy();

    @Test
    @DisplayName("首单获得总金额的 10% 折扣")
    void firstOrderGetsTenPercent() {
        var order = new Order("c-1", new BigDecimal("200000"), true);

        assertThat(policy.discountFor(order)).isEqualByComparingTo("20000");
    }

    @Test
    @DisplayName("首单折扣不超过 50,000 元")
    void firstOrderDiscountIsCapped() {
        var order = new Order("c-1", new BigDecimal("2000000"), true);

        assertThat(policy.discountFor(order)).isEqualByComparingTo("50000");
    }

    @Test
    @DisplayName("从第二单开始没有折扣")
    void repeatOrderGetsNoDiscount() {
        var order = new Order("c-1", new BigDecimal("2000000"), false);

        assertThat(policy.discountFor(order)).isZero();
    }
}

解释:

  • @DisplayName 将测试报告变成可读的规范。产品经理也能理解。
  • isEqualByComparingTo 按值比较 BigDecimal,所以 20000 和 20000.00 被认为相等。在这里使用 isEqualTo 是常见错误,因为它会比较 scale。
  • 如果 agent 被要求“提高折扣到 15%”,它会立即看到哪个测试需要改。如果它无意中跳过了上限,第二个测试会失败。

运行时,报告看起来像这样:

订单折扣
  ✔ 首单获得总金额的 10% 折扣
  ✔ 首单折扣不超过 50,000 元
  ✔ 从第二单开始没有折扣

7. 记录“为什么”:ADR 和 package-info

代码说明系统做什么。测试说明它必须做什么。但没有什么说明为什么选择这种方式。缺少“为什么”部分,agent 会“清理”看起来多余但实际有原因的东西。

Architecture Decision Record (ADR) 是每个决策一个简短的文件:

# 0003. Domain 层不依赖 Spring

状态:已接受

## 背景
测试 domain 过去需要启动 Spring context,很慢也容易出错。

## 决策
包 `..domain..` 只使用 JDK。Bean 在 `..infra.config` 中声明。

## 后果
- Domain 测试使用纯 JUnit。
- 必须写额外的配置类。通过 `ArchitectureTest` 检查。

对于绑定到某个包的说明,package-info.java 是合理的位置,因为 agent 打开目录时会立即看到:

/**
 * 订单业务规则。仅使用 JDK,不使用 Spring(ADR 0003)。
 * 所有金额都是 BigDecimal,单位为元,向下舍入到分。
 */
package com.nonla.shop.order.domain;

8. 常见错误及何时不应该做

文档重复。 同一规则在 README.md、AGENTS.md 和 wiki 中被记录。几个月后,三个地方说三件不同的事。选择一个真理来源,从其他地方指向它。

指导与 CI 矛盾。 AGENTS.md 说“运行 mvn test”,但 CI 还运行集成测试。Agent 会报告“完成”而 PR 仍然是红的。

用模糊的命令语气写给 agent。 “请写干净的代码”没有帮助。“公开方法在 ..service.. 中必须有测试”很有用,更好的是可以验证。

把所有东西都放在一个文件中。 一个数千行的 AGENTS.md 会稀释上下文。重要的信息会淹没。

注意:不是每个 repo 都需要全套。一个一次性脚本、一个即将放弃的原型,或者一个只有几百行代码的 repo,有一个 README.md 加上运行命令就够了。ArchUnit 也只在你至少有几个有真正需要保持的边界的层或模块时才值得添加。为不存在的东西写文档只会创建更多需要维护的东西。

相反,agent 的内存仍然有用处。它适合个人偏好,比如解释的语气或你正在使用的编辑器。这些不属于 repo。而项目的规则应该始终在 repo 中。

9. 结论

Agent 不需要更好的内存,它需要更容易理解的 repo。你已经看到如何:

  • 编写简短的 AGENTS.md,包含准确的命令和有验证方式的约定。
  • 将所有检查步骤合并到 scripts/verify.sh 中,CI 使用相同的脚本。
  • 将架构约定变成 ArchUnit 测试,错误消息指向原因。
  • 使用带 @DisplayName 的测试作为业务规范。
  • 用 ADR 和 package-info.java 记录“为什么”。

以上所有内容首先对人类有益。Agent 只是让我们不能再拖延这件事的理由。本周内开始吧:写 verify.sh 和 AGENTS.md 的前五行。

ai-agentdocumentationarchunittestingspring-boot