简历这行当有个挺残酷的现实:一个稍微像样点的岗位放出去,头两天收到的简历能上百份,其中真正符合硬性条件的可能不到三成。初筛的活儿落在 HR 或者一线技术负责人头上,一个人一天过几十份简历,大概率状态是“看了,但没完全看”。我们做的这个开源项目,就是专门解决这件事的——基于 LangGraph 搭了一套简历智能筛选系统,把“简历收取、字段解析、人岗匹配、评分卡输出”整条链路交给自动化流程,模型只负责它擅长的语义理解部分,硬规则交给代码去卡。
这套系统我称之为“企业级招聘自动化”方案,不是说它一定要部署在大型集群上才能跑,而是它的流程设计、权限边界、可追溯性都按生产环境标准来做。你拿去某个部门做试点可以,直接接进一个中型公司的招聘流程也可以。这篇文章我把整体设计、核心节点、Prompt 思路、踩过的坑、以及开源发布时怎么组织仓库都梳理了一遍,给想自己搭一套简历初筛工具,或者正在评估 LangGraph 能不能上真实业务的朋友做参考。
1. 从“投递”到“约面”之间,卡了整整一沓纸质简历
1.1 招聘初筛环节的真实瓶颈在哪里
大多数公司的招聘流程可以简化成投递→初筛→约面→Offer,但真正让效率崩掉的,往往就是“初筛”这一步。候选人端感觉投完简历石沉大海,业务端感觉简历看不过来,原因是两个:第一,JD(职位描述)里写的硬性条件和简历里的实际描述不是结构化对等关系,光靠关键词匹配会漏掉很多合格的候选人;第二,即便用上了关键词过滤,真正判断“这个人能不能干这个活儿”,还是得靠人来读、来猜、来权衡。
我自己见过一个某团队的真实案例:招一个偏底层的岗位实习,JD 写得挺清楚,投进来的简历里,有一半以上只是“相关”而非“匹配”。人工一封封看,先看学历、年限、技能标签,再看项目经历有没有相关性,一轮下来耗时是按天算的。而这个过程最大的问题不是慢,是标准漂移——上午精力好,可能卡得严;下午疲惫了,可能看到差不多就放进来。同一批简历换个人筛,结果也可能不一样。
1.2 为什么大模型适合做这件事,但直接调用又不够
大模型擅长的事情恰好是招聘初筛中最难的部分:理解一段自由文本描述,判断它和另一个自由文本描述(JD)之间的匹配程度。比如简历里写“负责过用户增长相关数据分析,搭建指标体系”,JD 里写“需要具备数据指标体系搭建经验”,关键词并不完全重叠,但语义是匹配的。传统字符串匹配在这里失效,大模型却很轻松。
但如果你真的写了段脚本,把所有简历丢给大模型让它输出“通过/不通过”,很快就会发现不合适。首先是不可控:模型有时候会给特别的简历打高分,理由也花里胡哨;其次是不可解释:HR 追问“这个候选人为什么被拒”,你很难给出一个结构化的、可以被复核的依据;再就是不可复用:你想调整打分权重、换一个 JD 对比、或者把标准从“严格”调到“宽松”,脚本和 Prompt 会搅在一起,改一处崩一片。
这也是我在调研阶段逐渐从“调用大模型”转向“用 LangGraph 编排流程”的原因。LangGraph 的价值不在于它会调大模型,而在于它把“大模型调用”变成整个系统中一个可以被编排、被控制、被替换的节点。系统的主干是确定性的流程,大模型在合适的位置做语义判断,两条腿走路,才不会把筛选结果变成掷骰子。
2. 为什么选 LangGraph?有状态编排和普通 AI Pipeline 差别很大
2.1 LangGraph 解决的核心问题:让流程有状态、能分支、会循环
很多朋友听到 LangGraph 第一反应是“又一个 LangChain 的封装”,这个理解不准确。LangGraph 的核心抽象是“图”,图上的每个节点是一个处理单元,节点之间通过边连接,整个图的执行过程由一个共享状态对象驱动。听起来和普通的 Pipeline 差不多,但有一个关键区别:Pipeline 是一条道走到黑,图的执行可以在节点之间来回跳动,可以条件分支,甚至可以循环回来重新处理。
我打个比方,普通 Pipeline 像流水线,工件从第一个工位流到最后一个工位,流过去就结束了。LangGraph 更像一个车间里的“工作流转系统”,每个工件带一张流转单(状态对象),到哪个工位做什么操作、做完之后去下一个工位还是回头返工,都由流转单上的信息决定。简历筛选这个场景显然更适合后者:一份简历解析出来字段缺失,可能要走“补充解析”的分支;一份简历模型打分太低,可能直接走“淘汰”分支,不再需要后续节点。
2.2 有状态的意义:每次筛选过程都留痕、可追溯、可回放
选型阶段很多人问,为什么不能用 LangChain 的 SequentialChain 串起来?关键差别其实在“状态是否显式存在”。SequentialChain 每个环节的输入输出虽然能传下去,但中间状态是隐式的,一旦某个环节需要临时修改下一步路径,就得写不少胶水代码。而 LangGraph 把状态对象做为第一公民,不仅传递数据,还记录每一步的执行内容和结果。
在我们的系统里,每个候选人的简历从进入系统开始,就生成一个独立的“筛选上下文”:原始文本、解析后的 structured 字段、每轮评分的中间值、命中的规则、模型给出的判断理由、最终评分卡,全部挂在同一个状态对象上。这个对象既可以被后续节点读取,也可以被导出成 JSON 存档。招聘团队后续如果要复盘“这批被淘汰的人里有没有误杀”,直接翻状态记录就行。这种可追溯性在实际部署里太重要了,因为没有哪个业务方敢无条件信任一个黑盒打分器。
2.3 LangGraph 在多智能体协作上的天然优势
另一个选 LangGraph 的理由是它的图结构天然支持多智能体协作。简历筛选表面上是一个流程,实际拆开看是多个角色的分工:有人负责解析(把 PDF 简历变成结构化字段),有人负责硬性资格审查(学历、年限、技能),有人负责语义匹配打分(和 JD 做匹配判断),有人负责最终结论(综合硬条件与软匹配)。LangGraph 里每个角色都可以是一个独立节点,各自维护自己的 Prompt、自己的输入输出格式,互不干扰。
更妙的是,如果将来想调整策略,比如把某个节点的模型从 A 换成 B,或者给某个节点增加一个前置质检步骤,只需要改这个节点的实现和对应的边,其他节点完全不用动。我在多个项目里的体会是,LangGraph 这类“显式编排”框架,初期写起来比直接写脚本麻烦一点点,但一旦进入迭代阶段,维护成本优势会非常明显。
3. 系统链路拆解:一份简历从投递到评分卡,到底走了哪几步
3.1 六个核心节点,构成一条完整简历处理链路
我们的系统在架构上拆成了六个核心节点,每个节点职责单一,输入输出都被明确定义。这里我用一个表格把链路和职责先列出来,后面再细节展开。
| 节点 | 职责 | 输入 / 输出 |
|---|---|---|
| 格式解析 | 识别简历文本内容,去除非正文元素 | 输入:PDF / DOCX / 文本 / HTML;输出:清洗后的正文文本 |
| 字段抽取 | 用大模型抽取候选人的结构化信息 | 输入:清洗后文本;输出:fields(学历、工作经历、技能、项目等) |
| 硬性资格审查 | 用规则引擎卡硬条件 | 输入:fields + 招聘配置;输出:pass / reject / need_more_info |
| 语义匹配评分 | 用大模型评估字段与 JD 的匹配度 | 输入:fields + JD;输出:match_score、匹配理由 |
| 加权汇总 | 按配置把硬性审查和语义评分合成为总分 | 输入:资格审查结果 + 匹配评分;输出:总分、等级 |
| 结果归档 | 生成结构化评分卡并归档 | 输入:总分、评分细节、状态;输出:JSON + 筛选结论 |
一开始我们想偷懒把“格式解析”和“字段抽取”合并,实际跑下来发现不行。PDF 里常常夹着页眉页脚、公司介绍、甚至一串报名二维码,如果不先清洗直接丢给模型,抽取出来的字段里面会混入大量噪声。后来把“格式解析”单独拆成一个确定性节点,用正则和文本处理把明显无关的内容剥掉,字段抽取的准确率明显提升。这个经验也分享给做同类系统的朋友:能确定性地清洗,就不要依赖模型自己去过滤。
3.2 状态流转怎么设计:每个候选人的“流转单”长什么样
LangGraph 里的 State 是整个系统的核心,我们在代码里把 State 定义成一个 TypedDict,结构上分成四个部分:原始输入、中间结果、最终结果、审计信息。原始输入包括简历原始文本、文件路径、当前招聘批次 ID;中间结果包括解析后的字段、硬审结果、语义匹配分;最终结果包括总分、等级、结论理由;审计信息则记录每个节点开始和结束的时间戳、调用链、Token 消耗。
这样设计的好处是每个节点只负责读它需要的 key、写它负责的 key,不会出现某个节点偷偷改了别的节点数据的情况。尤其当多个候选人简历并行处理时,State 对象之间完全隔离,不会串数据。审计信息这部分是后来加上去的,原因是上线后总有 HR 反馈“这个人的分感觉不对”,有了审计信息,我们能直接精确到“哪个节点、用了哪条规则/哪轮 Prompt、当时模型返回了什么东西”,排查效率提升了不止一个量级。
3.3 条件分支:什么情况下进入淘汰通道,什么情况下需要补充追问
LangGraph 的边(Edge)在系统中最典型的使用场景是条件路由。我们定义了一条规则:硬性资格审查如果不通过,直接进入 reject 分支,不再调用当前JD对应的语义匹配节点——因为对于硬性硬伤(比如岗位强制要求某类证书,候选人完全没有),让模型再评一次分意义不大,还浪费调用预算。
更值得说的是 need_more_info 分支。最开始我们没有这个分支,后来发现有些简历写得特别简短,比如只有工作单位没有具体职责描述,字段抽取出来信息维度不够,直接打分的结果往往偏低。于是加了一个“判断分支”:如果核心字段(工作经历、项目经历、技能标签)不足 N 个,状态标记为 need_more_info,流程会引导到一个“澄清模板”节点——生成一封委婉的补充材料请求邮件,同时给 HR 一个标记“该候选人可能需要人工复核”。这个改动在真实测试中把漏筛率降低了大概一两个百分点,因为很多简历简短但高匹配的候选人,其实只是不会写简历,而不是能力不行。
4. 评分节点不是“让模型随便打打分”:双轨设计值得细看
4.1 评分卡设计:把主观判断拆成可以复核的维度
如果直接把整份简历文本和 JD 丢给模型说“请打分”,你得到的分数大概率是稳定但不可解释的。为了让评分卡具备可复核性,我们把“匹配”这件事拆成了五个维度:必备技能覆盖度、项目经历相关度、业务领域契合度、层级与职责匹配度、稳定性信号。每个维度单独让模型输出一个 1-5 分,同时必须给出一句话理由。
理由这个东西很重要。一个模型打 4 分而不说原因,等于没打。要求每个维度都带理由,一方面强迫模型去“先看内容再下结论”,另一方面这些理由最终会拼接成评分卡反馈给 HR,HR 不用再去翻原简历也能大致了解这个人强在哪、弱在哪。我们实测下来,加了这个约束后,模型打分的稳定性也提高了,因为“得分必须有依据支撑”本身就是一种约束。
4.2 权重怎么配置:硬性条件永远优先于软性匹配
双轨设计中,硬性资格审查和语义匹配评分各自产生一个分数,最后加权汇总。权重怎么配,直接决定了系统的性格。我们的默认配置是:硬性资格一票否决制 + 软性匹配分修正。也就是说,硬审不过,直接淘汰,不需要进入加权;硬审通过,语义匹配分决定候选人的排序和等级。
这个配置的思路来自和招聘团队的反复讨论:硬性条件(比如学历门槛、工作年限、特定技术栈要求)是底线,软性匹配决定的是“同样满足底线的人里谁更合适”。如果两者直接相加,可能会出现“硬性差但软性极好”的人冲上来,这在很多企业场景里是 HR 不愿意接受的。所以我们在加权函数里加了一个惩罚系数:硬审不通过时,即使语义匹配分很高,最终等级也会被压到最低档,并在结果里明确标注“被硬性规则拦截”。
4.3 规则引擎与模型判断怎么配合:能写死的逻辑就不要让模型自由发挥
这一点是我在整个项目中最想强调的设计原则:凡是能写成确定性规则的事情,一律不要交给模型判断。比如“学历要求本科及以上,候选人是硕士”——这种判断直接对字段做字符串匹配就行,让模型打分属于大材小用,而且不可控。只有真正需要语义理解的环节(比如“这个项目的职责描述和 JD 里的职责要求是否匹配”)才交给模型。
我们的具体做法是用一个 Python 规则引擎先跑硬性条件,规则从招聘配置里读取,支持大于、小于、包含、正则匹配、枚举匹配这些操作符。规则引擎的输出是一个布尔结果加一个命中的规则名,这个结果会作为硬审节点的最终输出。规则引擎的好处是配置化,HR 可以在配置文件里改门槛,不需要动代码;模型判断集中在语义匹配节点,两边的职责边界非常清晰。
4.4 输出协议:结构化评分卡,让人和系统都能直接消费
整个链路的最终产物是一张结构化的评分卡,内容包含候选人 ID、总分、等级(推荐/待定/不推荐/命中硬性拦截)、各维度分、模型给出的理由摘要、命中的硬性规则列表、流程审计信息。这个结构设计参考了招聘系统里常见的“评估表”格式,既方便 HR 在后台直接看,也能作为候选人的“初筛档案”保存下来。
评分卡的输出协议定了,整个系统对外的边界也就清晰了。上游接收投递事件,下游输出评分卡事件,中间的过程全部在 LangGraph 内部完成。如果将来对接某招聘平台的 API,只需要把“简历文件下载”和“评分卡推送”两个适配器写出来,主流程完全不动。
5. 上真实业务之前,我踩过的那几个坑:脏 PDF、Token 超限、幻觉打分
5.1 PDF 解析的脏数据,比想象中更能带偏结果
简历的常见格式里,PDF 是最麻烦的。有些 PDF 是可以正常提取文本的,有些是用图片扫描件转存的,还有些是排版工具导出后文本顺序错乱。我们最初直接用解析库提取文本,结果发现三种问题:页眉页脚混入正文;多栏排版导致文本读取顺序乱七八糟;某些花哨模板里的装饰性文本被识别成了工作经历。
针对这些问题,我们做了两层处理:第一层是清洗规则,用正则去识别邮箱、电话、链接、页码等常见模式;第二层是字段抽取节点专设一个“低置信度标记”——如果构造出的字段里出现大量无法归类的文本片段,就把该候选人标记为“解析存疑”,进入人工复核池。这里我不建议强迫模型强行抽取,因为脏文本本身质量太差,模型填出来的字段大概率也是编的,反而污染后续评分。
5.2 长简历带来的 Token 超限,怎么压都有人超
你以为简历都很短?真不是。有些资深候选人的简历能达到大几页,加上项目描述,整份文本长度轻松超过几万字符。直接一次性丢给模型做字段抽取,Token 超限是很常见的事。我们的处理方案是“分段 + 分层抽取”:先把文本按逻辑块切分(教育背景、工作经历、项目经历等,用标题特征识别),再让模型按块抽取字段,最后合并一次。
分段策略上线后,字段抽取的完整度提升了不少,但 Token 总量反而下降了,因为很多重复的模板文本只被抽取一次。而且分段还有一个额外好处:如果某一段抽取失败,只用重试那一段,不需要整份简历重新过一遍模型,节省了成本也缩短了耗时。如果你也想搭类似的系统,我建议从一开始就把“分段抽取”当成默认策略,而不是等超限了再改。
5.3 幻觉打分:模型给出的高分理由,居然引用了一份不存在的项目
这是我在真实测试中遇到的最值得警惕的问题。一个候选人简历里其实只写了“负责后台模块的开发”,模型给出的语义匹配理由却是“候选人参与过与 JD 高度相关的支付系统架构设计,并在其中承担核心决策角色”——这份简历里根本不存在“支付系统”这四个字,典型幻觉。
这个问题的根因是模型在生成理由时“脑补”了细节。我们的解法是双管齐下:第一,在 Prompt 里强制要求理由必须引用原文中的具体片段,禁止无依据的推测性描述;第二,在输出协议里加一层校验——理由文本里的关键名词必须在候选人的原始简历中出现过,如果出现频率极低,则判定为“低置信度”,把该维度分打一个折扣。这套方案不能完全消灭幻觉,但确实把幻觉出现的概率压到了一个可以接受的范围。
5.4 并发和异步:多个候选人同时进来,不能让流程互相拖累
生产环境下简历不是一个一个进来的,而是一批一批进来的。最初我们用的是同步处理,一个候选人走完整个链路才处理下一个,结果简历一多,队列就堵了。后来改造为异步任务架构:收到简历事件后立刻返回“已接收”状态,后台任务队列把候选人简历逐个送进 LangGraph 执行。Graph 本身是无状态的(状态对象挂在任务上下文里),天然适合并行。
关于并发上限,我们没有直接给一个推荐值,因为模型服务商对你的并发配额是决定性因素。我们在自己的配置里把并发控制在模型服务商允许的调用上限以内,并在每个节点边上加了一个可控的调用间隔,避免突发流量把配额打爆。LangGraph 的多线程执行在这里帮了忙,因为不同候选人的状态对象是隔离的,不需要加锁也不需要共享内存。
5.5 模型轮次和降级:评分失败绝不能静默吞掉
模型调用偶尔会失败,超时、限流、返回格式不合法,都真实存在。我们的系统里给每个模型调用节点加了重试逻辑:第一次失败,延迟重试;第二次失败,换一个备用模型;备用也失败,把该候选人标记为“评分失败”,自动进入人工复核队列。绝不能静默地把它当成“不匹配”处理,这可能是整个系统里最重要的一条兜底策略。
如果有读者想把这个逻辑简化一下,最低限度也要做到:模型调用失败时状态必须有明确标记,让后续的人工环节能注意到这个候选人的评分是不完整的。宁可让一个人工复核,也不能因为稳定性问题把候选人的机会误杀。
6. 开源发布时,我把哪些东西一起放进了仓库
6.1 仓库结构:除了 README,还需要可跑的代码、示例数据和测试
开源发布不是“把代码丢到 GitHub 上就完事”。我整理仓库的时候刻意做了三件事:第一,跑通的最小可执行示例,包含一个小规模的模拟简历数据集;第二,完整的配置文件示例,涵盖规则配置、权重配置、Prompt 配置;第三,一套 CI 测试,至少保证示例数据跑流程不会报错。对于开源项目,让陌生人“拉下来五分钟就能跑起来”比任何文档都有说服力。
我把 Prompt 单独放在一个目录里而不是直接硬编码在代码中,是因为 Prompt 迭代速度远快于代码版本。贡献者如果想调优,只需要改 Prompt 文件,不需要碰逻辑代码。这个拆分在实际使用中非常顺手。
6.2 本地部署与数据隐私:简历不是可以随便出域的数据
做开源方案绕不开一个话题:简历数据非常敏感,很多企业是不允许把简历内容传到外部模型服务的。针对这一点,我们在文档里单独写了“本地化部署”章节,明确说明 LangGraph 框架本身不绑定云服务,模型接口层做了抽象,可以替换成本地部署的开源模型或企业内部模型网关。
这个抽象层是系统设计中花了比较多心思的地方。标准流程走外部模型拿效果,数据敏感的客户可以把接口切到内部推理服务。虽然本地模型的语义理解能力可能略逊于商用大模型,但胜在数据不出域。这类“可插拔模型层”的设计,增强了开源项目在不同合规要求下的生命力。
6.3 我预留的几个扩展方向,以及为什么先不做
仓库的 Issue 里有朋友问过要不要支持“批量导出到某ATS系统”“自动同步到某招聘平台”,我目前的答复是暂时不直接集成,但保留标准化的评分卡事件协议。原因是这些集成多半依赖具体平台的私有 API,维护成本会集中在适配层,而主流程的通用价值会被稀释。
我更建议后续的优化方向放在:进一步增强理由引用的校验逻辑,减少模型的幻觉打分;优化长文本分段抽取的策略,比如接入更智能的分块算法;以及完善测试基准确认“评分稳定性”变化。如果你正准备基于这个项目做二次开发,先从这几个方向入手,性价比会比再加一堆外部集成高得多。
ling