Boss直聘自动招聘助手:消息分流、意图识别与候选人打分
做招聘的朋友大概都有过这种体验早上打开 boss 直聘未读消息 200一半是在吗一半是方便发下简历吗还有几个是同行来打听薪资的。手动一条条回、一条条打招呼、一条条筛选一坐就是一上午真正有价值的沟通反而被淹没在重复劳动里。我做的这个boss直聘自动招聘助手核心目的不是刷量而是把 HR 和招聘负责人从重复性动作里拽出来——自动分类未读消息、按岗位维度聚合候选人、把符合硬性条件的人推到待沟通队列的顶部、把常见问答做成可复用的话术模板。它适合三类人每天要处理大量候选人消息的中小企业 HR、同时挂多个岗位的技术负责人以及想学一点自动化实战的招聘运营同学。整套东西的逻辑不复杂但要跑得稳、跑得久、不给自己惹麻烦细节比想象中多得多。1. 动手之前先把账算清楚我的设计思路1.1 招聘场景里真正被浪费掉的到底是什么时间很多人一听自动招聘助手第一反应是帮我自动打招呼、自动群发消息。我实测下来这个方向收益最低、风险最高而且对招聘结果几乎没有正向帮助。真正吃时间的其实是另外几件事第一是消息分流候选人发来的内容五花八门有问薪资的、有问加班的、有问能不能远程的、有直接甩简历的你得先读完才知道该回哪一句第二是信息抽取简历里的年限、学历、当前公司、期望城市得从一段自然语言里抠出来才能跟岗位 JD 做比对第三是状态跟踪谁约了面试、谁面完没反馈、谁三天没回靠脑子记一定会漏第四是重复话术同一个岗位的同一个问题一天要答二十遍。我把这四件事拆开之后发现前两件是典型的结构化问题特别适合交给程序后两件属于流程管理用一张表加一个看板就能解决大半。所以整个助手的定位就定下来了做消息的整理者和信息的提取者不做沟通的替代者。这一点在后期的所有技术选型里都是第一原则——凡是需要替人说话的动作我都保留了人工确认环节。这个定位带来的直接好处是不需要模拟大量账号行为不需要绕任何限制风险面收窄到几乎为零同时开发量也砍掉了一大半。我见过不少同行一上来就想着全自动结果卡在登录态维护和频率限制上折腾几周最后业务价值还不如一个筛选脚本。提示先定义清楚哪些动作必须人来做再决定哪些能自动化。顺序反了后面全是返工。1.2 三条实现路线的对比与我最终的选择在动手前我认真对比过三条路线这里把结论摊开讲省得你重复踩坑。路线具体做法优势主要问题我的评价官方开放能力走平台对外提供的数据接口稳定、字段规范、不用维护登录态能力有边界部分数据拿不到优先用能覆盖的部分一定用它浏览器自动化用 Playwright/Selenium 驱动自己的浏览器会话覆盖面广、所见即所得页面结构变动就会断需要长期维护只在官方能力缺失时补充使用纯脚本抓取直接请求页面接口跑得快稳定性最差最容易触发风控不建议收益和风险不成比例我最后采用的是混合方案能走官方接口的数据一律走接口官方没覆盖、但我确实需要的少数交互用 Playwright 在我自己的浏览器会话里完成并且全部做成半自动——程序只负责把信息整理好放到待办区实际点击发送的动作由我自己完成。这样既拿到了效率又把不确定性控制在可接受范围内。还有一个很关键的决定所有频率都人为压低。我没有做任何并发优化所有请求串行执行每次请求之间固定间隔 1.5 到 3 秒并且随机抖动。很多人觉得这是性能浪费但在这个场景里稳定跑一周比十分钟跑完一万条重要得多。做招聘工具最怕的不是慢是账号出问题导致岗位下架。2. 整体架构与核心模块拆解2.1 一个够用又不臃肿的四层结构整套助手我拆成了四层层与层之间只通过数据结构通信任何一层都可以单独替换。最底层是接入层负责所有跟平台打交道的事情。这一层对外只暴露三个方法拉取待处理消息、拉取候选人档案、提交一个动作比如标记、备注。所有重试、限流、异常处理都封在这一层里上层完全不用关心。往上一层是解析层负责把非结构化文本变成结构化字段。比如把我目前在杭州做后端五年了主要写 Java期望 30k 左右解析成{city: 杭州, years: 5, role: 后端, stack: [Java], expect_salary: 30000}。这一层是整个项目里技术含量最高的部分也是最能体现效果的地方。再往上是策略层负责判断这个人该不该优先沟通。我用的是一套加权打分而不是硬性 if-else。硬条件比如岗位要求本科以上做一票否决软条件工作经验匹配度、技能关键词命中数、活跃度做加权累加。打分的好处是可以随时调权重不用改逻辑。最上面是展示层我图省事直接用了 Streamlit 搭了个单页看板左边是待处理队列右边是候选人详情和打分理由。没有做前后端分离因为这东西只有我自己用多写一行前端代码都是浪费。2.2 数据模型怎么定决定了后面好不好改我在第一版的时候吃了数据模型的亏。当时图快把候选人信息直接塞进一个 JSON 字段里存着结果想按工作年限大于 3 年且期望薪资低于 25k筛人的时候写 SQL 写得想砸键盘。第二版我把模型拆细了核心就三张表第一张是candidate存候选人的稳定属性和解析结果。字段包括唯一标识、姓名、当前公司、工作年限、学历、期望城市、期望薪资区间、技能标签用逗号分隔的字符串够用了没必要上 JSON、首次接触时间、最近活跃时间。这里有个经验技能标签不要用关联表在这个数据量级下字符串匹配的性能远好于多表 join而且改起来方便。第二张是message存每一条往来消息的原始文本和解析结果。我保留了原始文本不做任何覆盖因为解析逻辑一定会迭代原始数据丢了就再也回不来了。这张表还有一个intent字段标记这条消息的意图问薪资、问加班、约面试、投递简历、闲聊意图识别是后面所有自动分类的基础。第三张是action_log记录所有我做过的操作和程序做过的事情。这张表看起来没用实际上是排查问题的命根子。有一次我发现某个候选人被重复推送到队列顶部翻了日志才发现是解析层在某个边界情况下返回了空值导致打分函数默认给了满分。没有这张表这种问题能查一整天。注意事项原始文本一定要原样落库不要解析完就丢。解析逻辑是你项目里迭代最频繁的部分第一版的分词和正则大概率撑不过两周。2.3 打分策略怎么把感觉合适变成可计算的数字打分是整个助手的灵魂。我把评分拆成四个维度总分 100硬性匹配度占 40 分。这是基础盘包括学历、工作年限、是否在期望城市、是否会要求到岗时间。任何一项不满足岗位硬性要求直接标记为不通过不进入后续流程。这里不要做模糊处理硬条件模糊化之后你会花大量时间在明显不合适的人身上。技能命中度占 30 分。我把岗位 JD 拆成一组关键词比如Java、Spring Cloud、MySQL、分布式、三年以上候选人简历里命中一个加几分命中核心关键词我手动标了几个必须项权重翻倍。实测这套粗暴的加权准确率已经能超过大部分人的第一直觉判断。活跃度占 20 分。平台上会显示候选人的活跃状态比如今日活跃三日内活跃。这个信号非常有用——一个五年前的简历再匹配也没意义。我给的规则是今日活跃满分三日内打七折一周内打四折超过两周直接零分。薪资匹配度占 10 分。候选人的期望薪资和岗位预算是否重叠。这里有个细节候选人写的期望薪资往往是个区间而且有相当比例的人写的是面议。我处理面议的方式是不给分也不扣分直接跳过这一项把剩下的 90 分按比例放大避免因为信息缺失误伤。分数算出来之后队列按分数倒序排列前 20 名作为今日优先沟通对象。这里我加了一个小机制同一个人 7 天内只会出现在优先队列一次避免反复打扰同一个人也避免自己反复看到同一张脸。3. 实操过程搭一个能跑起来的最小可用版本3.1 环境准备与项目骨架我用的是 Python 3.11主要依赖就几个httpx负责网络请求比 requests 好用原生支持异步和更好的超时控制、playwright处理少数必须走浏览器的场景、pydantic做数据校验这个非常值得能帮你挡住大量脏数据、streamlit做看板、sqlite3存数据。对数据库我用的是 SQLite没上 MySQL因为单机跑的数据量根本用不上而且备份就是一个文件复制省事。项目结构我按职责分目录recruit-assistant/ ├── adapters/ # 接入层所有跟平台打交道的东西 │ ├── official.py # 官方接口封装 │ └── browser.py # 浏览器自动化补充 ├── parser/ # 解析层 │ ├── profile.py # 候选人信息抽取 │ └── intent.py # 消息意图识别 ├── strategy/ # 策略层 │ ── scorer.py # 打分逻辑 ├── store/ # 数据层 │ ├── models.py # pydantic 模型 │ └── db.py # 建表与读写 ├── app.py # Streamlit 看板入口 └── config.yaml # 所有可调参数把配置全部抽到config.yaml是我踩坑之后改的。第一版我把间隔时间、权重、关键词都硬编码在代码里想调个参数就得改代码重启烦得要命。现在所有能调的东西都在配置文件里改完直接生效。3.2 凭据管理这一步千万别偷懒这一节我必须单独拎出来讲因为这是最容易被忽略、出事后果最严重的地方。第一不要把任何登录凭据写进代码或提交到版本库。我用的是环境变量加载配置文件里只写token: ${RECRUIT_TOKEN}运行时从环境变量注入。如果你用的是 Git.env一定要进.gitignore别问我怎么知道的。第二浏览器会话的持久化目录要单独管理。Playwright 支持把登录状态保存到指定目录下次启动直接复用不用每次重新登录。这个目录里包含的是完整的会话信息等同于你的登录凭证权限要收紧别放在共享盘里。第三做一次凭据失效的兜底处理。我的做法是所有请求如果连续返回两次鉴权失败程序立刻停止所有任务写一条告警日志然后把看板上的状态切成需要重新登录不做任何重试。因为在这种场景下反复重试只会让情况变糟。# config 加载把敏感信息和业务配置分开 import os from pathlib import Path import yaml def load_config(path: str config.yaml) - dict: raw Path(path).read_text(encodingutf-8) # 先做一次环境变量替换再交给 yaml 解析 for key, value in os.environ.items(): raw raw.replace(f${{{key}}}, value) cfg yaml.safe_load(raw) # 强制校验关键配置缺失就直接失败不要带着空值跑 required [token, min_interval, max_interval, daily_limit] missing [k for k in required if not cfg.get(k)] if missing: raise RuntimeError(f配置缺失拒绝启动: {missing}) return cfg这里那个配置缺失直接抛异常的设计很关键。我见过太多程序带着空 token 一路跑到底最后产生几百条无效日志才发现问题。启动即失败比运行中静默出错好一百倍。3.3 接入层的限流与重试怎么写才不失控接入层我写了一个统一的请求包装器所有对外请求都必须经过它。它做三件事间隔控制、重试、异常归类。间隔控制用的是最小间隔 随机抖动。比如配置最小 1.5 秒、最大 3 秒那么每次请求前随机睡 1.5 到 3 秒。为什么要随机因为固定间隔的请求模式过于规律而且在你调试的时候容易形成整点风暴。随机抖动是一个几乎零成本、收益很高的习惯。重试策略我定了三条规矩只重试网络超时和 5xx 这类可能是偶发的错误鉴权失败和 4xx 参数错误绝不重试单个请求最多重试 2 次采用指数退避1 秒、3 秒。超过就放弃并记日志。import asyncio import random import httpx from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type class ThrottledClient: def __init__(self, token: str, min_interval: float 1.5, max_interval: float 3.0): self.min_interval min_interval self.max_interval max_interval self._last_call 0.0 self._client httpx.AsyncClient( timeouthttpx.Timeout(10.0, connect5.0), headers{Authorization: fBearer {token}}, ) async def _respect_interval(self): wait self._last_call random.uniform(self.min_interval, self.max_interval) - asyncio.get_event_loop().time() if wait 0: await asyncio.sleep(wait) self._last_call asyncio.get_event_loop().time() retry( stopstop_after_attempt(3), waitwait_exponential(multiplier1, min1, max8), retryretry_if_exception_type((httpx.TimeoutException, httpx.NetworkError)), reraiseTrue, ) async def get(self, url: str, **params): await self._respect_interval() resp await self._client.get(url, paramsparams) if resp.status_code 401: # 鉴权问题绝不重试直接往上抛由主循环决定停机 raise PermissionError(鉴权失效请检查凭据) resp.raise_for_status() return resp.json()这段代码里有几个我反复调试后才定下来的细节。超时我拆成了总超时 10 秒、连接超时 5 秒因为连接阶段卡住通常是网络问题早点失败早点重试更划算。重试用的是 tenacity 库而不是手写循环指数退避的边界由它管代码干净很多。最关键的是那个PermissionError——它被明确排除在重试之外保证鉴权失效时程序立刻停下来而不是空转。3.4 解析层把一段大白话变成结构化字段解析层我分两步走。第一步是规则优先用正则和关键词表处理那些高频、格式固定的表达。比如工作年限常见的写法有5 年经验工作五年2019 年毕业至今我用一组正则覆盖了大概 80% 的情况剩下的交给第二步。第二步是意图识别。这部分我没有上大模型理由是成本不划算——我要判断的意图一共就七八类而且表述高度重复。我用了最朴素的做法给每类意图维护一组关键词和句式模板如果一条消息命中多个意图取权重最高的那个。意图类别我整理成了这样一张表这张表基本覆盖了招聘沟通里 95% 的场景意图典型表述建议处理方式询问薪资这个岗位多少钱薪资范围是多少用预设话术模板先给区间再约沟通询问加班加班多吗周末要上班吗实话实说含糊回复会浪费彼此时间询问技术栈用什么技术是不是要会 Go直接贴 JD 关键段落表达兴趣我很感兴趣什么时候方便聊优先级最高当天必须回投递简历直接甩附件或链接自动归档进入硬性条件筛选询问地点在哪个区能不能远程模板回复加地图信息顾虑表达我再考虑一下暂时不考虑标记为暂缓20 天后再触达一次无关消息同行打听、广告直接归档不进入队列之所以坚持用规则而不是模型还有一个很现实的原因规则是可解释的。当我看板上显示这条被判为询问薪资我可以立刻点开看到是哪几个词命中的判断错了可以马上改词表改完立即生效。如果换成模型调一次要重新标注、重新训练、重新评估投入产出比在这个场景下完全不成立。import re from typing import Optional YEARS_PATTERNS [ (re.compile(r(\d)\s*年(?:工作)?经验), lambda m: int(m.group(1))), (re.compile(r工作\s*(\d)\s*年), lambda m: int(m.group(1))), (re.compile(r(\d{4})\s*年毕业), lambda m: max(0, 2025 - int(m.group(1)))), ] def extract_years(text: str) - Optional[int]: for pattern, handler in YEARS_PATTERNS: m pattern.search(text) if m: value handler(m) # 边界保护出现 99 年经验这种脏数据直接丢弃 if 0 value 40: return value return None注意那个边界保护。不加它的话候选人写2008 年毕业会被某些正则误判成8 年经验或者更离谱的数字直接进库后面的打分全乱。所有解析结果在入库前都要过一遍合理性检查这是我第二版才补上的价值极高。3.5 主流程串起来一天是怎么跑的整个系统我做成了一次性任务而不是常驻服务每天早上我手动触发一次跑完就退出。这么做有两个好处一是不用担心进程跑久了内存泄漏二是每天开始的时候状态是干净的。主流程一共五步。第一步拉取过去 24 小时的新消息和状态更新落库。第二步跑解析把新消息和候选人档案都结构化。第三步跑打分重新计算所有活跃候选人的分数。第四步生成今日待办队列分成必回建议回可延后三档。第五步把结果写进看板我打开浏览器就能看到。async def daily_run(cfg: dict): client ThrottledClient(cfg[token], cfg[min_interval], cfg[max_interval]) db Database(cfg[db_path]) # 1. 同步 new_msgs await fetch_new_messages(client, sincehours_ago(24)) db.upsert_messages(new_msgs) # 2. 解析只处理还没解析过的避免重复劳动 for msg in db.messages_without_intent(): msg.intent classify_intent(msg.text) db.save(msg) # 3. 打分 for candidate in db.active_candidates(days30): profile db.get_profile(candidate.id) candidate.score score(profile, cfg[job_requirements]) db.save(candidate) # 4. 生成队列控制总量 queue db.top_candidates(limitcfg[daily_limit]) db.replace_today_queue(queue) print(f今日待办 {len(queue)} 人其中必回 {sum(1 for c in queue if c.tier must)} 人)这个主流程我故意写得很笨没有并发、没有缓存、全量重算。有人会说这不专业但在这个数据量下每天几百条消息、几千个候选人跑完只要两三分钟而笨代码的可调试性是无价的。优化之前先确认瓶颈在哪里我量过绝大部分时间花在网络请求的被动等待上算分那部分连一秒钟都不到优化它毫无意义。4. 常见问题与排查技巧实录4.1 高频问题速查表这张表是我这两个多月攒下来的基本每次出问题都能在里面找到对应项现象可能原因排查动作处理方式程序启动就退出配置缺项或凭据为空看启动日志里的 missing 列表补齐环境变量后重启拉取到的消息一直重复分页游标没更新断点打印游标值用最后一条记录的 ID 作为下次起点解析结果大量为空页面文案改版抽样看 20 条原始文本更新正则先跑历史数据回填打分全部一样硬条件全不通过或全通过打印中间分数明细拆开各维度分数单独看请求变慢甚至超时本机网络或对端限流记录每次请求耗时把间隔调大不要加并发看板打不开端口被占用换端口启动固定用一个不常用端口数据越跑越多没有做归档查各表行数超过 90 天的消息归档到冷表4.2 三个我印象最深的坑第一个坑是时间字段的时区问题。平台上返回的时间戳我用的是本地时间直接解析结果跨月那天所有今日活跃的判断全错了导致队列里推上来一批其实已经很久没上线的人。后来我统一改成全部用 UTC 存储、展示的时候再转本地时区。这个坑很小但影响面很大因为它污染的是活跃度这个权重最高的信号之一。第二个坑是消息去重。同一个候选人连续发三条消息接口返回的可能是一个会话对象而不是三条独立消息。我第一版没注意把会话当成消息处理导致解析的时候把三条不同意图的消息合成了一条意图判断全乱。修正方式是以消息 ID 为准去重而不是以候选人 ID同时在存储的时候保留会话 ID 作为分组键。第三个坑说起来有点丢人我在日志里输出了完整的请求头包括凭据。本地调试的时候无所谓但我顺手把日志文件放到了一个同步目录里。发现的时候已经同步上去了赶紧轮换了凭据。从那以后我立了一条规矩日志里任何跟鉴权相关的字段一律脱敏只保留前四位。注意调试便利性和数据安全永远冲突。冲突的时候选安全。凭据轮换的成本远高于你省下的那点调试时间。4.3 让程序跑得久的三条经验第一条所有外部依赖都要有降级路径。我的解析层在正则匹配失败的时候会退回到关键词统计关键词也没命中的时候会标成未知而不是抛异常。整个流程里没有任何一处因为单条数据异常而中断。这个设计让程序连续跑了两个月一次都没崩过。第二条每天做一次自检。我在主流程最后加了一步健康检查检查今天拉取到的消息数是否在合理区间比如 20 到 2000 之间、解析成功率是否高于 60%、队列是否非空。任何一项异常就写一条显眼的告警。这三项检查帮我提前发现过一次接口返回空数据的问题——如果等到我自己打开看板才发现可能已经浪费两天了。第三条保持简单主动拒绝功能蔓延。这个项目做到现在我砍掉的需求比实现的多不做自动发送、不做多账号管理、不做简历自动下载、不做自动约面。每一次想加功能的时候我都会问自己一句这个功能如果出问题最坏的结果是什么如果是给候选人发错消息或者账号异常那就直接不做。招聘工具的第一原则是别给自己制造麻烦效率是第二位的。5. 后续可以怎么扩展如果你把这套东西跑通了有几个方向是可以自然延伸的而且都不需要动核心架构。一个方向是岗位维度的对比分析。我现在的数据里已经有了消息的意图分布把这些数据按岗位聚合起来就能看到很有意思的规律比如某个岗位问加班的人是其他岗位的三倍说明 JD 里对工作节奏的描述不够清楚某个岗位问薪资的人特别多说明薪资区间写得模糊。这些结论反过来能指导你改 JD效果比闷头优化话术好得多。另一个方向是队列的自动化分发。如果你团队里有多个人同时在招同一个岗位可以按候选人的技术方向和面试官的擅长领域做匹配把队列自动分到不同人的待办里。实现上就是在策略层加一个分配函数改动量很小。还有一个我自己在试的方向是候选人的长期追踪。有些候选人当下不合适但半年后可能就合适了。我在action_log里已经存了全部历史只要加一个冷却期结束后重新进入评估池的逻辑就能把沉睡数据变成活水。不过这个功能我还没上线因为担心打扰到别人得先把触达频率控制好再说。这套东西我前后打磨了大概两个月代码量不到两千行但真正花时间的是想清楚哪些不做。如果你打算自己动手我建议第一版就砍到只做拉取消息 意图分类这一件事跑一周看看它到底帮你省了多少时间再决定要不要往下加。很多时候你会发现一个能准确分类的脚本价值已经超过一个功能齐全但天天出问题的系统。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →