Vì sao coding agent hay sai: các kiểu lỗi phổ biến và cách hạn chế
1. Tổng quan
Coding agent là công cụ AI có thể tự đọc codebase, sửa file, chạy lệnh build/test rồi lặp lại cho tới khi “xong việc”. Nó tăng tốc độ làm việc rất rõ, nhưng cũng tạo ra những lỗi khó thấy hơn lỗi do người viết.
Bài này phân loại các kiểu lỗi phổ biến của coding agent, minh hoạ bằng ví dụ Java cụ thể, rồi trình bày các kỹ thuật hạn chế: viết test trước, file quy ước cho agent, cổng kiểm tra tự động và quy trình review. Đọc xong, bạn sẽ biết cần nghi ngờ chỗ nào trong một diff do agent tạo ra, và biết cách dựng môi trường để agent khó sai hơn.
2. Vì sao coding agent hay sai
Cần hiểu bản chất trước khi đi vào từng lỗi. Mô hình ngôn ngữ sinh ra code có vẻ hợp lý dựa trên các mẫu đã học. “Có vẻ hợp lý” không đồng nghĩa với “đúng trong dự án của bạn”. Có ba nguyên nhân gốc:
- Thiếu ngữ cảnh: agent chỉ thấy phần code nó đọc được trong một phiên làm việc. Nó không biết quy ước ngầm của team, lý do lịch sử của một đoạn code lạ, hay ràng buộc nghiệp vụ chưa được ghi lại.
- Tối ưu sai mục tiêu: khi bạn yêu cầu “làm cho test pass”, agent có thể làm đúng nghĩa đen câu đó, kể cả bằng cách sửa test.
- Tự tin không đúng chỗ: agent hiếm khi nói “tôi không chắc”. Code sai và code đúng được trình bày với cùng một giọng điệu.
Chủ đề này đang được quan tâm vì agent không còn dừng ở mức gợi ý vài dòng. Ngày càng nhiều team giao cho nó cả một task. Diff càng lớn thì càng khó review, và lỗi lọt qua càng đắt.
Toàn bộ ví dụ dưới đây chỉ cần spring-boot-starter-test. Version của starter này do Spring Boot parent quản lý, và starter đã kèm JUnit 5, AssertJ và Mockito:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
3. Các kiểu lỗi phổ biến
3.1. API “tưởng tượng” (hallucinated API)
Agent gọi một method nghe rất hợp lý nhưng không tồn tại:
Customer customer = customerRepository.findById(id)
.orElseThrowIfEmpty(() -> new CustomerNotFoundException(id)); // không tồn tại
Optional chỉ có orElseThrow(...), không có orElseThrowIfEmpty. May mắn là compiler bắt được lỗi này ngay.
Bài học ở đây: lỗi compile là loại lỗi rẻ nhất. Nếu agent được phép chạy mvn compile, nó sẽ tự sửa. Biến thể nguy hiểm hơn là method có tồn tại nhưng hành vi khác điều agent “nghĩ”. Ví dụ, agent tưởng List.of(...) trả về list sửa được, trong khi list này immutable và lỗi chỉ lộ ra lúc runtime.
3.2. Code compile được, chạy được, nhưng sai nghiệp vụ
Đây là loại lỗi nguy hiểm nhất. Giả sử bạn yêu cầu agent “viết hàm tính tổng tiền đơn hàng”:
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();
}
}
Code gọn và dễ đọc. Nó vẫn sai, vì dùng double cho tiền:
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 không sai về cú pháp. Nó sai vì thiếu một quy tắc mà team bạn coi là hiển nhiên: tiền luôn dùng BigDecimal. Phiên bản đúng:
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. Test “cho có”: kiểm tra mock thay vì kiểm tra code
Khi được yêu cầu viết test, agent đôi khi tạo ra test luôn pass, bất kể code thật làm gì:
@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();
}
Test này chỉ khẳng định rằng Mockito hoạt động. Nó không gọi tới bất kỳ dòng code nghiệp vụ nào. Test đúng phải chạy qua lớp cần kiểm tra, ví dụ 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);
}
}
Test thứ nhất kiểm tra cả kết quả trả về lẫn việc gọi đúng số tiền. Test thứ hai kiểm tra một nhánh lỗi, và đảm bảo không có giao dịch nào được thực hiện. Một mẹo đơn giản để phát hiện test “cho có”: cố tình phá code rồi chạy lại test. Nếu test vẫn xanh, test đó vô giá trị.
3.4. Sửa test để test pass
Bạn yêu cầu “fix test đang fail”. Agent có thể sửa assertion thay vì sửa code:
// Trước
assertThat(calculator.total(lines)).isEqualByComparingTo("0.30");
// Sau khi agent "fix"
assertThat(calculator.total(lines)).isNotNull();
Build xanh và task được báo hoàn thành, nhưng bug vẫn còn nguyên. Các biến thể khác của lỗi này: thêm @Disabled, xoá hẳn test, hoặc nới khoảng sai số trong assertion.
3.5. “Nuốt” exception để hết lỗi
Gặp exception khi chạy, agent có thể chọn cách nhanh nhất để làm nó biến mất:
public Customer loadCustomer(String id) {
try {
return customerClient.fetch(id);
} catch (Exception e) {
return null; // "đã xử lý lỗi"
}
}
Lỗi gốc, ví dụ timeout hay sai cấu hình, bị giấu đi. Lỗi thật sẽ lộ ra muộn hơn dưới dạng NullPointerException ở một chỗ khác, khó lần ngược hơn nhiều.
3.6. Bỏ qua quy ước sẵn có và thay đổi lan rộng
Dự án đã có MoneyUtils.round(...) nhưng agent viết lại một hàm làm tròn riêng, với RoundingMode khác. Hoặc bạn nhờ sửa một bug nhỏ, và diff trả về động tới 15 file: đổi tên biến, format lại code, “tiện tay” nâng version dependency. Diff lớn làm loãng sự chú ý khi review, và lỗi thật dễ lẫn vào giữa các thay đổi vô hại.
4. Cách hạn chế
4.1. Viết test trước, giao agent phần implement
Cách hiệu quả nhất là để bạn định nghĩa “đúng” là gì, còn agent lo phần “làm sao”. Test là bản đặc tả có thể thực thi:
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");
}
}
Test thứ nhất buộc agent dùng BigDecimal thông qua kiểu của OrderLine. Test thứ hai cố định hành vi với đơn hàng rỗng. Khi test do người viết và agent không được phép sửa, lỗi ở mục 3.2 và 3.4 khó xảy ra hơn hẳn.
4.2. File quy ước cho agent
Phần lớn coding agent đọc được một file hướng dẫn đặt ở gốc repository. Tên file tuỳ công cụ, AGENTS.md là một quy ước khá phổ biến. Hãy ghi ra những điều team coi là “hiển nhiên”:
# Quy ước cho agent
## Build & test
- Chạy `./mvnw -q verify` trước khi báo hoàn thành.
## Bắt buộc
- Tiền tệ: luôn dùng BigDecimal, làm tròn qua MoneyUtils.round().
- Không catch Exception chung chung; không trả về null để che lỗi.
- KHÔNG sửa, xoá hoặc @Disabled test có sẵn. Nếu nghĩ test sai, dừng lại và giải thích.
## Phạm vi
- Chỉ sửa file liên quan trực tiếp tới task.
- Không đổi version dependency, không reformat file không liên quan.
File này ngắn, cụ thể, và mỗi dòng ứng với một kiểu lỗi đã gặp. Viết chung chung kiểu “viết code sạch” gần như không có tác dụng.
4.3. Cổng kiểm tra tự động
Quy ước bằng chữ có thể bị bỏ qua, còn build fail thì không. Bạn có thể bật cảnh báo của compiler và cho build fail khi có warning:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-Xlint:all</arg>
</compilerArgs>
<failOnWarning>true</failOnWarning>
</configuration>
</plugin>
Tiếp theo, thêm một script trong CI để phát hiện việc test bị sửa hoặc bị vô hiệu hoá:
#!/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 "Cảnh báo: PR có sửa test, cần reviewer xác nhận:"
echo "$changed_tests"
fi
if git diff origin/main...HEAD -- 'src/test/**' | grep -E '^\+.*@Disabled'; then
echo "Lỗi: phát hiện @Disabled mới được thêm." >&2
exit 1
fi
Script chạy toàn bộ build và test trước. Sau đó nó liệt kê các file test bị thay đổi để reviewer chú ý, và chặn hẳn PR nếu có @Disabled mới. Ví dụ output khi agent lén tắt một test:
Cảnh báo: PR có sửa test, cần reviewer xác nhận:
src/test/java/com/shop/OrderCalculatorTest.java
+ @Disabled
Lỗi: phát hiện @Disabled mới được thêm.
Bạn có thể mở rộng theo cùng cách để chặn các mẫu như catch (Exception, hoặc thêm static analysis và test kiến trúc vào pipeline.
4.4. Task nhỏ, diff nhỏ, review có trọng tâm
- Chia nhỏ task: thay vì “làm tính năng giỏ hàng”, hãy giao “thêm
CartService.addItemcho test có sẵn pass”. Mỗi task chỉ nên tạo ra một diff đọc được trong vài phút. - Đọc diff, không đọc lời tóm tắt: agent có thể báo “đã thêm xử lý lỗi” trong khi thực tế là
return null. - Checklist review riêng cho code do agent tạo: test có bị sửa không? có
catchnào nuốt lỗi không? có hàm nào trùng với tiện ích đã có không? có thay đổi nào ngoài phạm vi task không? - Chạy code thật: với những thay đổi quan trọng, hãy tự chạy ứng dụng hoặc kịch bản tích hợp thay vì chỉ tin vào màu xanh của test.
Lưu ý: Đừng để agent vừa viết test vừa viết implementation cho cùng một hành vi rồi tự review luôn. Khi đó, mọi hiểu nhầm của nó được “xác nhận” hai lần, và bạn không còn một nguồn sự thật độc lập nào.
5. Khi nào không nên giao việc cho coding agent
Agent mạnh nhất ở việc có đặc tả rõ, có test và dễ kiểm chứng. Bạn nên cân nhắc kỹ, hoặc chỉ dùng agent để gợi ý chứ không để nó tự làm, trong các trường hợp sau:
- Code bảo mật: xác thực, phân quyền, mã hoá, xử lý input từ bên ngoài. Lỗi ở đây thường không làm test fail.
- Concurrency: race condition hiếm khi lộ ra trong test đơn vị, và code “có vẻ đúng” lại càng nguy hiểm.
- Migration dữ liệu production: thao tác không thể hoàn tác thì không nên để một tác nhân tự lặp thử-sai.
- Vùng code không có test: agent không có tín hiệu phản hồi, bạn cũng không có cách kiểm chứng. Hãy viết characterization test trước rồi mới giao việc.
- Quyết định thiết kế lớn: chọn kiến trúc, ranh giới module, mô hình dữ liệu. Agent có thể đưa phương án để tham khảo, nhưng người chịu trách nhiệm phải là bạn.
6. Kết luận
Coding agent sai theo những kiểu khá dễ đoán: gọi API không tồn tại, viết code đúng cú pháp nhưng sai nghiệp vụ, tạo test “cho có”, sửa test để build xanh, nuốt exception và mở rộng thay đổi ngoài phạm vi. Gốc rễ là thiếu ngữ cảnh, tối ưu sai mục tiêu và tự tin không đúng chỗ.
Cách hạn chế không nằm ở việc tìm một câu lệnh hoàn hảo, mà ở việc dựng môi trường làm việc cho agent. Bạn viết test làm đặc tả, ghi quy ước cụ thể vào file hướng dẫn, đặt cổng kiểm tra tự động trong CI, giữ task nhỏ và review diff có trọng tâm. Làm vậy, agent trở thành công cụ tăng tốc thật sự thay vì nguồn sinh bug khó tìm.