为什么coding agent容易出错:常见错误类型和限制方法
1. 总体概述
Coding agent是一种AI工具,它可以自己读取代码库、修改文件、运行构建/测试命令,然后重复这个过程直到“完成任务”。它显著提高了工作速度,但也引入了比人工编写代码更难以察觉的错误。
本文分类介绍了coding agent的常见错误类型,以具体的Java示例说明,然后阐述了限制技术:先写测试、给agent的约定文件、自动化检查关卡和审查流程。读完后,你会知道在agent生成的diff中应该怀疑哪些地方,也知道如何构建环境让agent更难出错。
2. 为什么coding agent容易出错
需要先理解本质再进入各个错误类型。语言模型生成的代码看起来合理,基于它学到的模式。“看起来合理”不等于“在你的项目中正确”。有三个根本原因:
- 缺乏上下文:agent只能看到它在一个工作会话中读取的代码部分。它不知道团队的隐性约定、某段奇怪代码的历史原因,或未被记录的业务约束。
- 优化错误的目标:当你要求“让测试通过”时,agent可能会从字面上理解,甚至包括修改测试。
- 信心不在点子上:agent很少说“我不确定”。错误的代码和正确的代码用同样的语气呈现。
这个话题受关注是因为agent不再仅仅停留在建议几行代码的层面。越来越多的团队将整个任务交给它。diff越大就越难审查,漏过的错误成本越高。
下面所有示例只需要spring-boot-starter-test。这个starter的版本由Spring Boot parent管理,starter已经包含JUnit 5、AssertJ和Mockito:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
3. 常见错误类型
3.1. “虚构”的API (hallucinated API)
Agent调用一个听起来很合理但实际不存在的方法:
Customer customer = customerRepository.findById(id)
.orElseThrowIfEmpty(() -> new CustomerNotFoundException(id)); // 不存在
Optional只有orElseThrow(...),没有orElseThrowIfEmpty。幸运的是编译器会立即捕获这个错误。
这里的教训是:编译错误是最便宜的错误。如果agent被允许运行mvn compile,它会自己修复。更危险的变种是方法确实存在,但行为与agent“认为”的不同。例如,agent以为List.of(...)返回可修改的列表,实际上这个列表是不可变的,错误直到运行时才会显露。
3.2. 代码能编译、能运行,但业务逻辑错误
这是最危险的错误类型。假设你要求agent“写一个计算订单总金额的函数”:
public record OrderLine(String sku, double unitPrice, int quantity) {}
public class OrderCalculator {
public double total(List<OrderLine> lines) {
return lines.stream()
.mapToDouble(line -> line.unitPrice() * line.quantity())
.sum();
}
}
代码简洁易读。但它是错的,因为用了double表示金额:
var lines = List.of(new OrderLine("PEN", 0.1, 1), new OrderLine("BOOK", 0.2, 1));
System.out.println(new OrderCalculator().total(lines));
0.30000000000000004
Agent在语法上没错。它错在缺少了一条你的团队认为是常识的规则:金额总是用BigDecimal。正确的版本:
public record OrderLine(String sku, BigDecimal unitPrice, int quantity) {}
public class OrderCalculator {
public BigDecimal total(List<OrderLine> lines) {
return lines.stream()
.map(line -> line.unitPrice().multiply(BigDecimal.valueOf(line.quantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
3.3. “凑数”的测试:模拟mock而不是测试代码
当被要求编写测试时,agent有时会写出无论代码做什么都会通过的测试:
@Test
void shouldChargeCustomer() {
PaymentService payment = mock(PaymentService.class);
when(payment.charge("C1", new BigDecimal("100"))).thenReturn(true);
assertThat(payment.charge("C1", new BigDecimal("100"))).isTrue();
}
这个测试只验证了Mockito有效。它没有调用任何业务逻辑代码。正确的测试应该通过需要测试的类,例如CheckoutService:
public interface PaymentService {
boolean charge(String customerId, BigDecimal amount);
}
public class CheckoutService {
private final PaymentService paymentService;
public CheckoutService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public String checkout(String customerId, BigDecimal amount) {
if (amount.signum() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
return paymentService.charge(customerId, amount) ? "PAID" : "FAILED";
}
}
class CheckoutServiceTest {
private final PaymentService payment = mock(PaymentService.class);
private final CheckoutService checkout = new CheckoutService(payment);
@Test
void returnsPaidAndChargesExactAmount() {
when(payment.charge("C1", new BigDecimal("100.00"))).thenReturn(true);
assertThat(checkout.checkout("C1", new BigDecimal("100.00"))).isEqualTo("PAID");
verify(payment).charge("C1", new BigDecimal("100.00"));
}
@Test
void rejectsNonPositiveAmountWithoutCharging() {
assertThatThrownBy(() -> checkout.checkout("C1", BigDecimal.ZERO))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(payment);
}
}
第一个测试检查了返回值和调用的金额是否正确。第二个测试检查了一个错误分支,确保没有进行任何交易。检测“凑数”测试的一个简单技巧:故意破坏代码然后重新运行测试。如果测试仍然通过,这个测试就没有价值。
3.4. 修改测试使其通过
你要求“修复失败的测试”。Agent可能会修改断言而不是修复代码:
// 之前
assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");
// agent"修复"后
assertThat(calculator.total(lines)).isNotNull();
构建变绿了,任务被报告为完成了,但bug仍然存在。这个错误的其他变种包括:添加@Disabled、删除整个测试,或放宽断言中的误差范围。
3.5. “吞掉”异常来消除错误
遇到异常时,agent可能选择最快速的方式让它消失:
public Customer loadCustomer(String id) {
try {
return customerClient.fetch(id);
} catch (Exception e) {
return null; // "已处理错误"
}
}
根本原因(比如超时或配置错误)被隐藏起来。真正的错误会以NullPointerException的形式在其他地方露出来,更难追溯。
3.6. 忽视现有约定,广泛改动代码
项目已经有MoneyUtils.round(...)了,但agent自己写了一个舍入函数,用了不同的RoundingMode。或者你要求修复一个小bug,返回的diff涉及15个文件:重命名变量、重新格式化代码、“顺手”升级依赖版本。大diff会分散审查的注意力,真正的错误容易隐没在无害的改动中。
4. 限制方法
4.1. 先写测试,让agent实现
最有效的方法是让你定义“正确”是什么,由agent负责“如何做”。测试是可执行的规范:
class OrderCalculatorTest {
private final OrderCalculator calculator = new OrderCalculator();
@Test
void sumsLinesWithoutFloatingPointError() {
var lines = List.of(
new OrderLine("PEN", new BigDecimal("0.10"), 1),
new OrderLine("BOOK", new BigDecimal("0.20"), 1));
assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");
}
@Test
void returnsZeroForEmptyOrder() {
assertThat(calculator.total(List.of())).isEqualByComparingTo("0");
}
}
第一个测试通过OrderLine的类型强制agent使用BigDecimal。第二个测试固定了空订单的行为。当测试由人编写且agent不被允许修改时,第3.2和3.4节的错误就很难发生了。
4.2. 给agent的约定文件
大多数coding agent可以读取一个放在repository根目录的指导文件。文件名取决于工具,AGENTS.md是一个相当普遍的约定。把团队认为“显而易见”的东西都写下来:
# Agent约定
## 构建和测试
- 在报告完成前运行`./mvnw -q verify`。
## 必要要求
- 货币:总是使用BigDecimal,通过MoneyUtils.round()进行舍入。
- 不要catch通用Exception;不要返回null来隐藏错误。
- 不要修改、删除或@Disabled现有的测试。如果觉得测试有问题,停下来并解释。
## 范围
- 只修改与任务直接相关的文件。
- 不要改变依赖版本,不要重新格式化无关文件。
这个文件很短,很具体,每一行都对应一种已遇见过的错误。写得太笼统比如“写干净的代码”几乎没有作用。
4.3. 自动化检查关卡
纸面上的约定可能被忽视,但构建失败是不会的。你可以启用编译器警告,并在有警告时让构建失败:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-Xlint:all</arg>
</compilerArgs>
<failOnWarning>true</failOnWarning>
</configuration>
</plugin>
接下来,在CI中添加一个脚本来检测测试被修改或被禁用:
#!/usr/bin/env bash
set -euo pipefail
./mvnw -q verify
changed_tests=$(git diff --name-only origin/main...HEAD -- 'src/test/**' || true)
if [[ -n "$changed_tests" ]]; then
echo "警告:PR修改了测试,需要审查者确认:"
echo "$changed_tests"
fi
if git diff origin/main...HEAD -- 'src/test/**' | grep -E '^\+.*@Disabled'; then
echo "错误:检测到新添加的@Disabled。" >&2
exit 1
fi
脚本先运行完整的构建和测试。然后它列出被修改的测试文件供审查者注意,并在发现新的@Disabled时直接阻止PR。例如当agent偷偷禁用了一个测试时的输出:
警告:PR修改了测试,需要审查者确认:
src/test/java/com/shop/OrderCalculatorTest.java
+ @Disabled
错误:检测到新添加的@Disabled。
你可以用同样的方式来阻止catch (Exception这样的模式,或将静态分析和架构测试添加到流程中。
4.4. 任务要小、diff要小、审查要有重点
- 拆分任务:与其说“实现购物车功能”,不如说“添加
CartService.addItem使现有测试通过”。每个任务应该只产生几分钟内能读完的diff。 - 读diff,不要只读总结:agent可能会报告“已添加错误处理”,但实际上做的是
return null。 - 为agent生成的代码制定专门的审查清单:测试被修改了吗?有“吞掉”异常的
catch吗?有重复的函数与现有工具重复吗?有超出任务范围的改动吗? - 运行真实代码:对于重要的改动,自己运行应用或集成测试,而不只是相信测试是绿的。
注意: 不要让agent既写测试又写实现代码,然后自己审查。这样,它的任何误解都会被“验证”两次,而你失去了一个独立的真理来源。
5. 何时不应该将任务交给coding agent
Agent在规范清晰、有测试且容易验证的任务上表现最好。在以下情况下你应该谨慎考虑,或只让agent提建议而不是完全自动化:
- 安全相关代码:身份验证、授权、加密、输入验证。这些地方的错误通常不会导致测试失败。
- 并发代码:竞态条件在单元测试中很少显现,而看起来“正确”的代码反而更危险。
- 生产数据迁移:不可逆的操作不应该由一个不断试错的代理来执行。
- 测试覆盖不完整的代码:agent没有反馈信号,你也没有验证的方法。先写特征化测试,再交给agent。
- 大型设计决策:选择架构、划分模块边界、设计数据模型。agent可以提供方案参考,但你必须是做决策的人。
6. 结论
Coding agent按相当可预测的方式出错:调用不存在的API、写出语法正确但业务逻辑错误的代码、创建“凑数”的测试、修改测试使其通过、“吞掉”异常、扩大改动范围。根本原因是缺乏上下文、优化错误的目标和信心不在点子上。
限制这些错误不在于找到一条完美的命令,而在于为agent构建工作环境。你编写规范的测试、在指导文件中明确记录约定、在CI中设置自动检查关卡、保持任务和diff的小规模、进行有针对性的审查。这样一来,agent就真正成为了效率提升工具,而不是难以发现的bug的来源。