Java项目中的AGENTS.md:约束、命令与验收标准

AGENTS.md 解决什么问题

很多人第一次用 Codex,会把所有项目规则都写在当前对话里:项目用 Maven、不要改数据库结构、测试命令是什么、接口要保持兼容、日志不能打印手机号。这样当然能用,但每次都重复,很快就烦。

Codex 官方文档里把 AGENTS.md 解释成给 agent 用的开放格式 README。我的理解更直白:它是“写给 AI 看的项目交接文档”。对 Java 后端项目来说,AGENTS.md 不是越长越好,而是要把最容易犯错、最常重复、最影响结果的规则写进去。

用户口头上常说 agent.md,但 Codex 约定更常见的是 AGENTS.md。文章里我统一写 AGENTS.md,实际项目里建议按工具识别的文件名来。

Java项目AGENTS.md内容结构

应该写入哪些长期规则

Java 项目的 AGENTS.md 应该解决三个问题。

第一,让 Codex 快速知道项目结构。比如哪个模块是订单、哪个模块是库存、接口在哪、Mapper XML 在哪、测试放哪。

第二,让 Codex 遵守团队约定。比如 Controller 不写业务,事务放 Service,异常走统一异常码,DTO/VO 不混用。

第三,让 Codex 知道什么叫完成。比如要跑哪些测试,要检查哪些页面,要看哪些日志,要 review 哪些风险。

它不是需求文档,也不是架构百科。它应该短、准、可执行。

Java 项目配置示例

一个 Java 项目的 AGENTS.md 可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Project Rules

## Layout

- order-service: 订单业务
- inventory-service: 库存业务
- settlement-service: 结算业务
- mapper XML under src/main/resources/mapper
- tests under src/test/java

## Commands

- Run module tests: mvn -pl order-service test
- Build all: mvn clean package -DskipTests
- Run local app: mvn -pl order-service spring-boot:run

## Java conventions

- Controller only handles request/response mapping.
- Business logic belongs in Service.
- Database access goes through Mapper.
- Keep DTO, BO, Entity, VO separated.
- Use existing Result and BizException types.
- Do not introduce new frameworks without explicit request.

## Safety

- Do not print token, password, phone, address, or ID card in logs.
- Do not change public API response fields unless requested.
- Do not modify database schema unless the task asks for it.
- For inventory and payment changes, explain transaction and idempotency.

## Done means

- Relevant tests pass or failure is explained.
- Diff is scoped to the task.
- Risk points are summarized.

这份文件不需要一开始完美。我的经验是先写基础版,等 Codex 重复犯错时再补。比如它第二次把业务写进 Controller,就把“Controller only handles mapping”写进去;它第二次忘记跑模块测试,就把测试命令写进去。

如果项目很大,可以在根目录放一个总 AGENTS.md,再在子模块放更细的规则。比如 inventory-service/AGENTS.md 专门写库存锁、库存流水、幂等规则;settlement-service/AGENTS.md 专门写金额精度、对账和事务要求。

常见误区与维护建议

第一个坑,是把 AGENTS.md 写成长篇架构文档。Codex 每次读进去都占上下文,太长反而降低重点。详细资料可以放到 docs/,在 AGENTS.md 里写“需要时参考”。

第二个坑,是写模糊规则。比如“代码要优雅”“遵循最佳实践”。这类话没什么约束力。更好的写法是“新增接口必须补充 Controller 层参数校验和 Service 层单测”。

第三个坑,是不更新。项目命令改了、模块拆了、测试脚本变了,AGENTS.md 也要跟着改。旧规则会让 Codex 稳定地做错事。

第四个坑,是把一次性需求写进去。AGENTS.md 存长期规则,不存“今天新增一个字段”这种临时任务。

总结

Java 项目的 AGENTS.md,本质上是把团队开发习惯沉淀给 Codex。它应该包含项目结构、运行命令、代码约定、安全禁区和完成标准。

写好它以后,你会发现提示词可以短很多,Codex 也更容易按项目风格工作。它不是替代沟通,而是减少重复沟通。

参考资料

Codex用于Java项目:如何编写可执行、可验收的任务说明

Java 项目为什么需要结构化任务说明

用 Codex 做 Java 系统项目时,我越来越觉得,提示词不是“把需求说给 AI 听”这么简单。Java 后端项目通常有 Controller、Service、Mapper、DTO、数据库表、事务、缓存、消息队列、定时任务、异常码、接口兼容这些东西。你只说一句“帮我加个功能”,Codex 很可能能写出代码,但不一定写在正确的位置,也不一定符合项目习惯。

OpenAI Codex 的最佳实践里提到,一个好的任务上下文通常要包含目标、上下文、约束和完成标准。我在 Java 项目里会把它再落细一点:业务目标、涉及模块、现有入口、技术约束、验收标准、验证命令。这样 Codex 才更像一个会读项目的协作开发者,而不是只会生成代码片段的工具。

Codex Java项目提示词结构

高质量任务说明的组成

Java 项目的提示词,最重要的是让 Codex 知道“边界”。边界包括三类。

第一类是业务边界。比如这次只是新增订单状态筛选,不是重构订单中心;只是补库存预占失败提示,不是改库存模型。

第二类是技术边界。比如项目是 Spring Boot + MyBatis,不要引入 JPA;事务放在 Service 层,不要在 Controller 里写业务;已有统一异常处理,不要自己返回乱七八糟的 Map。

第三类是验收边界。比如接口返回字段不变,新增筛选条件要兼容旧调用,单测和构建要通过。

提示词写清楚边界,Codex 的发挥反而更稳定。

可直接复用的任务模板

我一般用这个模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
目标:
给订单列表接口增加按仓库和状态筛选。

上下文:
请先阅读 OrderController、OrderService、OrderMapper、OrderQueryDTO,以及 mapper XML。
当前项目是 Spring Boot + MyBatis,分页使用 PageHelper。

约束:
1. 不修改接口路径;
2. 不改已有返回字段;
3. 查询条件为空时保持原逻辑;
4. SQL 不要拼接字符串,使用参数绑定;
5. 不要改无关模块。

完成标准:
1. DTO 增加 warehouseId 和 status;
2. mapper 查询支持两个筛选条件;
3. 单元测试或 mapper 测试覆盖空条件和有条件;
4. 运行 mvn test;
5. 最后总结 diff 和风险点。

如果需求复杂,我会加一句:

1
先不要修改文件。请先给计划,列出要看的文件、要改的文件、测试方式和可能风险。

这句话很关键。很多返工都来自 Codex 一开始理解错层次。让它先计划,你可以提前发现它是不是准备改错模块。

如果是线上 bug,我会把错误现象也写进去:

1
2
3
4
现象:
订单列表传 status=已取消 时,页面仍然展示部分已完成订单。

请先定位查询链路,不要直接修。重点看参数从 Controller 到 Mapper XML 是否丢失,以及枚举值是否转换错误。

对于 Java 项目,我还喜欢明确“不要做什么”:

1
不要升级依赖,不要重构全局异常处理,不要修改数据库表结构,不要改 public API。

这比只写“保持最小改动”更有效。

常见问题与改进方式

第一个坑,是需求太像口号。比如“优化订单模块”。这句话没有验收标准,Codex 只能猜。要拆成“优化订单列表慢查询”“补订单状态变更测试”“修复取消订单库存回滚失败”。

第二个坑,是不给项目上下文。Java 项目里同名概念很多,Order、OrderInfo、SaleOrder、PurchaseOrder 可能完全不是一回事。让 Codex 先读文件,比让它凭名字猜靠谱。

第三个坑,是不写验证命令。Codex 可以跑测试,但它得知道跑什么。mvn testmvn -pl order-service testmvn -DskipTests package,写清楚会省很多来回。

第四个坑,是让它一次改太多。Java 系统里的改动常常牵扯数据库、缓存、接口、消息。大需求最好先切片,不要一轮做完。

总结

给 Codex 写 Java 项目提示词,我会坚持五段式:目标、上下文、约束、完成标准、验证命令。提示词不是越长越好,而是要让 Codex 少猜。

一条好的提示词,应该让人类开发者看了也知道怎么做。能达到这个标准,Codex 的输出通常就不会太离谱。

参考资料

MySQL悲观锁与乐观锁:供应链单据审核防并发

悲观锁和乐观锁不是 MySQL 独有的语法,而是两种并发控制思想。悲观锁假设冲突很常见,所以先加锁再修改。乐观锁假设冲突不多,所以先尝试更新,更新时验证版本或状态。

供应链系统里,单据审核、防重复提交、库存预占、结算过账都需要在这两种策略之间做选择。选择标准不是哪个更高级,而是冲突概率、业务代价、事务长度和用户体验。

悲观锁和乐观锁选择流程

MySQL 悲观锁和乐观锁选择流程

悲观锁:先锁住再处理

采购单审核可以使用 FOR UPDATE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
START TRANSACTION;

SELECT id, status, total_amount
FROM scm_purchase_order
WHERE id = 9001
FOR UPDATE;

UPDATE scm_purchase_order
SET status = 'APPROVED',
approved_by = 101,
approved_at = NOW()
WHERE id = 9001;

COMMIT;

当事务 A 持有这张采购单的排他锁时,事务 B 也执行 FOR UPDATE 会等待。这样可以保证同一张单据审核过程串行化。

悲观锁适合冲突概率高、重复执行代价大、必须读取多项数据后才能决定更新的场景。比如结算过账要检查供应商余额、发票状态、入库明细和付款计划,处理过程必须串行。

悲观锁的问题

悲观锁的缺点是持锁期间其他事务要等待。如果事务里包含慢操作,影响会放大。

错误示例:

1
2
3
4
5
6
@Transactional
public void approve(long purchaseOrderId) {
PurchaseOrder order = mapper.selectForUpdate(purchaseOrderId);
contractClient.check(order.getSupplierId());
mapper.approve(purchaseOrderId);
}

远程合同校验放在事务里,会让数据库锁一直持有。正确做法是事务外做可提前完成的校验,事务内只做最终状态判断和更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void approve(long purchaseOrderId) {
PurchaseOrderSnapshot snapshot = mapper.selectSnapshot(purchaseOrderId);
contractClient.check(snapshot.supplierId());
approveInTransaction(purchaseOrderId);
}

@Transactional
public void approveInTransaction(long purchaseOrderId) {
PurchaseOrder order = mapper.selectForUpdate(purchaseOrderId);
if (!order.waitApprove()) {
throw new BizException("状态已变化");
}
mapper.approve(purchaseOrderId);
}

乐观锁:更新时校验版本

乐观锁通常依赖 version 字段:

1
2
ALTER TABLE scm_purchase_order
ADD COLUMN version INT NOT NULL DEFAULT 0;

更新时带上旧版本:

1
2
3
4
5
6
7
8
UPDATE scm_purchase_order
SET status = 'APPROVED',
approved_by = #{userId},
approved_at = NOW(),
version = version + 1
WHERE id = #{id}
AND status = 'WAIT_APPROVE'
AND version = #{version};

如果影响行数是 1,审核成功。如果是 0,说明状态或版本已经被别人改过,业务层提示用户刷新。

Java 代码:

1
2
3
4
5
6
public void approveOptimistic(long id, int version, long userId) {
int affected = mapper.approve(id, version, userId);
if (affected != 1) {
throw new BizException("单据已被其他人处理,请刷新后重试");
}
}

乐观锁适合冲突概率较低、操作短、用户可以接受重试的场景。比如供应商资料维护、采购单草稿编辑、价格策略调整。

只用状态条件也可以

很多单据流转不一定需要单独 version,状态条件本身就是乐观锁:

1
2
3
4
UPDATE scm_sales_order
SET status = 'CANCELLED'
WHERE id = #{id}
AND status IN ('CREATED', 'WAIT_PAY');

如果订单已经出库,更新影响行数为 0。业务层返回“当前状态不允许取消”。

状态条件的优点是表达业务语义,缺点是不能发现所有字段覆盖问题。如果是编辑表单保存,还是应该使用 versionupdated_at 防止覆盖别人刚改的内容。

库存扣减选哪种

库存扣减常用条件更新:

1
2
3
4
5
6
7
UPDATE scm_inventory
SET available_qty = available_qty - #{qty},
locked_qty = locked_qty + #{qty},
version = version + 1
WHERE warehouse_id = #{warehouseId}
AND sku_id = #{skuId}
AND available_qty >= #{qty};

这更像乐观更新,但执行时 InnoDB 仍会对命中的记录加排他锁。它的优势是单 SQL 完成判断和修改,不需要先 SELECT FOR UPDATE

如果扣减逻辑必须读取复杂批次、先进先出规则、多个库位分配,再逐条修改,悲观锁或固定顺序的当前读会更清晰。

选择原则

高冲突、强顺序、长业务校验:考虑悲观锁,但事务必须短。

低冲突、短更新、用户可重试:优先乐观锁。

单据状态流转:优先状态条件更新。

库存余额扣减:优先条件更新,必要时结合版本和流水唯一键。

跨系统流程:不要靠长事务持锁等待外部系统,使用状态机、消息表和补偿任务。

小结

悲观锁和乐观锁是供应链系统里最常用的并发控制策略。悲观锁强调先占有资源,适合强冲突流程;乐观锁强调提交时校验,适合低冲突编辑和状态流转。实际项目中,两者经常结合使用:数据库条件更新保证最终一致,应用层根据影响行数决定成功、失败或重试。

Java CAS、Atomic和LongAdder:库存计数与并发指标

CAS 是 Compare And Swap,比较并交换。它是很多无锁并发工具的基础。Java 里的 AtomicIntegerAtomicLongAtomicReferenceLongAdder 都和 CAS 思路有关。

供应链系统里,CAS 适合应用层内存计数、指标统计、状态引用切换。它不适合替代数据库里的库存扣减。库存是跨节点共享的业务数据,最终必须由数据库条件更新、唯一约束或专门库存服务保证一致。

CAS 和指标统计流程

CAS 原子类和指标统计流程

AtomicInteger 的基本用法

库存同步任务需要统计本轮处理成功数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class InventorySyncMetrics {
private final AtomicInteger success = new AtomicInteger();
private final AtomicInteger failed = new AtomicInteger();

public void markSuccess() {
success.incrementAndGet();
}

public void markFailed() {
failed.incrementAndGet();
}

public int successCount() {
return success.get();
}
}

incrementAndGet() 内部会反复尝试 CAS:读取旧值,计算新值,尝试把旧值替换成新值。如果期间其他线程已经修改,就重试。

这比 volatile int count++ 安全,因为复合操作由原子类保证。

AtomicReference 切换配置

供应链系统常有策略配置,比如库存分配策略。可以用 AtomicReference 原子替换整份不可变配置:

1
2
3
4
5
6
7
8
9
10
11
12
public class AllocationConfigCenter {
private final AtomicReference<AllocationConfig> configRef =
new AtomicReference<>(AllocationConfig.defaultConfig());

public AllocationConfig current() {
return configRef.get();
}

public void refresh(AllocationConfig latest) {
configRef.set(latest);
}
}

如果需要基于旧配置做条件更新:

1
2
3
4
5
6
7
public boolean refreshIfVersionMatch(AllocationConfig latest) {
AllocationConfig current = configRef.get();
if (latest.version() <= current.version()) {
return false;
}
return configRef.compareAndSet(current, latest);
}

这适合本地配置引用。不要把它理解为分布式配置一致性的完整方案。

ABA 问题

CAS 的经典问题是 ABA:线程看到值从 A 变成 B,又变回 A,以为没有变化,但实际中间发生过修改。

比如内存里的任务状态:

1
WAIT -> RUNNING -> WAIT

另一个线程只看最终还是 WAIT,可能误以为任务从未被处理。

可以用版本号解决:

1
2
3
4
5
6
7
8
9
10
11
12
AtomicStampedReference<String> status =
new AtomicStampedReference<>("WAIT", 0);

int[] stampHolder = new int[1];
String current = status.get(stampHolder);

boolean success = status.compareAndSet(
current,
"RUNNING",
stampHolder[0],
stampHolder[0] + 1
);

业务系统里更常见的是数据库版本号:

1
2
3
4
5
6
UPDATE scm_inventory
SET available_qty = available_qty - 5,
version = version + 1
WHERE id = #{id}
AND version = #{oldVersion}
AND available_qty >= 5;

这就是乐观锁。它比 Java 内存 CAS 更适合跨实例共享数据。

LongAdder 适合高并发统计

高并发下,所有线程竞争同一个 AtomicLong 可能形成热点。LongAdder 通过分散计数降低竞争,最终求和。

1
2
3
4
5
6
7
8
9
10
11
public class ApiMetrics {
private final LongAdder reserveStockCount = new LongAdder();

public void markReserveStock() {
reserveStockCount.increment();
}

public long reserveStockCount() {
return reserveStockCount.sum();
}
}

它适合接口调用次数、同步成功数、报表处理行数这些指标。它不适合要求每次读取都严格精确的业务余额。

库存可售数不能这样做:

1
private final LongAdder salableStock = new LongAdder();

因为库存是强业务数据,不是指标。它要支持事务、回滚、审计和跨节点一致性。

CAS 与数据库条件更新的类比

CAS 的思想和数据库乐观锁很像:都要求“当前值还是我预期的值”才更新。

Java CAS:

1
compareAndSet(oldValue, newValue)

数据库乐观锁:

1
2
3
4
5
6
UPDATE scm_purchase_order
SET status = 'APPROVED',
version = version + 1
WHERE id = #{id}
AND status = 'WAIT_APPROVE'
AND version = #{version};

区别在于 Java CAS 只作用于当前 JVM 内存,数据库乐观锁作用于持久化数据,能被多个应用实例共同遵守。

使用边界

CAS 和原子类适合三类数据:

  1. 指标:接口调用次数、同步成功数、失败数。
  2. 引用:不可变配置、策略快照、路由规则版本。
  3. 本地状态:单 JVM 内的轻量状态切换。

不适合三类数据:

  1. 需要事务回滚的数据,例如库存扣减。
  2. 需要跨实例一致的数据,例如订单审核状态。
  3. 需要审计流水的数据,例如财务结算和库存流水。

如果一个数据需要“查得出历史、失败能回滚、多个实例共同遵守”,就应该进入数据库事务和业务状态机,而不是停留在 JVM 原子变量里。

小结

CAS 和原子类适合低成本地保护内存状态,尤其是计数、配置引用、轻量状态切换。供应链系统可以用它们做同步指标、缓存版本、任务状态引用。但库存扣减、单据审核、结算过账这些核心数据必须使用数据库条件更新或事务锁。不要把 JVM 内的原子性误认为全系统一致性。

Java读写锁和StampedLock:价格与库存快照缓存

读写锁适合读多写少的场景。供应链系统里有大量这种数据:商品基础资料、供应商等级、仓库配置、价格策略、库存快照。读请求频繁,写请求相对较少。如果用普通互斥锁,读读之间也会互相阻塞,吞吐会被压低。

ReentrantReadWriteLock 的规则是:读读共享,读写互斥,写写互斥。StampedLock 在此基础上提供乐观读,适合对性能要求更高但代码复杂度可控的场景。

缓存读写流程

读写锁和 StampedLock 缓存流程

价格策略缓存

假设订单试算接口需要频繁读取供应商价格策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class PricePolicyCache {
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();

private Map<Long, PricePolicy> policies = new HashMap<>();

public PricePolicy get(long supplierId) {
readLock.lock();
try {
return policies.get(supplierId);
} finally {
readLock.unlock();
}
}

public void refresh(Map<Long, PricePolicy> latest) {
writeLock.lock();
try {
policies = new HashMap<>(latest);
} finally {
writeLock.unlock();
}
}
}

多个订单试算线程可以同时读价格策略。刷新线程写入时,会阻塞读线程,确保读线程不会看到更新到一半的 Map。

不要在读锁里返回可变对象

上面的 PricePolicy 应该是不可变对象。如果返回的是可变对象,调用方可能绕过锁修改内部状态。

推荐:

1
2
3
4
5
6
7
8
9
10
11
public final class PricePolicy {
private final long supplierId;
private final BigDecimal discountRate;
private final List<PriceRule> rules;

public PricePolicy(long supplierId, BigDecimal discountRate, List<PriceRule> rules) {
this.supplierId = supplierId;
this.discountRate = discountRate;
this.rules = List.copyOf(rules);
}
}

读写锁保护的是引用替换过程,不应该承担对象内部到处可变的复杂度。

锁降级

ReentrantReadWriteLock 支持锁降级:线程持有写锁时,再获取读锁,然后释放写锁。这样可以在刷新后继续以读锁身份使用新数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public PricePolicy refreshAndGet(long supplierId) {
writeLock.lock();
try {
policies = loadFromDatabase();
readLock.lock();
} finally {
writeLock.unlock();
}

try {
return policies.get(supplierId);
} finally {
readLock.unlock();
}
}

不支持从读锁升级到写锁。读锁升级写锁很容易造成死锁,因为多个读线程都在等对方释放读锁。

StampedLock 的乐观读

StampedLock 提供乐观读。乐观读不阻塞写,但读取后要验证期间是否发生写入。

库存快照缓存例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class InventorySnapshot {
private final StampedLock lock = new StampedLock();

private int availableQty;
private int lockedQty;

public int salableQty() {
long stamp = lock.tryOptimisticRead();
int available = availableQty;
int locked = lockedQty;

if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
available = availableQty;
locked = lockedQty;
} finally {
lock.unlockRead(stamp);
}
}
return available - locked;
}

public void refresh(int available, int locked) {
long stamp = lock.writeLock();
try {
this.availableQty = available;
this.lockedQty = locked;
} finally {
lock.unlockWrite(stamp);
}
}
}

乐观读适合写入很少的场景。如果写入频繁,乐观读经常验证失败,反而增加复杂度。

StampedLock 的注意点

StampedLock 不是可重入锁。同一线程重复获取写锁可能把自己卡住。它也不直接配合 Condition。因此它适合局部、简单、性能敏感的缓存或数值快照,不适合复杂业务流程锁。

供应链系统里,仓库维度的实时库存核心数据不应该只靠本地 StampedLock 控制。因为多实例部署下,每个 JVM 都有自己的锁。它适合保护本地缓存视图,最终一致性仍然依赖数据库和消息刷新。

选择边界

读写锁适合本地内存视图,不适合直接保护核心库存事实。可以这样区分:

  1. 价格策略、仓库配置、库存展示快照:可以使用本地读写锁优化读取。
  2. 库存可售量、订单扣减、付款状态:必须以数据库或库存服务为准。
  3. 写入频率高的缓存:读写锁收益会下降,应该考虑分段、无锁快照或外部缓存。
  4. 跨实例一致性:本地锁无效,需要数据库条件更新、消息顺序、幂等和版本控制。

小结

读写锁适合读多写少的本地共享数据。供应链系统里的价格策略、仓库配置、库存快照缓存都可以使用。选择上,普通读多写少用 ReentrantReadWriteLock;极高频读取、低频刷新、逻辑简单时可以评估 StampedLock。不管使用哪种锁,都要避免返回可变对象,并明确它只保护单 JVM 内存。