Đánh giá agent: làm sao biết một thay đổi có giúp ích
Agent không đánh giá được là agent không sửa được. Bài này nói vì sao eval cho agent khác eval cho model, phép tính pass^k giải thích vì sao 'chạy đúng phần lớn thời gian' là chưa đủ, cách xây bộ task từ lỗi thật, chấm điểm tất định so với dùng judge, và một eval runner nhỏ bạn có thể đưa vào CI ngay tuần này.

English version: Evaluating Agents: How to Know a Change Helped. Phần 5 của series. Phần trước: Tool, permission và guardrail. Phần sau: Forward Deployed Engineer.
Sáu tháng đầu chạy agent trong production, quy trình release của chúng tôi là một senior thử năm prompt rồi phán "có vẻ tốt hơn". Chúng tôi ship một đợt nâng model theo kiểu đó. Có vẻ tốt hơn thật. Hai tuần sau một khách hàng báo agent trích xuất bắt đầu bỏ qua trang chữ ký của các hợp đồng nhiều bên, thứ nó xử lý đúng suốt nhiều tháng. Không có test nào bắt được chuyện này, vì không có test. Chúng tôi có một sản phẩm xây trên một thành phần mà hành vi không đo được, và chúng tôi đổi thành phần đó mỗi tuần.
Eval là thứ biến agent từ demo thành một sản phẩm kỹ thuật: thứ bạn thay đổi được một cách tự tin, vì trong vòng một giờ bạn sẽ biết thay đổi đó giúp, hại, hay chẳng làm gì. Bài này là cách chúng tôi đã xây, điều tôi sẽ làm khác đi, và đoạn code để bắt đầu.
Vì sao eval cho agent là bài toán khác
Đánh giá model trên benchmark là một hình dạng đã giải: input, output kỳ vọng, điểm số. Đánh giá agent khó hơn ở bốn chỗ cụ thể.
Mỗi lần chạy không tất định. Cùng task, cùng model, cùng harness, cho ra quỹ đạo khác nhau. Một con số cho mỗi task là một mẫu, không phải một phép đo; bạn cần vài lần chạy cho mỗi task mới nói được gì.
Thứ bạn quan tâm là kết quả, nhưng kết quả đến qua side effect. "Đã sửa bug" nghĩa là test pass và agent không tiện tay xoá luôn thư mục migration trên đường đi. Bạn phải chấm trạng thái cuối lẫn con đường.
Nhiều output không có một đáp án đúng duy nhất. Một bản tóm tắt, một bản nháp trả lời, một lần refactor. Những thứ đó cần rubric và judge, và judge phải được hiệu chỉnh, không thì nó sẽ lặng lẽ thích cái nào dài hơn.
Mỗi lần chạy tốn tiền thật và phút thật. Một bộ eval mất bốn giờ và sáu mươi đô sẽ không chạy trên mọi pull request, và eval không chạy trên mọi pull request là một báo cáo, không phải guardrail.
Phép tính khiến team tôi nghiêm túc với chuyện này là pass^k. Bài về SWE-bench của Anthropic phân biệt pass@k (thành công ít nhất một lần trong k lần thử) với pass^k (thành công mọi lần trong k lần thử). Người dùng trải nghiệm pass^k. Nếu agent thành công 90% số lần ở một task, xác suất nó thành công năm lần liên tiếp là 0,9⁵ ≈ 59%. Ở 95% mỗi lần là 77%. Ở 99% là 95%. Khoảng cách giữa "demo chạy" và "khách tin tưởng" là khoảng cách giữa pass@1 và pass^k, và bạn chỉ thấy nó khi chạy mỗi task nhiều hơn một lần.
Xây bộ task từ lỗi thật, không phải từ tưởng tượng
Bộ eval chúng tôi viết từ đầu trên bảng trắng gần như vô dụng: task dễ thì agent luôn pass, task khó thì không ai chấm nổi. Bộ eval có giá trị được xây từ trace log (phần 1): mỗi lần agent làm sai trong production, lần chạy đó thành một task. Năm mươi task từ lỗi thật hơn năm trăm task tổng hợp, vì chúng được cân đúng như vấn đề của người dùng.
Một task có năm phần, và tôi giữ chúng trong version control ngay cạnh code harness:
{
"id": "contract-signature-page-003",
"category": "extraction",
"setup": {"fixture": "fixtures/multi-party-contract", "env": {"DOC_ID": "mp-0193"}},
"prompt": "Extract all signatories with their roles and signing dates from document mp-0193.",
"checks": [
{"type": "json_path", "path": "$.signatories.length()", "equals": 4},
{"type": "json_path", "path": "$.signatories[*].signing_date", "all_match": "^\\d{4}-\\d{2}-\\d{2}$"},
{"type": "no_tool_called", "name": "crm_update_contact"},
{"type": "max_steps", "value": 15},
{"type": "judge", "rubric": "rubrics/extraction-faithfulness.md", "min_score": 4}
],
"budget": {"steps": 25, "usd": 0.80}
}
Khối setup là thứ làm task tái hiện được: một thư mục fixture được copy mới cho mỗi lần chạy, để không lần nào làm bẩn lần sau. Khối checks trộn ba loại chấm điểm, và đó là trái tim của chuyện này.
Ba loại grader, theo thứ tự độ tin cậy
Kiểm tra tất định là nền móng. Test pass. Một file tồn tại với nội dung cụ thể. Một trường JSON bằng một giá trị. Một tool bị cấm không được gọi. Lần chạy dưới N bước và tốn dưới M đô. Chúng rẻ, chính xác, và miễn nhiễm với tâm trạng của judge. Đẩy càng nhiều phần chấm điểm vào nhóm này càng tốt, và thiết kế tool finish của agent (phần 1) để trả về output có cấu trúc cho việc đó khả thi.
Kiểm tra quỹ đạo chấm con đường. Agent có đọc trang chữ ký trước khi trả lời không? Có chạy test trước khi tuyên bố xong không? Có thử cùng một lệnh fail quá hai lần không? Những cái này cũng tất định, trên trace thay vì trên output, và bắt được trường hợp đáp án đúng nhờ may.
Kiểm tra bằng judge dùng một model với rubric cho những phần không có đáp án chính xác. Ba quy tắc giữ chúng trung thực. Rubric được viết ra, cụ thể, chấm trên thang nhỏ (1 đến 5) với mô tả cho từng mức. Judge nhìn thấy trace và fixture, không chỉ text cuối, để đối chiếu tuyên bố với thực tế. Và judge được hiệu chỉnh: chúng tôi gán nhãn tay ba mươi output, chạy judge trên đó, và chỉnh rubric tới khi độ đồng thuận trên 85%. Trước khi hiệu chỉnh, judge của chúng tôi cho 5/5 một bản tóm tắt trôi chảy, tự tin và nói về nhầm hợp đồng.
Chấm theo cặp (output A tốt hơn B không?) đáng tin hơn chấm tuyệt đối và là công cụ đúng để so hai phiên bản harness, nhưng không cho bạn một con số để theo dõi theo thời gian, nên chúng tôi dùng cả hai.
Để tham khảo, file rubric trong task ở trên đại khái như thế này. Phần mô tả các mức là phần tốn công lặp đi lặp lại; bản đầu tiên chỉ ghi "5 = xuất sắc" và judge đồng ý với chúng tôi khoảng 60% số lần.
# Extraction faithfulness (1–5)
You are given the agent's final JSON, the trace of tools it called, and the source document.
Score ONLY faithfulness: whether every extracted value is supported by the document.
5 Every value is present in the document, with the correct role attached. No invented values.
The trace shows the signature page(s) were read before `finish`.
4 All values correct; one minor formatting issue (date format, trailing title) that a reviewer would fix in seconds.
3 One value is wrong or missing, OR a value is correct but the trace shows it was guessed (page not read).
2 Two or more values wrong or missing, or roles swapped between parties.
1 Output is about a different document, is empty, or invents parties.
Return a JSON object: {"score": <1-5>, "evidence": "<one sentence citing page/line for the most important finding>"}.
Hai thói quen làm rubric kiểu này hoạt động. Judge được yêu cầu trích dẫn bằng chứng, nên điểm của nó kiểm toán được và độ trôi của nó nhìn thấy được. Và rubric chấm đúng một thứ. "Chất lượng" không phải rubric; độ trung thực, độ đầy đủ và định dạng là ba rubric, và một task có thể đòi cả ba.
Một runner bạn đưa được vào CI
Đây là eval runner chúng tôi bắt đầu, cắt về phần cốt lõi. Nó cố ý độc lập với mọi agent framework: bạn đưa nó một hàm run_task(prompt, workdir) -> (output, trace) và nó lo phần còn lại. Agent giả ở cuối file giúp file chạy được ngay, để bạn thấy định dạng báo cáo trước khi nối agent thật.
# eval_runner.py — chạy mỗi task k lần trong fixture mới, chấm điểm, báo cáo pass@1 / pass^k
import json
import re
import shutil
import statistics
import tempfile
from pathlib import Path
TASKS = Path("evals/tasks")
FIXTURES = Path("evals")
def check(c: dict, output: dict, trace: list[dict]) -> bool:
kind = c["type"]
if kind == "json_path": # tập con tí hon của JSONPath: $.a.b, $.a[*].b, .length()
value = resolve(c["path"], output)
if "equals" in c:
return value == c["equals"]
if "all_match" in c:
return all(re.fullmatch(c["all_match"], str(v)) for v in value)
if kind == "no_tool_called":
return not any(e["kind"] == "tool" and e["name"] == c["name"] for e in trace)
if kind == "max_steps":
return sum(e["kind"] == "model" for e in trace) <= c["value"]
if kind == "tool_called_before_finish":
names = [e["name"] for e in trace if e["kind"] == "tool"]
return c["name"] in names
if kind == "judge":
return judge(c["rubric"], output, trace) >= c["min_score"]
raise ValueError(f"unknown check {kind}")
def resolve(path: str, obj):
wants_len = path.endswith(".length()")
parts = path.removesuffix(".length()").lstrip("$.").split(".")
cur = [obj]
for part in parts:
nxt = []
for item in cur:
if part.endswith("[*]"):
nxt.extend(item.get(part[:-3], []))
else:
nxt.append(item.get(part))
cur = nxt
value = cur[0] if len(cur) == 1 else cur
return len(value) if wants_len else value
def judge(rubric_path: str, output: dict, trace: list[dict]) -> int:
"""Thay bằng một lần gọi model trả về 1-5 theo rubric. Ở đây là stub."""
return 5
def run_once(task: dict, run_task) -> dict:
with tempfile.TemporaryDirectory() as tmp:
workdir = Path(tmp) / "work"
shutil.copytree(FIXTURES / task["setup"]["fixture"], workdir)
output, trace = run_task(task["prompt"], workdir, task["setup"].get("env", {}), task["budget"])
results = {c["type"] + ":" + c.get("name", c.get("path", "")): check(c, output, trace) for c in task["checks"]}
steps = sum(e["kind"] == "model" for e in trace)
usd = sum(e.get("usd", 0) for e in trace)
return {"passed": all(results.values()), "checks": results, "steps": steps, "usd": usd}
def evaluate(run_task, k: int = 3, baseline: dict | None = None) -> dict:
report = {}
for path in sorted(TASKS.glob("*.json")):
task = json.loads(path.read_text())
runs = [run_once(task, run_task) for _ in range(k)]
passes = sum(r["passed"] for r in runs)
report[task["id"]] = {
"pass_at_1": passes / k,
"pass_pow_k": 1.0 if passes == k else 0.0,
"steps": statistics.mean(r["steps"] for r in runs),
"usd": statistics.mean(r["usd"] for r in runs),
"failed_checks": sorted({name for r in runs for name, ok in r["checks"].items() if not ok}),
}
print_report(report, baseline)
return report
def print_report(report: dict, baseline: dict | None):
total = len(report)
p1 = sum(r["pass_at_1"] for r in report.values()) / total
pk = sum(r["pass_pow_k"] for r in report.values()) / total
print(f"\n{total} tasks pass@1={p1:.0%} pass^k={pk:.0%} "
f"mean steps={statistics.mean(r['steps'] for r in report.values()):.1f} "
f"mean usd={statistics.mean(r['usd'] for r in report.values()):.3f}\n")
print(f"{'task':40} {'p@1':>5} {'p^k':>5} {'steps':>6} {'usd':>7} failed checks")
for tid, r in report.items():
flag = ""
if baseline and tid in baseline and r["pass_at_1"] < baseline[tid]["pass_at_1"]:
flag = " REGRESSION"
print(f"{tid:40} {r['pass_at_1']:>5.0%} {r['pass_pow_k']:>5.0%} {r['steps']:>6.1f} {r['usd']:>7.3f} "
f"{', '.join(r['failed_checks'])}{flag}")
if __name__ == "__main__":
# Agent giả để runner chạy được không cần API key. Thay bằng harness của bạn.
def fake_agent(prompt, workdir, env, budget):
trace = [{"kind": "model", "usd": 0.01}, {"kind": "tool", "name": "read_section", "usd": 0},
{"kind": "model", "usd": 0.01}]
output = {"signatories": [{"name": f"Party {i}", "signing_date": "2026-01-1" + str(i)} for i in range(4)]}
return output, trace
TASKS.mkdir(parents=True, exist_ok=True)
(FIXTURES / "fixtures/multi-party-contract").mkdir(parents=True, exist_ok=True)
(TASKS / "contract-signature-page-003.json").write_text(json.dumps({
"id": "contract-signature-page-003", "category": "extraction",
"setup": {"fixture": "fixtures/multi-party-contract", "env": {"DOC_ID": "mp-0193"}},
"prompt": "Extract all signatories with their roles and signing dates from document mp-0193.",
"checks": [
{"type": "json_path", "path": "$.signatories.length()", "equals": 4},
{"type": "json_path", "path": "$.signatories[*].signing_date", "all_match": r"^\d{4}-\d{2}-\d{2}$"},
{"type": "no_tool_called", "name": "crm_update_contact"},
{"type": "max_steps", "value": 15},
{"type": "judge", "rubric": "rubrics/extraction-faithfulness.md", "min_score": 4},
],
"budget": {"steps": 25, "usd": 0.80},
}, indent=2))
evaluate(fake_agent, k=3)
Các lựa chọn thiết kế quan trọng: fixture mới cho mỗi lần chạy, k lần chạy cho mỗi task, báo cáo cả pass@1 lẫn pass^k, chi phí và số bước là số liệu hạng nhất, và so sánh với baseline in ra chữ REGRESSION để job CI grep được và fail.
Cái gì chạy khi nào
Ba nhịp đã hiệu quả với chúng tôi. Trên mọi pull request đụng tới prompt, tool, hook hoặc harness: một tập smoke khoảng mười lăm task ở k=2, dưới mười phút và vài đô, fail build nếu có bất kỳ regression nào so với baseline đã merge. Hằng đêm: toàn bộ bộ task ở k=5, báo cáo gửi vào kênh team, và đó là nơi chúng tôi nhận ra trôi dạt chậm. Và ở mọi lần đổi phiên bản model: toàn bộ bộ task, pin vào phiên bản mới, so với phiên bản cũ, trước khi bất kỳ traffic nào chuyển. Cái cuối chính là bài test lẽ ra đã bắt được regression trang chữ ký trong một buổi chiều thay vì hai tuần.
Pin phiên bản model tường minh trong eval. Alias "latest" tiện khi dev và là nguồn của những diff không giải thích nổi khi eval.
Production là nửa còn lại
Eval là offline. Vòng lặp giữ nó trung thực là online: trace từ các lần chạy thật, được lấy mẫu và gán nhãn, chảy ngược về bộ task. Chúng tôi lấy mẫu mọi lần chạy fail (người dùng bấm "sai", agent dừng vì budget, một check trong tool finish fail) và 2% lần thành công, và có người dành một giờ mỗi tuần biến những cái đáng chú ý thành task. Bộ eval tăng năm đến mười task mỗi tuần và luôn đại diện, vì nó được làm đúng từ những gì người dùng đang làm.
Dashboard production đi kèm có ít con số hơn bạn nghĩ: tỷ lệ thành công theo loại task, chi phí cho mỗi task thành công, phân phối số bước mỗi lần chạy, số lần phê duyệt và từ chối từ lớp policy (phần 4), và số lần cùng một file được đọc trong một lần chạy (phần 3). Median số bước tăng 30% với tỷ lệ thành công đi ngang là tín hiệu sớm nhất chúng tôi tìm được cho "có gì đó phía trên đã đổi", thường là một tool bắt đầu trả về nhiều hơn trước.
Sai lầm chúng tôi đã mắc, để bạn mắc những cái khác
Chúng tôi overfit vào bộ eval. Sau một quý tinh chỉnh prompt trên cùng năm mươi task, pass^k đạt 96% trên bộ đó và người dùng không vui hơn thấy rõ. Cách sửa là nhịp nhận lỗi mới hằng tuần, và giữ riêng một phần task không ai được tinh chỉnh theo.
Chúng tôi để judge chỉ nhìn output. Nó chấm cao những câu trả lời sai mà tự tin. Giờ judge nhìn cả trace và fixture.
Chúng tôi chấm "xong" bằng chính lời gọi finish của agent. Agent học được rằng gọi finish sớm là hết đau. Giờ finish là nơi việc chấm bắt đầu: các check chạy trên trạng thái, không trên lời tuyên bố.
Chúng tôi chạy eval trong môi trường dùng chung. Một file sót lại từ lần chạy ba khiến lần chạy bốn pass. Fixture mới cho mỗi lần chạy, luôn luôn.
Chúng tôi đo độ chính xác và bỏ qua chi phí. Một thay đổi tăng pass@1 hai điểm và gấp đôi chi phí mỗi task đã được ship, và biên lợi nhuận sản phẩm đó âm một tháng. Chi phí cho mỗi task thành công là con số lên dashboard bây giờ.
Không có gì trong đây tinh vi. Vẫn là kỷ luật ta đã áp cho code (test, CI, monitoring), điều chỉnh cho một thành phần có tính xác suất. Khác biệt giữa team ship agent và team demo agent phần lớn là kỷ luật đó có tồn tại hay không.
Tài liệu tham khảo
- Anthropic, Raising the bar on SWE-bench Verified (pass@k và pass^k)
- Anthropic, Building effective agents (12/2024)
- Hamel Husain, Your AI product needs evals (2024)
- OpenAI, Harness engineering (02/2026), về việc biến review feedback thành kiểm tra cơ học