给几个做外包/猎头招聘的 HR 朋友用的简历筛选辅助工具。他们帮多家客户招聘,JD 和规则经常变。核心思路一句话:AI 负责语义理解(抽取信息),代码负责严格匹配(打分排序),分三阶段——JD 结构化、简历结构化、规则匹配。
一个决策:LLM 只做它稳的事
需求里有个诱惑:让 LLM 直接从 JD 派生出每个条件的权重、再让 HR 拖滑块微调。我一度顺着「减少你工作量」切到了这个方案,然后自己把它推翻了——理由是:原始需求文档自己就写了「JD + 简历直接丢大模型不稳定」,那「JD → 权重」同样是不稳定的业务判断、没有事实依据;而且 HR 拖滑块是伪体验(HR 根本不知道该拖 30 还是 50)。最终定的是:LLM 只做信息抽取这种稳定任务,权重由模板均分。规则引擎分两类——硬条件(字符串/数值精确匹配,pass/fail)和语义偏好(embedding 相似度),分开处理,最终分按硬条件过/不过自然落到 [60,100] 和 [0,40] 两段。
核心 bug:短 JD 把真没经验的人排到了前面
测试时用一份只有一句话的短 JD(「有手机或平板的项目经验」做 stress test),结果排序彻底错了:3 个真有手机/平板经验的 sensor 工程师,反而被 3 个真没经验的咨询顾问压在下面。
根因拆下来很清楚:JD 太短 → 只有一个语义偏好、它占了 100% 权重 → 所有人硬条件都过、都拿 60 分 → 剩下 40 分全靠那一个 cosine 相似度。而 embedding 对「手机或平板的项目经验」这种宽泛 query 区分度不够,6 个人的相似度都挤在 0.62-0.74,咨询顾问简历里的泛行业词正好被算出偏高相似度。embedding 相似度在 query 宽泛、候选同质时会失灵——这是它的固有局限,不是参数没调好。
我没有用治标的办法(调阈值、或把短 JD 硬拆成 3-5 维),而是加了一层 LLM 布尔判断:把简历和每条偏好给 LLM,逐条输出 yes/no(强制 JSON)。打分公式改成 match = 0.3*cosine + 0.7*(1 if 判yes else 0)——LLM 布尔占 70%、cosine 保留 30% 作 API 失败的兜底 + 同样判 yes 的人之间做差异化。修复后那 3 个 sensor 从排 1/5/6 变成 1/2/3,分段差距从 5 分扩大到 27 分。另一份 13 条偏好的 JD,区分度也从「第一名第二名差 1 分」变成「差 20 分」。
部署踩的两个坑
- FastAPI worker 假死:curl 等 6 分钟没响应,uvicorn 进程 CPU 0% 但有 CLOSE-WAIT 连接,strace 看进程完全没动。根因是 zhipuai SDK 在长 prompt + FastAPI 同步线程池里阻塞假死(blocking IO 不耗 CPU,所以看着像没事)。解法:给 client 加 90 秒显式 timeout,让它超时报错而不是无限挂。
- 以为是死锁,其实是慢:后来报 JSON 解析失败,单独在服务器直接调那个判断函数,10.4 秒成功返回正确 JSON。所以不是死锁,是慢——6 份简历 × 10 秒判断 + 6 × 8 秒理由 ≈ 110 秒,而 curl 30 秒就超时了。解法是把超时拉到 240 秒。「卡住」要先分清是真死锁还是单纯慢,strace/直接单测一下就知道。
几个务实的取舍
- Embedding 从本地 BGE-M3 改成 API:部署服务器只有 2GB 内存,本地 BGE-M3 要 2.5GB 跑不动;而朋友圈量级一年不到 5000 份简历,API 费用预估一年不到 50 块。
- 识别出给的是智谱 key 不是 DashScope:key 格式
{32 位}.{16 位}是智谱特征,及时换成 glm-4-air + embedding-3(1024 维,pgvector 表结构不用改)。 - 不上 Celery/Redis、不做登录、company_id 软隔离:朋友圈量级,过度工程没必要。但 Provider 抽象层第一天就建(retrofit 比一开始做贵 3-5 倍)。
- 一个诚实的边界:偏好写得太具体(「荣耀或华为的平板/手机 Sensor 经验」)时,简历没明写公司名就会被判 False——这是 prompt 调优问题,我如实标注了,没假装它完美。
小结
这个项目最有价值的认知是 embedding 相似度的边界——它在 query 宽泛、候选同质时区分度不够,这时候得让 LLM 做个明确的布尔判断把信号拉开。以及那个「worker 假死」的排查:看着像死锁,其实是 SDK 阻塞 + 超时设太短,先分清「死」和「慢」再动手。