Skip to content

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)。把团队约定固化下来,比每次对话重复说一遍可靠得多。

一份有效的规则文件是具体的:

markdown
# 项目约定

## 技术栈(生成代码必须匹配这些版本)
- 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 边界条件 ​

模型对「正常流程」的建模远好于「边界」。逐项确认:

java
// 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 出错率最高,因为正确性依赖于代码之外的运行时语义。

java
// 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:

java
@Transactional
public void deductStock(long skuId, int count) {
    // 用带条件的 UPDATE 保证原子性,靠影响行数判断成败
    int affected = skuMapper.deductStock(skuId, count);
    if (affected == 0) {
        throw new BizException("库存不足");
    }
}
xml
<update id="deductStock">
    UPDATE sku SET stock = stock - #{count}
    WHERE id = #{skuId} AND stock >= #{count}
</update>

同一类问题还有:@Transactional 加在 private 方法上(不生效)、同类内部方法调用走不到代理(不生效)、事务里做 RPC 调用(长事务)。这些 AI 都会写出来,且都不报错。

3.3 依赖与版本 ​

java
// 模型可能生成过时或不存在的 API
import org.apache.commons.lang.StringUtils;   // commons-lang 2.x,早已停止维护

grep 一遍新引入的 import,确认都在 build.gradle 里、且是你想要的版本。

3.4 一条实用的评审启发式 ​

如果一段 AI 生成的代码你读起来毫无疑问,那更要停下来看一遍。 通顺是 AI 的强项,正确不是。你的疑虑水平和代码的正确率之间没有相关性——这一点和 review 人类代码很不一样。

四、回归防线:让机器挡住机器 ​

人工评审会疲劳,会遗漏,长期不可靠。真正的防线必须是自动化的。

提交前本地关卡格式、静态检查、单测1增量覆盖率只卡本次变更2变异测试测试本身有没有用3契约测试关键路径的行为约束4挡住低级错误挡住没测试的新代码挡住只断言不抛异常的测试挡住重写实现后的行为变化AI 写出的代码「看起来很对」,人工评审的注意力会下降,所以防线必须是机器执行的变异测试的价值最容易被低估:AI 生成的测试常常覆盖率很高,却什么都没验证
图 1 · 每一道关卡挡住一类问题;越往后成本越高,只用在越关键的代码上

4.1 提交前的本地关卡 ​

bash
#!/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%。要卡的是本次变更的覆盖率:

groovy
// build.gradle
jacocoTestCoverageVerification {
    violationRules {
        rule {
            element = 'CLASS'
            includes = changedClasses()      // 只检查本次改动的类
            limit {
                counter = 'BRANCH'           // 分支覆盖比行覆盖更能反映边界测试
                minimum = 0.8
            }
        }
    }
}

用分支覆盖而不是行覆盖,因为前面说的边界条件问题正好体现在分支上。

4.3 变异测试:验证测试本身有没有用 ​

这是我认为在 AI 辅助开发里价值被低估的一项。AI 也会生成测试,而它生成的测试经常是「调用一遍方法,断言不抛异常」——覆盖率很好看,实际什么都没验证。

变异测试的思路是主动改坏代码,看测试能不能发现:

bash
# PIT:修改字节码(把 > 改成 >=、把返回值改成 null 等),看测试是否失败
./gradlew pitest

如果一个变异体存活了(代码被改坏但测试还是绿的),说明那部分测试是无效的。这比覆盖率数字诚实得多。

4.4 关键路径的契约测试 ​

对支付、库存、权限这类核心逻辑,写一组跟实现无关的行为约束。这类测试的价值是:无论谁(人或 AI)重写了实现,行为约束都不能被破坏。

java
@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 辅助编码把瓶颈从「写得多快」移到了「验证得多可靠」。写代码的时间被压缩了,但省下来的时间应该有相当一部分投回到测试和评审上,而不是全部拿去写更多功能。

三条最核心的纪律:规则要可判定(否则模型无从遵守)、编译和测试是硬门槛(人工评审不可靠)、安全与并发不外包(错误成本太高)。

其余的都可以放松。


参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权