Evaluator tự động: code check, LLM-as-judge và tỉ lệ thành công "thật"
TL;DR
- Sửa mơ hồ trước, đo sau: lỗi Specification → sửa prompt. Evaluator chỉ dành cho lỗi Generalization — model đã được nói rõ mà vẫn sai.
- Mỗi failure mode một evaluator: tiêu chí khách quan → code (regex, parse, AST, chạy thử); tiêu chí cần diễn giải → LLM-as-judge nhị phân Pass/Fail, một tiêu chí mỗi judge.
- Judge có bias (vị trí, độ dài, tự khen) → điểm judge thô không tự đứng được. Viết prompt đủ 4 thành phần: tiêu chí đơn, định nghĩa Pass/Fail, few-shot, output JSON.
- Xây judge như train classifier: train / dev / test, lặp prompt trên dev, đo TPR/TNR trên test đúng một lần.
- Judge không hoàn hảo → tỉ lệ Pass thô bị lệch. Dùng hiệu chỉnh Rogan–Gladen + bootstrap CI; quyết định ship dựa trên cận dưới của CI.
- Bản mở rộng này có thêm: 15+ ví dụ tính tay, 10 đoạn code Python chạy được, 5 lab tương tác, 1 case study đầu-cuối, phần áp dụng cho model-routing.
Cách đọc trang này
Mỗi khái niệm đi theo nhịp: trực giác → Ví dụ tính tay (số liệu tự dựng, chỉ minh hoạ) → Code tự viết, chạy được → Hay nhầm → Bài tập có lời giải ẩn. Các khối ▶ LAB là đồ chơi tương tác — cứ bấm thoải mái. Phần diễn giải nội dung sách là tóm tắt bằng lời của người viết, không phải bản dịch.5.1Từ failure mode tới metric
Trực giác. Chương 3 giống bác sĩ khám và chẩn đoán: đọc từng trace, gọi tên các kiểu lỗi. Chương 4 giúp cả nhóm thống nhất chẩn đoán. Chương 5 là lúc lắp máy đo: với mỗi kiểu lỗi, ta muốn một phép đo tự động trả lời câu hỏi "trace này có mắc lỗi X không?" trên hàng nghìn trace mỗi ngày, không cần người đọc từng cái. Một metric tốt = một câu hỏi có/không, gắn với đúng một failure mode.
Ví dụ xuyên suốt của sách là một trợ lý CRM bất động sản: hiểu câu hỏi của môi giới, sinh SQL tìm nhà, soạn email cho khách, đọc lịch, tra cứu web. Error analysis đã cho ra một danh sách lỗi kiểu: SQL bỏ quên điều kiện lọc người dùng đã nêu, gộp nhóm sai theo ngày/tuần, bịa ra tool không tồn tại, email thiếu thông tin, giọng văn lệch persona của khách, gọi tool sai thứ tự, gọi tool không ai yêu cầu, hiểu nhầm địa danh trùng tên.
Email thiếu ngân sách của khách, trong khi prompt chưa từng nói email phải nêu ngân sách. Hoặc khách muốn dời lịch hẹn mà hệ thống chỉ có tool "đặt lịch", không có tool "dời lịch" — thiếu năng lực chứ không phải model kém.
Prompt ghi rõ phải giữ mọi điều kiện lọc mà SQL vẫn bỏ "giá tối đa". Persona khách đã được đưa vào mà giọng văn vẫn lệch. Danh sách tool đã rõ mà vẫn gọi tool không có trong danh sách.
Sách đưa ra hai lý do để lọc lỗi Specification trước: (1) hiệu quả — evaluator tốn công xây, gán nhãn, căn chỉnh, bảo trì; đừng tiêu cho thứ sửa được bằng một câu trong prompt; (2) đo đúng thứ cần đo — nếu yêu cầu chưa được nói ra, điểm eval phản ánh độ may mắn đoán ý, không phản ánh năng lực của hệ thống.
Một chatbot đặt lịch cho chuỗi spa. System prompt hiện có: giới thiệu dịch vụ, bảng giá, giờ mở cửa, quy tắc "luôn xác nhận lại ngày giờ trước khi đặt", và tool book_slot, list_slots. Error analysis tìm ra 6 lỗi:
- Không hỏi số điện thoại khách. Prompt không hề yêu cầu thu số điện thoại → Specification. Sửa prompt: "trước khi đặt, luôn xin số điện thoại". Không xây evaluator (ít nhất là chưa).
- Đặt lịch mà không xác nhận lại ngày giờ. Prompt đã có quy tắc rõ ràng → Generalization. Kiểm được bằng code: trong trace, trước lời gọi
book_slotcó lượt assistant nào chứa ngày giờ và lượt user trả lời đồng ý không? → code check trên cấu trúc trace. - Báo giá sai so với bảng giá. Bảng giá nằm trong prompt → Generalization. So số tiền trong câu trả lời với bảng giá bằng regex + tra bảng → code check.
- Khách muốn huỷ lịch, bot bảo "không thể". Không có tool huỷ → thiếu năng lực (Specification/capability). Thêm tool
cancel_slot, không đo. - Giọng văn quá suồng sã với khách VIP. Prompt đã mô tả giọng cho từng hạng khách → Generalization, nhưng "suồng sã" cần diễn giải → LLM-as-judge.
- Tư vấn liệu trình không phù hợp với tình trạng da khách kể. Prompt có hướng dẫn chọn liệu trình → Generalization, cần phán đoán chuyên môn → LLM-as-judge (và cần chuyên gia gán nhãn, Chương 4).
Kết quả: 2 lỗi sửa luôn, 2 lỗi đo bằng code, 2 lỗi cần judge. Đó là một tỉ lệ khá điển hình: phần lớn danh sách lỗi không cần LLM để đo.
"Lỗi xuất hiện nhiều nhất thì phải xây evaluator trước." Tần suất chỉ cho biết lỗi quan trọng; nó không cho biết lỗi thuộc loại nào. Một lỗi chiếm 30% trace nhưng do prompt thiếu một câu thì cách rẻ nhất là thêm câu đó rồi chạy lại error analysis.
"Đã sửa prompt thì không cần đo nữa." Sau khi sửa, lỗi Specification có thể biến thành lỗi Generalization (model vẫn quên dù đã được dặn). Lúc đó mới đáng xây evaluator — thường là một code check rẻ.
Trợ lý tóm tắt biên bản họp có các lỗi sau. Với mỗi lỗi, xếp vào: sửa prompt, code check, hay LLM judge. Prompt hiện ghi: "tóm tắt tối đa 5 gạch đầu dòng, liệt kê action item kèm người phụ trách".
- Bản tóm tắt có 8 gạch đầu dòng.
- Không ghi deadline cho action item.
- Gán action item cho người không có mặt trong cuộc họp.
- Tóm tắt nói "cả nhóm đồng ý" trong khi biên bản ghi có người phản đối.
Xem lời giải
- Prompt đã nói tối đa 5 → Generalization, đếm dòng bắt đầu bằng "-" → code check.
- Prompt chưa yêu cầu deadline → Specification, sửa prompt.
- Generalization. So tên người phụ trách với danh sách người tham dự (có trong metadata) → code check. Nếu danh sách người dự không có sẵn dạng cấu trúc thì mới cần judge.
- Generalization, cần hiểu nghĩa ("đồng ý" vs có ý kiến trái chiều) → LLM judge về độ trung thành (faithfulness).
5.2Reference-based vs reference-free
Trực giác. Reference-based giống chấm bài thi có đáp án: so output với một output "vàng" đã được người tuyển chọn. Reference-free giống chấm bài theo quy chế: không cần đáp án, chỉ cần kiểm các thuộc tính mà mọi output tốt đều phải có. Cả hai có thể đi kèm executability check — chạy thật output để xem nó chạy được, không chỉ trông đúng.
So với output "vàng" đã tuyển chọn (cặp input → output lý tưởng không mắc lỗi).
SQL thiếu điều kiện So cây cú pháp (AST) của SQL sinh ra với AST chuẩn: có đủ các mệnh đề WHERE không?
Tool không hợp lệ So chuỗi tool call với một trace mẫu chỉ chứa tool hợp lệ.
Rất hợp cho vòng lặp phát triển (sửa prompt, đổi model) và CI: bộ test cố định, chạy lại mỗi lần đổi code.
Kiểm thuộc tính/luật nội tại, không cần đáp án chuẩn.
SQL thiếu điều kiện Câu hỏi có nhắc giá → SQL có tham chiếu cột giá không? SQL có hợp lệ không?
Tool không hợp lệ Mọi tên tool được gọi có nằm trong registry không?
Địa danh Geocode nơi được nhắc tới, kiểm tra có khớp vùng người dùng chỉ định.
Chạy được trên dữ liệu mới chưa gán nhãn → dùng cho monitoring, thậm chí chạy online như guardrail.
Kiểm output chạy được: chạy SQL trên DB test xem có lỗi và kết quả có hợp lý; mô phỏng tool call xem chuỗi hành động có logic và đạt mục tiêu; compile/chạy unit test cho code sinh ra. Dùng kèm được với cả hai loại trên.
| Reference-based | Reference-free | |
|---|---|---|
| Cần gì | Bộ (input, output vàng) do người tuyển | Luật/thuộc tính + (có thể) metadata của request |
| Chạy ở đâu | Offline: dev loop, CI, regression | Offline và production: monitoring, guardrail |
| Điểm mạnh | Chính xác, dễ giải thích ("khác đáp án ở chỗ X") | Mở rộng vô hạn, không tốn nhãn mới |
| Điểm yếu | Tốn công tuyển; nhiều đáp án đúng khác nhau thì so khớp khó | Chỉ bắt được lỗi mà luật mô tả được; có thể bỏ sót |
Câu hỏi của môi giới: "Tìm căn 2 phòng ngủ dưới 5 tỷ ở Quận 7, nhà có nuôi chó." Agent sinh:
SELECT * FROM listings WHERE district='Q7' AND bedrooms >= 2
- Reference-based. Bộ test có SQL vàng:
... AND price <= 5000000000 AND pets_allowed = true. Parse cả hai thành AST, so tập điều kiện trong WHERE: vàng có 4, output có 2 → thiếuprice,pets_allowed→ Fail. Ưu: chính xác. Nhược: chỉ chạy được trên câu hỏi đã có SQL vàng. - Reference-free. Không có SQL vàng. Tách ràng buộc từ chính câu hỏi ("dưới 5 tỷ" → price, "nuôi chó" → pets_allowed, "2 phòng ngủ" → bedrooms), rồi kiểm từng ràng buộc có xuất hiện trong WHERE → thiếu 2 → Fail. Chạy được trên mọi trace production.
- Executability. Chạy cả SQL trên DB test: không lỗi cú pháp, trả về 214 căn — nhiều gấp ~9 lần kết quả của SQL vàng (23 căn). Một tín hiệu phụ rất rẻ: "số kết quả lệch quá xa so với kỳ vọng".
Lưu ý: cả ba đều là code, không cần LLM. Reference-free ở đây phụ thuộc vào bộ tách ràng buộc — chính nó cũng có thể sai (xem mục "Hay nhầm").
Code minh hoạ reference-free check ở bước 2 (tự viết, luật tách ràng buộc cố ý đơn giản):
import json, re
# --- (1) Reference-free: SQL có giữ đủ ràng buộc người dùng đã nêu? ---
def extract_constraints(request: str) -> dict:
"""Tách ràng buộc từ câu hỏi của môi giới (luật đơn giản, đủ cho demo)."""
c = {}
if m := re.search(r"dưới\s*(\d+)\s*(tỷ|triệu)", request):
c["price"] = int(m[1]) * (10**9 if m[2] == "tỷ" else 10**6)
if m := re.search(r"(\d+)\s*phòng ngủ", request):
c["bedrooms"] = int(m[1])
if re.search(r"nuôi (chó|mèo|thú)", request):
c["pets_allowed"] = True
return c
def sql_keeps_constraints(request: str, sql: str) -> dict:
where = sql.lower().split("where", 1)[1] if "where" in sql.lower() else ""
missing = []
for col, val in extract_constraints(request).items():
if col == "price" and not re.search(rf"price\s*(<=|<)\s*{val}\b", where):
missing.append(col)
elif col == "bedrooms" and not re.search(rf"bedrooms\s*(>=|=)\s*{val}\b", where):
missing.append(col)
elif col == "pets_allowed" and not re.search(r"pets_allowed\s*=\s*(true|1)", where):
missing.append(col)
return {"pass": not missing, "missing": missing}
if __name__ == "__main__":
req = "Tìm căn 2 phòng ngủ dưới 5 tỷ ở Quận 7, nhà có nuôi chó"
good = "SELECT * FROM listings WHERE district='Q7' AND bedrooms >= 2 AND price <= 5000000000 AND pets_allowed = true"
bad = "SELECT * FROM listings WHERE district='Q7' AND bedrooms >= 2"
print(sql_keeps_constraints(req, good)) # {'pass': True, 'missing': []}
print(sql_keeps_constraints(req, bad)) # {'pass': False, 'missing': ['price', 'pets_allowed']}
Và phiên bản reference-based theo kết quả thực thi (bước 1 + 3 gộp lại): chạy cả SQL sinh ra và SQL vàng trên một DB nhỏ trong bộ nhớ, so tập dòng trả về. Hai câu SQL viết khác nhau nhưng cùng nghĩa vẫn Pass — điều mà so chuỗi không làm được (tự viết, chỉ dùng thư viện chuẩn):
import sqlite3
ROWS = [ # (district, bedrooms, price_ty, pets_allowed)
("Q7", 2, 4.8, 1), ("Q7", 2, 5.6, 1), ("Q7", 3, 4.9, 0),
("Q7", 1, 3.2, 1), ("Q2", 2, 4.5, 1), ("Q7", 2, 4.1, 0),
]
def run(sql):
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE listings (district TEXT, bedrooms INT, price REAL, pets_allowed INT)")
db.executemany("INSERT INTO listings VALUES (?,?,?,?)", ROWS)
try:
return set(db.execute(sql).fetchall()), None
except sqlite3.Error as e:
return None, str(e)
def same_result(sql_gen, sql_gold):
"""Reference-based theo KẾT QUẢ THỰC THI: hai SQL viết khác nhau vẫn Pass nếu trả về cùng tập dòng."""
got, err = run(sql_gen)
if err:
return {"pass": False, "reason": f"không chạy được: {err}"}
want, _ = run(sql_gold)
return {"pass": got == want, "extra": len(got - want), "missing": len(want - got)}
if __name__ == "__main__":
gold = ("SELECT * FROM listings WHERE district='Q7' AND bedrooms >= 2 "
"AND price <= 5 AND pets_allowed = 1")
alt = ("SELECT * FROM listings WHERE pets_allowed = 1 AND price BETWEEN 0 AND 5 "
"AND bedrooms > 1 AND district = 'Q7'") # viết khác, cùng nghĩa
bad = "SELECT * FROM listings WHERE district='Q7' AND bedrooms >= 2"
print(same_result(alt, gold)) # {'pass': True, 'extra': 0, 'missing': 0}
print(same_result(bad, gold)) # {'pass': False, 'extra': 3, 'missing': 0}
print(same_result("SELEC * FROM listings", gold)) # không chạy được
"Reference-free thì không cần nhãn, nên không cần kiểm định." Code check cũng là một classifier. Regex ở trên sẽ báo Fail oan nếu SQL viết price < 5e9 hoặc price BETWEEN 0 AND 5000000000. Hãy chạy nó trên vài chục trace đã gán nhãn để chắc nó không báo nhầm hàng loạt — cùng tinh thần với việc đo TPR/TNR của judge ở mục 5.7.
"Reference-based = so khớp chuỗi." So chuỗi y hệt quá khắt khe: hai SQL tương đương có thể viết khác nhau. So ở mức cấu trúc (AST), hoặc so kết quả thực thi trên cùng DB, thường đáng tin hơn.
Thiết kế một metric reference-based và một metric reference-free cho lỗi: "agent gọi tool không tồn tại" và lỗi "email gửi khách bỏ sót tiêu chí khách đã nêu".
Xem lời giải
- Tool không tồn tại — Reference-based: so chuỗi tool call với trace mẫu chuẩn cho cùng input (có tool lạ nào không có trong trace mẫu?). Reference-free: mọi tên tool được gọi ∈ registry → code check một dòng, chạy được trên toàn bộ production.
- Email bỏ sót tiêu chí — Reference-based: có email mẫu do môi giới giỏi viết cho cùng yêu cầu; so danh sách tiêu chí được nhắc (có thể dùng judge để so). Reference-free: tách tiêu chí từ tin nhắn của khách (có cấu trúc thì code, không thì LLM), rồi kiểm email có nhắc từng tiêu chí. Vì email là văn bản tự do ("không quá 5 tỷ", "tầm 4,8 tỷ đổ lại"), bước kiểm thường cần LLM judge — xem prompt mẫu ở mục 5.5.
5.3Code check hay LLM-as-judge?
Trực giác. Nếu bạn viết được một unit test cho tiêu chí đó, đừng thuê giám khảo. Code check nhanh, rẻ, deterministic, dễ đọc: cùng input luôn cùng kết quả, sai thì đọc code là biết vì sao. LLM judge hiểu sắc thái nhưng đổi lại có bias, dao động giữa các lần chạy, tốn tiền inference và phải được kiểm định. Nguyên tắc: dùng code tới mức tối đa, chỉ gọi judge cho phần thật sự cần diễn giải.
| Code-based evaluator | LLM-as-judge |
|---|---|
| Parse cấu trúc: JSON hợp lệ, SQL đúng cú pháp | Giọng văn có hợp persona khách? |
| Regex/khớp chuỗi: cụm bắt buộc, cụm cấm | Tóm tắt có trung thành với nguồn? |
| Kiểm cấu trúc: đúng 3 bullet, dưới N ký tự | Câu trả lời có hữu ích? |
| Chạy tool call, kiểm tra không ném lỗi | Mỗi tool call có được yêu cầu hoặc biện minh bởi bước trước? |
| Logic: hỏi căn "cho nuôi chó" → output có nói cho phép thú cưng? | Lời khuyên có phù hợp với hoàn cảnh người dùng kể? |
| ✓ nhanh · rẻ · deterministic · dễ hiểu | ✓ hiểu sắc thái ✗ có bias · dao động · tốn tiền |
Hai quy tắc của sách đáng nhớ: judge là một LLM riêng (khác lời gọi của app), và mỗi judge chỉ lo một failure mode. Nhiều khía cạnh chất lượng → nhiều judge; đó là điều bình thường. Judge làm tốt nhất với câu hỏi hẹp và nhị phân.
Mẫu hình hay dùng: cổng code trước, judge sau
- Hệ thống có 10.000 trace/ngày, 4 failure mode cần judge. Giả sử mỗi lời gọi judge tốn khoảng 0,003 USD (prompt dài vì có few-shot).
- Judge tất cả: 10.000 × 4 × 0,003 = 120 USD/ngày ≈ 3.600 USD/tháng.
- Cổng code trước: 6% trace Fail luôn ở cổng (JSON hỏng, tool lạ). Còn 9.400 trace.
- Lấy mẫu 10% để ước lượng tỉ lệ (không cần chấm từng trace khi mục tiêu là ước lượng, không phải chặn từng request): 940 × 4 × 0,003 ≈ 11,3 USD/ngày.
- Sai số do lấy mẫu 940 trace: với tỉ lệ quanh 85%, độ lệch chuẩn ≈ √(0,85·0,15/940) ≈ 0,012 — nhỏ hơn nhiều so với độ bất định do TPR/TNR của judge (mục 5.9). Tức là chấm hết cũng không làm con số chính xác hơn bao nhiêu.
Một bộ code check điển hình cho output dạng JSON (tự viết):
import json
# --- (2) Bộ check cấu trúc/luật cho output dạng JSON ---
TOOL_REGISTRY = {"search_listings", "get_calendar", "send_email"}
def check_output(raw: str, max_bullets=3, banned=("cam kết chắc chắn", "đảm bảo 100%")):
res = {}
try:
obj = json.loads(raw); res["json_valid"] = True
except json.JSONDecodeError:
return {"json_valid": False}
res["has_keys"] = {"summary", "tool_calls"} <= obj.keys()
bullets = [l for l in obj.get("summary", "").splitlines() if l.strip().startswith("-")]
res["bullets_ok"] = len(bullets) <= max_bullets
res["no_banned"] = not any(b in obj.get("summary", "").lower() for b in banned)
res["tools_known"] = all(t.get("name") in TOOL_REGISTRY for t in obj.get("tool_calls", []))
return res
if __name__ == "__main__":
out = '{"summary": "- Căn A\\n- Căn B", "tool_calls": [{"name": "reschedule_viewing"}]}'
print(check_output(out)) # tools_known: False — reschedule_viewing không có trong registry
print(check_output("xin lỗi, tôi không chắc")) # {'json_valid': False}
Mỗi tiêu chí dưới đây (tự dựng) đến từ error analysis của một sản phẩm nào đó. Chọn cách xử lý hợp lý nhất. Có phần giải thích sau mỗi lựa chọn.
"LLM judge thông minh hơn nên chính xác hơn regex." Với tiêu chí khách quan (JSON hợp lệ? có đúng 3 bullet?), code đúng 100% và không dao động; judge thì đôi khi đếm sai, và bạn còn phải đo TPR/TNR của nó. Thông minh hơn ≠ phù hợp hơn.
"Một judge chấm 5 tiêu chí cùng lúc rẻ hơn 5 judge." Rẻ hơn về token, nhưng kết quả "Fail" không cho biết Fail vì đâu, không đo được TPR/TNR cho từng tiêu chí, và một tiêu chí khó sẽ kéo độ chính xác của cả gói xuống. Tách ra rồi mới tối ưu chi phí (lấy mẫu, dùng model judge nhỏ hơn cho tiêu chí dễ).
Một chatbot ngân hàng có tiêu chí "không bao giờ tiết lộ đầy đủ số tài khoản; chỉ hiện 4 số cuối". Bạn sẽ đo bằng gì? Nếu dùng code, viết regex ý tưởng. Có trường hợp nào code bỏ sót khiến bạn cần thêm judge không?
Xem lời giải
Code là lựa chọn chính: tìm chuỗi 9–14 chữ số liền (có thể có dấu cách/gạch) trong output, ví dụ \b(?:\d[ -]?){9,14}\b, loại trừ các dạng đã che như ****1234. Chạy trên 100% trace, thậm chí chạy online như guardrail chặn trước khi gửi.
Code có thể bỏ sót khi số được viết bằng chữ ("một hai ba...") hoặc tách thành nhiều câu ("đầu số là 0123, tiếp theo là 4567..."). Nếu error analysis thấy những ca đó thật sự xảy ra, thêm một judge hẹp: "output có tiết lộ nhiều hơn 4 chữ số cuối của số tài khoản dưới bất kỳ dạng nào?" — chạy trên mẫu, và đo TPR/TNR như mọi judge.
5.4Bias của LLM judge & cách thăm dò
Sách dẫn nghiên cứu của Zheng et al. (2023, nhóm LMSYS / Chatbot Arena) về ba kiểu bias kinh điển khi dùng LLM làm giám khảo:
So hai câu trả lời ngang nhau, judge thiên vị vị trí đầu (hoặc sau). Trong nghiên cứu đó, ngay cả GPT-4 cũng không nhất quán ở khoảng ¼ số cặp khi đảo thứ tự. Phòng: đảo thứ tự và chấm hai chiều, hoặc tránh so cặp.
Câu dài được chấm cao hơn dù không đúng hơn → agent được "thưởng" vì độn chữ. Phòng: tiêu chí hẹp, nói rõ độ dài không phải tiêu chí, có ví dụ Fail dài-mà-sai.
Model chấm output của chính nó (hoặc cùng họ) rộng tay hơn judge trung lập. Phòng: judge khác họ model với model bị chấm.
Bias không làm judge vô dụng, nhưng có nghĩa là điểm judge thô không tự đứng được — cần prompt tốt và quy trình kiểm định (mục 5.6–5.9). Tin tốt: bạn có thể tự thăm dò bias của judge trên dữ liệu của chính mình với vài chục ví dụ.
- Position probe. Lấy 60 cặp câu trả lời tương đương (cùng một câu trả lời được viết lại bằng từ khác). Hỏi judge "câu nào tốt hơn" ở cả hai thứ tự. Judge lý tưởng phải nói "hoà" hoặc đổi lựa chọn theo nội dung. Kết quả giả định: 38/60 lần judge chọn câu đứng trước ở cả hai thứ tự → 63% cặp bị ảnh hưởng bởi vị trí. Kết luận: không dùng so cặp một chiều.
- Verbosity probe. Lấy 40 câu trả lời đã gán Fail. Tạo bản "độn" bằng cách thêm 2 câu lịch sự không chứa thông tin ("Hy vọng thông tin này hữu ích cho anh chị..."). Chạy judge nhị phân trên cả bản gốc và bản độn. Giả định: 36/40 bản gốc bị chấm Fail, nhưng chỉ 29/40 bản độn → 7 ca "lật" chỉ vì dài hơn. TNR tụt từ 0,90 xuống 0,725 trên loại ca này. Sửa: thêm dòng "độ dài/lời lẽ lịch sự không ảnh hưởng kết luận" + một few-shot Fail dài.
- Self-enhancement probe. Cùng 80 trace có nhãn, chấm bằng judge cùng họ với model app và judge khác họ. So TNR: nếu judge cùng họ cho lọt nhiều Fail hơn rõ rệt (ví dụ TNR 0,78 vs 0,88), đổi họ judge.
Công cụ thăm dò nhỏ (tự viết): chạy judge nhiều lần để đo độ ổn định, và so cặp hai chiều để trung hoà position bias. call_llm là hàm gọi model của bạn (ở đây thay bằng hàm giả để chạy thử).
import random
from collections import Counter
from judge_prompt import parse_verdict
def stability(call_llm, prompt, n=5):
"""Chạy judge n lần (temperature > 0). flip_rate > 0 = tiêu chí còn mơ hồ với ca này."""
votes = Counter(parse_verdict(call_llm(prompt)) for _ in range(n))
top, cnt = votes.most_common(1)[0]
return {"votes": dict(votes), "majority": top, "flip_rate": 1 - cnt / n}
def pairwise_swapped(call_llm, request, ans_a, ans_b):
"""Judge so cặp, chấm cả hai thứ tự để trung hoà position bias."""
tpl = "Yêu cầu: {r}\nTrả lời 1: {x}\nTrả lời 2: {y}\nCâu nào tốt hơn? Chỉ trả về '1', '2' hoặc 'hoà'."
first = call_llm(tpl.format(r=request, x=ans_a, y=ans_b)).strip() # A đứng trước
second = call_llm(tpl.format(r=request, x=ans_b, y=ans_a)).strip() # B đứng trước
win1 = {"1": "A", "2": "B"}.get(first, "hoà")
win2 = {"1": "B", "2": "A"}.get(second, "hoà")
return win1 if win1 == win2 else "hoà (không nhất quán)"
if __name__ == "__main__":
flaky = lambda _: random.choice(['{"answer": "Pass"}', '{"answer": "Fail"}'])
print(stability(flaky, "prompt bất kỳ", n=5)) # flip_rate thường 0.2–0.4
always_first = lambda prompt: "1" # judge luôn chọn câu đứng trước
print(pairwise_swapped(always_first, "tóm tắt hợp đồng", "bản ngắn", "bản dài"))
"Chấm nhị phân từng output thì hết bias." Hết position bias (không có cặp để đảo), nhưng verbosity và self-enhancement vẫn còn nguyên — chúng làm lệch TNR/TPR của judge. Đó chính là lý do phải đo TPR/TNR và hiệu chỉnh.
"Temperature = 0 là judge deterministic." Nhiều API vẫn cho kết quả khác nhau giữa các lần gọi ở temperature 0 (batching, phần cứng, version model đổi ngầm). Coi judge là ngẫu nhiên và đo độ ổn định.
Bạn muốn biết model mới (dài dòng hơn) có tốt hơn model cũ không, bằng cách cho judge so cặp. Thiết kế thí nghiệm để kết quả không bị position bias và verbosity bias làm méo.
Xem lời giải
- Mỗi cặp chấm hai chiều; chỉ tính thắng khi hai chiều đồng ý, còn lại tính hoà (như hàm
pairwise_swapped). - Ngẫu nhiên hoá thứ tự trình bày giữa các cặp (không để model mới luôn là "câu 1").
- Nói rõ trong prompt tiêu chí so sánh cụ thể (ví dụ "trả lời đúng yêu cầu chính, không sai sự thật") và rằng độ dài không phải tiêu chí.
- Chạy thăm dò verbosity: độn các câu trả lời của model cũ cho dài bằng model mới, xem tỉ lệ thắng có đổi không. Nếu có, judge đang thưởng độ dài.
- Tốt hơn nữa: thay so cặp bằng hai judge nhị phân "chấp nhận được?" cho từng model, rồi so hai tỉ lệ đã hiệu chỉnh kèm CI (mục 5.14).
5.5Viết prompt LLM-as-judge
Trực giác. Prompt judge là "đề bài" cho một người chấm thuê ngoài chưa từng thấy sản phẩm của bạn. Người chấm đó chỉ giỏi khi đề bài hẹp, định nghĩa rõ, có bài mẫu đã chấm, và phải nộp bài theo mẫu cố định. Bốn thành phần sách nêu ra đúng là bốn thứ đó:
- Một task, một tiêu chíKhông hỏi "email có tốt không?", mà hỏi một câu hẹp như "giọng văn có hợp với khách hạng sang không?".
- Định nghĩa Pass/Fail chính xácPass = lỗi vắng mặt, Fail = lỗi xuất hiện — lấy thẳng từ mô tả failure mode ở error analysis, dùng đúng từ ngữ mà người gán nhãn đã dùng.
- Few-shot examplesVài ca Pass rõ và Fail rõ, lấy từ trace đã gán nhãn — chỉ từ tập train. Dùng thang nhiều mức thì phải có ví dụ cho mọi mức.
- Output có cấu trúcJSON gồm
reasoning(1–2 câu) +answer(Pass/Fail): vừa giúp judge "nghĩ trước khi kết luận", vừa dễ đọc lại khi debug.
Failure mode: email gửi khách bỏ sót tiêu chí tìm nhà khách đã nêu. Bản nháp đầu tiên của một kỹ sư:
Hãy đánh giá email sau có tốt không, cho điểm 1-10.
Email: {{email}}
- Thiếu tiêu chí đơn. "Tốt" gồm cả giọng văn, độ dài, chính tả, độ đầy đủ... Judge sẽ tự chọn tiêu chí. → Viết lại: "chỉ chấm MỘT tiêu chí: email có nhắc lại đủ các tiêu chí tìm nhà khách đã nêu".
- Thiếu dữ liệu để chấm. Judge không thấy tiêu chí của khách thì không thể biết email bỏ sót gì. → Đưa thêm trường
criteriavào prompt. (Lỗi rất hay gặp: judge được giao việc mà không được đưa bằng chứng.) - Thang 1–10 không định nghĩa. 6 điểm nghĩa là gì? Người gán nhãn đã gán nhị phân. → Đổi thành Pass/Fail với định nghĩa, và liệt kê những gì không thuộc tiêu chí (giọng văn, độ dài) để chặn verbosity/halo.
- Không có ví dụ và output tự do. → Thêm 2–4 few-shot từ tập train (có cả ca Fail tinh tế: nói "ngân sách phù hợp" nhưng không nêu con số), và yêu cầu JSON
{"reasoning", "answer"}.
Prompt sau khi sửa (tự viết, dùng trong code bên dưới):
Bạn là người chấm chất lượng cho trợ lý CRM bất động sản.
Chỉ chấm MỘT tiêu chí: email gửi khách có nhắc lại ĐỦ các tiêu chí tìm nhà mà khách đã nêu không.
FAIL nếu email bỏ sót hoặc nói sai ít nhất một tiêu chí khách đã nêu
(khu vực, ngân sách, số phòng ngủ, thú cưng, hướng nhà...).
PASS nếu mọi tiêu chí khách đã nêu đều được nhắc đúng. Giọng văn, độ dài, lỗi chính tả
KHÔNG thuộc tiêu chí này — đừng để chúng ảnh hưởng tới kết luận.
[2–4 ví dụ từ tập train: tiêu chí của khách · email · JSON kết quả]
Trả về DUY NHẤT một JSON: {"reasoning": "<1-2 câu>", "answer": "Pass" hoặc "Fail"}
Tiêu chí của khách: {criteria}
Email: {email}
Code dựng prompt từ tập train và bóc kết quả an toàn (tự viết):
import json, re
JUDGE_PROMPT = """Bạn là người chấm chất lượng cho trợ lý CRM bất động sản.
Chỉ chấm MỘT tiêu chí: email gửi khách có nhắc lại ĐỦ các tiêu chí tìm nhà mà khách đã nêu không.
FAIL nếu email bỏ sót hoặc nói sai ít nhất một tiêu chí khách đã nêu
(khu vực, ngân sách, số phòng ngủ, thú cưng, hướng nhà...).
PASS nếu mọi tiêu chí khách đã nêu đều được nhắc đúng. Giọng văn, độ dài, lỗi chính tả
KHÔNG thuộc tiêu chí này — đừng để chúng ảnh hưởng tới kết luận.
{examples}
Trả về DUY NHẤT một JSON: {{"reasoning": "<1-2 câu>", "answer": "Pass" hoặc "Fail"}}
Tiêu chí của khách: {criteria}
Email: {email}"""
def build_prompt(train_examples, criteria, email):
shots = "\n".join(
f"Ví dụ {i+1}\nTiêu chí của khách: {ex['criteria']}\nEmail: {ex['email']}\n"
f"Kết quả: {json.dumps({'reasoning': ex['why'], 'answer': ex['label']}, ensure_ascii=False)}\n"
for i, ex in enumerate(train_examples)) # CHỈ lấy từ tập train
return JUDGE_PROMPT.format(examples=shots, criteria=criteria, email=email)
def parse_verdict(text):
"""Bóc JSON đầu tiên trong output; lỗi định dạng -> None (đếm riêng, đừng lặng lẽ coi là Pass)."""
m = re.search(r"\{.*\}", text, re.S)
try:
obj = json.loads(m[0]) if m else {}
except json.JSONDecodeError:
return None
ans = str(obj.get("answer", "")).strip().capitalize()
return ans if ans in ("Pass", "Fail") else None
if __name__ == "__main__":
train = [{"criteria": "Q7, dưới 5 tỷ, 2PN", "email": "Gửi anh 3 căn 2PN ở Q7, giá 4,2–4,9 tỷ...",
"why": "Nhắc đủ khu vực, ngân sách, số phòng.", "label": "Pass"}]
print(build_prompt(train, "Thủ Đức, nuôi mèo, dưới 3 tỷ", "Em gửi chị vài căn ở Thủ Đức dưới 3 tỷ ạ."))
print(parse_verdict('Đây là kết quả: {"reasoning": "thiếu tiêu chí mèo", "answer": "fail"}')) # Fail
print(parse_verdict("Tôi nghĩ là Pass")) # None
Đặt reasoning trước answer trong JSON để model "nghĩ" trước khi kết luận. Nếu để answer trước, phần reasoning dễ thành biện hộ cho kết luận đã chọn.
Yêu cầu reasoning trích cụm từ cụ thể trong output ("email không nhắc 'nuôi mèo'"). Vừa giảm bịa, vừa giúp bạn đọc ca sai nhanh hơn ở vòng lặp tinh chỉnh.
Một dòng "độ dài, giọng văn không thuộc tiêu chí" là liều thuốc rẻ nhất cho verbosity bias và hiệu ứng hào quang (câu viết đẹp → được Pass).
Output không parse được phải được đếm riêng (retry hoặc gắn cờ), đừng lặng lẽ mặc định Pass hay Fail — nó sẽ làm lệch TPR/TNR mà bạn không biết.
"Càng nhiều few-shot càng tốt." In-context learning bão hoà sau vài ví dụ (thường 1–8). Thêm nữa chỉ tốn token, và nếu ví dụ lệch (toàn Fail dạng thiếu ngân sách) judge sẽ học bề mặt: chỉ soi ngân sách. Chọn ít nhưng đa dạng, gồm ca biên.
"Thang 1–5 cho nhiều thông tin hơn Pass/Fail." Chỉ khi mỗi mức được định nghĩa và có ví dụ, và người gán nhãn cũng dùng đúng thang đó. Thực tế thang nhiều mức khó căn chỉnh, khó hiệu chỉnh; nhiều judge nhị phân hẹp thường cho tín hiệu rõ hơn một thang điểm mờ.
Viết lại prompt judge sau cho đúng 4 thành phần: "Kiểm tra câu trả lời của bot hỗ trợ khách hàng có ổn không. Trả lời Yes/No." Failure mode gốc từ error analysis: "bot hứa hoàn tiền/đổi trả vượt quá chính sách được cung cấp".
Xem lời giải (một phương án)
Bạn chấm câu trả lời của bot CSKH. Chỉ chấm MỘT tiêu chí:
bot có hứa hẹn quyền lợi (hoàn tiền, đổi trả, bồi thường, thời hạn) VƯỢT QUÁ đoạn chính sách được cung cấp không.
FAIL: có ít nhất một lời hứa không có căn cứ trong đoạn chính sách (kể cả hứa gián tiếp như "chắc chắn sẽ được").
PASS: mọi quyền lợi được nhắc đều có trong chính sách, hoặc bot không hứa gì.
Không chấm: giọng văn, độ dài, có giải quyết được vấn đề hay không.
[3 ví dụ từ train: 1 Pass, 1 Fail rõ, 1 Fail tinh tế "em nghĩ chắc được hoàn tiền ạ"]
Trả về JSON {"reasoning": "<trích lời hứa nếu có>", "answer": "Pass"|"Fail"}
Chính sách: {policy}
Câu hỏi khách: {question}
Câu trả lời của bot: {answer}
Lưu ý chiều nhãn: Pass = không có lỗi. Giữ đúng quy ước này để TPR/TNR ở các mục sau không bị đảo nghĩa.
5.6Chia dữ liệu: train / dev / test
Trực giác. Xây judge ≈ train một classifier, chỉ khác là "huấn luyện" bằng cách viết prompt và chọn ví dụ thay vì gradient descent. Vì vậy nó cũng overfit được: nếu bạn sửa prompt cho tới khi judge đúng hết trên đúng những ca bạn đang nhìn, con số đó không nói gì về ca mới. Hình dung: train = sách giáo khoa (được chép vào prompt), dev = đề ôn tập (làm đi làm lại), test = đề thi thật (niêm phong, mở đúng một lần).
Lấy đâu ra 100–200 trace có nhãn?
Cách nhanh nhất: một chuyên gia tin cậy chốt rubric và gán hết (khỏi đo đồng thuận). Nếu buộc phải nhiều người: dùng 20–50 trace chung để mài rubric tới khi đồng thuận cao (Chương 4), rồi một người áp rubric gán phần còn lại. Lỗi hiếm? Lấy thêm ca Fail từ chính các trace đã tìm thấy ở error analysis (Chương 3) hoặc lọc trace nghi vấn bằng code check để gán nhãn có chủ đích.Chuyên gia gán 150 trace cho failure mode "email bỏ sót tiêu chí": 88 Pass, 62 Fail (lỗi đã được cố tình lấy dư từ error analysis; ngoài production tỉ lệ lỗi chỉ khoảng 10%).
- Train: chọn 6 Pass + 6 Fail rõ ràng, đa dạng (Fail do thiếu ngân sách, thiếu khu vực, nói sai số phòng...). 12 trace.
- Fail còn lại: 62 − 6 = 56 → chia đôi: 28 dev, 28 test.
- Pass còn lại: 88 − 6 = 82. Lấy 28 cho dev, 28 cho test để cân bằng; 26 Pass dư để dành (bổ sung sau, hoặc làm "ngân hàng" thay few-shot).
- Kiểm tra: dev = 28/28, test = 28/28 — hơi dưới mức 30–50 mỗi lớp. Ưu tiên gán thêm ~10–20 Fail nữa (lớp khan hiếm) hơn là thêm Pass.
- Kiểm tra rò rỉ: không trace nào (và không bản gần trùng nào, ví dụ cùng một khách hỏi lại) nằm ở hai tập.
Code chia dữ liệu có kiểm tra rò rỉ + (tiện thể) ước lượng Pass@k dùng ở mục 5.11 (tự viết):
import random
from math import comb
def split_labeled(traces, n_train_per_class=6, seed=42):
"""traces: list[dict] có 'id' và 'label' ('Pass'/'Fail').
Train: vài ca rõ ràng mỗi lớp. Phần còn lại chia đôi dev/test, giữ cân bằng lớp."""
rng = random.Random(seed)
out = {"train": [], "dev": [], "test": []}
for label in ("Pass", "Fail"):
group = [t for t in traces if t["label"] == label]
rng.shuffle(group)
clear = [t for t in group if t.get("clear", True)] # ưu tiên ca rõ cho few-shot
train = clear[:n_train_per_class]
rest = [t for t in group if t not in train]
half = len(rest) // 2
out["train"] += train
out["dev"] += rest[:half]
out["test"] += rest[half:]
ids = [set(t["id"] for t in v) for v in out.values()]
assert not (ids[0] & ids[1] or ids[0] & ids[2] or ids[1] & ids[2]), "rò rỉ giữa các tập!"
return out
def pass_at_k(n, c, k):
"""Ước lượng không chệch Pass@k khi sinh n mẫu, c mẫu đúng (n >= k)."""
if n - c < k:
return 1.0
return 1 - comb(n - c, k) / comb(n, k)
if __name__ == "__main__":
data = [{"id": i, "label": "Pass" if i % 2 else "Fail"} for i in range(96)]
s = split_labeled(data)
print({k: len(v) for k, v in s.items()}) # {'train': 12, 'dev': 42, 'test': 42}
print(round(pass_at_k(n=10, c=3, k=1), 3), round(pass_at_k(10, 3, 5), 3)) # 0.3 0.917
"Test set phải giữ đúng tỉ lệ production (ví dụ 10% Fail)." Với 100 trace, bạn chỉ có 10 Fail → TNR được ước lượng từ 10 ca, sai số khổng lồ (một ca đổi ý = TNR lệch 10 điểm). Hãy cân bằng lớp; phần tỉ lệ thật sẽ do bước hiệu chỉnh ở mục 5.8 lo, vì TPR và TNR không phụ thuộc tỉ lệ Pass/Fail trong tập.
"Nhìn test một lần rồi sửa prompt thêm chút cũng không sao." Ngay khi bạn sửa prompt dựa trên kết quả test, test đã thành dev. Nếu buộc phải làm vậy, hãy gán một test set mới (và chuyển test cũ vào dev) — xem case study mục 5.10.
Bạn có 120 trace có nhãn: 105 Pass, 15 Fail. Có nên chia train/dev/test ngay không? Nếu không, làm gì trước?
Xem lời giải
Chưa nên. 15 Fail chia 3 tập thì dev/test chỉ có ~6 Fail mỗi tập → TNR vô nghĩa. Trước hết tăng số Fail có nhãn: (1) lấy lại các trace Fail đã gặp ở error analysis; (2) dùng code check hoặc tìm kiếm từ khoá để lọc trace nghi có lỗi rồi gán nhãn; (3) nếu vẫn thiếu, tạo input thử thách (Chương 3) để gây lỗi. Mục tiêu tối thiểu ~70–100 Fail (vài cho train, 30–50 cho mỗi dev/test). Số Pass dư thì không vội gán thêm.
5.7Vòng lặp tinh chỉnh judge & TPR/TNR
Trực giác. Bạn không viết được prompt judge hoàn hảo ngay lần đầu, cũng như không viết được code không bug ngay lần đầu. Quy trình là một vòng lặp debug: chạy judge trên dev, đọc các ca judge nói khác người, sửa định nghĩa/ví dụ, chạy lại.
TPR và TNR đọc từ confusion matrix
Quy ước của sách: positive = Pass. Vậy TPR = p/P: trong các trace người gán Pass, judge cũng nói Pass bao nhiêu phần. TNR = f/F: trong các trace người gán Fail, judge bắt được bao nhiêu phần. Ví dụ tự dựng: test set có 50 trace Pass và 50 trace Fail theo nhãn người.
Tiêu chí DUY NHẤT: câu trả lời có giải quyết đúng yêu cầu chính của người dùng, không sai sự thật quan trọng? Chấm Pass/Fail từng ca (12 ca tự dựng). Nhãn gold của "chuyên gia" đang được giấu; confusion matrix cập nhật theo lựa chọn của bạn. Thử cả hai nút "judge dễ dãi / khắt khe" để thấy hai cực.
Vì sao TPR/TNR mà không phải precision/recall?
Mục tiêu cuối cùng là ước lượng tỉ lệ Pass thật của app. Judge chỉ có thể làm lệch con số đó theo hai cách: bỏ sót Pass thật (TPR thấp) hoặc cho qua Fail thật (TNR thấp). TPR và TNR đo đúng hai kênh sai đó, và — quan trọng — chúng là tỉ lệ trong từng lớp, nên không đổi khi tỉ lệ Pass/Fail thay đổi. Precision thì phụ thuộc tỉ lệ đó (ví dụ ngay dưới). Ngưỡng "đủ tốt" tuỳ bối cảnh: nếu bỏ lọt lỗi đắt hơn báo nhầm, ưu tiên TNR.Cùng judge TPR = 0,90, TNR = 0,80 như confusion matrix phía trên.
- Trên test cân bằng (θ = 50%): judge nói Pass 45 + 10 = 55 ca, trong đó 45 đúng → precision(Pass) = 45/55 ≈ 0,818.
- Trên production với θ = 95%: tỉ lệ judge nói Pass = 0,90·0,95 + 0,20·0,05 = 0,855 + 0,010 = 0,865; phần đúng = 0,855 → precision = 0,855/0,865 ≈ 0,988.
- Judge không hề đổi, nhưng precision nhảy từ 0,82 lên 0,99 chỉ vì tỉ lệ lớp đổi. Nếu bạn "kiểm định" judge bằng precision trên test cân bằng rồi áp dụng cho production, con số không mang sang được. TPR/TNR thì mang sang được — đó là lý do công thức hiệu chỉnh ở mục 5.8 dùng chúng.
Judge "email bỏ sót tiêu chí" (prompt ở mục 5.5).
| Vòng | TPR dev | TNR dev | Đọc ca sai thấy gì | Sửa gì |
|---|---|---|---|---|
| v1 | 27/28 = 0,96 | 17/28 = 0,61 | 9/11 false pass: email viết "trong tầm ngân sách của anh" không nêu con số, judge coi là đủ. | Định nghĩa "nhắc" = nêu giá trị cụ thể hoặc diễn đạt tương đương rõ ràng; thêm 1 few-shot Fail dạng này. |
| v2 | 25/28 = 0,89 | 24/28 = 0,86 | 3 false fail mới: judge đòi email nhắc "hướng nhà" dù khách không nêu hướng. | Thêm "chỉ xét tiêu chí khách ĐÃ nêu" + 1 few-shot Pass tương ứng. |
| v3 | 27/28 = 0,96 | 25/28 = 0,89 | 3 false pass còn lại: 2 ca đọc kỹ thì chính nhãn người sai (email có nhắc, người gán bỏ sót). | Sửa 2 nhãn (Fail→Pass), không sửa prompt. |
| v3 + nhãn sửa | 29/30 = 0,97 | 25/26 = 0,96 | — | Dừng, chốt prompt, sang test. |
Quan sát: sửa để tăng TNR ở v2 làm TPR tụt (judge khắt khe quá tay) — hiện tượng bập bênh rất thường gặp. Và bước "kiểm lại nhãn" đem lại cải thiện lớn nhất ở vòng cuối mà không phải đụng vào prompt.
Code in TPR/TNR trên dev kèm danh sách ca sai để đọc (tự viết):
def review_dev(rows):
"""rows: list[dict] có 'id', 'human' ('Pass'/'Fail'), 'judge', 'reasoning'.
In TPR/TNR trên dev + liệt kê ca sai để đọc tay (bước 4 của vòng lặp)."""
P = [r for r in rows if r["human"] == "Pass"]
F = [r for r in rows if r["human"] == "Fail"]
tpr = sum(r["judge"] == "Pass" for r in P) / len(P)
tnr = sum(r["judge"] == "Fail" for r in F) / len(F)
print(f"dev: TPR={tpr:.2f} ({len(P)} Pass) TNR={tnr:.2f} ({len(F)} Fail)")
for kind, group, wrong in (("FALSE PASS (lọt lỗi)", F, "Pass"),
("FALSE FAIL (đánh trượt oan)", P, "Fail")):
bad = [r for r in group if r["judge"] == wrong]
print(f"\n== {kind}: {len(bad)} ca")
for r in bad:
print(f" #{r['id']}: {r['reasoning'][:90]}")
return tpr, tnr
if __name__ == "__main__":
rows = [
{"id": 1, "human": "Pass", "judge": "Pass", "reasoning": "Nhắc đủ Q7, 5 tỷ, 2PN."},
{"id": 2, "human": "Fail", "judge": "Pass", "reasoning": "Email nói 'ngân sách phù hợp' nên coi như đủ."},
{"id": 3, "human": "Fail", "judge": "Fail", "reasoning": "Không nhắc 'nuôi mèo'."},
{"id": 4, "human": "Pass", "judge": "Fail", "reasoning": "Không ghi hướng nhà (nhưng khách không hề nêu hướng)."},
]
review_dev(rows)
Khi TPR và TNR đủ cao cho nhu cầu — sách lấy ví dụ > 90%. Ngưỡng tuỳ ứng dụng: nếu bỏ lọt một lỗi thật tốn kém hơn báo nhầm, đặt yêu cầu TNR cao hơn.
(1) Dùng model judge mạnh hơn; (2) tách tiêu chí thành nhiều check nhỏ, mỗi check một judge; (3) bổ sung ví dụ đa dạng, nhất là ca biên, vào train; (4) kiểm lại nhãn — nhãn do người thiếu bối cảnh (hoặc do LLM) gán là nguồn "lỗi judge" rất phổ biến.
"Accuracy 85% là đủ tốt." Accuracy gộp hai lớp làm một. Trên test cân bằng, accuracy = (TPR + TNR)/2, nên 85% có thể là TPR 0,99 + TNR 0,71 — một judge cho lọt gần 30% lỗi. Luôn báo cáo cả hai con số.
"False pass và false fail tệ như nhau." Tuỳ hậu quả. Với lỗi an toàn (bot hứa hoàn tiền sai chính sách), false pass nghĩa là lỗi lọt ra ngoài báo cáo — nguy hiểm hơn. Với metric dùng để tối ưu chất lượng, false fail làm bạn "sửa" những thứ không hỏng.
"Nhầm chiều positive." Trong ML thường positive = "có lỗi". Sách dùng positive = Pass. Nếu bạn quen chiều ngược lại, TPR của bạn chính là TNR của sách — cẩn thận khi cắm vào công thức.
Trên dev (40 Pass, 40 Fail), judge cho ra: 38 Pass đúng, 2 false fail, 12 false pass, 28 Fail đúng. (a) Tính TPR, TNR, accuracy. (b) Bạn nên đọc nhóm ca sai nào trước? (c) Gợi ý hai hướng sửa.
Xem lời giải
(a) TPR = 38/40 = 0,95; TNR = 28/40 = 0,70; accuracy = 66/80 = 0,825.
(b) 12 false pass — judge đang dễ dãi, cho lọt 30% lỗi. Đọc 12 reasoning để tìm mẫu chung (judge chấp nhận kiểu diễn đạt nào mà người không chấp nhận?).
(c) Làm rõ định nghĩa Fail bằng đúng dạng lỗi đang lọt; thêm 1–2 few-shot Fail tinh tế lấy từ train (không lấy từ chính 12 ca dev — sẽ là rò rỉ). Sau khi sửa, theo dõi TPR xem có tụt không (hiệu ứng bập bênh).
5.8Hiệu chỉnh tỉ lệ thành công: công thức Rogan–Gladen
Đã chốt judge, đã có TPR/TNR từ test. Giờ chạy judge trên m trace production chưa gán nhãn, được k cái "Pass" → pobs = k/m. Nhưng pobs không phải tỉ lệ thật θ, vì judge vừa đánh trượt oan vừa cho lọt lỗi.
Trực giác. Giống cân nhà bếp luôn lệch: nếu biết cân "ăn bớt" bao nhiêu thì đọc số rồi cộng bù là ra cân nặng thật. TPR/TNR chính là "độ lệch của cân", đo một lần trên test set bằng các quả cân chuẩn (nhãn người).
- Một trace judge nói "Pass" đến từ hai nguồn: Pass thật được nhận đúng (xác suất θ·TPR) hoặc Fail thật bị cho lọt (xác suất (1−θ)·(1−TNR)).
- Vậy pobs = θ·TPR + (1−θ)·(1−TNR) = θ·(TPR + TNR − 1) + (1 − TNR).
- Chuyển vế: θ·(TPR + TNR − 1) = pobs − (1 − TNR) = pobs + TNR − 1.
- Chia: θ = (pobs + TNR − 1) / (TPR + TNR − 1), rồi kẹp vào [0, 1] vì nhiễu có thể đẩy kết quả ra ngoài.
Nhận xét: mẫu số TPR + TNR − 1 là "độ phân biệt" của judge (còn gọi là Youden's J). Judge tung đồng xu có TPR + TNR = 1 → mẫu số 0 → không thể suy ra gì. Judge hoàn hảo có mẫu số 1 → θ = pobs.
- Judge dễ dãi nhẹ. TPR = 0,90, TNR = 0,80; 1.000 trace, judge nói Pass 750 → pobs = 0,75. θ̂ = (0,75 + 0,80 − 1)/(0,90 + 0,80 − 1) = 0,55/0,70 ≈ 0,786. Kiểm ngược: 0,9·0,786 + 0,2·0,214 ≈ 0,707 + 0,043 = 0,75 ✓. Tỉ lệ thật cao hơn số thô, vì phần Pass bị đánh trượt (7,9%) nhiều hơn phần Fail lọt qua (4,3%).
- Judge khó tính. TPR = 0,60, TNR = 0,95; pobs = 0,50. θ̂ = (0,50 + 0,95 − 1)/(0,60 + 0,95 − 1) = 0,45/0,55 ≈ 0,818. Số thô 50% đánh giá thấp app tới hơn 30 điểm phần trăm — nếu tin số thô, bạn có thể đi "sửa" một hệ thống đang chạy khá tốt.
- Bị kẹp. TPR = 0,90, TNR = 0,90; pobs = 0,08. θ̂ = (0,08 + 0,90 − 1)/0,80 = −0,025 → kẹp về 0. Số âm nghĩa là quan sát còn thấp hơn mức "chỉ có Fail lọt qua" (10%); thường do nhiễu mẫu nhỏ hoặc TNR thật trên production khác TNR đo trên test. Đừng báo cáo "0%" mà không kèm CI và kiểm tra lại giả định.
Muốn tự tin rằng hiệu chỉnh "không chệch"? Đừng tin lời — mô phỏng. Đoạn code dưới lặp lại toàn bộ quy trình 5.000 lần với một judge khó tính (TPR 0,70, TNR 0,95) trên app có θ thật = 0,80:
import numpy as np
def simulate(theta=0.80, tpr=0.70, tnr=0.95, n=50, m=2000, runs=5000, seed=7):
"""Lặp lại cả quy trình nhiều lần: gán test mới, chạy judge trên production mới.
So trung bình của số thô và số đã hiệu chỉnh với θ thật."""
rng = np.random.default_rng(seed)
p_true = tpr * theta + (1 - tnr) * (1 - theta) # xác suất judge nói Pass
raw, corr = [], []
for _ in range(runs):
tpr_hat = rng.binomial(n, tpr) / n # đo trên test n Pass
tnr_hat = rng.binomial(n, tnr) / n # và n Fail
p_obs = rng.binomial(m, p_true) / m
raw.append(p_obs)
d = tpr_hat + tnr_hat - 1
if d >= 0.05:
corr.append(np.clip((p_obs + tnr_hat - 1) / d, 0, 1))
return np.mean(raw), np.mean(corr), np.percentile(corr, [2.5, 97.5])
if __name__ == "__main__":
raw, corr, (lo, hi) = simulate()
print(f"θ thật = 0.800 | trung bình số thô = {raw:.3f} | trung bình θ̂ = {corr:.3f}")
print(f"95% giữa của θ̂ qua các lần lặp: [{lo:.3f}, {hi:.3f}]")
Kết quả khi chạy: số thô trung bình ≈ 0,570 (lệch 23 điểm), θ̂ trung bình ≈ 0,806. Phần lệch nhỏ còn lại (+0,006) đến từ việc kẹp ở 1 và việc chia cho TPR ước lượng — gần như không đáng kể so với độ rộng dao động [0,66; 1,00]. Bài học giống thí nghiệm tư duy của sách: hiệu chỉnh xử lý độ lệch, còn lỗi judge hiện ra dưới dạng độ bất định.
Hàm hiệu chỉnh dùng ở các mục sau:
import numpy as np
def tpr_tnr(y, yhat):
"""y, yhat: 1 = Pass, 0 = Fail. Trả về (TPR, TNR)."""
y, yhat = np.asarray(y), np.asarray(yhat)
return yhat[y == 1].mean(), 1 - yhat[y == 0].mean()
def rogan_gladen(p_obs, tpr, tnr, min_denom=0.05):
d = tpr + tnr - 1
if d < min_denom:
return np.nan # judge ~ tung đồng xu: không hiệu chỉnh
return float(np.clip((p_obs + tnr - 1) / d, 0, 1))
| TPR | TNR | Chuyện gì xảy ra |
|---|---|---|
| Cao (> 0,85) | Cao (> 0,85) | Ước lượng chuẩn, CI hẹp. |
| Cao | Thấp (< 0,7) | Judge cho lọt nhiều Fail → pobs cao hơn thật; hiệu chỉnh kéo xuống; CI rộng ra. |
| Thấp | Cao | Judge đánh trượt oan nhiều Pass → pobs thấp hơn thật; hiệu chỉnh kéo lên; CI rộng ra. |
| Thấp | Thấp | Không tin được, CI rất rộng → sửa judge trước khi kết luận. |
Giả định ngầm cần nhớ
Công thức giả định judge có cùng TPR/TNR trên production như trên test. Điều đó đúng khi các ca Pass/Fail trong test "giống" các ca Pass/Fail ngoài production. Nó sai khi: production có kiểu lỗi mới mà test chưa có; model app đổi và viết theo phong cách khác (ví dụ dài hơn → judge dễ dãi hơn); hoặc lọc trace trước khi chấm làm lệch thành phần. Khi nghi ngờ, gán nhãn một mẫu nhỏ trace production mới và đo lại TPR/TNR."Hiệu chỉnh là bóp số cho đẹp." Không — nó có thể kéo xuống (ca 1 của bảng) cũng như kéo lên, và trung bình nó cho đúng tỉ lệ thật. Chính số thô mới là số bị méo.
"Đo TPR/TNR trên test cân bằng 50/50 thì không dùng được cho production 95/5." Dùng được, vì TPR/TNR là tỉ lệ trong từng lớp (Ví dụ 5.7a). Điều cần lo là giả định ở khung trên, không phải tỉ lệ lớp.
(a) TPR = 0,95, TNR = 0,70; production pobs = 0,80. Tính θ̂. (b) TPR = 0,55, TNR = 0,50. Có nên hiệu chỉnh không? (c) Nếu θ thật = 0,60, TPR = 0,85, TNR = 0,90, pobs kỳ vọng là bao nhiêu?
Xem lời giải
(a) θ̂ = (0,80 + 0,70 − 1)/(0,95 + 0,70 − 1) = 0,50/0,65 ≈ 0,77. Thấp hơn 80% vì TNR thấp làm số thô bị thổi phồng.
(b) Mẫu số = 0,05 → judge gần như ngẫu nhiên; mọi sai số nhỏ của pobs bị nhân 20 lần. Không hiệu chỉnh — sửa judge trước.
(c) pobs = 0,85·0,60 + 0,10·0,40 = 0,51 + 0,04 = 0,55. Kiểm ngược: (0,55 + 0,90 − 1)/(0,75) = 0,45/0,75 = 0,60 ✓.
5.9Bootstrap CI & quyết định ship
Trực giác. TPR/TNR được đo trên một test set cụ thể. Nếu tình cờ rút được test set khác, TPR/TNR sẽ hơi khác, và θ̂ cũng khác theo. Bootstrap hỏi: "nếu test set của tôi chỉ là một lần rút ngẫu nhiên, thì θ̂ có thể dao động bao nhiêu?" — và trả lời bằng cách giả lập việc rút lại từ chính dữ liệu đang có.
Test set tí hon: 5 Pass (judge đúng 4) và 5 Fail (judge đúng 4) → TPR = TNR = 0,8. Production pobs = 0,70 → θ̂ = (0,70 + 0,8 − 1)/0,6 ≈ 0,833.
- Mẫu lại #1 (rút 10 có hoàn lại): được 6 Pass (judge đúng 5), 4 Fail (đúng 4) → TPR* = 0,833, TNR* = 1,0 → θ* = (0,70 + 1 − 1)/0,833 = 0,84.
- Mẫu lại #2: 4 Pass (đúng 2), 6 Fail (đúng 5) → TPR* = 0,5, TNR* = 0,833 → θ* = 0,533/0,333 = 1,6 → kẹp về 1,0.
- Mẫu lại #3: 5 Pass (đúng 5), 5 Fail (đúng 3) → TPR* = 1,0, TNR* = 0,6 → θ* = 0,30/0,60 = 0,50.
- Chỉ 3 vòng mà θ* đã chạy từ 0,50 tới 1,0 — với 10 nhãn, hầu như không biết gì về θ. Lặp hàng nghìn lần, lấy phân vị 2,5% và 97,5% là ra CI. Đây là lý do sách khuyên 30–50 ca mỗi lớp.
Trong thí nghiệm tư duy của sách (50 Pass + 50 Fail có nhãn, tỉ lệ thật 80%), đường ước lượng đã hiệu chỉnh nằm yên ở 80% dù judge tệ theo kiểu nào; lỗi của judge chủ yếu làm CI rộng ra, còn số thô thì lệch hẳn.
Khi TNR = 1: θ̂ = pobs/TPR. TPR nhỏ → chia cho số nhỏ → dao động nhỏ của TPR* phóng thành dao động lớn của θ*. Muốn CI hẹp: dạy judge nhận ra Pass thật.
Thanh xanh = CI 95% (bootstrap 4.000 lần trên test set) · chấm = θ̂ · vạch vàng đứt = pobs thô · vạch đỏ = ngưỡng ship. Thử hạ "judge đúng trong P" xuống 30 và xem CI phình ra.
Code đầy đủ (tự viết; ý tưởng giống thư viện nhỏ judgy mà tác giả mở mã nguồn, chỉ phụ thuộc numpy). Tham số prod_noise là phần mở rộng của người viết: rút lại cả phía production để tính thêm độ bất định do m hữu hạn.
import numpy as np
def tpr_tnr(y, yhat):
"""y, yhat: 1 = Pass, 0 = Fail. Trả về (TPR, TNR)."""
y, yhat = np.asarray(y), np.asarray(yhat)
return yhat[y == 1].mean(), 1 - yhat[y == 0].mean()
def rogan_gladen(p_obs, tpr, tnr, min_denom=0.05):
d = tpr + tnr - 1
if d < min_denom:
return np.nan # judge ~ tung đồng xu: không hiệu chỉnh
return float(np.clip((p_obs + tnr - 1) / d, 0, 1))
def corrected_rate(y, yhat, k, m, B=10_000, seed=0, prod_noise=False):
"""Ước lượng tỉ lệ Pass thật + CI 95% bootstrap.
y, yhat: nhãn người & judge trên TEST. k/m: judge nói Pass k trên m trace production.
prod_noise=True: resample thêm phía production (mở rộng, không có trong sách)."""
rng = np.random.default_rng(seed)
y, yhat = np.asarray(y), np.asarray(yhat)
p_obs = k / m
theta_hat = rogan_gladen(p_obs, *tpr_tnr(y, yhat))
thetas = []
for _ in range(B):
idx = rng.integers(0, len(y), len(y)) # resample CẶP (nhãn, dự đoán)
yy, pp = y[idx], yhat[idx]
if yy.all() or not yy.any():
continue # mẫu thiếu hẳn một lớp
po = rng.binomial(m, p_obs) / m if prod_noise else p_obs
t = rogan_gladen(po, *tpr_tnr(yy, pp))
if not np.isnan(t):
thetas.append(t)
lo, hi = np.percentile(thetas, [2.5, 97.5])
return theta_hat, lo, hi
def make_labels(P, tp, F, tn):
"""Dựng lại vector nhãn từ confusion matrix (tiện cho ví dụ)."""
y = [1] * P + [0] * F
yhat = [1] * tp + [0] * (P - tp) + [0] * tn + [1] * (F - tn)
return y, yhat
if __name__ == "__main__":
y, yhat = make_labels(P=50, tp=45, F=50, tn=40)
print("TPR=%.2f TNR=%.2f" % tpr_tnr(y, yhat)) # TPR=0.90 TNR=0.80
print("θ̂=%.3f CI95=[%.3f, %.3f]" % corrected_rate(y, yhat, k=750, m=1000))
# θ̂=0.786 CI95≈[0.69, 0.90] (số chính xác tuỳ seed)
Cố định TPR = 0,90, TNR = 0,80, pobs = 0,75 (θ̂ ≈ 0,786). Thay đổi số ca mỗi lớp trong test (n) và số trace production (m):
| n mỗi lớp (m = 1.000) | CI 95% | Độ rộng |
|---|---|---|
| 25 | [0,67; 1,00] | 0,34 |
| 50 | [0,69; 0,91] | 0,22 |
| 100 | [0,71; 0,87] | 0,16 |
| 200 | [0,73; 0,85] | 0,13 |
| 400 | [0,73; 0,84] | 0,10 |
| m production (n = 50) | CI 95% | Độ rộng |
|---|---|---|
| 200 | [0,66; 0,93] | 0,27 |
| 1.000 | [0,69; 0,91] | 0,22 |
| 10.000 | [0,69; 0,90] | 0,21 |
- Nhân đôi n (25 → 50) thu hẹp CI rõ rệt; từ 200 → 400 thì lợi ích giảm dần (độ rộng tỉ lệ khoảng 1/√n).
- Tăng m từ 1.000 lên 10.000 gần như không đổi gì: nút thắt là nhãn test, không phải số trace production. Chạy judge trên thêm 9.000 trace tốn tiền mà không mua được độ chắc chắn.
- Cùng thí nghiệm, so hai judge "tệ một nửa" với θ = 0,80, n = 50: TPR = 0,60/TNR = 0,96 cho CI rộng ~0,36; TPR = 0,96/TNR = 0,60 cho CI rộng ~0,20. Khớp với nhận xét của sách: TPR thấp làm CI phình mạnh hơn.
Giả lập 1.500 "thế giới song song": mỗi thế giới rút một test set mới (n Pass + n Fail) và một mẻ production mới (m trace) từ cùng một sự thật θ, rồi tính θ̂. Histogram cho thấy θ̂ dao động bao nhiêu — đây chính là thứ bootstrap cố ước lượng từ một test set duy nhất. Cột xanh lá = θ thật; cột đậm = 95% giữa.
Đọc CI để quyết định ship
Quy tắc ngón tay cái
Độ rộng của CI, không chỉ điểm ước lượng, quyết định bạn đã đủ bằng chứng để hành động hay chưa. So sánh cận dưới với ngưỡng, và cân nhắc cái giá của việc sai: lỗi gây hại → đòi cận dưới qua ngưỡng; lỗi rẻ, dễ rollback → có thể ship theo điểm ước lượng rồi theo dõi."CI 95% nghĩa là 95% xác suất θ nằm trong khoảng này." Chính xác hơn: nếu lặp lại toàn bộ quy trình (gán test mới, tính CI) nhiều lần, khoảng 95% các CI tạo ra sẽ chứa θ thật. Trong thực hành ra quyết định, đọc nó như "vùng giá trị hợp lý" là đủ — miễn là đừng coi hai đầu mút là tuyệt đối.
"Chạy judge trên nhiều trace production hơn sẽ thu hẹp CI." Như Ví dụ 5.9b cho thấy, sau vài trăm tới một nghìn trace thì gần như không. Muốn hẹp CI: thêm nhãn test (nhất là lớp khan hiếm) hoặc làm judge chính xác hơn (nhất là TPR).
"Cận trên bị kẹp ở 1,00 là lỗi code." Không — khi θ gần 1 và test nhỏ, nhiều mẫu bootstrap cho θ* > 1 và bị kẹp. Nó báo hiệu "chưa đủ dữ liệu để loại trừ khả năng gần hoàn hảo", không phải bug.
θ̂ = 0,90, CI [0,78; 0,97], ngưỡng 0,85, lỗi gây hại trực tiếp cho người dùng. (a) Ship không? (b) Bạn có ngân sách gán nhãn thêm 100 trace. Phân bổ thế nào? (c) Nếu thay vào đó bạn cải thiện được judge, nên ưu tiên TPR hay TNR?
Xem lời giải
(a) Chưa ship rộng — cận dưới 0,78 dưới ngưỡng và lỗi đắt. Có thể canary cho một phần nhỏ traffic nếu có cơ chế rollback.
(b) Thêm vào test set (không phải dev), cân bằng hai lớp; nếu lớp Fail đang ít hơn thì ưu tiên Fail. Đừng tiêu ngân sách vào việc chạy judge trên thêm trace production.
(c) Ưu tiên TPR: TPR thấp làm mẫu số của công thức nhỏ và dao động mạnh, nên thu hẹp CI nhiều nhất khi được cải thiện. (Vẫn phải giữ TNR đủ cao nếu false pass nguy hiểm.)
5.10Case study đầu-cuối: judge "hứa quyền lợi vượt chính sách"
Bối cảnh. Bot trả lời khách về đơn hàng, đổi trả, hoàn tiền. System prompt có sẵn đoạn chính sách (đổi trả trong 7 ngày nếu lỗi nhà sản xuất, hoàn tiền 3–5 ngày làm việc, không bồi thường phí vận chuyển...) và quy tắc "không hứa bất kỳ quyền lợi nào ngoài chính sách". Error analysis tuần trước: khoảng 1/10 hội thoại có nhắc quyền lợi chứa một lời hứa vượt chính sách. Mục tiêu: trước khi mở bot cho toàn bộ kênh chat, chứng minh tỉ lệ hội thoại không hứa bịa ≥ 0,90 với độ tin cậy hợp lý.
Bước 1 · Phân loại lỗi
Prompt đã nói rõ → Generalization. Lời hứa có thể gián tiếp ("chắc chắn bên em sẽ hỗ trợ hoàn tiền ạ"), nên regex không đủ → LLM judge. Nhưng dùng code làm cổng: hội thoại không chứa từ khoá quyền lợi nào (hoàn, đổi, trả, bồi thường, miễn phí, voucher...) thì không thể hứa quyền lợi → tự động Pass. Chỉ ~30% hội thoại đi qua judge.
Bước 2 · Gán nhãn & chia dữ liệu
Một chuyên gia CSKH lâu năm gán nhãn 320 hội thoại đã qua cổng (lọc có chủ đích để có đủ Fail): 96 Fail. Lấy cân bằng 92 Pass + 92 Fail:
| Tập | Pass | Fail | Dùng để |
|---|---|---|---|
| Train | 12 | 12 | chọn few-shot |
| Dev | 40 | 40 | lặp prompt |
| Test | 40 | 40 | niêm phong |
Bước 3 · Prompt v1 → v3 trên dev
[v1]
Bạn chấm câu trả lời của bot CSKH. Tiêu chí duy nhất: bot có cam kết quyền lợi
không có trong chính sách dưới đây không?
FAIL: có cam kết ngoài chính sách. PASS: không có.
[2 ví dụ từ train]
Trả về JSON {"reasoning": "...", "answer": "Pass"|"Fail"}
Chính sách: {policy} Hội thoại: {conversation}
Dev: TPR = 39/40 = 0,975, TNR = 23/40 = 0,575. Đọc 17 false pass: judge chỉ coi là "cam kết" khi có từ "cam kết/đảm bảo"; bỏ qua lời hứa mềm ("chắc được hoàn ạ", "em sẽ xin cho mình miễn phí ship").
[v2 — thêm vào v1]
"Lời hứa" gồm cả khẳng định mềm hoặc gián tiếp khiến khách tin rằng mình SẼ nhận
được quyền lợi (ví dụ: "chắc chắn được", "em sẽ xin cho anh", "yên tâm là được hoàn").
[+2 ví dụ Fail lời hứa mềm, +1 ví dụ Pass từ chối lịch sự]
Dev: TPR = 37/40 = 0,925, TNR = 33/40 = 0,825. Ca sai mới: 3 false fail — bot trích đúng điều kiện ("nếu lỗi nhà sản xuất trong 7 ngày thì được đổi"), judge vẫn đánh Fail vì thấy chữ "được đổi". 7 false pass — hứa về thời gian ("tiền về trong 24h" trong khi chính sách là 3–5 ngày).
[v3 — thêm vào v2]
Nhắc lại đúng một điều kiện có trong chính sách (kèm điều kiện) là PASS.
Cam kết về THỜI GIAN hoặc SỐ TIỀN khác với chính sách cũng là lời hứa vượt chính sách.
Trong reasoning, trích nguyên văn lời hứa và dòng chính sách liên quan (nếu có).
[+1 ví dụ Pass trích điều kiện, +1 ví dụ Fail hứa thời gian]
Dev: TPR = 37/40 = 0,925, TNR = 37/40 = 0,925. Cả hai > 0,9 → chốt v3.
Bước 4 · Đo trên test (đúng một lần)
Test: TPR = 36/40 = 0,90, TNR = 36/40 = 0,90. Thấp hơn dev một chút — bình thường, vì prompt đã được "mài" theo dev.
Bước 5 · Ước lượng production
- Một tuần: 5.000 hội thoại qua cổng code được chấm; judge nói Pass 4.260 → pobs = 0,852.
- Hiệu chỉnh: θ̂ = (0,852 + 0,90 − 1)/(0,90 + 0,90 − 1) = 0,752/0,80 = 0,94.
- Bootstrap 10.000 lần trên 80 cặp test: CI 95% ≈ [0,85; 1,00].
- Cận dưới 0,85 < ngưỡng 0,90 → chưa đủ bằng chứng, dù điểm ước lượng 0,94 trông rất ổn.
Bước 6 · Làm hẹp CI
| Phương án (mô phỏng) | TPR / TNR test | θ̂ | CI 95% | Qua ngưỡng? |
|---|---|---|---|---|
| Giữ v3, test 40/40 | 0,90 / 0,90 | 0,94 | [0,85; 1,00] | Không |
| Giữ v3, gán thêm để test 80/80 | 0,90 / 0,90 | 0,94 | [0,87; 1,00] | Không |
| v4 (TPR cao hơn) + test mới 80/80 | 0,9625 / 0,95 | 0,94 | [0,90; 0,99] | Có (cận dưới ≈ 0,904) |
v4 được làm thế nào mà không làm bẩn test? Nhóm chuyển test cũ vào dev (dev thành 80/80), đọc 4 false fail trên đó (bot xin lỗi kèm "bên em sẽ kiểm tra và phản hồi" bị coi là lời hứa → thêm định nghĩa "cam kết xử lý ≠ cam kết quyền lợi"), rồi gán một test set hoàn toàn mới 80/80. Chạy lại v4 trên production: pobs = 0,908 → θ̂ = 0,94 (điểm ước lượng không đổi — hiệu chỉnh vốn không chệch), nhưng CI hẹp đủ để cận dưới qua ngưỡng.
Bước 7 · Quyết định & vận hành
- Ship cho toàn bộ kênh chat. Tỉ lệ toàn hệ thống (kể cả 70% hội thoại tự Pass ở cổng code) ≈ 0,7·1 + 0,3·0,94 ≈ 0,98 — nhưng báo cáo chính vẫn là 0,94 trên nhóm có nhắc quyền lợi, vì đó là nơi rủi ro nằm.
- Mỗi tuần: gán 20 hội thoại production mới (10 judge Pass, 10 judge Fail) để kiểm tra TPR/TNR có trôi không; chạy 5 lần judge trên 20 ca để đo độ ổn định.
- Khi chính sách đổi (ví dụ thêm "miễn phí đổi size"), cập nhật prompt judge và gán lại một phần test — nhãn cũ có thể không còn đúng.
Bài học rút ra
(1) Điểm ước lượng tốt chưa đủ; cận dưới mới là thứ quyết định. (2) Gán thêm nhãn với cùng judge chỉ giúp chậm; tăng TPR giúp nhanh hơn. (3) Muốn sửa judge sau khi đã nhìn test → test cũ thành dev, gán test mới. (4) Cổng code rẻ giúp judge chỉ chạy nơi có rủi ro — nhưng phải báo cáo rõ tỉ lệ là trên tập nào.Trong case study, giả sử cổng code bị lỗi: 5% hội thoại có hứa quyền lợi dùng từ lóng ("refund", "back tiền") nên không qua cổng và bị tự Pass. Điều này ảnh hưởng thế nào tới con số 0,98 toàn hệ thống? Làm sao phát hiện?
Xem lời giải
Con số 0,98 bị thổi phồng: một phần hội thoại "tự Pass" thật ra thuộc nhóm rủi ro, và trong đó có ca hứa bịa. Cổng code cũng là một classifier — cần đo nó. Cách phát hiện: lấy mẫu ngẫu nhiên vài chục hội thoại không qua cổng, cho chuyên gia đọc (hoặc chạy judge) để ước lượng tỉ lệ bỏ lọt; mở rộng từ khoá (thêm "refund", "back tiền", "trả lại tiền"...); định kỳ lặp lại vì ngôn ngữ khách hàng thay đổi.
5.11(Tuỳ chọn) Metric cho nhóm output
Khi agent sinh nhiều ứng viên cho một input (nhiều completion, nhiều tài liệu truy xuất, nhiều phương án), sách gợi ý một nhóm metric riêng:
| Metric | Đo gì | Ví dụ |
|---|---|---|
| Success@k (Pass@k) | % bài toán có ít nhất một trong top k output đúng | 5 bản code/bài; Pass@5 = % bài có ≥ 1 bản qua hết test |
| Precision@k | Trong top k, tỉ lệ đúng | 5 API call đề xuất, 3 hợp lệ → 0,6 |
| Recall@k | Top k phủ được bao nhiêu trong tổng số đáp án đúng | 3 trong 10 đáp án đúng lọt top 3 → 0,3 |
| Cosine similarity | Gần nghĩa với đáp án/ý định dù khác câu chữ (qua embedding) | So câu trả lời với đáp án mẫu |
| Avg pairwise similarity | Mức trùng lặp giữa các output — thấp = đa dạng | Gợi ý hằng ngày: chỉ so hôm nay với 7 ngày gần nhất |
- Cho model viết code cho một bài, sinh n = 10 mẫu, c = 3 mẫu qua hết unit test.
- Pass@1 = xác suất một mẫu ngẫu nhiên đúng = 3/10 = 0,30.
- Pass@5 = 1 − P(chọn 5 mẫu mà không trúng mẫu đúng nào) = 1 − C(7,5)/C(10,5) = 1 − 21/252 ≈ 0,917.
- Cách "ngây thơ" — sinh đúng 5 mẫu rồi xem có mẫu nào đúng — cho kết quả 0 hoặc 1 mỗi bài, rất nhiễu. Sinh n > k rồi dùng công thức tổ hợp (ước lượng không chệch, phổ biến từ bài báo Codex 2021) ổn định hơn nhiều. Hàm
pass_at_ktrong code mục 5.6 làm đúng việc này. - Đa dạng: một app gợi ý thực đơn có độ tương đồng trung bình giữa gợi ý hôm nay và 7 ngày trước là 0,82 (cosine) — người dùng sẽ thấy "ngày nào cũng như nhau". Mục tiêu có thể là < 0,6. Lưu ý: chỉ so các cặp bạn quan tâm (hôm nay vs 7 ngày gần nhất); lấy trung bình mọi cặp trong lịch sử sẽ làm loãng tín hiệu.
Pass@k vs "đúng cả k lần". Pass@k thưởng cho việc có ít nhất một lần đúng — hợp khi có bước chọn lọc phía sau (unit test, người dùng chọn). Với agent phục vụ trực tiếp, bạn thường quan tâm điều ngược lại: chạy k lần thì lần nào cũng đúng (độ tin cậy). Một số benchmark agent gọi đó là pass^k (ghi chú của người viết, không nằm trong chương này).
n = 20 mẫu, c = 2 mẫu đúng. Tính Pass@1 và Pass@10.
Xem lời giải
Pass@1 = 2/20 = 0,10. Pass@10 = 1 − C(18,10)/C(20,10) = 1 − 43.758/184.756 ≈ 1 − 0,237 = 0,763. (Kiểm bằng pass_at_k(20, 2, 10).)
5.12Vận hành: judge cũng cần bảo trì
Judge không phải "xây một lần dùng mãi". Dữ liệu người dùng trôi, model của app được nâng cấp, chính model judge được nhà cung cấp cập nhật, và tiêu chí của sản phẩm thay đổi. Sách khuyên căn chỉnh lại định kỳ (ví dụ hàng tuần): gán thêm vài trace, tính lại TPR/TNR và CI; lỗi mới phát hiện thì bổ sung vào cả ba tập. Judge còn là một hệ ngẫu nhiên: chạy 3–5 lần trên cùng trace — lý lẽ đổi mà nhãn giữ thì ổn; nhãn lật là dấu hiệu tiêu chí còn mơ hồ ở loại ca đó.
| Tuần | Nhãn mới | TPR / TNR trên nhãn mới | Flip rate (5 lần chạy) | Hành động |
|---|---|---|---|---|
| 1 | 20 | 10/10 · 9/10 | 4% | Không làm gì |
| 2 | 20 | 9/10 · 9/10 | 6% | Không làm gì |
| 3 (app đổi model) | 20 | 10/10 · 6/10 | 18% | Cảnh báo: TNR tụt, judge lật nhiều |
| 3b | +40 (20 Pass, 20 Fail) | 19/20 · 14/20 | — | Đọc false pass: model mới viết dài, lời hứa chôn trong đoạn cuối → thêm few-shot, căn chỉnh lại, đo lại trên test mới |
20 nhãn/tuần quá ít để đo chính xác TPR/TNR, nhưng đủ làm chuông báo: 4/10 false pass trong một tuần là tín hiệu để gán thêm và điều tra.
Hàm đo độ ổn định (trích từ judge_probes.py ở mục 5.4) — chạy nó hàng tuần trên ~20 ca, theo dõi flip_rate trung bình:
def stability(call_llm, prompt, n=5):
"""Chạy judge n lần (temperature > 0). flip_rate > 0 = tiêu chí còn mơ hồ với ca này."""
votes = Counter(parse_verdict(call_llm(prompt)) for _ in range(n))
top, cnt = votes.most_common(1)[0]
return {"votes": dict(votes), "majority": top, "flip_rate": 1 - cnt / n}
Nhà cung cấp thông báo sẽ ngừng model đang dùng làm judge; bạn phải chuyển sang model mới. Liệt kê các bước tối thiểu trước khi tin con số θ̂ từ judge mới.
Xem lời giải
- Chạy judge mới (cùng prompt) trên dev → so TPR/TNR với judge cũ; đọc ca sai; tinh chỉnh nếu cần (vẫn chỉ trên dev).
- Đo TPR/TNR trên test (nếu đã tinh chỉnh theo test cũ thì gán test mới).
- Chạy song song judge cũ và mới trên cùng mẻ production một thời gian; so θ̂ đã hiệu chỉnh của hai judge — chúng nên nằm trong CI của nhau.
- Đo độ ổn định (flip rate) của judge mới.
- Ghi lại version judge cùng mọi con số báo cáo, để biết một thay đổi θ̂ đến từ app hay từ thước đo.
5.13Cạm bẫy thường gặp
- Prompt judge không có ví dụ. Lỗi phổ biến nhất: judge thiếu neo về thế nào là Fail trong bối cảnh của bạn → chấm lỏng lẻo.
- Một judge ôm nhiều tiêu chí. Gộp giọng văn + nội dung + bước tiếp theo vào một Pass/Fail → mơ hồ, khó chẩn đoán. Tách thành nhiều judge hẹp.
- Bỏ qua căn chỉnh. Judge "chạy luôn" với tiêu chí phổ thông (cảm xúc tích cực/tiêu cực), nhưng tiêu chí mang "vibe" riêng của sản phẩm phải được dạy.
- Rò rỉ dữ liệu. Trace dùng làm few-shot lại nằm trong tập đo, hoặc sửa prompt theo kết quả test → TPR/TNR đẹp giả.
- Báo accuracy thay vì TPR + TNR. Che mất judge đang lệch về phía nào.
- Báo số thô pobs như tỉ lệ thành công. Số thô bị méo theo lỗi của judge; hãy báo θ̂ đã hiệu chỉnh kèm CI.
- So hai hệ thống bằng điểm ước lượng. 0,86 vs 0,88 với CI rộng ±0,08 là "chưa phân biệt được", không phải "B tốt hơn".
- Căn chỉnh một lần rồi thôi. Dữ liệu drift, model update, tiêu chí đổi. Gán thêm vài trace và tính lại TPR/TNR, CI định kỳ; lỗi mới → bổ sung vào cả ba tập.
- Quên judge cũng ngẫu nhiên. Chạy 3–5 lần cùng trace: lý lẽ đổi mà nhãn giữ → ổn; nhãn lật → tiêu chí còn mơ hồ.
5.14Áp dụng cho dự án model-routing
Bối cảnh: bạn xây một router gửi mỗi request LLM tới model phù hợp — model nhỏ/rẻ cho request dễ, model lớn/đắt cho request khó — để cân bằng chất lượng, chi phí và latency. Câu hỏi eval thật sự không phải "model X giỏi không?" mà là: "chính sách routing P có giữ chất lượng chấp nhận được trong khi rẻ/nhanh hơn không — và tôi chắc chắn tới mức nào?" Mọi công cụ của chương này đều dùng được, với vài điều chỉnh riêng.
A · Đo cái gì, bằng gì
| Failure mode của hệ routing | Loại | Evaluator |
|---|---|---|
| Output model nhỏ không đúng JSON schema mà client yêu cầu | Generalization | Code: parse + validate schema |
| Vượt SLA latency (p95 > 3 s) · chi phí/request | — | Code: đọc từ log, không cần judge |
| Model nhỏ từ chối oan request hợp lệ | Generalization | Code (regex cụm từ chối) để lọc + judge hẹp để xác nhận |
| Câu trả lời của model được route tới không chấp nhận được cho request | Generalization | LLM judge nhị phân — metric chính |
| Router gửi request viết code tới model nhỏ dù config chưa có luật cho loại "code" | Specification | Sửa config/luật router, chưa cần đo |
B · Judge "chấp nhận được" — tuyệt đối hay tham chiếu?
"Câu trả lời này có giải quyết đúng yêu cầu chính, không sai sự thật quan trọng?" Chạy được trên mọi request, không cần gọi model lớn. Khó hơn cho judge ở câu hỏi chuyên môn.
Đưa kèm câu trả lời của model lớn làm tham chiếu: "câu trả lời của model nhỏ có chấp nhận được so với tham chiếu này?" Dễ căn chỉnh hơn, nhưng tốn thêm một lời gọi model lớn (chỉ làm trên mẫu) và thừa hưởng lỗi của model lớn.
Prompt tự viết cho judge có tham chiếu (tiêu chí hẹp, chặn verbosity và "phong cách khác = sai"):
Bạn đánh giá câu trả lời ỨNG VIÊN cho một yêu cầu của người dùng.
Tiêu chí DUY NHẤT: ứng viên có CHẤP NHẬN ĐƯỢC để gửi cho người dùng không.
PASS: giải quyết đúng yêu cầu chính; không có lỗi sự thật/logic làm hỏng kết quả;
nếu yêu cầu có ràng buộc (định dạng, độ dài, ngôn ngữ) thì tuân thủ.
FAIL: sai hoặc thiếu phần chính của yêu cầu; có lỗi làm người dùng hành động sai;
từ chối một yêu cầu hợp lệ.
Câu trả lời THAM CHIẾU chỉ để giúp bạn hiểu yêu cầu — ứng viên KHÔNG cần giống tham chiếu.
Ngắn hơn, khác cách diễn đạt, khác cách giải nhưng đúng → vẫn PASS.
Nếu tham chiếu sai mà ứng viên đúng → PASS.
[3–4 ví dụ từ train: 1 Pass ngắn gọn, 1 Fail dài-mà-sai, 1 Fail từ chối oan, 1 Pass khác cách giải]
Trả về JSON {"reasoning": "<1-2 câu, chỉ ra lỗi cụ thể nếu Fail>", "answer": "Pass"|"Fail"}
Yêu cầu: {request}
Tham chiếu: {reference}
Ứng viên: {candidate}
C · Hiệu chỉnh theo từng model (stratified correction)
Chương gốc giả định một cặp TPR/TNR cho toàn bộ dữ liệu. Với routing, output của model nhỏ và model lớn khác nhau về phong cách: model lớn thường dài, trau chuốt → judge dễ bị verbosity bias → TNR trên output model lớn có thể thấp hơn. Vì vậy: test set riêng cho từng model (mỗi model ~50 Pass + 50 Fail), hiệu chỉnh θ̂ riêng cho từng model, rồi gộp theo tỉ lệ traffic: θ̂policy = Σ wmodel · θ̂model.
Judge đo trên test: với output S: TPR = 46/50 = 0,92, TNR = 44/50 = 0,88. Với output L: TPR = 48/50 = 0,96, TNR = 39/50 = 0,78 (judge dễ dãi với câu trả lời dài). Chi phí giả định: S = 0,40 USD, L = 6,00 USD cho 1.000 request.
- Canary A (10.000 request): S nhận 4.000, judge Pass 3.520 (pobs = 0,880); L nhận 6.000, judge Pass 5.449 (pobs = 0,908).
- Hiệu chỉnh riêng: θ̂S|A = (0,880 + 0,88 − 1)/(0,92 + 0,88 − 1) = 0,76/0,80 = 0,95. θ̂L|A = (0,908 + 0,78 − 1)/(0,96 + 0,78 − 1) ≈ 0,688/0,74 ≈ 0,93. Gộp: θ̂A = 0,4·0,95 + 0,6·0,93 = 0,938.
- Canary B (10.000 request): S nhận 7.000, judge Pass 5.824 (pobs = 0,832); L nhận 3.000, judge Pass 2.702 (pobs ≈ 0,901). θ̂S|B ≈ 0,89 (S giờ nhận cả request khó hơn), θ̂L|B ≈ 0,92. Gộp: θ̂B = 0,7·0,89 + 0,3·0,92 = 0,899.
- So sánh: Δ = θ̂B − θ̂A ≈ −0,039. Paired bootstrap (resample test set của từng model một lần mỗi vòng, dùng chung cho A và B; rút lại cả phía production): CI 95% của Δ ≈ [−0,071; +0,011].
- Chi phí: A = 0,4·0,40 + 0,6·6,00 = 3,76 USD/1k; B = 0,7·0,40 + 0,3·6,00 = 2,08 USD/1k → B rẻ hơn ~45%.
- Quyết định (non-inferiority): nhóm chấp nhận tụt tối đa 3 điểm (biên −0,03). Cận dưới −0,071 < −0,03 → chưa chứng minh được B "không tệ hơn đáng kể". Lưu ý: CI chứa 0 không có nghĩa là "hai policy như nhau" — nó chỉ nói chưa đủ bằng chứng theo cả hai hướng.
- Hướng tiếp: thử policy trung gian (55/45), hoặc cải thiện bộ phân loại độ khó của router để S chỉ nhận thêm request thật sự dễ; và tăng test set cho judge để CI hẹp lại. Nếu chỉ nhìn số thô (A: 0,897 vs B: 0,853), bạn sẽ ước lượng mức tụt là −0,044 — lệch so với con số đã hiệu chỉnh.
Code so sánh hai policy (tự viết, dùng lại judge_stats.py ở mục 5.9):
import numpy as np
from judge_stats import rogan_gladen, make_labels
def rates(y, yhat):
return yhat[y == 1].mean(), 1 - yhat[y == 0].mean()
def policy_theta(policy, judge_rates, p_obs_override=None):
"""policy: {model: (k, m)}. Trọng số = tỉ lệ traffic thật của từng model."""
total = sum(m for _, m in policy.values())
theta = 0.0
for model, (k, m) in policy.items():
p_obs = (p_obs_override or {}).get(model, k / m)
t = rogan_gladen(p_obs, *judge_rates[model])
if np.isnan(t):
return np.nan
theta += (m / total) * t
return theta
def compare_policies(test, pol_a, pol_b, B=5000, seed=1):
"""test: {model: (y, yhat)} — judge được hiệu chỉnh RIÊNG cho output của từng model.
Trả về θ_A, θ_B, Δ = θ_B − θ_A và CI 95% của Δ (paired bootstrap)."""
rng = np.random.default_rng(seed)
test = {mdl: (np.asarray(y), np.asarray(p)) for mdl, (y, p) in test.items()}
base = {mdl: rates(y, p) for mdl, (y, p) in test.items()}
th_a, th_b = policy_theta(pol_a, base), policy_theta(pol_b, base)
deltas = []
for _ in range(B):
jr = {}
for mdl, (y, p) in test.items(): # resample test MỘT lần, dùng chung cho A và B
idx = rng.integers(0, len(y), len(y))
if y[idx].all() or not y[idx].any():
break
jr[mdl] = rates(y[idx], p[idx])
else:
noise = lambda pol: {mdl: rng.binomial(m, k / m) / m for mdl, (k, m) in pol.items()}
a = policy_theta(pol_a, jr, noise(pol_a))
b = policy_theta(pol_b, jr, noise(pol_b))
if not (np.isnan(a) or np.isnan(b)):
deltas.append(b - a)
lo, hi = np.percentile(deltas, [2.5, 97.5])
return th_a, th_b, th_b - th_a, (lo, hi)
if __name__ == "__main__":
test = {"small": make_labels(50, 46, 50, 44), # TPR .92, TNR .88
"large": make_labels(50, 48, 50, 39)} # TPR .96, TNR .78 (verbosity!)
policy_a = {"small": (3520, 4000), "large": (5449, 6000)} # 40% / 60% traffic
policy_b = {"small": (5824, 7000), "large": (2702, 3000)} # 70% / 30% traffic
th_a, th_b, d, (lo, hi) = compare_policies(test, policy_a, policy_b)
print(f"θ_A={th_a:.3f} θ_B={th_b:.3f} Δ={d:+.3f} CI95=[{lo:+.3f}, {hi:+.3f}]")
margin = -0.03 # chấp nhận tụt tối đa 3 điểm
print("non-inferior" if lo > margin else "CHƯA chứng minh được không-tệ-hơn")
D · Bẫy riêng của bài toán routing
- Selection bias của chính router. Model S chỉ thấy request "dễ" mà router gửi tới. θ̂S dưới policy A (0,95) không cho biết S làm tốt thế nào nếu nhận thêm request (0,89 dưới B). Muốn ước lượng một policy mới trước khi triển khai: giữ một lát ngẫu nhiên (ví dụ 5% request route ngẫu nhiên), hoặc shadow — gửi song song một mẫu request tới cả hai model và chấm cả hai.
- Judge nằm trong pool. Nếu judge cùng họ với L, self-enhancement làm θ̂L đẹp giả. Chọn judge ngoài pool, hoặc đo TPR/TNR riêng cho từng model để phát hiện chênh lệch.
- Một TPR/TNR chung cho mọi model. Bấm nút "Giả sử judge như nhau" trong LAB 5 để thấy θ̂ thay đổi thế nào khi bỏ qua việc judge dễ dãi hơn với L.
- Thêm model mới vào pool mà không đo lại judge. Output của model mới có thể rơi vào vùng judge chưa được căn chỉnh — cần test set cho model đó.
- Tối ưu một chiều. Báo cáo luôn đi thành bộ ba: θ̂ + CI (chất lượng), chi phí/1k, latency p95. Một policy "rẻ hơn 45%" chỉ có ý nghĩa khi đặt cạnh CI của mức tụt chất lượng.
(a) Với số liệu Ví dụ 5.14, nếu bạn (sai lầm) dùng chung TPR = 0,94, TNR = 0,83 cho cả S và L, θ̂L|A sẽ là bao nhiêu? Lệch theo hướng nào so với 0,93? (b) Thiết kế thí nghiệm để ước lượng chất lượng của policy C (55% S / 45% L) mà không cần chạy canary C trên người dùng thật.
Xem lời giải
(a) θ̂L|A = (0,908 + 0,83 − 1)/(0,94 + 0,83 − 1) = 0,738/0,77 ≈ 0,958 — cao hơn 0,93, vì TNR chung (0,83) "tin" judge bắt lỗi trên L tốt hơn thực tế (0,78), nên trừ bù quá ít cho các Fail lọt qua. Bạn sẽ đánh giá quá cao model đắt.
(b) Lấy mẫu ngẫu nhiên vài nghìn request production; với mỗi request, ghi điểm độ khó mà router tính ra; gọi cả S và L (shadow, không trả về người dùng) và chấm cả hai bằng judge. Khi đó bạn có thể "phát lại" bất kỳ ngưỡng routing nào: policy C = gửi 55% request dễ nhất cho S. Tính θ̂ theo từng model (hiệu chỉnh riêng) trên đúng tập request mà C sẽ gửi tới model đó, gộp theo traffic, bootstrap để lấy CI. Chi phí: gấp đôi lời gọi model trên mẫu — rẻ hơn nhiều so với rủi ro canary.
E · Chính router đúng sai thế nào: route thừa & route thiếu
Ngoài "policy tốt tới đâu", bạn còn muốn biết router sai ở đâu để sửa bộ phân loại độ khó. Dùng dữ liệu shadow (cả S và L cùng trả lời một mẫu request, judge chấm cả hai), mỗi request rơi vào một trong bốn ô — giống confusion matrix, nhưng lần này người bị chấm là router:
| S chấp nhận được | S không chấp nhận được | |
|---|---|---|
| Router gửi S | Đúng: rẻ và đủ tốt | Route thiếu: mất chất lượng |
| Router gửi L | Route thừa: tốn tiền vô ích | Đúng: cần model lớn |
- Router (policy A) gửi 800 request cho S, 1.200 cho L. Với shadow, S cũng trả lời 1.200 request gửi L; judge chấm tất cả output của S (dùng TPR/TNR của judge trên output S: 0,92/0,88).
- Nhóm gửi S: judge nói Pass 704/800 → pobs = 0,88 → θ̂ = (0,88 + 0,88 − 1)/0,80 = 0,95. Route thiếu ≈ 5% × 800 ≈ 40 request.
- Nhóm gửi L (S chạy shadow): judge nói Pass cho output S ở 780/1.200 → pobs = 0,65 → θ̂ = (0,65 + 0,88 − 1)/0,80 ≈ 0,66. Tức là khoảng 66% × 1.200 ≈ 795 request đã có thể gửi S mà vẫn chấp nhận được — route thừa.
- Giá của route thừa: 795 × (6,00 − 0,40)/1.000 ≈ 4,45 USD trên 2.000 request, ≈ 2.200 USD cho mỗi triệu request (giá giả định).
- Đọc: router đang quá thận trọng — route thiếu ít (40), route thừa nhiều (795). Nhưng 795 là con số trung bình; câu hỏi thật là router có nhận ra được nhóm request nào trong 1.200 là dễ không. Chia nhóm theo điểm độ khó của router, tính θ̂ của S trên từng nhóm (kèm CI), rồi hạ ngưỡng đúng ở những nhóm mà cận dưới của θ̂S vẫn qua ngưỡng chất lượng.
Trong nhóm 1.200 request router gửi L, bạn chia theo điểm độ khó: nhóm "vừa" (500 request, judge Pass output S 455 ca) và nhóm "khó" (700 request, judge Pass 325 ca). Dùng TPR = 0,92, TNR = 0,88. Nhóm nào nên chuyển sang S nếu ngưỡng chất lượng là 0,90 (tạm bỏ qua CI)?
Xem lời giải
Nhóm vừa: pobs = 0,91 → θ̂ = (0,91 + 0,88 − 1)/0,80 = 0,79/0,80 ≈ 0,99 → chuyển sang S (tiết kiệm 500 × 5,6/1.000 = 2,8 USD mỗi 2.000 request). Nhóm khó: pobs ≈ 0,464 → θ̂ = (0,464 + 0,88 − 1)/0,80 ≈ 0,43 → giữ ở L. Kiểm tra: 500·0,9875 + 700·0,430 ≈ 494 + 301 = 795, khớp ví dụ trên ✓. Trước khi quyết thật, tính CI cho θ̂ nhóm vừa (500 request, cộng độ bất định TPR/TNR) và so cận dưới với 0,90.
Checklist cho dự án routing của bạn
- Khách quan → code: schema, latency, cost, từ chối (lọc). Chất lượng → judge nhị phân "chấp nhận được", few-shot chỉ từ train.
- Judge ngoài pool routing; test set 50/50 cho mỗi model trong pool; hiệu chỉnh θ̂ theo model rồi gộp theo traffic.
- Báo cáo mỗi policy: θ̂ + CI 95%, chi phí/1k, latency p95. So hai policy bằng Δ + CI (paired bootstrap) và một biên non-inferiority định trước.
- Giữ lát ngẫu nhiên hoặc shadow để tránh selection bias; đo lại judge mỗi khi pool đổi.
5.15Quy trình tổng kết & nối với các chương khác
- Mỗi failure mode → một metric tự độngChỉ cho lỗi Generalization còn sót sau khi đã sửa prompt (taxonomy từ Chương 3).
- Tiêu chí chính xác → codeSo AST của SQL, regex trường bắt buộc, chạy thử tool call. Nhớ kiểm cả code check trên vài chục nhãn.
- Tiêu chí cần phán đoán → LLM-as-judgeGiọng văn, độ đầy đủ, mức liên quan — nhị phân, một tiêu chí mỗi judge, 4 thành phần prompt.
- Kiểm định judgeNhãn gold (Chương 4) → train/dev/test; lặp trên dev; TPR/TNR trên test đúng một lần.
- Báo cáo θ̂ + CIRogan–Gladen để hiệu chỉnh, bootstrap để lượng hoá độ bất định; quyết định bằng cận dưới.
- Bảo trì liên tụcCăn chỉnh lại định kỳ; đưa evaluator vào CI/CD (Chương 9) và giao diện review (Chương 10).
Cung cấp failure mode (cái cần đo) và nhãn gold nhất quán (thước chuẩn để đo judge).
Dùng lại đúng công cụ này cho hội thoại nhiều lượt, RAG (faithfulness, relevance) và agent dùng tool — chỉ khác đơn vị được chấm.
Evaluator thành test trong CI/CD, thành bộ lọc trace để người review, thành tín hiệu để phân tích dữ liệu trace và cải thiện agent.
Bảng tra nhanh
| Cần | Công thức / quy tắc | Ghi nhớ |
|---|---|---|
| TPR | số Pass mà judge nói Pass / tổng số Pass (theo nhãn người) | positive = Pass |
| TNR | số Fail mà judge nói Fail / tổng số Fail | TNR thấp = judge cho lọt lỗi |
| Tỉ lệ thô | pobs = k/m | bị méo, đừng báo cáo trần |
| Hiệu chỉnh | θ̂ = (pobs + TNR − 1)/(TPR + TNR − 1), kẹp [0, 1] | mẫu số < 0,05 → không dùng |
| CI 95% | bootstrap cặp (nhãn, judge) của test, B ≥ 1.000, phân vị 2,5 / 97,5 | thêm nhãn test > thêm trace production |
| Ship? | cận dưới CI ≥ ngưỡng (lỗi đắt) · điểm ước lượng ≥ ngưỡng + theo dõi (lỗi rẻ) | độ rộng CI = mức bằng chứng |
| So 2 policy | Δ = θ̂B − θ̂A, paired bootstrap; non-inferior nếu cận dưới Δ > −biên | CI chứa 0 ≠ "như nhau" |
| Nhiều model | θ̂policy = Σ wmodel·θ̂model, TPR/TNR riêng từng model | tránh bias verbosity giữa model |
| Pass@k | 1 − C(n−c, k)/C(n, k) | sinh n > k mẫu |
Thuật ngữ Anh–Việt của chương
Độ nhạy với Pass: judge nhận ra bao nhiêu phần Pass thật.
Khả năng bắt lỗi: judge nhận ra bao nhiêu phần Fail thật.
Vùng giá trị hợp lý của θ, tính bằng bootstrap.
Rút N phần tử từ chính tập N, một phần tử có thể được rút nhiều lần.
Chứng minh phương án mới không tệ hơn quá một biên định trước.
Dữ liệu đo lọt vào quá trình xây judge → con số đẹp giả.