为什么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的来源。

aicoding-agenttestingjavabest-practices