Chuyển đến nội dung
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ột description ngắn, agent spec cần tạo (model, provider, tools, system_prompt, và subagents tùy chọn), input của người dùng, và danh sách checks. 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ới write_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 định succeeded). 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,...>] đọc HANGO_EVAL_BASE_URL và HANGO_EVAL_API_KEY (một key owner trong 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 slug eval-<task-id>, tạo session, tạo run, theo dõi GET /v1/runs/{id}/events cho đến khi run kết thúc, đọc các message cuối, trace_id củ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_approval thì tác vụ bị fail với lý do awaiting_approval; agent eval không cấu hình interrupt_on nà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_KEY và LANGFUSE_SECRET_KEY được đặt, lệnh upsert một dataset Langfuse hango-eval vớ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ột task_score lê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/usage nhận thêm bộ lọc run_id để lệnh eval đọc được tổng token và chi phí của một run. Index (tenant_id, run_id) trên usage_records từ 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/hango cạnh probe và 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ộng internal/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) checkResult
evals/
  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 run
hango 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.json

Instrumentation

  • 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_id củ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 subcommand eval run và 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 tra evals/tasks/*.json.
  • cmd/hango/eval_score.go, cmd/hango/eval_score_test.go — score cho mọi loại check, test dạng bảng với các giá trị observed dự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ới httptest.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ọc run_id trên GET /v1/usage.
  • .gitignore — evals/results/.
  • Makefile — các target eval và 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

  1. Viết eval_task.go: nạp mọi *.json trong thư mục tác vụ, từ chối loại check lạ, id trùng và thiếu input, và sắp xếp tác vụ theo id để các lần chạy tái lập được.
  2. Viết eval_score.go và 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).
  3. Thêm bộ lọc run_id cho GET /v1/usage cùng một integration test chứng minh hai run trong một session báo tổng riêng biệt.
  4. Viết eval_client.go: upsert agent theo slug, tạo session và run, theo dõi luồng SSE với Last-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.
  5. Viết eval.go cho eval 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ụ.
  6. Viết eval_langfuse.go: upsert dataset hango-eval và 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ột task_score. Test hình dạng request với httptest.Server. Bỏ qua kèm một dòng log khi chưa đặt key.
  7. Viết eval_compare.go và 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.
  8. 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.
  9. Chạy make eval LABEL=base với docker compose up, cố ý đổi một thứ (ví dụ gỡ planning middleware khỏi stack hoặc đổi model), chạy make eval LABEL=candidate, rồi ghi kết quả eval compare và phép so sánh dataset run của Langfuse vào learning doc.
  10. 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 5 trê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.go bao 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=base với docker compose up chạy mọi tác vụ và ghi evals/results/base.json với điểm số, trace id và chi phí cho từng tác vụ.
  • Khi đặt key Langfuse, dataset hango-eval hiển thị một dataset run tên base, và mỗi trace được liên kết mang một điểm cho mỗi check cộng task_score (kiểm tra thủ công, ghi trong learning doc).
  • Khi chưa đặt key Langfuse, hango eval run ghi log rằng việc đẩy bị bỏ qua và vẫn ghi file kết quả.
  • hango eval compare in 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 roKhả năng x Tác độngDấu hiệuCách xử lý đã quyết định trước
Đầu ra model không tất định làm kết quả nhiễuCertain x MediumMột tác vụ lúc pass lúc fail dù code không đổiBá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ậtHigh x MediumHóa đơn provider tăng theo mỗi lần evalCheck 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 saiMedium x MediumMột tác vụ pass trong khi trace cho thấy đường đi bị hỏngMỗ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 đổiMedium x LowCác lời gọi score hoặc dataset trả 4xxClient 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ớiLow x LowXuất hiện một package eval dùng chungLệ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.