招聘是人力资源工作中最消耗人力的环节之一。一个中等规模的公司放出热门岗位,一周内收到五百份以上简历并不稀奇,而HR平均花在每份简历上的时间往往不足三十秒,这种高强度的粗筛很容易造成合适候选人的流失。简历筛选Agent的目标,就是把这种机械式的初筛工作交给程序完成:它读取简历文件,抽取结构化信息,对照岗位要求逐项比对打分,最后输出一份带理由的排序清单,HR只需要重点复核排名靠前的候选人即可。本文将围绕一个实际案例,完整拆解这套Agent的设计思路与实现细节。

一、整体架构:解析、抽取、打分、生成四个阶段
一个可用的简历筛选Agent通常不是单次调用大模型就能搞定的,而是由四个模块串联成的流水线。第一阶段是文件解析,负责把PDF、Word、图片等格式的简历统一转成纯文本;第二阶段是信息抽取,从非结构化文本中提取出姓名、学历、工作年限、技能列表等字段;第三阶段是匹配打分,将抽取结果与岗位要求做对照,给出量化分数与理由;第四阶段是报告生成,汇总成HR可直接阅读的候选人摘要。
之所以要拆成多阶段而不是把简历原文直接丢给大模型问一句“这个人合不合适”,主要有两个原因。首先是稳定性,单次调用长文本模型,输出格式很难保证一致,批量处理时会出现有的输出JSON、有的输出自然语言的情况,后续无法自动化归档。其次是可解释性,分阶段处理后每个环节的中间结果都可落库审计,当HR质疑某个候选人为什么被淘汰时,可以精确定位是抽取错了还是打分规则偏了。工程实践中,这种流水线结构也让每个模块可以独立迭代,比如换了更好的PDF解析库,只需替换第一阶段,不影响下游。
二、简历解析与信息抽取的实现细节
文件解析是最容易踩坑的环节。很多简历是扫描版PDF,本质上是一张张图片,直接用文本提取工具只能拿到空结果,必须先走OCR。即使是文字版PDF,双栏排版也常导致提取出的文本顺序错乱,工作经历的时间线被打乱后,抽取的准确性会大幅下降。实践中建议先用pdfplumber这类库提取文本,检测到提取结果过短时自动降级到OCR方案,两条路径统一输出纯文本。
信息抽取阶段建议用大模型配合JSON Schema约束输出。直接让模型“提取简历信息”容易输出宽泛的自然语言,加上格式约束后字段对齐率会显著提升。下面是一个可参考的抽取Prompt与解析代码:
EXTRACT_PROMPT = """你是一名简历信息抽取助手。请从以下简历文本中抽取字段,
严格按照JSON格式输出,找不到的字段填null,不要编造。
字段定义:
- name: 姓名
- education: 最高学历(博士/硕士/本科/大专及以下)
- years: 总工作年限,数字
- skills: 技能列表,数组形式
- last_company: 最近一家公司名称
- job_history: 工作经历数组,每项包含company/title/duration
简历文本:
{resume_text}
"""
def extract_resume(llm_client, resume_text: str) -> dict:
resp = llm_client.chat(
messages=[{"role": "user", "content": EXTRACT_PROMPT.format(resume_text=resume_text)}],
response_format={"type": "json_object"}, # 强制JSON输出
temperature=0
)
return json.loads(resp.content)
这里有两个细节值得注意。一是temperature要设为0,信息抽取是事实性任务,不需要模型发挥创造力,低温度能保证同一份简历多次抽取结果一致。二是“找不到填null,不要编造”这句约束必不可少,否则模型倾向于给空缺字段编一个看起来合理的值,比如候选人没写工作年限,模型可能根据毕业年份自行估算一个数字塞进去,这类幻觉数据会直接影响打分公正性。
三、打分机制:规则引擎与大模型结合的混合方案
打分环节如果完全交给大模型,让它直接给出一个总分,会出现分数漂移问题:同一份简历在不同时间打分,可能一次78分一次85分,HR无法信任这个数字。更稳妥的做法是混合方案——硬性条件用规则引擎判断,软性素质用大模型评估,两者加权合成总分。
具体来说,学历是否达标、工作年限是否满足、是否具备必备技能这类可以精确匹配的条件,用代码直接判断,结果百分百可复现。而项目经历与岗位的匹配度、职业发展轨迹的合理性这类需要语义理解的部分,交给大模型打分并要求输出理由。规则部分示例代码如下:
def rule_score(extracted: dict, requirement: dict) -> tuple:
"""规则引擎打分,返回(得分, 未通过项列表)"""
score = 0
failed = []
total = 0
# 学历检查
total += 1
edu_rank = {"博士": 4, "硕士": 3, "本科": 2, "大专及以下": 1}
if extracted.get("education") and edu_rank.get(extracted["education"], 0) >= edu_rank.get(requirement["min_education"], 0):
score += 1
else:
failed.append(f"学历不满足,要求{requirement['min_education']}")
# 年限检查
total += 1
if extracted.get("years") is not None and extracted["years"] >= requirement["min_years"]:
score += 1
else:
failed.append(f"工作年限不足,要求{requirement['min_years']}年")
# 必备技能检查
for skill in requirement["must_skills"]:
total += 1
if any(skill.lower() in s.lower() for s in extracted.get("skills") or []):
score += 1
else:
failed.append(f"缺少必备技能: {skill}")
return score / total, failed
大模型评估部分则要求输出结构化的分项分数和理由,例如“项目经历相关性:8分,候选人近三年在做推荐系统,与本岗位的搜索排序业务高度相关”。把规则分和模型分按6比4左右的权重合成,既保证了硬性条件的确定性,又保留了语义层面的灵活性。输出淘汰理由是这个方案的关键价值——HR看到的不只是一个数字,而是“缺少必备技能:Kubernetes”这样可直接行动的信息。
四、人才库检索与工程化落地
简历筛选Agent做完单份处理,自然延伸到人才库场景:新岗位发布后,从历史简历库中召回曾经投递过的潜在匹配者。这就需要用到向量检索。做法是把每份简历的技能描述、项目经历做Embedding后存入向量数据库,新岗位的JD同样向量化,通过相似度检索召回候选人,再用前文的打分流程精排。召回阶段用向量、精排阶段用大模型,这套两阶段架构能有效控制成本,避免对上万份简历逐个调用大模型。
工程化落地还有几个容易被忽视的问题。第一是隐私合规,简历包含大量个人信息,送第三方大模型API前需要评估数据安全,条件允许的话优先部署开源模型做抽取,敏感字段做脱敏处理。第二是保留人工复核入口,Agent的定位是初筛助手而非最终决策者,排序结果只作为参考顺序,HR界面要支持一键标记误判,这些标注数据反过来可以优化打分权重。第三是做好失败兜底,OCR失败、JSON解析异常的简历要进入人工队列而不是静默丢弃,否则漏掉的可能正是那份最好的简历。把这三点处理好,这套系统才能真正在招聘流程中稳定跑起来。