职位搜索的排序做完了,怎么证明它排得好?这是招聘平台、猎头系统、搜索推荐团队里绕不开的老问题。难点在于:职位搜索的 query 背后是复杂的用户意图,既要关键词相关,又要技能匹配,还要考虑工作地点、经验年限、薪资范围这些结构化条件。传统做法是拉一批标注团队手动打分,用 NDCG、MRR 算离线指标,但标注周期长、成本高,而且不同标注员的口径很难完全一致,排错的原因也没法逐条解释。
LLM judge 是另一种思路:把排序结果直接交给大语言模型,让它按照预先定义的评估维度逐条打分,再聚合成排序质量分。它不需要大规模人工标注,评估维度可以按业务自定义,批量跑完之后还能和人工打标结果做一致性校验。这篇文章就围绕这套方案展开,从一个可落地的评估框架出发,依次给出数据格式、Prompt 模板、单条评估代码、批量任务实现和排序质量聚合指标。整套方案不绑定具体模型厂商,只要模型服务支持 OpenAI 兼容的接口格式,就能接入现有评测流程。
先说明一点:这不是某个特定开源仓库的使用教程,而是一套通用的"LLM judge 评估排序"工程实践。你可以在自己团队的搜索评测流水线里直接改造使用。
1. 核心能力速览
先把这套方案的关键属性列出来,方便你快速判断适不适合自己的场景。
| 能力项 | 说明 |
|---|---|
| 评估任务 | 职位搜索 query 对应的排序结果质量评估 |
| LLM 角色 | Judge 评分器,输出维度分、每个职位的总体分和评估理由 |
| 评估维度 | 相关性、技能匹配、经验适配、地点适配等,可按业务增减 |
| 所需硬件 | 无特殊要求,调用 LLM API 或内网模型服务即可,不需要本地 GPU |
| 支持批量任务 | 支持,JSONL 输入、并发调用、失败重试 |
| 输出形式 | JSONL 原始结果 + 聚合指标(平均分、位置质量曲线、LLM-NDCG) |
| 与传统指标关系 | 可与 NDCG、MRR 并行使用,也可把 LLM 分数当作相关性标签计算 nDCG |
| 人工对齐 | 支持与人工标注计算 Cohen's Kappa、Spearman 相关系数 |
| 适合场景 | 排序模型离线回归、搜索体验监控、A/B 实验辅助、人工评估抽样复核 |
这套方案的核心优势有三个:第一,评估口径稳定,同一套 Prompt 在低温参数下可以重复复现;第二,可解释性强,LLM 会输出理由,定位问题 case 时不用猜;第三,成本低于全量人工标注,适合排序迭代过程中的高频回归测试。
2. 为什么用 LLM judge 评估职位搜索排序
传统搜索排序评估主要靠两类手段。一类是离线指标,比如 NDCG、MRR、Recall@K,这些指标依赖人工标注的相关性标签,标注成本高,更新慢;另一类是线上指标,比如点击率、转化率、停留时长,但线上指标受位置偏差影响大,第一名的点击率高可能只是因为位置靠前,并不代表排序真的合理。
职位搜索场景还有一个特殊问题:排序结果的正确性不只看关键词相关性,还看候选人与职位之间的"供需匹配度"。比如用户搜索"Java 后端开发工程师",一个标题里同时包含 Java 和 Go 的岗位关键词相关度很高,但它要求 5 年以上大规模分布式系统经验,而候选人是 2 年经验的校招生,这个岗位排在前面就是有问题的。这类判断,传统标注员需要看完整职位描述才能做,成本非常高。
LLM judge 在解决这个问题上有几个明显优点:
- 评估维度可编程:可以在 Prompt 里明确要求模型同时评估"关键词相关性""技能重叠度""经验年限适配""地点适配",输出结构化的维度分数。
- 理由可追溯:每个分数后面跟着一句自然语言理由,开发人员可以直接从理由里看出排序问题的具体原因,比如"该岗位要求 5 年以上 Kafka 经验,候选人技能列表中无 Kafka"。
- 一致性稳定:把 temperature 设为 0,同一输入基本能得到相同评分。即使需要更高稳定性,也可以多次采样取平均分。
- 扩展成本低:新增一个评估维度只改 Prompt 和输出 schema,不需要重新培训标注团队。
但也要明确边界。LLM judge 不能完全替代人工评估,尤其在两类场景下要谨慎:一是涉及招聘决策的高风险岗位,比如高管、法务、医疗岗位,最终筛选结果必须有人工复核;二是评估数据中包含真实候选人简历、联系方式时,直接把 PII 数据发给外部 API 存在隐私风险,需要脱敏或使用内网模型服务。
3. LLM judge 评估体系设计
评估体系是整个方案的地基。Prompt 设计得好不好,直接影响评分质量和稳定性。建议先想清楚三件事:评估维度、评分标准、输出格式。
3.1 评估维度
职位搜索排序的核心评估维度不需要太多,控制在五个以内会让模型输出更稳定。推荐一组初始维度:
| 维度 | 说明 | 常见判断依据 |
|---|---|---|
| relevance | 搜索 query 与职位标题、描述的相关性 | 关键词命中、语义相似度 |
| skill_match | 候选人的技能与职位要求技能的匹配程度 | 必需技能、加分技能、技能缺失 |
| experience_fit | 候选人的工作年限、职级与职位要求是否匹配 | 年限范围、职级要求、项目复杂度 |
| location_fit | 候选人的所在地与工作地点是否匹配 | 当前城市、是否接受远程、是否标注可搬迁 |
如果需要,可以增加"薪资匹配""公司规模偏好"等维度。但建议分阶段增加:先用 4 个维度跑通,再根据实际失败 case 决定是否添加。维度过多时模型容易顾此失彼,输出质量会下降。
每个维度都需要一个明确的操作性定义。比如 skill_match,不是简单数一下技能重合个数,而是要区分"职位要求中明确列为必需的技能"和"加分项技能"。这个规则要写进 Prompt。
3.2 评分标准
每个职位按 0 到 4 分五档打分。具体定义建议如下:
| 分数 | 含义 | 判断标准 |
|---|---|---|
| 4 | 完全匹配 | 核心条件和多数加分条件都满足,排在该位置合理 |
| 3 | 大部分匹配 | 主要条件满足,有少量不一致但影响不大 |
| 2 | 部分匹配 | 一部分条件满足,存在明显不满足项 |
| 1 | 弱匹配 | 少数条件满足,整体匹配度差 |
| 0 | 完全不匹配 | 属于误召回,明显不应出现在此 query 下 |
评分标准要在 Prompt 中完整描述,否则模型可能打出各种奇怪的中间值。
3.3 评估 Prompt 模板
这里给出一套可以直接使用的 Prompt 模板。系统 Prompt 负责定义角色、评分标准、输出 JSON schema;用户消息携带具体的 query、候选人画像和职位排序列表。
JUDGE_SYSTEM_PROMPT = """你是一个职位搜索排序质量评估专家。你的任务是评估一组排序结果对给定搜索意图的匹配质量。 ## 评估流程 1. 阅读用户提供的搜索 query、候选人画像和按当前排序输出的职位列表。 2. 先概括搜索意图。 3. 对职位列表中的每个职位逐一评分,必须使用 rank 字段标识职位顺序。 4. 输出 JSON,不要输出额外解释。 ## 评分维度 - relevance:query 与职位标题/描述的语义相关性,关键词命中程度。 - skill_match:候选人技能与职位要求技能的匹配程度,重点看职位必需技能。 - experience_fit:候选人经验年限与职位要求的匹配程度。 - location_fit:候选人所在地与工作地点的匹配程度,若职位支持远程需充分考虑。 ## 评分标准(每个维度) - 4:完全满足,无瑕疵 - 3:基本满足,有少量不足 - 2:部分满足,存在明显不满足项 - 1:少数满足,整体匹配度差 - 0:完全不满足 ## output JSON schema { "intent_summary": "一句话概括搜索意图", "job_scores": [ { "rank": 1, "overall_score": 0, "dimension_scores": { "relevance": 0, "skill_match": 0, "experience_fit": 0, "location_fit": 0 }, "reason": "一句话说明给分理由,必须引用职位或候选人信息" } ], "list_feedback": "对整个排序列表的总体评价,指出头部排序是否合理" } ## 注意事项 1. 只根据提供的职位信息和候选人画像做判断,禁止编造职位描述中不存在的条件。 2. 如果某个维度信息不足,该维度给 2 分,并在 reason 中标注"信息不足"。 3. job_scores 数组必须覆盖列表中的每个职位,缺失一个 rank 都算失败。 """用户消息部分按实际数据组装。这里的关键是让模型看到完整的上下文:query、候选人画像、职位列表。职位描述建议截断到 500 字以内的摘要,避免 token 超限,也避免模型被冗长描述干扰。
4. 数据准备与预处理
4.1 输入数据格式
推荐使用 JSONL 文件,一行一个评估 case。每个 case 包含 query、候选人画像和当前排序的职位列表。示例如下:
{ "case_id": "case_0001", "query": "Java 后端开发工程师 上海 3-5年", "candidate_profile": { "years_of_experience": 4, "current_city": "上海", "skills": ["Java", "Spring Boot", "MySQL", "Redis", "Kafka"], "expected_salary": "30K-40K" }, "ranked_jobs": [ { "rank": 1, "job_id": "JOB-3341", "title": "Java后端开发工程师", "company": "某金融科技公司", "location": "上海·浦东", "salary_range": "25K-40K", "experience_required": "3-5年", "skills": ["Java", "Spring Boot", "MySQL", "Kafka"], "description_snippet": "负责交易系统后端开发,要求扎实的 Java 基础,熟悉高并发场景,有金融系统经验优先。" }, { "rank": 2, "job_id": "JOB-2210", "title": "高级Python后端开发工程师", "company": "某电商公司", "location": "杭州", "salary_range": "35K-50K", "experience_required": "5-10年", "skills": ["Python", "Django", "Go"], "description_snippet": "负责电商中台服务开发,需要使用 Python 和 Go 进行微服务设计。" } ] }注意两个细节。第一,description_snippet不要放完整的长文本,建议只保留前 200 到 500 字并做截断;第二,rank字段是排序位置,必须和线上排序输出保持一致,否则后面的位置质量曲线会算错。
4.2 数据清洗与脱敏
职位搜索评估中可能涉及求职者和企业的敏感数据。在构造评估 case 前,必须做以下处理:
- 删除候选人画像中的姓名、手机号、邮箱、身份证号等个人身份信息。
- 公司名称如果涉及内部敏感信息,可以替换为"某金融科技公司"这类占位。
- 如果职位描述中包含面试官姓名、内部系统链接等内容,要一并清洗。
- 确认职位信息、简历数据具备合法使用授权后再用于评估,尤其是调用外部 LLM API 时。
脱敏不只是合规要求,也是评测质量要求。如果模型在理由中引用了候选人姓名,说明输入数据里还残留 PII,会干扰后续结果分析和数据共享。
5. 基础评估实现:单条调用
5.1 调用方式
假设你的 LLM 服务提供 OpenAI 兼容的/chat/completions接口,可以用 requests 直接调用。这里把 base_url 放在环境变量里,方便切换外部 API 或内网模型服务。
import json import os import re import time import requests LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") LLM_API_KEY = os.getenv("LLM_API_KEY", "") LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini") def ask_llm_judge(case_data: dict, retry: int = 2) -> dict: headers = {"Content-Type": "application/json"} if LLM_API_KEY: headers["Authorization"] = f"Bearer {LLM_API_KEY}" messages = [ {"role": "system", "content": JUDGE_SYSTEM_PROMPT}, {"role": "user", "content": json.dumps(case_data, ensure_ascii=False)}, ] body = { "model": LLM_MODEL, "messages": messages, "temperature": 0, "response_format": {"type": "json_object"}, } url = f"{LLM_BASE_URL}/chat/completions" last_exc = None for attempt in range(retry + 1): try: resp = requests.post(url, headers=headers, json=body, timeout=60) resp.raise_for_status() content = resp.json()["choices"][0]["message"]["content"] return parse_llm_json(content) except Exception as exc: last_exc = exc print(f"[LLM call failed] attempt={attempt + 1}, error={exc}") time.sleep(2 ** attempt) raise last_exc两个注意点。第一,temperature必须设为 0,这是评分稳定性的基础;第二,response_format的json_object参数只有部分模型服务支持,如果服务不支持就把它去掉,但要增加一个健壮的 JSON 解析函数。
5.2 返回结果解析
模型可能输出带 Markdown 代码块的 JSON,也可能在 JSON 前后夹带额外文字。所以解析函数要做两层兜底:先尝试json.loads,失败则用正则提取 JSON 块。
def parse_llm_json(content: str) -> dict: try: return json.loads(content) except json.JSONDecodeError: pass pattern = r"\{[\s\S]*\}" match = re.search(pattern, content) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError as exc: raise ValueError(f"failed to parse LLM output: {content[:500]}") from exc raise ValueError(f"no JSON object found in LLM output: {content[:500]}")解析成功后,要校验job_scores是否覆盖了输入列表中的所有 rank。缺失任何一个 rank 都说明模型输出不完整,需要重试或标记失败。
def validate_judge_output(case_data: dict, judge_output: dict) -> bool: jobs = case_data.get("ranked_jobs", []) expected_ranks = {job["rank"] for job in jobs} actual_ranks = {item.get("rank") for item in judge_output.get("job_scores", [])} return expected_ranks == actual_ranks这一步很关键。批量跑完几百个 case 后,如果没做完整性校验,后面聚合指标会静默出错。
6. 批量评估任务框架
单条评估跑通后,就可以进入批量阶段。批量任务的核心是三个问题:输入输出怎么组织、并发开多大、失败怎么重试。
6.1 JSONL 输入输出
输入文件用eval_cases.jsonl,每个 case 一行;输出文件用judge_results.jsonl,每行对应一个 case 的评估结果。这样做的好处是可以断点续跑:处理完的 case 已经落盘,程序中断后不需要重跑全部数据。
import random from concurrent.futures import ThreadPoolExecutor, as_completed INPUT_PATH = "./data/eval_cases.jsonl" OUTPUT_PATH = "./outputs/judge_results.jsonl" MAX_WORKERS = 8 MAX_RETRY = 3 def process_one_case(line_no: int, line: str) -> dict: case = json.loads(line) for attempt in range(MAX_RETRY): try: output = ask_llm_judge(case) if not validate_judge_output(case, output): raise ValueError(f"judge output missing ranks, line={line_no}") output["case_id"] = case.get("case_id", line_no) output["query"] = case.get("query", "") return output except Exception as exc: print(f"[line {line_no}] attempt {attempt + 1} failed: {exc}") time.sleep(2 ** attempt + random.uniform(0, 1)) return { "case_id": case.get("case_id", line_no), "query": case.get("query", ""), "error": "failed after retries", "job_scores": [], } def run_batch(): with open(INPUT_PATH, "r", encoding="utf-8") as f: lines = f.readlines() with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: future_map = { pool.submit(process_one_case, idx, line): idx for idx, line in enumerate(lines) } with open(OUTPUT_PATH, "w", encoding="utf-8") as out: for future in as_completed(future_map): result = future.result() out.write(json.dumps(result, ensure_ascii=False) + "\n") out.flush()6.2 并发控制与失败重试
并发数不是越大越好。外部 API 普遍有限流策略,并发过高会触发 429 或超时,反而降低整体吞吐。建议从 4 到 8 个 worker 开始,观察一段时间的成功率和耗时,再逐步上调。
失败重试采用指数退避策略:第一次失败等 2 秒,第二次等 4 秒,第三次等 8 秒,并加入少量随机抖动,避免同一时刻大量任务同时重试。连续失败三次后,该 case 写成 error 记录,不阻塞整个批次。
如果用的是内网部署的本地 LLM 服务(例如 vLLM、Ollama 这类自建推理服务),LLM 服务不要求和你跑批量评估脚本的机器放在同一台服务器上,只要设置LLM_BASE_URL为内网地址即可。用 OpenAI 的 Python SDK 时也支持自定义base_url,本质上只是换一个 HTTP 端点。
7. 排序质量聚合指标
批量评估完成后,原始 JSONL 只是一堆打分结果,还需要聚合出可决策的指标。推荐从三个维度看:整体平均分、位置质量曲线、LLM-NDCG。
7.1 整体平均分与维度得分
import json import pandas as pd def load_results(path: str) -> list[dict]: results = [] with open(path, "r", encoding="utf-8") as f: for line in f: if line.strip(): results.append(json.loads(line)) return results def build_score_frame(results: list[dict]) -> pd.DataFrame: rows = [] for res in results: for job_score in res.get("job_scores", []): rows.append({ "case_id": res.get("case_id"), "rank": job_score.get("rank"), "overall_score": job_score.get("overall_score"), "relevance": job_score["dimension_scores"].get("relevance"), "skill_match": job_score["dimension_scores"].get("skill_match"), "experience_fit": job_score["dimension_scores"].get("experience_fit"), "location_fit": job_score["dimension_scores"].get("location_fit"), }) return pd.DataFrame(rows) results = load_results("./outputs/judge_results.jsonl") df = build_score_frame(results) print("== 整体均分 ==") print(df["overall_score"].describe()) print("== 按维度均分 ==") print(df[["relevance", "skill_match", "experience_fit", "location_fit"]].mean())7.2 位置质量曲线
位置质量曲线统计每个排名位置的平均分。一个合理的排序结果应该是分数随 rank 递减,也就是第 1 名最高,第 2 名次之,依次下降。如果某个位置出现明显反弹,比如第 3 名平均分高于第 2 名,说明排序模型在该位置附近有系统性错误。
def position_quality_curve(df: pd.DataFrame) -> pd.DataFrame: curve = ( df.groupby("rank")["overall_score"] .agg(["mean", "count", "std"]) .reset_index() ) return curve print("== 位置质量曲线 ==") print(position_quality_curve(df))这条曲线在排序模型迭代中非常直观。每次替换排序模型后,重新跑同样的评估 case 集合,对比位置曲线变化,就能快速确认头部排序是否改善。
7.3 LLM-NDCG 与人工一致性
可以把 LLM 对每个职位的overall_score当作相关性标签,然后计算 nDCG@K。这样 LLM judge 的结果可以无缝接入传统排序指标体系,兼容你已有的回归流程。
import math from itertools import islice def ndcg_at_k(scores: list[float], k: int = 10) -> float: dcg = sum((2 ** s - 1) / math.log2(i + 2) for i, s in enumerate(scores[:k])) ideal = sum((2 ** s - 1) / math.log2(i + 2) for i, s in enumerate(sorted(scores, reverse=True)[:k])) return dcg / ideal if ideal > 0 else 0.0 df_sorted = df.sort_values(["case_id", "rank"]) case_ndcg = ( df_sorted.groupby("case_id")["overall_score"] .apply(lambda x: ndcg_at_k(x.tolist(), k=10)) ) print("== LLM-NDCG@10 ==") print(case_ndcg.mean())与人工评估的一致性可以用 Cohen's Kappa 计算。你需要准备一组人工对每个 case 的"预期 top1 是否合理"或"每个职位是否匹配"的二分类标注,再和 LLM 的最高分职位对比。
from sklearn.metrics import cohen_kappa_score # human_binary: 0/1 列表,表示人工认为 top1 是否合理 # llm_binary: 0/1 列表,表示 LLM 认为 top1 是否合理(例如 top1 的 overall_score >= 3 记为 1) kappa = cohen_kappa_score(human_binary, llm_binary) print(f"Cohen's Kappa: {kappa:.3f}")一致性达到什么水平算可用,没有绝对标准。经验上 Kappa 在 0.6 以上可以认为 LLM judge 和人工评审有较好的一致性,0.4 到 0.6 之间说明有参考价值但需要继续调整 Prompt,低于 0.4 就要重点检查评估维度和评分标准是否偏离业务认知。
8. 性能与成本观察
批量评估脚本跑起来后,要重点关注三个指标:单 case 耗时、Token 消耗、失败率。
单 case 耗时取决于输入大小和模型响应速度。一个包含 10 个职位的 case,可能产生 2000 到 4000 个 Token 的输入和 1000 个 Token 左右的输出。外部 API 每个请求通常需要 1 到 3 秒。如果列表很长,建议先截断 description_snippet,或者限制每次评估最多打分 20 个职位,超出部分拆分多次评估。
Token 消耗是主要成本来源。可以在请求响应中读取 usage 字段并写入日志。
resp_json = resp.json() usage = resp_json.get("usage", {}) print(f"prompt_tokens={usage.get('prompt_tokens')}, " f"completion_tokens={usage.get('completion_tokens')}")如果预算有限,有几个降本手段:
- 减少每个职位的描述长度,只保留 title、skills、experience_required、location 等结构化字段。
- 先用小模型跑全量,再用大模型只复核小模型打分异常或置信度低的 case。
- 对结果做缓存,同一 query 和同一职位列表的评估结果直接复用。
失败率主要来自限流和超时。如果失败率超过 5%,先降低并发数,再检查服务端的限流策略,不要盲目加大重试次数。大量超时通常说明输入 token 过大,优先优化输入格式。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| LLM 输出不是合法 JSON | 模型能力不足,或服务不支持 json_object 模式 | 打印原始输出,查看前后文是否有附加文字 | 关闭 response_format,改用正则提取 JSON,或换更强的模型 |
| job_scores 数组缺失某些 rank | 职位列表过长,模型漏掉部分职位 | 打印批次的校验失败日志 | 增大上下文、拆分职位列表、在 Prompt 中强调必须覆盖全部 rank |
| 同一 case 两次评分不一致 | temperature 非 0,或模型本身随机性较大 | 用相同输入跑多次,记录分数分布 | 设置 temperature=0,多次采样取平均分 |
| 批次任务频繁失败 | API 限流、超时、输入 token 超出上限 | 查看异常类型和 HTTP 状态码 | 降低并发、指数退避重试、截断职位描述 |
| 评分结果与人工评估偏差大 | 评估维度定义不清晰,或 Prompt 缺少 few-shot 示例 | 抽样 20 个 case 对比 LLM 理由与人工理由 | 迭代 Prompt,增加正反示例,细化评分标准 |
| 评估耗时过长 | 职位列表过长,单请求 token 量过大 | 查看 usage 日志和单请求耗时 | 截断 description_snippet,或限制单次评估职位数 |
| 数据隐私风险 | 输入中残留候选人姓名、手机号 | 抽查构造的 case 文件 | 完善脱敏流程,优先使用内网模型服务 |
| 位置质量曲线异常 | 输入数据 rank 字段与线上排序不一致 | 对比线上日志与 case 文件的 rank | 修正数据导出逻辑,确保 rank 按最终排序输出写入 |
10. 最佳实践与使用建议
- 先小样本跑通,再全量执行。建议先准备 50 到 100 个覆盖不同 query 类型的 case,人工抽检一遍 LLM 的评分质量,确认方向正确后再扩展到全量数据集。
- 建立校准集。选取 10 到 20 个边界情况 case,比如跨城市搜索、经验不足但技能匹配、职位描述含糊等情况,放到 Prompt 的 few-shot 示例中,帮助模型理解评估口径。
- 保留最小可运行配置。把
JUDGE_SYSTEM_PROMPT、评测维度定义、评分标准、代码脚本放到同一个目录,用配置文件维护,方便排序模型迭代时复用。 - 批量任务要留日志和缓存。每个 case 的耗时、Token 用量、重试次数都要记录,方便后续排查问题;相同输入避免重复调用,节省成本。
- 评估完必须人工抽检。LLM judge 适合做"初筛",不适合做"终审"。每次跑完批量任务,抽样 20 到 30 个结果,重点看理由是否合理、评分是否符合业务直觉。
- 注意 Prompt 注入。职位描述是外部内容,可能包含"忽略以上指令"之类的对抗性文本。在系统 Prompt 中要明确要求模型只依据结构化字段和评分标准判断,不接受职位描述中的额外指令。
- 合规和数据授权。评估数据如果涉及候选人简历、真实求职者信息,必须先做脱敏和授权确认。涉及招聘决策的环节,LLM judge 的评分只能作为辅助参考,不能直接替代人工判断。
11. 总结与下一步
这套 LLM judge 评估方案最值得试的地方,是用不到人工标注十分之一的成本,得到一套可解释、可复现、可按业务定制维度的排序质量评估流程。建议你先跑通单条评估,确认输出 JSON 解析和完整性校验没问