07 · Eval và observability cho agent — đo cái không tất định

Với code thường, test pass là pass. Với LLM, cùng input có thể lúc đúng lúc sai, output là văn bản tự do, và "đúng" thường là chuyện mức độ. Eval (bộ đánh giá tự động) là unit test của tính năng AI — nhưng là unit test thống kê. Observability (quan sát) là cách bạn biết agent đã làm gì trong production. Hai thứ này là điều phân biệt team "demo AI" với team "vận hành AI", và là chủ đề interviewer mức lead gần như chắc chắn hỏi: "làm sao anh biết đổi prompt này là tốt hơn?"

Đã verify gì, chưa verify gì. Lab chạy thật: mô phỏng thống kê (Python, numpy) cho thấy eval nhỏ kết luận sai bao nhiêu phần trăm, lợi ích của so sánh paired, và việc judge không hoàn hảo làm co chênh lệch; một agent loop dùng SDK Python anthropic 1.9.0 thật (gọi mock server) được trace theo OpenTelemetry GenAI semantic conventions với opentelemetry-sdk 1.45 / semconv 0.66b0. Chưa verify: hành vi LLM-as-judge thật (thiên vị vị trí, độ dài...) — dẫn nguồn nghiên cứu; OpenTelemetry cho PHP. Không có Context7.

Câu trả lời 60 giây

"Tôi coi eval là unit test thống kê: một bộ câu hỏi thật, một cách chấm, và một con số kèm khoảng tin cậy. Ưu tiên chấm bằng code và chấm kết quả — ví dụ agent hoàn tiền thì kiểm bảng refunds có đúng một dòng — hơn là chấm câu chữ. LLM-as-judge chỉ dùng khi phải chấm văn bản, và phải hiệu chỉnh với người chấm. Cỡ mẫu quan trọng: tôi mô phỏng hai prompt đúng thật 82% và 86% — với 50 câu, một lần chạy kết luận sai hướng 34% số lần; cần khoảng 500 câu mới xuống dưới 5%. Với agent, tôi đo cả pass^k — chạy k lần đều đúng — vì khách hàng gặp độ ổn định chứ không gặp điểm trung bình. Observability thì mỗi lượt agent là một trace dạng cây: invoke_agent → chat → execute_tool, gắn token, chi phí, stop_reason; transcript lỗi trong production quay về làm câu hỏi mới cho bộ eval."

Thuật ngữ

Thuật ngữNghĩa ngắn
eval / golden setBộ đầu vào + đáp án hoặc tiêu chí dùng để chấm tự động.
graderCách chấm: code (so khớp, kiểm DB), model (LLM-as-judge), người.
pass@k / pass^kÍt nhất 1 trong k lần đúng / cả k lần đều đúng (đo độ ổn định — τ-bench, Sierra 2024).
offline / online evalChạy trên bộ dữ liệu cố định trước khi deploy / đo trên traffic thật sau deploy.
trace / spanTrace: toàn bộ một lượt xử lý; span: một bước trong đó (một lần gọi model, một tool call), lồng nhau thành cây.
semantic conventionsQuy ước tên thuộc tính chuẩn của OpenTelemetry (vd gen_ai.usage.input_tokens).
faithfulness / groundednessCâu trả lời có được tài liệu nguồn hỗ trợ hay không (RAG).

1. Bài toán gốc và bối cảnh ra đời

Test truyền thống giả định hàm tất định và output so khớp được. LLM phá cả hai. Cách làm tự nhiên đầu tiên là "vibe check": thử vài câu, thấy ổn, deploy. Nó không scale: đổi một câu trong prompt sửa được 3 ca nhưng làm hỏng 5 ca khác mà không ai thấy. Benchmark công khai (MMLU, SWE-bench) đo năng lực chung, không đo nhiệm vụ của bạn. Từ 2023–2024, cộng đồng làm sản phẩm (Hamel Husain, Eugene Yan và nhiều người khác) hình thành cách làm eval-driven development: đọc lỗi thật, phân loại, biến thành bộ eval, lặp.

Về quan sát: APM truyền thống (New Relic, Datadog) thấy một HTTP call 12 giây tới API, không thấy prompt, token, tool call, stop_reason. Công cụ chuyên cho LLM (Langfuse, LangSmith, Arize Phoenix, Braintrust…) ra đời, rồi OpenTelemetry đưa ra GenAI semantic conventions để chuẩn hoá tên thuộc tính.

Eval tối ưu cho: ra quyết định (đổi prompt, đổi model, đổi chunking) bằng số liệu. Không thay được: đọc transcript thật bằng mắt — eval chỉ đo những kiểu lỗi bạn đã biết để đo.

2. Mô hình tư duy cốt lõi

Eval = thí nghiệm thống kê có kiểm soát (bộ dữ liệu, cách chấm, con số kèm khoảng tin cậy). Observability = event log có cấu trúc của mỗi lượt agent (trace dạng cây). Hai thứ nối thành một vòng: lỗi thấy trong trace → thành ca eval → sửa → eval xác nhận → deploy → quan sát.
flowchart LR
  P["Production: trace mọi lượt"] --> F["Lọc ca đáng xem: thumbs down, escalate, lỗi tool, chi phí cao"]
  F --> A["Đọc transcript, phân loại kiểu lỗi"]
  A --> D["Thêm vào bộ eval (có đáp án/tiêu chí)"]
  D --> X["Sửa prompt / tool / retrieval"]
  X --> E["Chạy eval: so với baseline, có CI"]
  E -->|"không regression"| DEP["Deploy (canary)"]
  E -->|"regression"| X
  DEP --> P

3. Internals và quyết định thiết kế

3.1 Chấm cái gì

LoạiChấmVí dụ shopƯuNhược
Outcome (kết quả cuối)Trạng thái thế giới sau khi agent chạyBảng refunds có đúng 1 dòng 350 000đ cho đơn 1001; không có dòng nào cho đơn của người khácKhách quan, chấm bằng code, không phụ thuộc cách diễn đạtCần môi trường sandbox để agent chạy thật
Trajectory (đường đi)Chuỗi tool callCó gọi get_order trước refund_order; không quá 5 vòngBắt được lỗi quy trình, chi phíNhiều đường đúng — chấm quá chặt sẽ phạt cách làm hợp lệ
Response (câu chữ)Văn bản trả lờiCó xưng "em", không hứa quá chính sách, có trích dẫnBắt lỗi giọng điệu, bịaThường cần LLM-as-judge
ComponentMột bộ phậnRecall@10 của retrieval (bài 04), độ chính xác của routerKhoanh vùng lỗi nhanhTốt từng phần chưa chắc tốt tổng thể

3.2 Chấm bằng gì

GraderDùng khiRủi ro
Code: so khớp, regex, JSON schema, truy vấn DB, chạy testBất cứ khi nào có thể — rẻ, nhanh, tất địnhChấm quá cứng (đúng ý nhưng khác chữ)
LLM-as-judge: rubric (thang điểm có tiêu chí) hoặc so sánh cặpTiêu chí mềm: giọng văn, đầy đủ, bám nguồnThiên vị vị trí (thích đáp án đứng trước), thiên vị độ dài, tự ưu ái output cùng họ model (Zheng et al., 2023, "Judging LLM-as-a-Judge with MT-Bench"); bản thân judge không tất định; đổi model judge là đổi thước đo
NgườiHiệu chỉnh judge, ca nhạy cảm, định nghĩa "đúng"Chậm, đắt, người chấm không đồng ý với nhau

Quy tắc cho judge: một tiêu chí mỗi lần chấm (không "chấm 1–10 tổng thể"), yêu cầu lý do trước điểm, đảo vị trí khi so sánh cặp, dùng structured outputs cho điểm, và đo mức đồng ý với người trên 50–100 mẫu trước khi tin.

3.3 Thống kê — lab

Hai prompt: A đúng thật 82%, B đúng thật 86%. Chạy eval một lần với n câu, bao nhiêu phần trăm số lần ta kết luận sai (A ≥ B)?

1) Chạy eval n câu MỘT lần: bao nhiêu % trường hợp kết luận SAI (A >= B)?
  n câu  A>=B (sai)   CI95 ± (điểm %)
     30       40.6%             13.7
     50       34.0%             10.6
    100       24.9%              7.5
    200       15.2%              5.3
    500        4.6%              3.4
   1000        0.8%              2.4

2) Paired: cùng bộ câu hỏi cho A và B (độ khó từng câu tương quan) — nhiễu giảm bao nhiêu?
   n=200, không paired (2 bộ câu khác nhau): sai 18.6%
   n=200, paired (cùng bộ câu):               sai 14.9%

3) LLM-as-judge đồng ý với người 90%: chênh lệch đo được bị co lại
   chênh lệch thật 4.0 điểm; judge đo được trung bình 3.2 điểm (điểm tuyệt đối A: thật 82% -> judge 75.6%)

pass@k vs pass^k: agent đúng 90% mỗi lần có pass^8 = 0,9^8 ≈ 0,43 — chạy 8 lần thì chỉ 43% khả năng cả 8 đều đúng. τ-bench (Sierra, 6/2024) đưa pass^k vào đo agent CSKH chính vì lý do này: khách hàng gặp từng lần chạy, không gặp trung bình.

3.4 Dựng bộ eval từ đâu

  1. Đọc 50–100 transcript thật, ghi chú từng lỗi bằng lời (open coding), rồi gom thành nhóm (thiếu tool, sai chính sách, bịa mã đơn, quá dài…). Đây là bước bị bỏ qua nhiều nhất và đáng giá nhất.
  2. Mỗi nhóm lỗi → vài ca eval có đáp án/tiêu chí, ưu tiên chấm bằng code.
  3. Thêm ca âm: câu hỏi phải từ chối, câu hỏi phải trả lời "không có thông tin", câu hỏi chéo tenant.
  4. Dữ liệu tổng hợp (LLM sinh) chỉ để mở rộng biến thể quanh ca thật, không thay ca thật.
  5. Version bộ eval như code; giữ một phần holdout không dùng khi tinh chỉnh prompt để tránh overfit.

3.5 Observability: trace theo OpenTelemetry GenAI — lab

Agent loop dùng SDK anthropic Python thật (gọi mock server), mỗi bước là một span với thuộc tính theo semconv GenAI:

invoke_agent cskh  [256 ms]  {"operation.name": "invoke_agent", "agent.name": "cskh", "conversation.id": "conv_demo_1"}
  chat claude-opus-5-5  [89 ms]  {"operation.name": "chat", "provider.name": "anthropic", "request.model": "claude-opus-5-5", "response.id": "msg_af946cb4b78041078b78", "response.finish_reasons": ["tool_use"], "usage.input_tokens": 124, "usage.output_tokens": 41}
  execute_tool get_order  [31 ms]  {"operation.name": "execute_tool", "tool.name": "get_order", "tool.call.id": "toolu_6975d508f6254b3498cc"}
  chat claude-opus-5-5  [8 ms]  {"operation.name": "chat", ..., "response.finish_reasons": ["tool_use"], "usage.input_tokens": 219, "usage.output_tokens": 32}
  execute_tool refund_order  [30 ms]  {"operation.name": "execute_tool", "tool.name": "refund_order", "tool.call.id": "toolu_65d950f11a5a410f94df"}
  chat claude-opus-5-5  [96 ms]  {"operation.name": "chat", ..., "response.finish_reasons": ["end_turn"], "usage.input_tokens": 295, "usage.output_tokens": 16}

(Tiền tố gen_ai. được lược bớt khi in. Số token do mock ước lượng.) Mỗi span nên có: model, token (tách cache read/write — semconv có gen_ai.usage.cache_read.input_tokens, gen_ai.usage.cache_creation.input_tokens), finish_reasons, tool.call.id để nối với tool_result, id người dùng/tenant (băm), phiên bản prompt. Nội dung prompt/response (gen_ai.input.messages, gen_ai.output.messages) chứa PII → bật có chủ đích, lấy mẫu, có hạn lưu trữ.

Semconv GenAI vẫn đang thay đổi: gói semconv 0.66b0 dùng trong lab chứa cả gen_ai.system (tên cũ) lẫn gen_ai.provider.name (tên mới), cả gen_ai.usage.prompt_tokens lẫn gen_ai.usage.input_tokens. Ghim phiên bản semconv và có lớp map ở một chỗ, đừng rải tên thuộc tính khắp code.

Trong Laravel, trace phải đi xuyên qua queue: controller tạo trace, job agent là con của trace đó (truyền trace context trong payload job) — nếu không, mỗi job là một trace mồ côi.

4. Đảm bảo và giới hạn

5. Failure modes

LỗiHệ quảPhát hiệnXử lý
Eval quá nhỏ (lab)Chọn sai phiên bản 1/3 số lầnTính CI trước khi kết luậnTăng cỡ mẫu, paired, chỉ ra quyết định khi chênh vượt CI
Chạy một lần với hệ không tất định"Regression" giả, "cải thiện" giảChạy lại ra kết quả khácChạy k lần, báo trung bình + pass^k
Judge đổi modelĐiểm nhảy dù hệ không đổiĐiểm baseline đổiGhim model judge; khi đổi, chấm lại baseline
Chấm câu chữ thay vì kết quảAgent nói "đã hoàn tiền" nhưng không gọi tool — vẫn passSo với trạng thái DBOutcome grader
Eval phạt "không biết"Tối ưu hoá theo eval làm model bịa nhiều hơn (Kalai et al., 2025)Tỉ lệ bịa tăng khi điểm tăngChấm "không đủ thông tin" là đúng khi đúng là không có
Trace mồ côi qua queueKhông nối được request với job agentTrace thiếu conTruyền trace context trong payload job
Log PII vô thời hạnRủi ro pháp lý (Luật Bảo vệ dữ liệu cá nhân 91/2025/QH15, hiệu lực 1/1/2026, thay Nghị định 13/2023; GDPR nếu có khách EU)Audit log storeChe PII, lấy mẫu, hạn lưu trữ, phân quyền xem

6. Vận hành trong production

Trong CI

Trên production

Gánh nặng cho team

Cần một người (thường là PM hoặc chuyên gia nghiệp vụ, không nhất thiết là kỹ sư) sở hữu định nghĩa "đúng" và gán nhãn. Kỹ sư sở hữu harness chạy eval và pipeline trace. Không có chủ sở hữu nghiệp vụ, bộ eval sẽ đo sai thứ.

7. So sánh và ma trận quyết định

Cách đánh giáChi phíTốc độĐộ tin cậyDùng khi
Vibe checkRất thấpNhanhRất thấpChỉ để thăm dò ý tưởng
Benchmark công khaiThấpNhanhThấp cho nhiệm vụ của bạnChọn danh sách model ứng viên
Eval offline chấm bằng codeThấp–TBNhanhCao (trong phạm vi đo)Mặc định, chạy trong CI
LLM-as-judgeTBNhanhTB, cần hiệu chỉnhTiêu chí mềm, số lượng lớn
Người chấmCaoChậmCao (nếu có hướng dẫn chấm)Hiệu chỉnh, ca rủi ro cao
Online A/BTBChậm (cần traffic)Cao nhất cho metric kinh doanhQuyết định cuối cho thay đổi lớn
Công cụ observabilityƯuNhược
Log + APM có sẵn (ELK, Datadog)Không thêm hệ thốngKhông có UI xem transcript, cây agent
Nền tảng LLM mã nguồn mở tự host (Langfuse, Phoenix)Dữ liệu ở lại công ty, UI transcript, eval tích hợpThêm một hệ thống vận hành
SaaS (LangSmith, Braintrust…)Nhanh, nhiều tính năng evalDữ liệu (có PII) ra ngoài; lock-in
OTel GenAI → backend bất kỳChuẩn mở, đổi backend đượcSemconv chưa ổn định; phải tự làm UI transcript

Mặc định hợp lý cho team Laravel vừa và nhỏ: ghi trace theo semconv GenAI (qua lớp gateway ở bài 01), đẩy về một nền tảng tự host; eval chấm bằng code trong CI; judge chỉ cho vài tiêu chí mềm; review transcript hằng tuần.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

Điều người làm lâu đều biết: 80% giá trị của eval đến từ bước đọc transcript và phân loại lỗi, không phải từ con số cuối. Con số chỉ nói "tốt hơn hay tệ hơn"; đọc transcript nói "vì sao".

10. Câu hỏi phụ interviewer hay hỏi tiếp

Làm sao biết đổi prompt là tốt hơn?
Chạy cả hai phiên bản trên cùng bộ eval (paired), đủ lớn, nhiều lần nếu không tất định; so chênh lệch với khoảng tin cậy; đọc các ca đổi kết quả; canary trên production.
LLM-as-judge có đáng tin không?
Đủ tin cho tiêu chí mềm nếu có rubric cụ thể, một tiêu chí mỗi lần, đảo vị trí khi so cặp, và đã đo mức đồng ý với người. Không dùng cho thứ chấm được bằng code.
Eval agent khác eval chatbot thế nào?
Chấm kết quả (trạng thái sau khi chạy) và đường đi (tool, số vòng, chi phí), cần môi trường sandbox; đo pass^k vì agent nhiều bước dao động mạnh.
Cần bao nhiêu ca eval?
Tuỳ chênh lệch muốn phát hiện: phân biệt 82% vs 86% đáng tin cần ~500 ca (lab); bắt regression lớn thì 50–100 ca đủ. Bắt đầu nhỏ nhưng tính CI.
Trace nên ghi gì?
Cây span agent → chat → tool; model, token (tách cache), stop_reason, tool call id, latency, chi phí, phiên bản prompt, tenant (băm). Nội dung prompt có kiểm soát PII.

Phụ lục: lab đã chạy

Môi trường: Python 3.11.15, numpy 2.4.6, anthropic 1.9.0, opentelemetry-sdk 1.45.0, opentelemetry-semantic-conventions 0.66b0. Output gốc: labs/ai/out/07-*.txt.

pip install numpy anthropic opentelemetry-sdk
python3 labs/ai/py/eval_stats.py                         # thống kê eval
labs/ai/mockctl.sh start cskh
python3 labs/ai/py/otel_agent_trace.py                   # trace agent loop theo semconv GenAI
labs/ai/mockctl.sh stop

Chưa verify: thiên vị của LLM-as-judge với model thật; OpenTelemetry PHP. Nguồn: Sierra, "τ-bench" (arXiv 2406.12045, 2024); Zheng et al., "Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena" (2023); Kalai et al., "Why Language Models Hallucinate" (2025); OpenTelemetry GenAI semantic conventions (gói semconv 0.66b0).

Bài trước: 06 · MCP chuyên sâu · Bài tiếp: 08 · Bảo mật agent.