Có JSON ứng viên chuẩn hóa từ Bài 2, giờ ta xây phần "não" của agent: chấm điểm. Nguyên tắc cốt lõi: điểm số phải giải thích được và tái lập được, không phải một con số bí ẩn từ LLM.
Vì sao không để LLM tự cho điểm 0–100 tùy hứng
Nếu bạn hỏi "chấm CV này bao nhiêu điểm?", LLM sẽ cho số khác nhau mỗi lần, không nhất quán giữa các ứng viên, và không giải thích được với ứng viên/pháp lý. Giải pháp: tách tiêu chí → LLM đánh giá từng tiêu chí → công thức có trọng số cộng lại.
Bước 1: Định nghĩa rubric từ JD
Biến JD thành bảng tiêu chí có trọng số. Ví dụ vị trí Backend:
Tiêu chí (weight) | Cách tính
---------------------------|---------------------------
Kỹ năng bắt buộc (40) | % skill JD yêu cầu mà CV có
Số năm kinh nghiệm (25) | min(năm_CV / năm_JD, 1) * 25
Cùng ngành (15) | có/không
Học vấn (10) | đạt/không đạt mức tối thiểu
Skill ưu tiên (10) | % skill nice-to-have có
Tổng weight = 100. Trọng số này do con người quyết định, phản ánh ưu tiên tuyển dụng thật — đây là nơi bạn cài giá trị vào hệ thống.
Bước 2: Chấm từng tiêu chí (phần rule-based)
Những tiêu chí đo đếm được thì dùng code, không dùng LLM — rẻ, nhanh, chính xác:
def score_hard_skills(cv_skills, required):
cv = {s.lower() for s in cv_skills}
hit = sum(1 for r in required if r.lower() in cv)
return hit / len(required) * 40 # weight 40
def score_experience(years_cv, years_jd):
if not years_cv: return 0
return min(years_cv / years_jd, 1) * 25
Bước 3: Dùng LLM cho phần "mềm"
Chỉ dùng LLM cho việc nó giỏi: đánh giá mức độ liên quan mà keyword không bắt được (ví dụ "đã build hệ thống thanh toán" ≈ kinh nghiệm fintech dù không ghi chữ "fintech"). Yêu cầu LLM trả điểm 0–1 kèm lý do, rồi bạn nhân với weight:
LLM_RELEVANCE = """Cho JD và CV, đánh giá độ liên quan ngành nghề 0.0–1.0.
Trả JSON: {"industry_match": float, "reason": str}. Chỉ dựa trên bằng chứng trong CV."""
Bước 4: Tổng hợp & giải thích
def final_score(cv, jd):
parts = {
"hard_skills": score_hard_skills(cv["skills"], jd["required"]),
"experience": score_experience(cv["years_experience"], jd["years"]),
"industry": llm_industry(cv, jd) * 15,
# ... các phần khác
}
total = round(sum(parts.values()))
reasons = build_reasons(parts, cv, jd) # 3 dòng lý do rõ ràng
return {"score": total, "breakdown": parts, "reasons": reasons}
Điểm mạnh: khi ai đó hỏi "vì sao ứng viên này 78 điểm?", bạn chỉ ra breakdown từng phần — hoàn toàn minh bạch.
Khung phân tầng để hành động
85–100 Tier A → mời phỏng vấn ngay
70–84 Tier B → recruiter xem nhanh rồi mời
50–69 Tier C → giữ lại, xem nếu thiếu ứng viên
<50 Tier D → không phù hợp vòng này
BẤT KỲ điểm nào có field null quan trọng → flag "xem tay"
Chống thiên vị (bias)
- Loại trường gây bias khỏi input chấm điểm: tên, tuổi, giới tính, ảnh, trường học (nếu không thực sự cần).
- Chấm trên năng lực và bằng chứng, không phải xuất thân.
- Lưu log điểm để audit: nếu một nhóm bị điểm thấp bất thường, phải rà lại rubric.
Checklist engine chấm điểm
- [ ] Rubric có trọng số, tổng = 100, do người duyệt
- [ ] Tiêu chí đo đếm được dùng code, không dùng LLM
- [ ] LLM chỉ chấm phần "mềm", trả điểm chuẩn hóa + lý do
- [ ] Mỗi điểm có breakdown giải thích được
- [ ] Đã loại trường gây bias khỏi input
- [ ] Có log để audit
Sai lầm thường gặp
- Để LLM cho điểm tổng trực tiếp. Không nhất quán, không giải thích được, không audit được.
- Chấm bằng đếm keyword thuần. Ứng viên "biết chơi chữ" nhồi keyword sẽ điểm cao giả; ứng viên giỏi mô tả bằng ngôn ngữ khác bị thiệt. Kết hợp code + LLM ngữ nghĩa.
- Không chuẩn hóa điểm LLM. Nếu LLM trả 0–100 tự do và bạn cộng vào, thang điểm loạn. Luôn ép về 0–1 rồi nhân weight.
- Đưa tên/ảnh/tuổi vào chấm. Mở đường cho bias và rủi ro pháp lý.
- Quên nhánh "thiếu dữ liệu". CV thiếu thông tin bị chấm thấp oan; phải flag cho người xem thay vì loại thẳng.