Vòng lặp đánh giá (evaluation loop)
Vòng lặp đánh giá (evaluation loop)
Tổng quan
Phase 1-16 chứng minh phần cơ chế hoạt động: provider giả, golden stream, các bộ test race và isolation. Nhưng chưa có gì đo được việc đổi một prompt, một middleware hay một model làm agent giỏi lên hay kém đi trên các tác vụ thật. Giai đoạn này thêm bộ test hồi quy (regression suite) cho hành vi của agent: một tập tác vụ cố định nằm trong repo, lệnh `hango eval` chạy từng tác vụ qua các run thật trên một server đang chạy, các check tất định (deterministic) để chấm điểm từng run, điểm số và dataset run được đẩy lên Langfuse gắn vào chính trace của run, và một phép so sánh giữa hai lần chạy của tập tác vụ. Giai đoạn dạy điều cuối cùng mà một harness cần: biết một thay đổi có thật sự giúp ích hay không.Yêu cầu
Chức năng
- Tập tác vụ nằm trong
evals/tasks/, mỗi tác vụ là một file JSON gồm:id, mộtdescriptionngắn, agent spec cần tạo (model,provider,tools,system_prompt, vàsubagentstùy chọn),inputcủa người dùng, và danh sáchchecks. Tập này gồm 8-12 tác vụ bao phủ các tính năng harness đã xây: trả lời thuần, một tool call, phục hồi sau lỗi tool, lập kế hoạch vớiwrite_todos, ghi một file qua virtual filesystem, ủy quyền cho subagent, một cuộc hội thoại dài kích hoạt compaction, và đọc memory ở session thứ hai. - Các check là tất định và đóng (closed set):
final_contains(chuỗi con),final_matches(regex),tool_called(tên, kèm số lần tối thiểu tùy chọn),tool_not_called,file_exists(đường dẫn ảo),file_contains(đường dẫn và chuỗi con),max_turns,max_cost_micro_cents,run_state(trạng thái kết thúc mong đợi, mặc địnhsucceeded). Mỗi check chấm 0 hoặc 1. Điểm của một tác vụ là trung bình các check, và tác vụ chỉ pass khi mọi check đều được 1. hango eval run --tasks evals/tasks --label <name> [--repeat N] [--only <id,...>]đọcHANGO_EVAL_BASE_URLvàHANGO_EVAL_API_KEY(một keyownertrong tenant dành riêng cho eval), rồi với mỗi tác vụ và mỗi lần lặp: upsert agent theo slugeval-<task-id>, tạo session, tạo run, theo dõiGET /v1/runs/{id}/eventscho đến khi run kết thúc, đọc các message cuối,trace_idcủa run, các file mà check nhắc tên, và tổng token cùng chi phí của run, rồi chấm các check.- Một run kết thúc ở
awaiting_approvalthì tác vụ bị fail với lý doawaiting_approval; agent eval không cấu hìnhinterrupt_onnào ngoài mặc định, nên chuyện này chỉ xảy ra khi một tác vụ ghi vào một prefix bị dành riêng. - Kết quả được ghi vào
evals/results/<label>.json(bị git bỏ qua): với mỗi tác vụ và lần lặp có điểm số, kết quả và lý do của từng check, run id, trace id, số turn, token, chi phí và thời lượng, cộng với git commit và model của từng tác vụ. - Khi
LANGFUSE_PUBLIC_KEYvàLANGFUSE_SECRET_KEYđược đặt, lệnh upsert một dataset Langfusehango-evalvới một item cho mỗi tác vụ, gắn trace của từng run vào dataset run tên<label>, và gửi một điểm cho mỗi check cộng với mộttask_scorelên trace. Trace id của Langfuse chính là OTel trace id, nên điểm rơi đúng vào trace mà run đã tạo sẵn. Khi chưa đặt key, lệnh ghi log rằng việc đẩy lên Langfuse bị bỏ qua và vẫn ghi file kết quả. hango eval compare <base.json> <candidate.json> [--max-regression 0]in một bảng theo từng tác vụ (tỷ lệ pass, điểm trung bình, turn, token, chi phí, thời lượng, mỗi cột dạng base → candidate kèm delta), rồi in tổng, và thoát với mã khác 0 khi tỷ lệ pass tổng của candidate giảm quá--max-regressionđiểm phần trăm.GET /v1/usagenhận thêm bộ lọcrun_idđể lệnh eval đọc được tổng token và chi phí của một run. Index(tenant_id, run_id)trênusage_recordstừ Phase 13 đã phục vụ được query này.
Phi chức năng
- Không thêm package mới. Lệnh nằm trong
cmd/hangocạnhprobevàmigrate; nó là một HTTP client của API công khai của hango cộng với một client HTTP Langfuse nhỏ, nên đi qua cùng các đường auth, tenancy, audit và telemetry như một người dùng thật. Trần số package vẫn là 18 cộnginternal/tool/builtin. - Code client chỉ dùng thư viện chuẩn:
net/http,encoding/json,regexp. Không dùng Langfuse SDK. - Lệnh không bao giờ in API key hay secret của Langfuse, và file kết quả không chứa nội dung prompt nào ngoài đoạn text cuối của assistant mà các check đã đọc.
- Việc lặp lại (repeat) tồn tại vì đầu ra của model không tất định. Kết quả compare báo tỷ lệ pass trên các lần lặp, không phải một lần pass hay fail đơn lẻ, để một mẫu may hay rủi không quyết định số phận của một thay đổi.
Kiến trúc
// cmd/hango/eval_task.go
type evalTask struct {
ID string `json:"id"`
Description string `json:"description"`
Agent json.RawMessage `json:"agent"` // POST /v1/agents body, slug forced to eval-<id>
Input string `json:"input"`
Checks []evalCheck `json:"checks"`
}
type evalCheck struct {
Kind string `json:"kind"` // final_contains|final_matches|tool_called|tool_not_called|file_exists|file_contains|max_turns|max_cost_micro_cents|run_state
Value string `json:"value,omitempty"`
Path string `json:"path,omitempty"`
Min int `json:"min,omitempty"`
}
// cmd/hango/eval_score.go
type checkResult struct {
Kind, Reason string
Score float64 // 0 or 1
}
// observed is everything a check may read about one finished run.
type observed struct {
State, FinalText, TraceID string
ToolCalls map[string]int
Files map[string]string // path -> content, only the paths the checks name
Turns int
CostMicroCents, Tokens int64
Duration time.Duration
}
func score(c evalCheck, o observed) checkResultevals/
tasks/plain-answer.json
tasks/tool-call.json
tasks/tool-error-recovery.json
tasks/plan-with-todos.json
tasks/write-file.json
tasks/delegate-to-subagent.json
tasks/long-conversation-compaction.json
tasks/memory-second-session.json
results/ # git-ignored, one <label>.json per eval runhango eval run --label base
for each task, for each repetition:
upsert agent eval-<id> → POST session → POST run → follow SSE to terminal
read messages, files, usage(run_id), trace_id → score checks
Langfuse: dataset item, dataset run item (trace_id, run name = label), scores on trace_id
write evals/results/base.json
hango eval compare evals/results/base.json evals/results/candidate.jsonInstrumentation
- Lệnh eval không tự gửi telemetry nào. Mọi run nó tạo ra đều là một run hango bình thường, nên trace
invoke_agent, log, metric, audit row và generation Langfuse của run đã có sẵn; điểm số được gắn vào trace đó. - File kết quả lưu
trace_idcủa từng run, nên một check fail mở thẳng vào Tempo và Langfuse theo runbook của Phase 14.
Các file liên quan
Tạo mới
cmd/hango/eval.go— các subcommandeval runvàeval compare, phân tích flag, vòng lặp tác vụ và file kết quả.cmd/hango/eval_task.go— kiểu dữ liệu task và check, việc nạp và kiểm traevals/tasks/*.json.cmd/hango/eval_score.go,cmd/hango/eval_score_test.go—scorecho mọi loại check, test dạng bảng với các giá trịobserveddựng tay.cmd/hango/eval_client.go— client của API hango: upsert agent, session, run, theo dõi SSE, message, file, usage.cmd/hango/eval_langfuse.go,cmd/hango/eval_langfuse_test.go— các lời gọi dataset, dataset run item và score, test vớihttptest.Server.cmd/hango/eval_compare.go,cmd/hango/eval_compare_test.go— bảng so sánh và mã thoát khi hồi quy.evals/tasks/*.json— tập tác vụ.docs/learning/17-evaluation-loop.md.
Sửa đổi
internal/server/handlers_usage.go,internal/store/pg/usage.go— bộ lọcrun_idtrênGET /v1/usage..gitignore—evals/results/.Makefile— các targetevalvàeval-compare..env.example— giá trị giữ chỗHANGO_EVAL_BASE_URL,HANGO_EVAL_API_KEY.README.md,docs/learning/README.md,docs/system-architecture.md.
Các bước triển khai
- Viết
eval_task.go: nạp mọi*.jsontrong thư mục tác vụ, từ chối loại check lạ, id trùng và thiếuinput, và sắp xếp tác vụ theo id để các lần chạy tái lập được. - Viết
eval_score.govà test dạng bảng của nó trước: mỗi loại check một case cho cả hai kết quả, thêm trường hợp tác vụ không có check nào (không hợp lệ ngay khi nạp, không bao giờ được chấm). - Thêm bộ lọc
run_idchoGET /v1/usagecùng một integration test chứng minh hai run trong một session báo tổng riêng biệt. - Viết
eval_client.go: upsert agent theo slug, tạo session và run, theo dõi luồng SSE vớiLast-Event-IDđể resume cho đến khi có trạng thái kết thúc hoặc hết timeout của từng tác vụ (--timeout, mặc định 5m), rồi đọc message, các file được nêu tên và usage. Đếm turn và tool call từ các message đã lưu bền, không đếm từ stream, để một stream được resume không đếm trùng. - Viết
eval.gochoeval run: lặp tuần tự qua tác vụ và các lần lặp, chấm điểm, ghi file kết quả theo kiểu nguyên tử (ghi ra file tạm rồi rename), và in một dòng tóm tắt cho mỗi tác vụ. - Viết
eval_langfuse.go: upsert datasethango-evalvà các item của nó, tạo dataset run item nối trace với tên run<label>, gửi một điểm cho mỗi check và mộttask_score. Test hình dạng request vớihttptest.Server. Bỏ qua kèm một dòng log khi chưa đặt key. - Viết
eval_compare.govà test của nó: căn tác vụ theo id, tính tỷ lệ pass trên các lần lặp, điểm trung bình, và median của turn, token, chi phí, thời lượng, in bảng, và trả mã thoát từ--max-regression. - Viết tập tác vụ. Mỗi tác vụ xuất phát từ một khả năng thật của một giai đoạn, giữ các check tất định, và đặt cho mỗi tác vụ một trần chi phí để một vòng lặp chạy mất kiểm soát làm fail check thay vì rút cạn ngân sách.
- Chạy
make eval LABEL=basevớidocker compose up, cố ý đổi một thứ (ví dụ gỡ planning middleware khỏi stack hoặc đổi model), chạymake eval LABEL=candidate, rồi ghi kết quảeval comparevà phép so sánh dataset run của Langfuse vào learning doc. - Cập nhật Makefile,
.gitignore,.env.example, README và docs, rồi viết learning doc.
Dàn ý tài liệu học
docs/learning/17-evaluation-loop.md phải có:
- Khái niệm: test chứng minh cơ chế, eval đo hành vi. Vì sao một thay đổi của harness (prompt, thứ tự middleware, ngưỡng compaction, model) cần một lần chạy eval trước và sau, và vì sao tập tác vụ được quản lý phiên bản cùng với code.
- Các lựa chọn thiết kế: check tất định trước check do model chấm, tỷ lệ pass trên các lần lặp thay vì một mẫu đơn lẻ, trần chi phí và số turn như các check, lệnh eval là một API client bình thường nên đi qua auth, tenancy và audit như mọi người dùng.
- Đánh đổi: check tất định so với LLM judge (rẻ và ổn định so với chấm được các câu trả lời mở); một tập nhỏ cố định so với một tập lớn lấy mẫu; file kết quả offline so với dataset Langfuse (giữ cả hai: file là cổng CI, Langfuse là nơi đọc các trace cạnh nhau).
- Cách dataset, dataset run và score của Langfuse khớp với nhau, và vì sao OTel trace id là thứ giúp một điểm số rơi vào trace sẵn có của run.
- So sánh với goclaw: goclaw dùng gì để kiểm tra hành vi agent, nếu có, và nó sẽ cắm vào chỗ nào.
- So sánh với deepagents: dataset và evaluator của LangSmith là câu trả lời của LangChain, và đường Langfuse của hango ánh xạ sang chúng ra sao.
- Cách debug tầng này: một check fail nêu lý do và trace id; mở trace trong Langfuse để đọc các generation, rồi theo runbook của Phase 14. Một tác vụ lúc pass lúc fail qua các lần lặp là tác vụ flaky chứ không phải hồi quy, và bảng compare hiển thị nó dưới dạng tỷ lệ pass dưới 100%.
- Việc tiếp theo: một loại check LLM-judge, chạy một tập con nhỏ trong CI với một provider đã ghi lại, và một tập tác vụ lớn hơn dựng từ các run thất bại thật.
- Bài tập: thêm một tác vụ cho một bug bạn đã sửa ở giai đoạn trước; phá hỏng một middleware và xem tác vụ nào tụt điểm; chạy
--repeat 5trên một tác vụ và đọc tỷ lệ pass của nó. - Ngân sách thời gian: 8h triển khai + 2h viết learning doc.
Tiêu chí thành công
-
go build ./... && go vet ./... && go test ./...đều pass, và danh sách package không đổi (không có package mới). -
eval_score_test.gobao phủ mọi loại check cho cả hai kết quả. - Nạp một tác vụ có loại check lạ, id trùng hoặc không có input thì thất bại với thông báo nêu tên file.
-
GET /v1/usage?run_id=trả tổng của một run; một integration test với hai run trong một session cho thấy tổng riêng biệt. -
make eval LABEL=basevớidocker compose upchạy mọi tác vụ và ghievals/results/base.jsonvới điểm số, trace id và chi phí cho từng tác vụ. - Khi đặt key Langfuse, dataset
hango-evalhiển thị một dataset run tênbase, và mỗi trace được liên kết mang một điểm cho mỗi check cộngtask_score(kiểm tra thủ công, ghi trong learning doc). - Khi chưa đặt key Langfuse,
hango eval runghi log rằng việc đẩy bị bỏ qua và vẫn ghi file kết quả. -
hango eval comparein delta tỷ lệ pass, điểm, turn, token, chi phí và thời lượng theo từng tác vụ, và thoát với mã khác 0 khi tỷ lệ pass giảm quá--max-regression(test). - Một hồi quy cố ý (gỡ một middleware hoặc đổi model) hiện ra thành tỷ lệ pass thấp hơn trong
eval compare, ghi trong learning doc. - Cả stdout lẫn file kết quả đều không chứa API key hay secret của Langfuse (test trên một lần chạy với
httptest.Server).
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Dấu hiệu | Cách xử lý đã quyết định trước |
|---|---|---|---|
| Đầu ra model không tất định làm kết quả nhiễu | Certain x Medium | Một tác vụ lúc pass lúc fail dù code không đổi | Báo tỷ lệ pass trên --repeat lần chạy; coi tỷ lệ pass dưới 100% là tác vụ flaky cần siết lại, không phải hồi quy |
| Chạy eval tốn tiền thật | High x Medium | Hóa đơn provider tăng theo mỗi lần eval | Check max_cost_micro_cents cho từng tác vụ, quota tenant của Phase 13 áp lên tenant eval, và --only để lặp trên một tác vụ |
| Check pass vì lý do sai | Medium x Medium | Một tác vụ pass trong khi trace cho thấy đường đi bị hỏng | Mỗi tác vụ kết hợp một check kết quả với một check đường đi (tool_called, file_exists, max_turns) |
| Hình dạng public API của Langfuse đổi | Medium x Low | Các lời gọi score hoặc dataset trả 4xx | Client nhỏ và được test với httptest.Server; file kết quả và eval compare không phụ thuộc Langfuse |
| Code eval trôi thành một package mới | Low x Low | Xuất hiện một package eval dùng chung | Lệnh ở lại trong cmd/hango; package mới cần sửa trần package trong plan.md trước |
Rollback
Lệnh eval và tập tác vụ là phần cộng thêm và nằm ngoài đường đi của server. Xóa cmd/hango/eval*.go và evals/ đưa binary về hành vi của Phase 16. Bộ lọc run_id trên GET /v1/usage là một query parameter tương thích ngược và có thể giữ lại.