tva
← 洞察

LLM 驱动的职位匹配:构建适配度评分管道

关键词过滤速度快、成本低,却容易在关键判断上出错。招聘人员定义的理想候选人画像包含领域知识、技术深度、沟通风格及十几项情境偏好,词袋方法无法准确判断某条职位信息是否符合画像。“5+ 年 Python 经验”与“使用 Python 编写大量依赖异步 I/O 的生产级数据工程工作流”之间的语义差异,关键词过滤看不出来。但在合适的提示词下,语言模型恰好能评估这种差异。

我们构建的职位匹配管道根据固定候选人画像,对队列中的职位信息逐条进行 LLM 评分,并行执行匹配、实时跟踪 token 成本,在原生 SwiftUI macOS 应用中提供暂停和恢复控制。本文介绍系统的结构,以及在哪些情况下,LLM 匹配相较于更简单的方法确实值得这笔开销。


提示词架构

系统的核心是一条评分提示词,每条职位信息调用一次。每次调用接收两项输入:候选人画像和职位文本。画像在整个会话中保持不变,包含技能、经验水平、领域偏好、不可妥协的约束,以及招聘人员希望模型考虑的情境说明。职位信息则提供抓取所得的职位名称、公司、描述和要求。

模型返回包含两个字段的结构化 JSON 对象:0 到 100 的数值分数,以及说明匹配或不匹配关键因素的简短理由。这里必须强制使用结构化输出。非结构化响应需要依赖脆弱的解析逻辑,分数的呈现形式也可能不一致。通过工具调用或函数调用接口配合 JSON 数据模式(在供应商提供时使用严格模式或结构化输出模式),可以在很大程度上消除这类问题,让后续聚合变得简单;不过,仍值得对解析后的对象进行验证。

有一项设计选择需要明确:画像应足够详细。简短画像会让评分波动较大,因为模型缺乏区分 70 分与 85 分的上下文。画像应清楚说明各项约束为何重要,而不只是列出约束,这样模型才能获得足够的信息,给出有区分度的评分。应把画像当作详细的评分标准,而不是过滤字符串。

使用 ThreadPoolExecutor 并行执行

队列中有 50 或 100 条职位信息时,串行 API 调用的速度无法满足交互需求。Python 的 concurrent.futures.ThreadPoolExecutor 可轻松处理并行执行。工作线程数根据 API 供应商的速率限制设置,通常 5 到 10 个并发请求能明显提升吞吐量,又不触发配额错误。

这一模式使用 as_completed(),按完成顺序而非提交顺序返回 future 对象。因此,最快完成的匹配结果会先显示,界面可逐步更新,无需等待整批任务完成:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=8) as executor:
    futures = {
        executor.submit(score_listing, listing, profile): listing
        for listing in listings
    }
    for future in as_completed(futures):
        listing = futures[future]
        score, rationale = future.result()
        update_results(listing, score, rationale)

这里需要专门处理错误。单次 API 调用可能因速率限制、临时网络故障或内容政策拒绝而失败。每个工作线程都应捕获异常并返回哨兵值(上面的代码示例为简洁起见省略了这部分处理);否则,future.result() 会在结果循环中重新抛出工作线程的异常,停止处理其余结果。评分失败的职位信息进入重试队列,而不是被静默丢弃。明确呈现失败情况,比让评分列表出现原因不明的缺项更便于理解和排查。

实时跟踪 token 用量

大量调用时,token 成本容易被低估。向 100 条职位信息分别发送包含 400 个 token 的提示词,并获得包含 150 个 token 的响应,费用会随模型档次迅速累积。管道通过线程安全计数器跟踪累计用量:一个由 threading.Lock 保护的简单整数,每次 API 响应后,根据响应元数据中的 token 数增加计数。

SwiftUI 前端定时轮询计数器,显示累计输入 token、输出 token,以及按当前模型单 token 价格估算的费用。这样,招聘人员可以实时了解会话成本,也能发现画像是否让模型给出了异常冗长的理由。这通常意味着提示词需要精简,或职位文本中的噪声比预期更多。

一项实用改进是:发送前也估算 token 数,而不只在响应后统计。主要供应商提供分词库或 token 计数端点,例如 OpenAI 的 tiktoken 库或 Anthropic 的 token 计数端点。预先计数可以让管道在职位文本异常冗长、会占用过多上下文时发出警告,也能发现组合后的提示词是否接近模型的上下文上限。在 API 调用前发现这些问题,可避免为会被截断或拒绝的请求支付不必要的费用。

暂停与恢复

招聘人员在会话中途审阅结果时,可能希望暂停新的评分请求,同时保留正在进行的工作。暂停和恢复机制使用 threading.Event 作为共享信号。工作线程在每次 API 调用前检查事件状态;暂停时,阻塞于 event.wait()。重新设置事件后,执行继续。

这比终止再重启线程更简洁,因为已经发出的 API 调用可以自然完成。暂停意味着“停止启动新调用”,而不是“立即中止所有工作”。当您希望当前批次先完成,再让用户审阅结果时,这一区别很重要:只完成了一部分的批次比完整批次更难判断。

暂停和恢复过程中的状态持久化也应明确处理。管道维护一个检查点文件,以简单的 JSON 列表记录已评分的职位 ID。恢复时,跳过检查点中已有的职位信息。只要分数和理由也与检查点一起持久化保存,即使应用在会话中途崩溃,重启后也无需重新评分已处理的职位信息。实现会话级幂等性几乎不增加多少工作,却能省去不少麻烦。

SwiftUI macOS 前端

目标机器是 macOS,因此我们选择 SwiftUI 构建前端,以获得响应及时、开销低且完整支持 macOS 窗口管理的原生体验。Electron 外壳也可实现,但会为单用户效率工具增加不必要的复杂性。Python 后端作为本地进程运行,用 Flask 暴露最小 HTTP API;SwiftUI 应用通过 localhost 与其通信。

SwiftUI 的 ObservableObject 和 @Published 模式可简洁地处理响应式更新循环。视图模型定时轮询后端,更新驱动结果列表和 token 计数显示的发布数组。主窗口显示三个面板:按分数降序排列的结果列表、实时 token 用量及估算费用计数器,以及带有暂停/恢复和停止按钮的控制栏。

每行结果显示职位名称、公司名称、以颜色区分的分数徽标,以及 LLM 给出的一句话评分理由。点击后打开详情面板,展示完整理由、模型标出的关键因素,以及指向原始职位信息的直接链接。招聘人员的大部分审阅时间都花在详情面板上。分数用于引导关注重点,而不是作出最终决定。

何时 LLM 匹配优于关键词过滤

当要求明确、且只有符合与不符合两种结果时,关键词过滤就是合适的工具:必须持有指定证书、必须位于指定城市,或必须在某类职位上达到最低任职年限。对于这些要求,简单过滤即可在毫秒内完成,而且没有费用。

但在实际应用中,多数值得关注的约束都依赖上下文。“希望找到能编写整洁且测试充分的代码的人”是一项偏好,它体现在职位描述的写法中,而不是一个独立关键词。“不考虑只有旧技术栈且无现代化路径的职位”则需要阅读和理解描述。关键词过滤无法提取这种信号。只要画像明确列出这些约束,LLM 就能直接评估。

在招聘信息大量堆砌关键词的市场中,两者的差距会进一步扩大。如果每条招聘信息都列出相同技能,而不论这些技能是否真是职位的核心要求,关键词匹配就会得出几乎没有差别的分布,让大多数职位看起来都差不多。语义评分能从这种噪声中识别差异,因为它评估的是职位信息整体上与画像的契合程度,而不只是是否出现了某些词。

何时无需使用 LLM

LLM 评分会增加实际成本和延迟。对于要求简单、规模很大的筛选任务,例如根据明确的二元要求筛选数千条职位信息,结构化过滤链的速度和成本优势可达数个数量级。合适的架构通常结合两种方法:先用快速预过滤排除明显不匹配项,将队列缩减到可处理的规模,再仅对剩余信息进行 LLM 评分。如果预过滤排除 80% 的职位信息,LLM 评分成本也会降低大致相同的比例;只要预过滤仅排除明显不匹配项,匹配质量就能保持。

另一项需要考虑的因素是一致性。LLM 会引入非确定性,同一条职位信息评分两次,结果可能略有不同。对多数招聘应用来说,这种差异可以接受:上下浮动几分,不会改变哪些职位值得审阅。但如果不同会话使用了不同模型版本,或提示词经过重大修订,在比较这些会话的评分时就需要注意这一点。

相关洞察

延伸阅读