Tuần này trên Hacker News có một bài hơn 580 điểm chỉ ra rằng bài viết kiểu "Astra can read and write weights to improve Qwen" đang gây hiểu lầm. Mình không bàn chuyện ai đúng ai sai. Điều đáng nói là cả cộng đồng mất mấy ngày tranh cãi chỉ vì một lý do: claim "model tốt hơn" mà không có eval đủ chặt. Chuyện này không chỉ xảy ra với các lab lớn. Team mình từng merge một LoRA adapter chỉ vì "test thử 10 câu thấy ngon hơn". Hai tuần sau, khách hàng báo model trả lời sai format JSON nhiều gấp đôi. Bài này chia sẻ cách mình dựng một eval harness tối thiểu (khoảng 100 dòng Python) để mỗi lần đổi prompt, fine-tune hay đổi model, mình có con số đáng tin chứ không phải cảm giác.
Vì sao "nhìn thấy tốt hơn" không phải là bằng chứng
Có 4 cái bẫy mà mình gặp đi gặp lại:
- Cherry-picking: bạn nhớ 3 câu model mới trả lời hay và quên 5 câu nó trả lời tệ hơn.
- Sample size quá nhỏ: 10 câu thì chênh 1 câu đã là 10%. Mức nhiễu đó đủ lớn để bạn "thấy" cải thiện ở bất cứ đâu.
- Variance do sampling: với
temperature=0.7, cùng một câu hỏi chạy 3 lần có thể ra 3 kết quả khác nhau. So một lần chạy của model A với một lần chạy của model B chẳng nói lên được gì. - Data contamination: test set của bạn có thể đã nằm trong data fine-tune, hoặc trong pre-training data của base model nếu bạn lấy từ benchmark công khai.
Cách chữa không cần gì cao siêu: một golden dataset do chính bạn viết, chạy nhiều seed, rồi tính confidence interval thay vì chỉ nhìn một con số trung bình.
Kiến trúc tối thiểu của một eval harness
flowchart LR D[golden.jsonl] --> R[Runner] M1[Base model] --> R M2[Model mới hoặc LoRA] --> R R --> S[Scorers: exact, contains, regex] R --> J[LLM judge pairwise] S --> A[Bootstrap CI] J --> A A --> G{CI gate} Có 3 thành phần chính:
- Dataset: file JSONL, mỗi dòng là một test case. Nên có 100–300 case lấy từ use case thật của bạn, như log production đã ẩn danh hay các ticket support. Đừng copy từ MMLU hay GSM8K.
- Scorers: ưu tiên chấm bằng code như exact match, contains, regex hay JSON schema validation. Chỉ dùng LLM-as-judge cho các câu trả lời mở.
- Aggregation: tính mean kèm bootstrap CI 95%, rồi so với baseline.
File evals/golden.jsonl trông như sau:
{"id": "vn-001", "input": "Thủ đô của Việt Nam là gì? Chỉ trả lời tên.", "expected": "Hà Nội", "scorer": "exact"} {"id": "fmt-002", "input": "Trả về JSON có key status cho đơn hàng đã giao.", "expected": "\"status\"", "scorer": "contains"} Code: harness với Python + OpenAI-compatible API
Mình dùng openai>=1.50 và trỏ vào endpoint local: Ollama 0.4+ (/v1) hoặc vLLM 0.6+ đều chạy được, không tốn tiền API.
# eval_harness.py — Python 3.12, openai>=1.50 import json, re, random, statistics, sys from openai import OpenAI client = OpenAI(base_url='http://localhost:11434/v1', api_key='ollama') def load(path): with open(path, encoding='utf-8') as f: return [json.loads(line) for line in f if line.strip()] def ask(model, prompt, seed, temperature=0.7): r = client.chat.completions.create( model=model, messages=[{'role': 'user', 'content': prompt}], temperature=temperature, seed=seed, max_tokens=256, ) return r.choices[0].message.content.strip() SCORERS = { 'exact': lambda out, exp: float(out.strip().lower() == exp.strip().lower()), 'contains': lambda out, exp: float(exp.lower() in out.lower()), 'regex': lambda out, exp: float(re.search(exp, out) is not None), } def bootstrap_ci(scores, n=2000, alpha=0.05): means = sorted( statistics.mean(random.choices(scores, k=len(scores))) for _ in range(n) ) return means[int(n * alpha / 2)], means[int(n * (1 - alpha / 2)) - 1] def run(model, dataset, seeds=(1, 2, 3)): rows, scores = [], [] for case in dataset: scorer = SCORERS[case['scorer']] per_seed = [scorer(ask(model, case['input'], s), case['expected']) for s in seeds] score = statistics.mean(per_seed) scores.append(score) rows.append({'id': case['id'], 'score': score}) lo, hi = bootstrap_ci(scores) return {'model': model, 'mean': statistics.mean(scores), 'ci95': [lo, hi], 'rows': rows} if __name__ == '__main__': random.seed(42) model, path = sys.argv[1], sys.argv[2] print(json.dumps(run(model, load(path)), ensure_ascii=False, indent=2)) Có hai điểm đáng chú ý. Thứ nhất, mỗi case chạy 3 seed và lấy trung bình, nên một lần "may mắn" không làm lệch kết quả. Thứ hai, bootstrap CI cho bạn biết khoảng dao động thật. Nếu base model đạt 0.72 [0.66, 0.78] và model mới đạt 0.75 [0.69, 0.81], hai khoảng chồng lên nhau gần hết. Bạn chưa có bằng chứng là model mới tốt hơn, dù trung bình cao hơn 3 điểm.
LLM-as-judge: dùng được, nhưng phải chống position bias
Với các câu trả lời mở như tóm tắt hay giải thích code, không có cách nào chấm bằng regex. Lúc đó bạn cần một model mạnh hơn làm giám khảo. Có điều judge model nổi tiếng là thiên vị vị trí: nó hay chọn câu trả lời đứng đầu tiên. Cách xử lý đơn giản nhất là chấm hai lần và đổi chỗ.
sequenceDiagram participant H as Harness participant J as Judge H->>J: Q, A=base, B=new J-->>H: B H->>J: Q, A=new, B=base J-->>H: A Note over H: Nhất quán thì new thắng, mâu thuẫn thì tính tie JUDGE_PROMPT = '''Bạn là giám khảo nghiêm khắc. Câu hỏi: {q} Câu trả lời A: {a} Câu trả lời B: {b} Câu nào đúng và hữu ích hơn? Chỉ trả lời MỘT ký tự: A, B hoặc T (hòa).''' def judge_once(q, a, b, judge_model='qwen2.5:32b'): out = ask(judge_model, JUDGE_PROMPT.format(q=q, a=a, b=b), seed=0, temperature=0) return out[:1].upper() def pairwise(q, ans_base, ans_new): first = judge_once(q, ans_base, ans_new) # new ở vị trí B second = judge_once(q, ans_new, ans_base) # new ở vị trí A if first == 'B' and second == 'A': return 'new' if first == 'A' and second == 'B': return 'base' return 'tie' # judge đổi ý khi đổi chỗ => kết quả không đáng tin Mẹo thực tế: hãy log lại tỉ lệ tie do mâu thuẫn. Nếu tỉ lệ này vượt khoảng 30%, judge của bạn quá yếu so với task hoặc prompt chấm đang mơ hồ. Lúc đó nên sửa judge trước rồi mới tin vào kết quả. Ngoài ra, đừng bao giờ để một model tự chấm chính nó, vì self-preference bias là có thật.
Gắn vào CI: không có bằng chứng thì không merge
Phần cuối là biến eval thành một gate. Script bash dưới đây chạy được trong GitHub Actions hoặc GitLab CI nếu runner có GPU, hoặc gọi sang một inference server nội bộ.
#!/usr/bin/env bash # eval_gate.sh — cần jq 1.7+, bc set -euo pipefail python eval_harness.py qwen2.5:7b evals/golden.jsonl > base.json python eval_harness.py my-lora:latest evals/golden.jsonl > new.json echo "base: $(jq .mean base.json) $(jq -c .ci95 base.json)" echo "new : $(jq .mean new.json) $(jq -c .ci95 new.json)" # Liệt kê các case bị regress để review bằng mắt jq -r --slurpfile b base.json ' .rows[] as $r | ($b[0].rows[] | select(.id == $r.id) | .score) as $s | select($r.score < $s) | "REGRESS \($r.id): \($s) -> \($r.score)"' new.json NEW_LO=$(jq '.ci95[0]' new.json) BASE_HI=$(jq '.ci95[1]' base.json) if (( $(echo "$NEW_LO > $BASE_HI" | bc -l) )); then echo "✅ Cải thiện rõ ràng, CI không chồng nhau" else echo "⚠️ Chưa đủ bằng chứng — đừng claim là tốt hơn" exit 1 fi Điều kiện "CI không chồng nhau" khá bảo thủ, nhưng mình thấy nó an toàn. Muốn chặt chẽ hơn thì bạn có thể chuyển sang paired bootstrap trên hiệu số từng case. Có một điều quan trọng không kém: danh sách REGRESS. Ngay cả khi điểm trung bình tăng, model mới vẫn có thể làm hỏng đúng những case quan trọng nhất của bạn.
Kết luận
Những tranh cãi kiểu "model X được cải thiện" trên HN sẽ còn lặp lại, và bạn không cần phải là nạn nhân, dù ở vai người viết hay người đọc. Các bước nên làm ngay:
- Viết golden set 100 case từ use case thật trong tuần này. Đây là tài sản quý nhất, quý hơn cả code harness.
- Chạy nhiều seed và báo cáo kèm CI 95%. Không có CI thì con số trung bình gần như vô nghĩa.
- Ưu tiên scorer bằng code. Chỉ dùng LLM-as-judge cho câu trả lời mở, luôn đổi chỗ A/B, và theo dõi tỉ lệ judge mâu thuẫn.
- Gắn eval vào CI cho mọi thay đổi về prompt, LoRA hay model version, và review danh sách regress trước khi merge.
- Version hóa dataset cùng code, và đừng bao giờ đưa nó vào data fine-tune.
Khi đọc một claim kiểu "model tốt hơn X%", hãy hỏi ngay ba câu: test trên bao nhiêu case, có baseline không, và confidence interval là bao nhiêu. Nếu không ai trả lời được, đó mới chỉ là cảm giác chứ chưa phải kết quả.
