Self-hosted eval cho AI agent: Cách làm và đo pass rate
Tự xây vòng lặp eval với case từ trace thật, kiểm tra deterministic trước rồi dùng LLM judge, theo dõi pass rate sau mỗi commit bằng Python và SQLite.
Self-hosted eval cho AI agent là gì
Self-hosted eval cho AI agent gồm 4 thứ được lưu trong repository của bạn: một file chứa các case đã lưu, một script chạy agent trên các case đó, một bộ kiểm tra để chấm điểm từng câu trả lời và một bảng kết quả có thể truy vấn. Không thành phần nào trong số này cần vendor. Toàn bộ vòng lặp chỉ cần vài trăm dòng Python và một file SQLite.
Agent hoạt động trong bản demo vì bạn tự chọn 5 input. Đến tuần thứ 2, agent bắt đầu lỗi vì một dòng trong prompt thay đổi, model thay đổi hoặc mô tả tool thay đổi, trong khi không có phép đo nào bao quát các thay đổi đó. Vòng lặp eval biến nhận xét “bây giờ có vẻ tệ hơn” thành “tỷ lệ pass giảm từ 58 trên 60 xuống 51 trên 60 ở commit 4f1c9ab”.
Vòng lặp này có 4 bước và guide này dành 1 section cho mỗi bước: thu thập trace thực tế, đưa các trace đáng chú ý thành case, chấm điểm mọi case sau mỗi thay đổi và lưu tỷ lệ pass cạnh commit tạo ra kết quả đó. Vòng lặp này hoạt động với bất kỳ hệ thống nào bạn chạy agent trên đó, còn các agent framework self-hosted đáng dùng chủ yếu khác nhau ở lượng trace chúng cung cấp sẵn cho bạn.
Vì sao agent hỏng ở tuần thứ hai
Một agent gồm prompt, model, tập định nghĩa tool và mọi context được truy xuất lúc chạy. Cả 4 thành phần này đều có thể thay đổi mà không cần sửa code ứng dụng, nên một lần code review thông thường sẽ không phát hiện được vấn đề.
Nguyên nhân phổ biến nhất là chỉnh sửa prompt. Bạn thêm một câu để ngăn phản hồi thô lỗ. Câu đó làm thay đổi hành vi với những input chưa được test lại, và trace thể hiện rất rõ: trace của tuần trước cho cùng một câu hỏi có một create_refund lần gọi tool, còn tuần này thì không có lần nào, và câu trả lời thay bằng một lời xin lỗi lịch sự. Không có lỗi nào được phát sinh nên không có alert nào được kích hoạt.
Nguyên nhân thứ hai là model. Hãy ghi lại chính xác chuỗi model được gửi trong mỗi lần chạy, claude-haiku-4-5-20251001 thay vì dùng một tên viết tắt mà chỉ bạn nhớ, vì chỉ có thể chẩn đoán việc pass rate giảm đúng vào ngày đổi model khi model được ghi trong bản ghi.
Nguyên nhân thứ ba là tool. Việc viết lại mô tả tool sẽ thay đổi thời điểm model quyết định gọi tool đó. Nếu các tool của bạn đến từ MCP server chạy trên VPS, schema nằm trong một process khác, nên nó có thể thay đổi mà repository của bạn hoàn toàn không có diff. Nguyên nhân thứ tư là retrieval: cùng một câu hỏi truy vấn vào một index vừa được rebuild qua đêm, và câu trả lời sử dụng tài liệu mới.
Tạo golden set từ các trace đã thu thập
Không tự nghĩ ra các eval case. Hãy lấy chúng từ traffic thực tế. Nếu bạn đã chạy Langfuse tự host để tracing agent, mọi request đều được lưu cùng input, các lần gọi tool và output. Đây chính là dữ liệu thô cần có để tạo một case.
Export một khoảng thời gian gồm các root observation qua public API. API này dùng basic authentication, với public key làm username và secret key làm password.
export LF_HOST="https://langfuse.example.com"
curl -sS -u "$LF_PUBLIC_KEY:$LF_SECRET_KEY" \
"$LF_HOST/api/public/v2/observations?limit=50&isRootObservation=true&fromStartTime=2026-07-01T00:00:00Z" \
| jq '.data[0]'Đọc một record trước khi viết bất kỳ logic parsing nào. Các row được trả về trong data, nhưng tên field chứa câu hỏi và câu trả lời phụ thuộc vào cách agent instrument các span. Vì vậy, hãy ánh xạ theo dữ liệu thực tế thay vì theo điều bạn dự kiến. Sau đó, tự viết các case, mỗi dòng một JSON object, vào evals/cases.jsonl:
{"id": "refund-double-charge", "tags": ["smoke"], "input": "I was charged twice for order 41822.", "must_call": ["lookup_order", "create_refund"], "must_not_include": ["I cannot help"], "rubric": "The reply confirms exactly one refund for order 41822 and states the amount."}Năm quy tắc sau giúp set luôn có giá trị khi chạy:
- Bắt đầu với 40 đến 80 case là đủ. Dưới 20 case, một case flaky có thể làm pass rate thay đổi 5 điểm, khiến mọi người bỏ qua một con số tăng giảm không có lý do.
- Mỗi production bug được sửa phải trở thành một case ngay trong ngày sửa. Thói quen này giúp set phát triển đúng hướng.
- Mỗi case chỉ kiểm tra một hành vi. Một case vừa kiểm tra số tiền hoàn lại vừa kiểm tra giọng điệu sẽ không cho bạn biết gì khi nó fail.
idkhông bao giờ thay đổi, vì id là cách so sánh run hôm nay với run của tháng trước.- Redact trước khi commit. File này sẽ được đưa vào git, nên hãy xóa tên khách hàng và mọi order number mà bạn không sở hữu.
Ưu tiên đánh giá bằng các kiểm tra xác định, vì chúng không tốn chi phí
Bất kỳ nội dung nào có đáp án đúng đều nên dùng assertion đơn giản. Không gọi model, không tốn chi phí và không có sự mơ hồ. Các kiểm tra xác định phát hiện những hồi quy về cấu trúc. Đây là các lỗi làm hỏng những hệ thống xung quanh agent: JSON không parse được, tool chưa bao giờ được gọi, cụm từ bị cấm xuất hiện trở lại hoặc câu trả lời không trích dẫn nguồn nào.
Chỉ một hàm cần biết về agent của bạn. Mọi thành phần khác trong harness đều dùng chung.
import json, os, urllib.request
def run_agent(case):
req = urllib.request.Request(
os.environ["AGENT_URL"],
data=json.dumps({"input": case["input"]}).encode(),
headers={"content-type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.load(resp)
def deterministic(case, result):
text = result.get("output", "")
called = [c["name"] for c in result.get("tool_calls", [])]
failures = []
for tool in case.get("must_call", []):
if tool not in called:
failures.append(f"tool not called: {tool}")
for phrase in case.get("must_not_include", []):
if phrase.lower() in text.lower():
failures.append(f"forbidden phrase: {phrase}")
if len(called) > case.get("max_tool_calls", 12):
failures.append(f"too many tool calls: {len(called)}")
return failuresĐưa giới hạn số lần gọi tool vào danh sách đó. Một agent giải quyết một case trong 3 lần gọi hôm nay nhưng cần 11 lần vào ngày mai đã bị hồi quy, ngay cả khi câu trả lời cuối cùng vẫn đúng, vì bạn phải trả phí cho mọi lần gọi mà nó thực hiện.
LLM làm giám khảo và 4 cách nó có thể sai
Mọi thứ vượt qua các assertion vẫn cần một grader đọc nội dung. LLM judge là một lần gọi model thứ hai: nó nhận câu hỏi, câu trả lời của agent và một tiêu chí, rồi trả về verdict. Đây là cách thực tế duy nhất để chấm “câu trả lời có đáp ứng đúng điều người dùng hỏi hay không”.
4 quy tắc giúp judge có thể sử dụng được:
- Verdict nhị phân, không bao giờ chấm theo thang điểm từ 1 đến 10. Thang điểm thường trả về 7 và 8 cho gần như mọi thứ, nên con số không thay đổi và bạn không học được gì từ đó.
- Mỗi lần gọi chỉ dùng một tiêu chí. Hãy hỏi về số tiền hoàn lại hoặc giọng điệu, không hỏi cả hai cùng lúc.
- Cung cấp câu trả lời kỳ vọng cho judge mỗi khi case có câu trả lời cụ thể. Chấm dựa trên reference dễ hơn nhiều so với chấm một cách trừu tượng.
- Bắt buộc output có đúng format và parse nghiêm ngặt.
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
def judge_prompt(case, output):
return (
"You grade one answer against one criterion.\n"
"Reply with JSON only, in this exact shape:\n"
'{"verdict": "pass", "confidence": "high", "reason": "one short sentence"}\n'
f"Criterion: {case['rubric']}\n"
f"Question: {case['input']}\n"
f"Answer: {output}\n"
"Length is not a criterion. Judge only the criterion above."
)
def judge(case, output, model):
msg = client.messages.create(
model=model,
max_tokens=200,
messages=[{"role": "user", "content": judge_prompt(case, output)}],
)
return json.loads(msg.content[0].text)Sau đây là các failure mode. Mỗi failure mode đều có một bài test bạn có thể chạy ngay trong hôm nay. Việc chạy các bài test này rất quan trọng, vì một judge chưa được kiểm tra sẽ tạo ra những con số trông có vẻ chính xác nhưng không có ý nghĩa.
Thiên lệch độ dài. Câu trả lời dài thường pass nhiều hơn. Cách test: lấy 10 câu trả lời mà judge đã đánh fail, thêm vào mỗi câu 2 đoạn văn khẳng định chắc chắn nhưng không bổ sung fact mới, rồi chấm lại. Nếu verdict nào chuyển sang pass thì đó là thiên lệch độ dài, và rubric là thứ cần sửa.
Ưu tiên output cùng model. Judge thường chấm output từ cùng model family dễ hơn output từ model khác. Cách test: dùng judge từ 2 model family khác nhau để chấm cùng 30 câu trả lời, rồi so sánh verdict theo từng case. Với những case có kết quả khác nhau, hãy tự đọc case đó.
Thiên lệch vị trí. Nếu dùng judge để so sánh 2 câu trả lời A và B, hãy đổi thứ tự rồi chạy lại. Nếu verdict thay đổi sau khi đổi thứ tự, phép so sánh theo cặp chưa an toàn cho rubric đó.
Rubric bị trôi. Tiêu chí mơ hồ sẽ tạo ra các judge dễ đồng ý. “Câu trả lời có hữu ích không” gần như luôn pass. “Câu trả lời có nêu số tiền hoàn lại bằng đô la không” chỉ pass khi có đúng điều bạn muốn kiểm tra. Viết lại từng tiêu chí cho đến khi tiêu chí nêu rõ fact cần kiểm tra.
Một biện pháp bảo vệ bao quát cả 4 vấn đề là giữ lại 30 case đã được bạn gắn nhãn thủ công, rồi chấm judge dựa trên các nhãn đó mỗi khi thay đổi judge model hoặc judge prompt. Nếu judge bất đồng với bạn ở hơn 1 case trên 10, hãy sửa rubric trước khi tin bất kỳ pass rate nào do nó tạo ra. Judge là code, vì vậy phải được version và review như code.
Ưu tiên grader giá rẻ, chỉ chuyển lên frontier model khi cần
Dùng model đắt nhất để đánh giá mọi trường hợp trong mỗi commit là cách khiến chi phí eval tăng nhanh hơn agent đang được kiểm thử. Sắp xếp các grader theo giá và dừng ngay khi kết quả đã rõ.
The data behind this chart
[
{
"label": "Haiku 4.5, Batch API",
"usd_per_1000_judge_calls": "0.90"
},
{
"label": "Haiku 4.5",
"usd_per_1000_judge_calls": "1.80"
},
{
"label": "Sonnet 5",
"usd_per_1000_judge_calls": "3.60"
},
{
"label": "Opus 5",
"usd_per_1000_judge_calls": "9.00"
}
]Các số liệu đó giả định mỗi lần gọi judge có khoảng 1,200 input token và 120 output token. Đây là kích thước thực tế cho một câu hỏi, một câu trả lời và một tiêu chí. Đánh giá 1,000 trường hợp tốn 1.80 đô la Mỹ trên Claude Haiku 4.5 và 9.00 trên Claude Opus 5. Chênh lệch có vẻ nhỏ cho đến khi nhân lên. Một bộ 60 trường hợp, được đánh giá sau mỗi commit với 40 commit mỗi tuần, tạo ra 2,400 lần gọi judge mỗi tuần trước khi có ai chạy job hằng đêm.
Có 2 loại giảm giá áp dụng trực tiếp cho công việc eval và chúng có thể cộng dồn. Eval run không mang tính tương tác, nên Batch API giảm một nửa cả giá input lẫn output để đổi lấy việc trả kết quả bất đồng bộ. Đây là hàng đầu tiên trong biểu đồ. Rubric và instruction giống hệt nhau đến từng byte trong mọi lần gọi, nên prompt caching phù hợp: cache read có giá bằng một phần mười giá input cơ bản, còn cache write trong 5 phút có giá bằng 1.25 lần giá input cơ bản. Vì vậy, cache hoàn vốn sau một lần cache hit. Đây là giá niêm yết của Anthropic tính đến tháng 8 năm 2026. Sonnet 5 được áp dụng giá giới thiệu đến ngày 31 tháng 8 năm 2026, nên cột thứ ba sẽ tăng sau ngày đó.
Các tầng, theo thứ tự:
- Chạy kiểm tra deterministic trên mọi trường hợp. Hoàn toàn không tốn phí API.
- Dùng small model làm judge cho các trường hợp vượt qua những kiểm tra đó.
- Chỉ dùng frontier judge khi small judge cho kết quả fail hoặc pass nhưng độ tin cậy thấp.
- Hằng tuần, cho con người review một mẫu nhỏ.
CHEAP = "claude-haiku-4-5-20251001"
STRICT = "claude-opus-5"
def grade(case, result):
hard = deterministic(case, result)
if hard:
return False, "deterministic", "; ".join(hard)
first = judge(case, result["output"], CHEAP)
if first["verdict"] == "pass" and first["confidence"] == "high":
return True, CHEAP, first["reason"]
second = judge(case, result["output"], STRICT)
return second["verdict"] == "pass", STRICT, second["reason"]Cách này đánh đổi một phần độ chính xác của việc grading để giảm chi phí. Vì vậy, hãy đo lường sự đánh đổi thay vì giả định. Mỗi tháng một lần, dùng strict judge để grade toàn bộ bộ dữ liệu rồi so sánh 2 cột. Nếu 2 judge bất đồng trong hơn một vài trường hợp, rubric của bạn quá lỏng đối với small model. Khi đó, thứ cần sửa là rubric. Kiểm soát khoản chi mà chính agent sử dụng là một công việc riêng, được trình bày trong kiểm soát chi phí cho một AI agent trên VPS.
Theo dõi tỷ lệ pass theo thời gian trong hệ thống bạn sở hữu
Một tỷ lệ pass không gắn được với commit chỉ là cảm nhận. Lưu một dòng cho mỗi case trong mỗi lần chạy, kèm commit và model trong chính dòng đó.
CREATE TABLE IF NOT EXISTS results (
run_id TEXT NOT NULL,
ran_at TEXT NOT NULL,
git_sha TEXT NOT NULL,
agent_model TEXT NOT NULL,
case_id TEXT NOT NULL,
passed INTEGER NOT NULL,
graded_by TEXT NOT NULL,
reason TEXT
);SELECT run_id, git_sha, agent_model,
count(*) AS cases,
round(100.0 * sum(passed) / count(*), 1) AS pass_pct
FROM results
GROUP BY run_id
ORDER BY ran_at DESC
LIMIT 10;Nạp schema bằng sqlite3 evals/results.db < evals/schema.sql, rồi đọc xu hướng bằng sqlite3 -box evals/results.db < evals/passrate.sql. Một năm chạy hằng ngày với 60 case tạo ra khoảng 22,000 dòng, nên store không bao giờ trở thành một project riêng. Chạy SQLite trong production trên VPS trình bày các thiết lập bắt đầu quan trọng khi file này được chia sẻ giữa nhiều máy.
Runner in cùng thông tin cho người dùng:
run 2026-08-05T09:14:22Z sha 4f1c9ab model claude-sonnet-5 58/60 pass (96.7%)
FAIL refund-double-charge deterministic: tool not called: create_refund
FAIL pto-policy-question judge(opus): reply gives no dollar amountChạy bộ test trên những thay đổi có thể làm hỏng agent, tức là các chỉnh sửa prompt, thay đổi model và thay đổi tool, thay vì chạy trên mọi commit trong repository. Một hook pre-push xử lý phần test nhanh:
cat > .git/hooks/pre-push <<'EOF'
#!/bin/sh
python3 evals/run.py --set smoke || exit 1
EOF
chmod +x .git/hooks/pre-pushCác lần chạy đầy đủ chậm hơn và nên chạy theo lịch. Một systemd service và timer trên VPS chạy toàn bộ bộ test mỗi đêm với prompt đã deploy. Cách này phát hiện các thay đổi đến từ bên ngoài repository, chẳng hạn như một hosted tool đã thay đổi behavior.
Đánh giá thủ công theo mẫu, không đánh giá toàn bộ
Judge được hiệu chỉnh dựa trên các nhãn do con người gán, nên phải có người tạo các nhãn đó. Mỗi tuần, hãy đọc một mẫu gồm tất cả các case mà judge đánh giá sai, cộng thêm 10 case đạt được chọn ngẫu nhiên. Các case đạt được chọn ngẫu nhiên là phần quan trọng hơn, vì một judge đã âm thầm cho qua các câu trả lời không đạt sẽ trông hoàn hảo trên mọi dashboard được xây dựng từ chính các verdict của nó.
15 case, mỗi case mất 3 phút, tổng cộng là 45 phút mỗi tuần. Việc này giúp cập nhật rubric tại những điểm bạn và judge không đồng ý, đồng thời bổ sung các case cho những dạng lỗi trước đó chưa ai nghĩ đến. Ghi verdict của người đánh giá vào cùng bảng, đặt graded_by thành human, để mức độ đồng thuận giữa judge và người đánh giá trở thành một query thay vì chỉ nằm trong trí nhớ.
Những lỗi phát sinh ngay trong eval harness
anthropic.RateLimitError ở lần chạy đầy đủ đầu tiên. Chạy đồng thời 60 case vượt quá giới hạn request hoặc token của tier bạn đang dùng. Giới hạn concurrency ở 4 worker và chuyển lịch chạy hằng đêm sang Batch API.
json.JSONDecodeError: Expecting value: line 1 column 1 (char 0) từ judge. Model trả lời bằng văn xuôi hoặc bọc JSON trong code fence. Hãy retry 1 lần, sau đó ghi nhận case là lỗi. Không bao giờ tính lỗi parse là pass, vì một suite biến lỗi thành pass sẽ tiến dần đến 100% trong khi agent ngày càng kém hơn.
Case không ổn định. Cùng một input pass ở lần chạy này nhưng fail ở lần chạy sau vì agent lấy mẫu output. Chạy case không ổn định 3 lần và ghi nhận tỷ lệ thay vì xóa case. Case pass 2 trên 3 lần là lỗi robustness thực sự, và khách hàng sẽ phát hiện ra.
Golden set bị xuống cấp. Có người chỉnh câu trả lời kỳ vọng để suite chuyển sang trạng thái green. Hãy review diff đến evals/cases.jsonl cẩn thận như diff của agent, vì file đó là định nghĩa bằng văn bản của bạn về kết quả đúng.
Một suite không bao giờ fail. Tỷ lệ pass giữ ở mức 100% trong 1 tháng có nghĩa là set đã không còn theo sát product. Lấy 10 trace gần đây, tìm những trace agent xử lý kém và thêm chúng vào set.
FAQ
Bộ eval cho AI agent cần bao nhiêu case?
Hãy bắt đầu với 40 đến 80 case và mở rộng bộ này từ các lỗi thực tế. Khi có dưới khoảng 20 case, chỉ một kết quả không ổn định cũng làm tỷ lệ đạt thay đổi 5 điểm, nên con số này không còn mang nhiều thông tin. Khi vượt quá vài trăm case, mỗi lần chạy đều tốn thêm chi phí và thời gian thực, trong khi mỗi case bổ sung chỉ tăng thêm rất ít độ bao phủ. Chỉ số quan trọng không phải là số lượng case. Đó là tỷ lệ các loại lỗi production đã biết xuất hiện ít nhất một lần trong bộ eval.
Tôi có thể tin LLM judge chấm điểm agent của mình không?
Chỉ sau khi bạn đo độ chính xác của nó bằng nhãn do chính bạn gán. Giữ lại 30 case đã được bạn chấm thủ công, rồi đối chiếu judge với các case đó mỗi khi thay đổi model hoặc prompt của judge. Judge thường có thiên lệch về độ dài, khiến câu trả lời dài dòng dễ đạt hơn, và thiên lệch tự ưu tiên, khiến output từ cùng một model family được chấm dễ hơn. Cả hai đều có thể kiểm tra: thêm nội dung vào một câu trả lời bị trượt rồi chấm lại, hoặc dùng judge thuộc model family khác để chấm cùng các câu trả lời đó. Nếu judge không nhất quán với nhãn của bạn ở hơn 1 trên 10 case, rubric quá mơ hồ để sử dụng.
Nên dùng model nào để chấm các eval?
Hãy chấm bằng model rẻ trước rồi mới chuyển cấp. Deterministic assertion không tốn chi phí, nên chạy đầu tiên trên mọi case. Một model nhỏ có thể xử lý các case đạt rõ ràng. Chỉ các case trượt và các verdict có độ tin cậy thấp mới chuyển đến frontier model. Theo giá niêm yết vào tháng 8 năm 2026, chấm 1,000 case tốn khoảng 1.80 đô la Mỹ với Claude Haiku 4.5 và khoảng 9.00 với Claude Opus 5. Vì các lần chạy eval là asynchronous, Batch API giảm một nửa cả hai mức chi phí này.
Evals có thay thế được monitoring production không?
Không, vì chúng trả lời các câu hỏi khác nhau. Một eval suite cho biết thay đổi bạn sắp ship làm một bộ case cố định tốt hơn hay tệ hơn. Tracing và monitoring cho biết người dùng thực tế đang gặp những gì ngay lúc này, bao gồm cả các input mà không case nào bao phủ. Chúng bổ trợ cho nhau: trace cung cấp các case mới, còn eval suite xác định bản sửa của bạn có thực sự hiệu quả hay không.