AI 辅助编码的工程纪律:上下文、评审与回归防线
用 AI 写代码最大的风险不是它写错,而是它写得看起来很对。人对通顺代码的审查强度天然低于对可疑代码的审查强度,这是 AI 辅助开发需要专门用流程去对抗的东西。
一、真正的风险在哪
先排除几个不是主要问题的问题:语法错误编译器会拦、明显的逻辑错误测试会拦、风格问题 linter 会拦。
真正会漏到生产的是这三类:
1. 似是而非的实现。 代码结构合理、命名规范、注释清楚,但边界条件处理错了。这类代码在 review 时最容易被放过,因为它读起来很顺。
2. 幻觉出来的 API。 模型会生成不存在的方法名、不存在的配置项、参数顺序错误的调用。编译型语言里这类问题会被编译器拦住,但配置文件、SQL、Shell 脚本、动态语言里不会。
3. 过时的模式。 训练数据里旧写法的样本量远大于新写法。让模型写 React 组件,它可能给你 propTypes;写 Java 并发,它可能给你手动管理线程池。这些不报错,只是把技术债提前埋进去了。
这三类的共同点是:它们不会在写的时候暴露,只会在跑一段时间后暴露。所以纪律的重点是在合并之前把它们挡住。
二、上下文管理:决定输出质量的关键变量
同一个模型,给不同的上下文,输出质量差距非常大。
2.1 把项目约定写成文件,而不是每次口述
大部分工具都支持项目级的规则文件(Kiro 的 .kiro/steering/、Cursor 的 .cursorrules、Copilot 的 copilot-instructions.md)。把团队约定固化下来,比每次对话重复说一遍可靠得多。
一份有效的规则文件是具体的:
# 项目约定
## 技术栈(生成代码必须匹配这些版本)
- Java 21,Spring Boot 3.4,MyBatis-Plus 3.5
- 构建:Gradle Kotlin DSL,不要生成 Maven 配置
## 必须遵守
- 所有对外接口返回 `Result<T>`,不要直接返回实体
- 数据库访问只走 Mapper 接口,不要在 Service 里拼 SQL
- 新增配置项必须同时更新 `application.yml` 和 `ConfigProperties` 类
- 时间统一用 `Instant`,禁止 `Date` 和 `Calendar`
- 日志用参数化写法 `log.info("x={}", x)`,禁止字符串拼接
## 禁止
- 不要引入新的依赖,需要新库时先问
- 不要生成 `@Autowired` 字段注入,用构造器注入
- 不要写 `try { } catch (Exception e) { e.printStackTrace(); }`
## 测试
- 单测用 JUnit 5 + AssertJ,不要用 Hamcrest
- 测试方法名用 `should_xxx_when_yyy` 格式无效的规则文件长这样:「写高质量的代码」「遵循最佳实践」「注意性能」。这些话对输出没有任何影响。
判断标准:每条规则都应该能让人判断出「生成的代码符合还是不符合」。
2.2 给足参考,不要让它猜
新增功能时,最有效的做法是明确指向一个已有的同类实现:
参考 OrderController 和 OrderService 的写法,新增一个 RefundController,
支持创建退款单和查询退款状态两个接口。
错误码沿用 ErrorCode 里的既有定义,不要新增。这比「帮我写一个退款接口」的输出质量高一个档次,因为模型能从参考实现里学到你的分层方式、异常处理约定、命名习惯。
2.3 上下文过长时主动重置
长对话里的上下文会互相干扰:前面被否掉的方案、中途改过的需求、失败的尝试都还在里面。模型可能会捡起一个你已经放弃的思路。
我的做法是:一个子任务结束就开新会话,并把结论带过去:
上一轮的结论:退款单表已建好(schema 见 V12__refund.sql),
RefundService 的接口已定义。现在实现 RefundServiceImpl。比让它在几十轮的历史里自己找重点可靠得多。
三、评审:把注意力放在 AI 最容易错的地方
人工 review 的注意力是有限资源。AI 生成的代码要专门盯这几处,因为它们恰好是 AI 的薄弱区,也恰好是人容易扫过去的地方。
3.1 边界条件
模型对「正常流程」的建模远好于「边界」。逐项确认:
// AI 生成的代码
public Page<Order> query(OrderQuery q) {
return orderMapper.selectPage(
new Page<>(q.getPageNum(), q.getPageSize()),
Wrappers.<Order>lambdaQuery()
.eq(Order::getUserId, q.getUserId())
.between(Order::getCreateTime, q.getStart(), q.getEnd()));
}要问的问题:
pageSize没有上限,传10000000会怎样?start晚于end时between的行为是什么?start或end为 null 时between会生成什么 SQL?userId为 null 时,会不会变成「查所有人的订单」?
最后一条是真实事故的常见来源:条件为 null 时被静默忽略,一个「查我的订单」的接口变成了「查所有订单」。
3.2 并发与事务
这两处 AI 出错率最高,因为正确性依赖于代码之外的运行时语义。
// AI 很容易生成这样的代码,看起来完全正常
@Transactional
public void deductStock(long skuId, int count) {
Sku sku = skuMapper.selectById(skuId);
if (sku.getStock() < count) {
throw new BizException("库存不足");
}
sku.setStock(sku.getStock() - count);
skuMapper.updateById(sku);
}问题是典型的「读-改-写」竞态,并发下会超卖。正确写法要把判断下推到 SQL:
@Transactional
public void deductStock(long skuId, int count) {
// 用带条件的 UPDATE 保证原子性,靠影响行数判断成败
int affected = skuMapper.deductStock(skuId, count);
if (affected == 0) {
throw new BizException("库存不足");
}
}<update id="deductStock">
UPDATE sku SET stock = stock - #{count}
WHERE id = #{skuId} AND stock >= #{count}
</update>同一类问题还有:@Transactional 加在 private 方法上(不生效)、同类内部方法调用走不到代理(不生效)、事务里做 RPC 调用(长事务)。这些 AI 都会写出来,且都不报错。
3.3 依赖与版本
// 模型可能生成过时或不存在的 API
import org.apache.commons.lang.StringUtils; // commons-lang 2.x,早已停止维护grep 一遍新引入的 import,确认都在 build.gradle 里、且是你想要的版本。
3.4 一条实用的评审启发式
如果一段 AI 生成的代码你读起来毫无疑问,那更要停下来看一遍。 通顺是 AI 的强项,正确不是。你的疑虑水平和代码的正确率之间没有相关性——这一点和 review 人类代码很不一样。
四、回归防线:让机器挡住机器
人工评审会疲劳,会遗漏,长期不可靠。真正的防线必须是自动化的。
4.1 提交前的本地关卡
#!/bin/sh
# .git/hooks/pre-commit
set -e
echo "→ 格式化与静态检查"
./gradlew spotlessCheck checkstyleMain --daemon
echo "→ 编译(拦住幻觉 API)"
./gradlew compileJava compileTestJava --daemon
echo "→ 受影响模块的单测"
./gradlew test --daemon
echo "→ 检查是否引入了新依赖"
if git diff --cached --name-only | grep -q "build.gradle"; then
echo "⚠️ 构建文件有变更,请确认新依赖是否必要"
fi
echo "→ 检查是否有调试残留"
if git diff --cached -U0 | grep -E "^\+.*(System\.out\.println|printStackTrace|TODO: remove)"; then
echo "❌ 存在调试残留,请清理"
exit 1
fi编译这一步是性价比最高的:它能挡住几乎所有幻觉 API。
4.2 覆盖率只看增量
整体覆盖率是个钝指标——一个 80% 覆盖率的项目,新加的一坨没测试的代码可能只让它掉到 79%。要卡的是本次变更的覆盖率:
// build.gradle
jacocoTestCoverageVerification {
violationRules {
rule {
element = 'CLASS'
includes = changedClasses() // 只检查本次改动的类
limit {
counter = 'BRANCH' // 分支覆盖比行覆盖更能反映边界测试
minimum = 0.8
}
}
}
}用分支覆盖而不是行覆盖,因为前面说的边界条件问题正好体现在分支上。
4.3 变异测试:验证测试本身有没有用
这是我认为在 AI 辅助开发里价值被低估的一项。AI 也会生成测试,而它生成的测试经常是「调用一遍方法,断言不抛异常」——覆盖率很好看,实际什么都没验证。
变异测试的思路是主动改坏代码,看测试能不能发现:
# PIT:修改字节码(把 > 改成 >=、把返回值改成 null 等),看测试是否失败
./gradlew pitest如果一个变异体存活了(代码被改坏但测试还是绿的),说明那部分测试是无效的。这比覆盖率数字诚实得多。
4.4 关键路径的契约测试
对支付、库存、权限这类核心逻辑,写一组跟实现无关的行为约束。这类测试的价值是:无论谁(人或 AI)重写了实现,行为约束都不能被破坏。
@Test
void 库存扣减必须在并发下保持不超卖() throws Exception {
long skuId = givenSkuWithStock(100);
int threads = 50;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var latch = new CountDownLatch(1);
var futures = IntStream.range(0, threads)
.mapToObj(i -> executor.submit(() -> {
latch.await(); // 尽量让它们同时开始
try { stockService.deduct(skuId, 3); return true; }
catch (BizException e) { return false; }
}))
.toList();
latch.countDown();
long ok = futures.stream().filter(f -> get(f)).count();
// 核心约束:成功次数 × 单次数量,不能超过初始库存
assertThat(ok * 3).isLessThanOrEqualTo(100);
assertThat(currentStock(skuId)).isEqualTo(100 - ok * 3);
assertThat(currentStock(skuId)).isGreaterThanOrEqualTo(0);
}
}五、划清界限:哪些活别交给 AI
不是能力问题,是风险收益比问题。
| 任务 | 适合度 | 说明 |
|---|---|---|
| 样板代码(DTO、Mapper、CRUD) | 高 | 模式固定,错了也容易发现 |
| 单元测试骨架 | 高 | 但要人工补边界用例 |
| 正则、SQL、Shell 一次性脚本 | 高 | 必须实际跑一遍验证 |
| 代码解释、陌生模块导读 | 高 | 出错成本低 |
| 重构(改名、提取方法、换 API) | 中 | 要有测试兜底 |
| 业务规则实现 | 中 | 必须逐条对照需求核验 |
| 并发控制、事务边界 | 低 | 出错概率高且后果严重 |
| 安全相关(认证、加密、权限) | 低 | 模型会生成看起来对的弱实现 |
| 架构决策 | 低 | 它不知道你的团队、历史债和运维能力 |
安全那一行值得展开:模型很容易生成 MD5 存密码、把密钥硬编码、用 == 比较 token(时序攻击)、Random 当作安全随机源。这些代码能跑、能过 review、能上线,然后成为漏洞。
六、一份可以贴在墙上的清单
开始之前
- [ ] 项目有规则文件,且规则是可判定的
- [ ] 给出了同类实现作为参考
- [ ] 明确了不许引入新依赖
评审时
- [ ] 边界条件逐项确认(null、空集合、越界、上下限、顺序颠倒)
- [ ] 并发路径确认(读-改-写、事务范围、锁的位置)
- [ ] 新增 import 全部核对过
- [ ] 安全相关代码人工重写,不采纳生成结果
- [ ] 对「读起来毫无疑问」的代码额外看一遍
合并之前
- [ ] 编译通过(挡幻觉 API)
- [ ] 增量分支覆盖率达标
- [ ] 核心路径的契约测试通过
- [ ] 关键模块跑过变异测试
小结
AI 辅助编码把瓶颈从「写得多快」移到了「验证得多可靠」。写代码的时间被压缩了,但省下来的时间应该有相当一部分投回到测试和评审上,而不是全部拿去写更多功能。
三条最核心的纪律:规则要可判定(否则模型无从遵守)、编译和测试是硬门槛(人工评审不可靠)、安全与并发不外包(错误成本太高)。
其余的都可以放松。
参考资料