Agent cần tài liệu tốt hơn bộ nhớ: viết repo cho cả người và agent

1. Tổng quan

Coding agent đang dần trở thành một “thành viên” trong team: nó đọc code, sửa bug, viết test và mở pull request. Vấn đề là agent không nhớ gì giữa các phiên. Có công cụ cung cấp tính năng “memory”, nhưng phần ghi nhớ đó thường gắn với từng người, khó review và không đi theo version của code.

Bài này giải thích vì sao tài liệu nằm trong repo đáng tin hơn bộ nhớ của agent. Bài cũng hướng dẫn cách viết một repo Spring Boot sao cho người mới vào team và agent đều làm việc đúng ngay từ lần đầu. Đọc xong, bạn sẽ biết cách viết tệp hướng dẫn AGENTS.md, gom việc kiểm tra về một lệnh duy nhất, biến quy ước thành test, và ghi lại các quyết định thiết kế.

2. Vì sao bộ nhớ không đủ

Hãy so sánh hai cách một agent “biết” rằng tầng domain không được phụ thuộc Spring:

  • Bộ nhớ: lần trước bạn đã nhắc nó, và nó lưu lại một ghi chú ở đâu đó.
  • Repo: quy tắc nằm trong AGENTS.md và có một test sẽ fail nếu ai đó vi phạm.

Cách thứ nhất có mấy điểm yếu rõ ràng:

  • Ghi chú chỉ tồn tại ở máy hoặc tài khoản của một người. Đồng nghiệp dùng agent khác sẽ không có nó.
  • Không ai review ghi chú đó. Nếu agent “nhớ sai”, bạn rất khó phát hiện.
  • Ghi chú không đi theo branch. Khi quy tắc đổi trên main, bộ nhớ cũ vẫn còn.

Cách thứ hai thì được version hoá, được review qua pull request, và đúng với từng commit. Quan trọng hơn, nó cũng giúp con người. Thứ mà agent cần để làm đúng gần như trùng khớp với thứ một developer mới cần trong tuần đầu tiên.

Đó là lý do chủ đề này đang được quan tâm. Agent không làm lộ ra vấn đề mới. Nó làm lộ ra cái giá của tài liệu kém, vì agent không thể đi hỏi “anh ơi, cái này chạy sao?” như một người mới.

3. Tệp hướng dẫn cho agent: AGENTS.md

Nhiều công cụ agent đọc một tệp Markdown ở gốc repo để lấy chỉ dẫn. Tên tệp tuỳ công cụ, nhưng AGENTS.md là một quy ước phổ biến. Nếu công cụ của bạn dùng tên khác, bạn có thể tạo symlink thay vì nhân bản nội dung.

3.1. Nội dung nên có

# AGENTS.md

## Dự án
Dịch vụ đặt hàng (Spring Boot 3, Java 21). Module chính: `order`, `payment`, `customer`.

## Lệnh
- Build + toàn bộ kiểm tra: `./scripts/verify.sh`
- Chạy một test: `./mvnw -q test -Dtest=DiscountPolicyTest`
- Chạy app local: `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`

## Quy ước bắt buộc
- `..domain..` không import `org.springframework..` (có test: `ArchitectureTest`).
- Tiền tệ dùng `BigDecimal`, không dùng `double`.
- Mọi thay đổi hành vi nghiệp vụ phải có test trong `src/test/java/.../domain`.

## Không được làm
- Không sửa file trong `src/main/resources/db/migration` đã merge; tạo migration mới.
- Không commit `application-local.yml`.

## Đọc thêm
- Quyết định kiến trúc: `docs/adr/`
- Luồng thanh toán: `docs/payment-flow.md`

Mỗi phần có một mục đích riêng:

  • Lệnh: agent chạy lệnh rất nhiều. Một lệnh sai sẽ làm nó tốn nhiều vòng thử. Hãy ghi lệnh chính xác, copy-paste được.
  • Quy ước bắt buộc: mỗi dòng nên chỉ ra cách kiểm chứng (tên test, tên lint rule). Quy ước không có cơ chế kiểm tra rất dễ bị bỏ qua.
  • Không được làm: liệt kê những thao tác gây hậu quả khó đảo ngược.
  • Đọc thêm: trỏ tới tài liệu chi tiết thay vì chép lại vào đây.

3.2. Giữ ngắn, trỏ tới nguồn sự thật

AGENTS.md nên ngắn, đủ để đọc hết trong một phút. Agent nạp tệp này vào ngữ cảnh ở mỗi phiên. Tệp càng dài thì càng tốn ngữ cảnh và càng dễ chứa thông tin lỗi thời.

Nguyên tắc chung: đừng mô tả lại thứ mà code hoặc build đã nói rõ. Đừng liệt kê từng dependency, vì pom.xml đã có. Đừng giải thích từng endpoint, vì OpenAPI spec đã có. Chỉ ghi những thứ không suy ra được từ code.

Lưu ý: Tuyệt đối không đặt secret, token hay URL nội bộ nhạy cảm vào AGENTS.md. Tệp này được đọc bởi công cụ bên ngoài và nằm trong lịch sử git mãi mãi.

4. Một lệnh kiểm tra duy nhất

Agent làm việc tốt nhất khi có một vòng phản hồi rõ ràng: sửa code, chạy lệnh, xem pass hay fail. Nếu để kiểm tra đầy đủ bạn phải chạy bốn lệnh khác nhau theo thứ tự, agent sẽ bỏ sót. Người cũng vậy.

#!/usr/bin/env bash
# scripts/verify.sh — chạy y hệt CI. Exit code khác 0 nghĩa là chưa được merge.
set -euo pipefail

echo ">> Compile + unit test + architecture test"
./mvnw -B -q verify

echo ">> Kiểm tra không còn TODO-AGENT sót lại"
if grep -rn "TODO-AGENT" src/; then
  echo "Còn TODO-AGENT chưa xử lý" >&2
  exit 1
fi

echo "OK: sẵn sàng tạo pull request"

Giải thích:

  • set -euo pipefail dừng script ngay khi một lệnh lỗi. Nhờ đó agent nhận exit code khác 0 thay vì một output dài rồi chữ “OK” lừa đảo ở cuối.
  • -B (batch mode) tắt output màu và thanh tiến trình. Log gọn hơn nên agent dễ đọc lỗi hơn.
  • Bước grep là ví dụ về quy ước riêng của team: agent được phép đánh dấu chỗ chưa xong bằng TODO-AGENT, nhưng không được để sót khi mở PR.

Khi chạy thành công, bạn sẽ thấy:

>> Compile + unit test + architecture test
>> Kiểm tra không còn TODO-AGENT sót lại
OK: sẵn sàng tạo pull request

Điều quan trọng nhất: CI phải gọi đúng script này. Nếu CI chạy một bộ lệnh khác, verify.sh sẽ dần lệch khỏi thực tế và trở thành tài liệu sai.

5. Biến quy ước thành test với ArchUnit

Một câu như “domain không phụ thuộc Spring” trong tài liệu chỉ là lời khuyên. Nếu biến nó thành test, nó trở thành luật. ArchUnit cho phép bạn viết quy tắc kiến trúc dưới dạng test 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 phải test được mà không cần Spring context, xem docs/adr/0003-domain-thuan.md");

    @ArchTest
    static final ArchRule webKhongGoiThangRepository = noClasses()
            .that().resideInAPackage("..web..")
            .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
            .because("controller đi qua service để giữ transaction ở một chỗ");
}

Điểm đáng giá nhất nằm ở .because(...). Khi quy tắc bị vi phạm, thông điệp lỗi sẽ chứa lý do và đường dẫn tới ADR. Giả sử ai đó thêm @Component vào DiscountPolicy trong package domain, output sẽ có dạng:

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 phải test được mà không cần Spring context, xem docs/adr/0003-domain-thuan.md'
was violated (1 times):
Class <com.nonla.shop.order.domain.DiscountPolicy> is annotated with <org.springframework.stereotype.Component> ...

Một agent đọc thông điệp này sẽ biết ngay phải làm gì: bỏ annotation, rồi đăng ký bean trong một class @Configuration ở tầng infra. Thông điệp lỗi tốt chính là tài liệu xuất hiện đúng lúc người đọc cần nó.

6. Test là tài liệu nghiệp vụ

Quy tắc nghiệp vụ là thứ agent dễ làm sai nhất. Nó không thể đoán rằng “giảm giá đơn đầu tiên tối đa 50.000đ”. Hãy để code và test nói điều đó.

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);
    }
}

Test đi kèm nên đặt tên theo hành vi, không theo tên method:

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("Giảm giá đơn hàng")
class DiscountPolicyTest {

    private final DiscountPolicy policy = new DiscountPolicy();

    @Test
    @DisplayName("Đơn đầu tiên được giảm 10% tổng tiền")
    void firstOrderGetsTenPercent() {
        var order = new Order("c-1", new BigDecimal("200000"), true);

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

    @Test
    @DisplayName("Giảm giá đơn đầu tiên không vượt quá 50.000đ")
    void firstOrderDiscountIsCapped() {
        var order = new Order("c-1", new BigDecimal("2000000"), true);

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

    @Test
    @DisplayName("Từ đơn thứ hai trở đi không được giảm")
    void repeatOrderGetsNoDiscount() {
        var order = new Order("c-1", new BigDecimal("2000000"), false);

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

Giải thích:

  • @DisplayName biến báo cáo test thành một bản đặc tả đọc được. Người làm sản phẩm cũng hiểu được.
  • isEqualByComparingTo so sánh BigDecimal theo giá trị, nên 20000 và 20000.00 được coi là bằng nhau. Dùng isEqualTo ở đây là một lỗi hay gặp, vì nó so sánh cả scale.
  • Nếu agent được yêu cầu “tăng mức giảm lên 15%”, nó sẽ thấy ngay test nào cần đổi. Nếu nó vô tình bỏ mức trần, test thứ hai sẽ fail.

Khi chạy, báo cáo trông như sau:

Giảm giá đơn hàng
  ✔ Đơn đầu tiên được giảm 10% tổng tiền
  ✔ Giảm giá đơn đầu tiên không vượt quá 50.000đ
  ✔ Từ đơn thứ hai trở đi không được giảm

7. Ghi lại “vì sao”: ADR và package-info

Code cho biết hệ thống làm gì. Test cho biết nó phải làm gì. Nhưng không thứ nào giải thích vì sao lại chọn cách đó. Thiếu phần “vì sao”, agent hay “dọn dẹp” thứ trông có vẻ thừa nhưng thực ra có lý do.

Architecture Decision Record (ADR) là một tệp ngắn cho mỗi quyết định:

# 0003. Tầng domain không phụ thuộc Spring

Trạng thái: Đã chấp nhận

## Bối cảnh
Test domain từng phải khởi động Spring context, chậm và dễ vỡ.

## Quyết định
Package `..domain..` chỉ dùng JDK. Bean được khai báo ở `..infra.config`.

## Hệ quả
- Test domain chạy bằng JUnit thuần.
- Phải viết thêm class cấu hình. Được kiểm tra bởi `ArchitectureTest`.

Với ghi chú gắn chặt vào một package, package-info.java là chỗ hợp lý, vì agent mở thư mục là thấy ngay:

/**
 * Quy tắc nghiệp vụ của đơn hàng. Chỉ dùng JDK, không dùng Spring (ADR 0003).
 * Mọi số tiền là BigDecimal, đơn vị VND, làm tròn xuống tới đồng.
 */
package com.nonla.shop.order.domain;

8. Lỗi hay gặp và khi nào không nên làm

Tài liệu trùng lặp. Cùng một quy tắc được ghi trong README.md, AGENTS.md và wiki. Sau vài tháng, ba nơi nói ba điều khác nhau. Hãy chọn một nguồn sự thật và trỏ tới nó từ các nơi còn lại.

Hướng dẫn mâu thuẫn với CI. AGENTS.md nói “chạy mvn test”, nhưng CI còn chạy thêm integration test. Agent sẽ báo “xong” trong khi PR vẫn đỏ.

Viết cho agent bằng giọng ra lệnh mơ hồ. Câu “Hãy viết code sạch” không giúp được gì. Câu “Method public trong ..service.. phải có test” thì hữu ích, và tốt hơn nữa là có thể kiểm chứng.

Dồn mọi thứ vào một tệp. Một AGENTS.md dài hàng nghìn dòng làm loãng ngữ cảnh. Thông tin quan trọng sẽ bị chìm.

Lưu ý: Không phải repo nào cũng cần đủ bộ này. Một script dùng một lần, một prototype sắp bỏ, hay một repo chỉ có vài trăm dòng code thì một README.md với lệnh chạy là đủ. ArchUnit cũng chỉ đáng thêm khi bạn có ít nhất vài tầng hoặc module với ranh giới thật sự cần giữ. Viết tài liệu cho thứ chưa tồn tại chỉ tạo thêm thứ phải bảo trì.

Ngược lại, bộ nhớ của agent vẫn có chỗ dùng. Nó phù hợp với sở thích cá nhân như giọng văn khi giải thích hay editor bạn đang dùng. Những thứ đó không thuộc về repo. Còn quy tắc của dự án thì luôn nên nằm trong repo.

9. Kết luận

Agent không cần một bộ nhớ tốt hơn, nó cần một repo dễ hiểu hơn. Bạn đã thấy cách:

  • Viết AGENTS.md ngắn, chứa lệnh chính xác và quy ước có cách kiểm chứng.
  • Gom mọi bước kiểm tra vào scripts/verify.sh và cho CI dùng chung script đó.
  • Biến quy ước kiến trúc thành test ArchUnit, với thông điệp lỗi trỏ về lý do.
  • Dùng test có @DisplayName làm đặc tả nghiệp vụ.
  • Ghi lại “vì sao” bằng ADR và package-info.java.

Mọi thứ ở trên đều có ích cho con người trước tiên. Agent chỉ là lý do khiến chúng ta không thể trì hoãn việc này thêm nữa. Hãy bắt đầu nhỏ: viết verify.sh và năm dòng đầu tiên của AGENTS.md ngay trong tuần này.

ai-agentdocumentationarchunittestingspring-boot