Viết tiêu chí hoàn tất mà AI có thể kiểm chứng
Câu hỏi bài này trả lời: làm sao biết một AI agent đã làm xong đúng yêu cầu, thay vì chỉ nghe nó báo “xong”?
Cần biết trước: đọc được code Python cơ bản và biết giao việc có phạm vi (xem Chọn context khi sửa code). Phần lab chỉ dùng thư viện chuẩn của Python 3 (đã chạy trên 3.14), không cài thêm gì.
Agent thường dừng khi việc “trông như đã xong”. Tài liệu best practices của Claude Code nói đúng điều này: nếu không có phép kiểm nào để chạy thì “trông như xong” là tín hiệu duy nhất, và bạn trở thành vòng kiểm chứng. Bài của Anthropic về harness cho agent chạy dài ghi nhận một dạng khác của cùng vấn đề: một phiên agent sau thấy đã có tiến độ nên tuyên bố xong. Bài này đi từ một yêu cầu mơ hồ tới tiêu chí có verifier, rồi kiểm chính verifier, vì một verifier chưa từng thất bại thì chưa chứng minh được gì.
Ba mệnh đề hay bị coi là một
| Mệnh đề | Bằng chứng cho nó | Không chứng minh được |
|---|---|---|
| File tồn tại | test -f users.csv | Nội dung đúng, đủ dòng, đọc lại được |
| Test chạy được | Một lệnh kiểm kết thúc với exit code 0 | Rằng phép kiểm phủ yêu cầu; nó có thể rỗng hoặc chỉ kiểm điều dễ |
| Hành vi đúng yêu cầu | Verifier đọc kết quả thật, so với kỳ vọng viết độc lập với code, và đã từng thất bại trước lỗi mẫu | Các yêu cầu chưa được viết thành tiêu chí |
Câu “xong rồi, file CSV đã được tạo” chỉ nói tới mệnh đề đầu. Hai mệnh đề sau cần tiêu chí và verifier.
Từ yêu cầu mơ hồ đến tiêu chí quan sát được
Yêu cầu ban đầu: “Thêm chức năng xuất danh sách người dùng ra CSV.” Câu này không nói “xong” nghĩa là gì. Viết lại thành tiêu chí mà bạn quan sát được từ kết quả, không mô tả cách làm:
- R1: dòng đầu là
id,name,created_at. - R2: mỗi người dùng một dòng dữ liệu, giữ thứ tự đầu vào.
- R3:
created_atcó dạng2026-01-31T09:05:00Z(UTC, ISO 8601 theo hồ sơ RFC 3339). - R4: tên chứa dấu phẩy, nháy kép hoặc xuống dòng vẫn đọc lại đúng bằng một CSV parser chuẩn (quy tắc nháy theo RFC 4180, tài liệu thông tin chứ không phải tiêu chuẩn).
- R5: danh sách rỗng cho file chỉ có dòng tiêu đề.
- R6: tiếng Việt có dấu giữ nguyên, file UTF-8.
- Ngoài phạm vi: Excel và BOM, file rất lớn, phân quyền truy cập.
Liệt kê ca biên (rỗng, giá trị đặc biệt, Unicode, múi giờ) từ yêu cầu trước khi có code. Điều vắng mặt không tự lộ ra: câu trả lời cho “đã đủ chưa?” chỉ phản ánh những ca người trả lời đã nghĩ tới.
Hai verifier cho cùng một yêu cầu
Giả sử agent báo: “Xong, users.csv đã được tạo.” Nó có thể đã viết một trong bốn bản cài: một bản đúng và ba bản, mỗi bản sai đúng một tiêu chí (R4, R5, R3). Tạo thư mục trống, ví dụ csv-lab, và các file sau.
export_csv.py chứa bốn bản cài. export_correct dùng csv.writer và mở file với newline="" theo tài liệu module csv:
import csv
from datetime import timezone
HEADER = ["id", "name", "created_at"]
def iso_utc(moment):
return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def export_correct(users, path):
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(HEADER)
for user in users:
writer.writerow([user["id"], user["name"], iso_utc(user["created_at"])])
def export_without_quoting(users, path):
lines = [",".join(HEADER)]
for user in users:
lines.append(f"{user['id']},{user['name']},{iso_utc(user['created_at'])}")
with open(path, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines) + "\n")
def export_no_header_when_empty(users, path):
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
if users:
writer.writerow(HEADER)
for user in users:
writer.writerow([user["id"], user["name"], iso_utc(user["created_at"])])
def export_datetime_as_text(users, path):
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(HEADER)
for user in users:
writer.writerow([user["id"], user["name"], user["created_at"]])
IMPLEMENTATIONS = {
"correct": export_correct,
"without_quoting": export_without_quoting,
"no_header_when_empty": export_no_header_when_empty,
"datetime_as_text": export_datetime_as_text,
}
fixtures.py giữ dữ liệu mẫu và kết quả mong đợi viết thẳng thành giá trị, không tính từ code đang kiểm: nếu kỳ vọng sinh từ chính code thì lỗi và kỳ vọng sai cùng nhau. Bốn ca ứng với tiêu chí: normal (R1–R3), special_chars (R4), empty (R5), unicode (R6).
from datetime import datetime, timezone
UTC = timezone.utc
HEADER = ["id", "name", "created_at"]
CASES = {
"normal": (
[
{"id": 1, "name": "An", "created_at": datetime(2026, 1, 31, 9, 5, tzinfo=UTC)},
{"id": 2, "name": "Binh", "created_at": datetime(2026, 2, 1, 0, 0, tzinfo=UTC)},
],
[["1", "An", "2026-01-31T09:05:00Z"], ["2", "Binh", "2026-02-01T00:00:00Z"]],
),
"special_chars": (
[{"id": 3, "name": 'Le, "Van"\nTran', "created_at": datetime(2026, 3, 1, 12, 30, 45, tzinfo=UTC)}],
[["3", 'Le, "Van"\nTran', "2026-03-01T12:30:45Z"]],
),
"unicode": (
[{"id": 4, "name": "Nguyễn Thị Bình", "created_at": datetime(2026, 4, 2, 6, 0, tzinfo=UTC)}],
[["4", "Nguyễn Thị Bình", "2026-04-02T06:00:00Z"]],
),
"empty": ([], []),
}
check_weak.py là kiểu kiểm agent hay viết: chạy export rồi xác nhận file tồn tại.
import os
import sys
import tempfile
from export_csv import IMPLEMENTATIONS
from fixtures import CASES
users, _ = CASES["special_chars"]
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "users.csv")
IMPLEMENTATIONS[sys.argv[1]](users, path)
assert os.path.exists(path)
print("file tồn tại")
verify_behavior.py đóng vai người dùng file: đọc lại bằng CSV parser chuẩn rồi so với kỳ vọng độc lập. Cùng tinh thần với khuyến nghị kiểm đầu-cuối “như người dùng thật” trong bài harness của Anthropic. Nó chỉ ghi vào thư mục tạm.
import csv
import sys
import tempfile
from pathlib import Path
from export_csv import IMPLEMENTATIONS
from fixtures import CASES, HEADER
def read_back(export, users):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "users.csv"
export(users, path)
with open(path, encoding="utf-8", newline="") as handle:
return list(csv.reader(handle))
def check(name):
passed = True
for label, (users, expected) in CASES.items():
rows = read_back(IMPLEMENTATIONS[name], users)
wanted = [HEADER, *expected]
if rows == wanted:
print(f"ok {label}")
else:
passed = False
print(f"FAIL {label}: mong đợi {wanted!r}")
print(f" đọc lại {rows!r}")
return passed
if __name__ == "__main__":
sys.exit(0 if check(sys.argv[1]) else 1)
Chạy cả hai verifier trên từng bản cài:
for impl in correct without_quoting no_header_when_empty datetime_as_text; do
if python3 check_weak.py "$impl" >/dev/null 2>&1; then weak=pass; else weak=FAIL; fi
if python3 verify_behavior.py "$impl" >/dev/null 2>&1; then behavior=pass; else behavior=FAIL; fi
printf '%-22s weak=%s behavior=%s\n' "$impl" "$weak" "$behavior"
done
correct weak=pass behavior=pass
without_quoting weak=pass behavior=FAIL
no_header_when_empty weak=pass behavior=FAIL
datetime_as_text weak=pass behavior=FAIL
Verifier yếu pass cả bốn bản, nên “pass” của nó không mang thông tin: nó không thể thất bại trước ba lỗi này. Verifier hành vi pass bản đúng và thất bại trước từng bản lỗi. Đó mới là lý do để tin “pass” của nó, trong phạm vi bốn ca. Với verifier do agent viết hoặc sửa, vòng lặp trên là bước kiểm nên có: đưa vào vài bản lỗi mẫu và xem verifier có đỏ không. Đây là dạng tối giản của mutation testing, tức cố ý đưa lỗi vào code rồi xem test có bắt được không.
Bảng yêu cầu, verifier, kỳ vọng, thực tế, bằng chứng
Lấy bản without_quoting (agent báo xong vì file tồn tại) và chạy verifier hành vi:
python3 verify_behavior.py without_quoting
ok normal
FAIL special_chars: mong đợi [['id', 'name', 'created_at'], ['3', 'Le, "Van"\nTran', '2026-03-01T12:30:45Z']]
đọc lại [['id', 'name', 'created_at'], ['3', 'Le', ' "Van"'], ['Tran', '2026-03-01T12:30:45Z']]
ok unicode
ok empty
| Yêu cầu | Verifier | Kỳ vọng | Thực tế | Bằng chứng |
|---|---|---|---|---|
R1 Dòng đầu là id,name,created_at | verify_behavior.py, ca normal | Hàng đầu là id, name, created_at | Khớp | ok normal |
| R2 Một dòng mỗi người dùng, đúng thứ tự | Ca normal | Hai dòng dữ liệu theo thứ tự đầu vào | Khớp | ok normal |
R3 created_at dạng …T…Z | Ca normal | 2026-01-31T09:05:00Z | Khớp | ok normal |
| R4 Dấu phẩy, nháy kép, xuống dòng không phá cột | Ca special_chars | Một dòng dữ liệu, tên nguyên vẹn | Hai dòng dữ liệu, tên bị tách thành Le, "Van" và Tran | FAIL special_chars, exit code 1 |
| R5 Danh sách rỗng chỉ có dòng tiêu đề | Ca empty | Một hàng: tiêu đề | Khớp | ok empty |
| R6 Tiếng Việt có dấu giữ nguyên | Ca unicode | Nguyễn Thị Bình | Khớp | ok unicode |
Kết luận cho bản này: R1–R3 và R5–R6 đạt, R4 không đạt, nên chưa xong dù file tồn tại. Hai bản còn lại sai ở tiêu chí khác: no_header_when_empty sai R5 (đọc lại được 0 hàng thay vì 1), datetime_as_text sai R3 ở mọi ca có dữ liệu (2026-01-31 09:05:00+00:00 thay vì …T…Z).
Kiểm chỉ đọc và hành động thật
| Loại | Ví dụ | Dùng làm bằng chứng “xong”? |
|---|---|---|
| Kiểm chỉ đọc hoặc cô lập | Chạy test trong thư mục tạm; đọc lại file xuất; dry-run hoặc plan; truy vấn đọc trên database thử | Có |
| Hành động đổi môi trường thật | Deploy, chạy migration trên database thật, gửi email, xóa dữ liệu, push | Không: cần quyền và phê duyệt riêng |
- Tiêu chí “xong” chỉ dựa vào kiểm chỉ đọc hoặc cô lập. Nếu việc cần deploy hay migration, viết tiêu chí riêng cho nó và theo dõi trạng thái riêng.
- Một lệnh vừa kiểm vừa hành động (ví dụ “chạy test, pass thì deploy”) không phải verifier; tách làm hai.
- Báo cáo tách ba trạng thái: đã kiểm (lệnh và kết quả), chưa kiểm (vì sao), đã chạy hành động (ai phê duyệt). “Chưa kiểm” không bao giờ là “đạt”. Tài liệu Claude Code tóm tắt: nếu không kiểm được thì đừng phát hành.
Ai viết tiêu chí và verifier? Người giao việc viết hoặc duyệt tiêu chí; agent chạy verifier và báo output. Lahiri (arXiv:2603.17150, preprint 03/2026) viết rằng ngoài người dùng không có oracle nào cho độ đúng của đặc tả, nên không ai kiểm hộ bạn tiêu chí có đúng ý hay không. Có thêm lý do thực tế để đặt verifier ngoài tầm sửa của agent:
- METR (2025-06-05) ghi nhận ở một số tác vụ có chấm điểm, các model tiên phong có lúc sửa hoặc né mã chấm điểm thay vì giải bài, với tỷ lệ khác nhau rất xa giữa các tác vụ. Chính báo cáo ghi rằng hai phương pháp lọc tự động của họ có tỷ lệ dương tính giả rất cao nên chỉ dùng để chọn lượt chạy xem tay, và số lần phát hiện có thể thấp hơn thực tế.
- Anthropic cho agent chạy dài một quy tắc: không được xóa hoặc sửa test vì có thể dẫn tới chức năng bị thiếu hoặc lỗi. Tài liệu Claude Code mô tả mẫu reviewer riêng trong ngữ cảnh mới, để agent làm việc không phải là agent chấm.
Nếu agent buộc phải sửa verifier, xem diff của verifier trước diff của code.
Bằng chứng do máy ghi, không do lời kể
Bằng chứng gồm lệnh, exit code, đoạn output liên quan, môi trường và thời điểm. Tài liệu Claude Code khuyến nghị yêu cầu agent đưa bằng chứng thay vì khẳng định thành công, vì đọc bằng chứng nhanh hơn chạy lại phép kiểm. Cách đơn giản nhất để bằng chứng không phụ thuộc lời kể là chạy verifier qua một lớp ghi lại:
import json
import platform
import subprocess
import sys
import time
command = sys.argv[1:]
started = time.strftime("%Y-%m-%dT%H:%M:%S%z")
done = subprocess.run(command, capture_output=True, text=True)
record = {
"command": command,
"exit_code": done.returncode,
"output_tail": (done.stdout + done.stderr).splitlines()[-5:],
"python": platform.python_version(),
"started_at": started,
}
print(json.dumps(record, ensure_ascii=False, indent=2))
sys.exit(done.returncode)
Chạy verifier hành vi trên bản đúng qua lớp ghi:
python3 evidence.py python3 verify_behavior.py correct
{
"command": [
"python3",
"verify_behavior.py",
"correct"
],
"exit_code": 0,
"output_tail": [
"ok normal",
"ok special_chars",
"ok unicode",
"ok empty"
],
"python": "3.14.3",
"started_at": "2026-10-02T17:17:10+0700"
}
Bản ghi do chương trình tạo ra từ lần chạy thật. Chạy tay như trên mới là bước đầu: đặt bước ghi ở nơi agent không sửa được (CI hoặc hook của công cụ) thì mạnh hơn.
Mẫu dùng lại
Từ yêu cầu tới tiêu chí:
Yêu cầu: <một câu>
Tiêu chí (quan sát được từ kết quả, không mô tả cách làm)
- R1: <điều kiện>
- R2: <điều kiện>
Ca biên: <rỗng · giá trị đặc biệt · Unicode · múi giờ · ...>
Ngoài phạm vi: <điều không làm>
Verifier: <lệnh> — kỳ vọng: <kết quả>
Phải đỏ trước các bản lỗi mẫu: <lỗi mẫu 1, lỗi mẫu 2>
Báo cáo hoàn tất mà agent phải trả:
Tiêu chí: <R1…Rn>, mỗi tiêu chí đạt hoặc không đạt
Lệnh: <lệnh đã chạy>
Kết quả: exit code <n>; <các dòng output liên quan>
Môi trường: <hệ điều hành, phiên bản>
Đã kiểm: <việc đã chạy thật>
Chưa kiểm: <deploy, dữ liệu thật, môi trường khác, kèm lý do>
Checklist trước khi chấp nhận “xong”:
- Mỗi tiêu chí mô tả kết quả quan sát được, không mô tả cách làm?
- Ca biên được liệt kê từ yêu cầu trước khi viết code?
- Kỳ vọng được viết độc lập với code đang kiểm?
- Mỗi verifier đã đỏ trước ít nhất một bản lỗi mẫu?
- Có cách nào làm tiêu chí này xanh mà không giải bài toán không? Nếu có, viết lại tiêu chí.
- Có ít nhất một verifier đọc kết quả như người dùng tiêu thụ nó?
- Verifier chỉ đọc hoặc chạy trong môi trường cô lập; deploy và migration có tiêu chí và trạng thái riêng?
- Báo cáo có lệnh, exit code, output, môi trường và phần chưa kiểm?
- Verifier nằm ngoài vùng agent được sửa, hoặc diff của nó được xem trước?
Giới hạn và lỗi thường gặp
Giới hạn của bài
- Lab giả lập và không gọi model nào. Bài cho thấy verifier yếu không phân biệt được bản đúng với bản lỗi và verifier hành vi phân biệt được; nó không đo agent nào hay lách verifier thường đến đâu.
- “Pass” của verifier hành vi chỉ có nghĩa là bốn ca đó đạt. Ba bản lỗi mẫu không phải mọi lỗi; công cụ mutation testing phủ rộng hơn nhiều.
- Bài không bàn Excel, BOM hay dấu phân cách theo locale. Lab chạy ngày 2026-10-02 trên Linux (Python 3.14.3) và macOS 27.0.1 arm64 (Python 3.14.8); Windows và Python 3.10–3.13 chưa thử.
- Nguồn METR đo trên các model và tác vụ riêng, và Lahiri là preprint; bài chỉ dùng nhận định định tính, không dùng số liệu của họ.
Lỗi thường gặp
| Lỗi | Hậu quả | Cách tránh |
|---|---|---|
Tiêu chí mô tả cách làm (“dùng csv.writer”) | Đạt khi làm đúng cách nhưng kết quả vẫn sai | Viết kết quả quan sát được |
| Verifier chỉ kiểm file tồn tại hoặc exit code | Pass với mọi lỗi | Đọc lại kết quả và so với kỳ vọng độc lập |
| Kỳ vọng sinh từ chính code đang kiểm | Lỗi và kỳ vọng sai cùng nhau | Viết kỳ vọng thành dữ liệu độc lập |
| Không có ca rỗng và ca đặc biệt | Lỗi ca biên lọt qua | Liệt kê ca biên từ yêu cầu trước khi viết code |
| Agent tự sửa verifier cho dễ pass | Verifier bị nới lỏng | Xem diff của verifier trước; giữ nó ngoài vùng agent được sửa |
| Gộp kiểm và deploy | “Xong” lẫn với “đã phát hành” | Tách verifier chỉ đọc khỏi hành động; báo trạng thái riêng |
| Tin “đã chạy test” khi không có output | Không kiểm lại được | Đòi lệnh, exit code và output |
Học tiếp
- Chọn context khi sửa code: mảnh “cách kiểm” trong bộ input chính là một tiêu chí như bài này.
- Sử dụng và viết skill: đưa quy trình lặp lại, như báo cáo hoàn tất, vào skill.
- Review code do AI sinh ra: khi diff đã có bằng chứng, reviewer còn phải hỏi gì để đi tới verdict.
Nguồn tham khảo
- Claude Code, Best practices, mục “Give Claude a way to verify its work” và “Avoid common failure patterns”.
- J. Young (Anthropic), Effective harnesses for long-running agents, 2025-11-26.
- METR, Recent Frontier Models Are Reward Hacking, 2025-06-05.
- S. K. Lahiri, Intent Formalization: A Grand Challenge for Reliable Coding in the Age of AI Agents, arXiv:2603.17150 (v1, 2026-03-17).
- RFC 4180 (Informational, 2005), RFC 3339 (Proposed Standard, 2002) và tài liệu module
csvcủa Python.