摘要:前面的文章介绍过冰石机器人(冰石微信智能客服系统)做的招聘机器人:在微信里替招聘公司和求职者聊天、约面试、把报名信息推给负责人。但真实的人力资源中心手上往往同时挂着几十上百个工厂的岗位,求职者一句"我 28 岁,男的,想在长春找个包住的活",机器人得先从这一大堆岗位里挑出合适的几个,才谈得上约面试。这一步如果交给大模型直接做,token 花销和准确率都扛不住。本文介绍为冰石机器人新写的扩展 recruit_matcher(蓝领招聘岗位匹配):岗位由机器人从招聘群里自动收集入库,匹配由程序按关键属性硬过滤加打分,大模型只负责"读懂通知"和"把结果说成人话"。最后拆解这个扩展是怎么"插"进冰石机器人的,看看一个平台的可扩展性体现在哪里。


一、招聘机器人还差的一环:岗位匹配

先回顾一下招聘机器人原来的流程:求职者加微信 → 机器人按话术问情况 → 介绍岗位 → 约面试时间 → 收集姓名电话 → 推送给招聘负责人。这套流程在岗位不多的时候跑得很好,岗位介绍直接写在提示词或知识库里就行。

但当客户换成一个区域性的人力资源中心或者劳务公司,情况就完全不一样了:

  • 岗位多。同时在招的工厂少则二三十家,多则上百家,每家又分好几个岗位(普工、缝纫工、检修工……),年龄、性别、薪资、食宿要求各不相同;
  • 变化快。今天"年龄放宽到 45",明天"面试改到周五",后天"已招满,停止报名"。岗位信息以小时为单位在变;
  • 来源散。岗位通知不是谁规规矩矩录进系统的,而是企业 HR、驻厂、合作渠道随手发在微信招聘群里的一段文字,格式五花八门。

于是就有两个绕不开的问题:

  1. 岗位库从哪来? 让员工每天把群里的通知手工录一遍,人力成本高、还容易漏改漏删;
  2. 怎么给求职者挑岗位? 求职者的条件有年龄、性别、城市、期望薪资、要不要住宿、学历、想干什么活……要在上百个岗位里精确筛出合适的。

recruit_matcher 扩展就是解决这两件事的。


二、为什么不能让大模型直接匹配

最省事的想法是:把所有岗位通知塞进提示词,让大模型自己挑。我们算一笔账:

  • 一条岗位通知原文大约 300~800 字,按 100 个岗位粗算,就是四五万字;
  • 这四五万字要随每一轮对话一起发给模型——求职者说一句"嗯"、问一句"包吃吗",都要再付一遍;
  • 一个机器人一天要聊几百个求职者、发几千条消息,token 成本直接翻几十倍。

钱还是次要的,更麻烦的是准确性:

  • 大模型做"28 岁是否在 18~40 之间"这种比较没问题,但在几万字里同时比对年龄、性别、城市、住宿、学历五个条件,漏看、错看是常态;
  • 岗位过期了、停招了,模型不知道——除非提示词每次都精确地只放有效岗位;
  • 求职者说"换一批",模型要记住刚才推荐过哪些——而模型本身是无状态的。

所以分工的原则很清楚:

谁擅长在这个扩展里做什么
大模型读懂非结构化文本、理解口语、组织自然语言① 把群里的岗位通知解析成结构化字段;② 从聊天中提取求职者条件;③ 把匹配结果说成人话
程序精确比较、过滤、排序、计数、记忆、定时任务岗位去重合并、有效期计算、过期清理、硬过滤 + 打分、多轮记忆、报名校验与通知

关键在于:大模型解析岗位只在"写入时"发生一次(一条通知解析一次),而"查询时"的匹配完全由程序在内存里做,模型最终只看到排名前 3 的精简结果——一两千字,和岗位库有多大无关。


三、整体架构

求职者私聊

招聘群

recruit_job_upsert

recruit_job_match

Top 3

recruit_job_detail

recruit_apply_record

每轮注入提示词

删除过期岗位

企业 HR / 驻厂 / 渠道
随手发岗位通知

求职者

大模型
解析通知

岗位库
jobs.json

大模型
提取条件 / 组织回复

程序匹配
硬过滤 + 打分

报名登记
校验 + 去重

员工微信 / 内部群

求职者会话状态

每日清扫线程

扩展一共向冰石机器人注册了 4 个大模型工具:

工具用在哪作用
recruit_job_upsert招聘群把群里的岗位通知解析后新建 / 更新 / 下架
recruit_job_match求职者私聊按条件匹配岗位,返回最合适的几个
recruit_job_detail求职者私聊求职者追问"第二个怎么样"时,取岗位完整信息
recruit_apply_record求职者私聊登记报名,并给工作人员推送微信通知

再加上一个提示词注入 Provider(每轮把求职者的招聘进度注入提示词)、一个每日清扫线程、一个后台配置页。下面逐个展开。


四、岗位收集:让机器人在招聘群里"自己记账"

4.1 大模型解析通知,程序负责入库

机器人账号被拉进招聘群(企业和渠道发岗位的群)。群聊的提示词只做一件事:看到岗位通知就调用 recruit_job_upsert。一条典型的群通知长这样(已脱敏):

【长春·XX汽车线束 北湖厂区】
面试时间:10.8-10.16 每天早上8:20
岗位:操作工 男女不限 18-40周岁
薪资:17+1/小时,日预支170
住宿:上铺200/月 下铺300/月
面试地址:北湖XX路XX号
联系人:孙经理 180****9338

大模型调用工具时,会把它解析成结构化参数:

{
  "action": "create",
  "company": "XX汽车线束北湖厂区",
  "city": "长春", "district": "北湖",
  "interview_times": ["10.8-10.16 早上8:20"],
  "housing": "paid", "housing_fee": "上铺200/月 下铺300/月",
  "positions": [{
    "title": "操作工", "gender": "any", "age_min": 18, "age_max": 40,
    "salary_min": 4800, "salary_max": 5200,
    "salary_text": "17+1/小时 日预支170"
  }],
  "contact_name": "孙经理",
  "raw_text": "【长春·XX汽车线束 北湖厂区】面试时间:……"
}

这里有几个设计细节:

  • 字段口径写在工具描述里。比如"薪资统一换算成月综合(元),只给时薪时按 时薪×11小时×26天 估算并把原文写进 salary_text";住宿统一成 free / paid / none / unknown 四个枚举,学历统一成 none / junior / senior / college。枚举化是为了后面程序能直接比较;
  • 原文必须保留(raw_text 必填)。结构化字段用来匹配,原文用来在求职者追问细节时兜底——费用、路线、离职规则这些零碎信息不值得一一建字段;
  • 不回群消息。工具描述里要求 requires_tool_result=false:调用后直接结束,机器人在群里保持安静,不会对着企业 HR 说"已收到"。

4.2 同一企业重复发通知:归一化去重 + 增量合并

招聘群里最常见的是"更改通知":

XX线束北湖厂:年龄放宽到18-45周岁,可继续报名

如果每条都新建一个岗位,岗位库很快就是一堆重复和矛盾的记录。扩展的处理方式是:

① 企业名归一化做去重键。去掉空格标点、“有限公司”“集团”“厂区”"招聘"等后缀,单字"厂"只在结尾去:

_COMPANY_STRIP_WORDS = ("有限责任公司", "股份有限公司", "有限公司", "公司", "集团", "厂区", "工厂", "通知", "招聘")

def normalize_company_key(name: str) -> str:
    s = str(name or "").strip().lower()
    s = _COMPANY_PUNCT_RE.sub("", s)
    for w in _COMPANY_STRIP_WORDS:
        s = s.replace(w, "")
    # 「安波福长隆厂」→「安波福长隆」:单字「厂」只在结尾去
    if s.endswith("厂"):
        s = s[:-1]
    return s

精确命中优先;没有精确命中时允许双向包含(键长 ≥ 4 才允许,且只命中一条才合并,多条就宁可另建,不乱合)。

② 更新只改传了的字段。工具描述要求大模型"update 时只传改动的字段,positions 里也只传改动的属性"。程序这边规范化岗位时用 fill_defaults=False,只保留模型真正传了的键:

def normalize_position(raw, fill_defaults=True):
    """fill_defaults=False 时只保留模型实际传了的键(空串视为没传),
    这样更改通知里显式的 gender=any / age_min=0 能把原来的限制放开,没传的字段不动。"""

这样"年龄放宽到 45"只会改 age_max,不会把薪资、住宿冲掉;"男女不限"就显式传 gender=any 把原来的性别限制放开。更改通知的原文追加到原记录后面,首次的完整通知不会被覆盖。

③ 零碎更改找不到原记录时,不凭空造岗位。如果群里先出现了"XX厂年龄改为18-45",而岗位库里根本没有 XX 厂(比如原通知发在机器人进群之前),程序会返回 not_found,而不是新建一条没有地址、没有薪资、没有面试时间的残缺岗位推荐给求职者:

substantive = "positions" in incoming and any(
    k in incoming for k in ("interview_times", "interview_deadline_hint", "address")
)
if action == "update" and not substantive:
    return {"status": "not_found", ...}

④ “取消”“停止报名”“招满了”,大模型调用 action=close,岗位立刻下架,同时从所有求职者的推荐记录里剔除(已报名的记录保留)。

4.3 有效期:日期不信模型,程序自己算

蓝领岗位有一个特点:面试日就是岗位的生命周期,最后一个面试日过了,这条岗位就没用了。所以扩展的规则是:有效期 = 最后面试日,过了自动删除。

问题是群通知里的日期写法千奇百怪:周四(10/8)、10.8~10.16、10月8日-10月9日、2026年10月7日-10月9日、10月8日至10月16日全天……模型给的 interview_deadline 不能全信,所以服务端 dates.py 自己从面试时间原文里把所有日期抠出来,取最大值。这里面坑不少:

坑例子处理
没写年份12 月收到"1月5日"按收到日附近补年:早于收到日超过 60 天的算下一年
跨年回头看1 月 3 日收到"12月30日"当年的那天离得超过 300 天、去年的那天在 60 天内,取去年
点号是时刻不是日期“10月8日 1.30-3.30” “中午12.30”同一段里已有"月"或"/"写法时,点分隔一律当时刻;前面紧挨"午/早/晚/点"的也跳过
横杠是范围不是日期“18-42周岁”“4-6人间”短写法只认 月 . / 作分隔,不认 -
点写法离得太远“1.30-3.30” 被当成 1 月 30 日点/斜杠短写法离收到日超过 60 天的一律不认

一个日期都解析不出来时,按"收到日 + 默认天数(可配置,默认 7 天)“推测,并打上 deadline_guessed 标记,岗位清单里显示”?"提醒人工核对。

过期清理有三道:启动时清一次、每天固定时刻(默认 06:00)清一次、每次匹配前再清一次。所以求职者无论什么时候问,都不会被推荐到已经过了面试日的岗位。被删的岗位物理删除,同时追加写入 jobs_archive.jsonl 归档,方便追溯。

4.4 员工看得见:自动生成岗位清单

岗位库存的是 JSON,给员工看的是自动生成的 Markdown 报告:按城市分组,一个岗位一行,列出编号、企业、岗位、性别、年龄、薪资、住宿、面试时间、有效期、联系人,底部附"近 7 天失效 / 取消"的岗位。每次岗位入库、更新、下架都会重新生成,员工在冰石后台的"扩展配置"页里直接查看。

到这里,"岗位收集"这件事已经不需要人参与了:企业在群里发通知 → 机器人解析入库 → 更改自动合并 → 停招自动下架 → 过期自动清理 → 清单自动更新。


五、岗位匹配:程序筛选,大模型回复

5.1 硬过滤:任一不过即淘汰

求职者私聊时,大模型从对话里提取条件,调用 recruit_job_match:

求职者:我28,男的,想在长春找个活,要包住的
→ recruit_job_match {"age": 28, "gender": "male", "city": "长春", "need_housing": true}

匹配逻辑在 matcher.py 里,是纯函数、不碰文件,方便单独测试。第一步是硬过滤,五个条件任一不满足就淘汰,并记录淘汰原因:

条件淘汰规则
年龄不在岗位的 age_min ~ age_max 区间(0 表示不限)
性别求职者和岗位都明确了性别且不一致
住宿求职者要住宿,而岗位明确不提供("没提"不算)
学历求职者学历低于岗位要求
城市求职者说了城市,且和岗位城市不一致(去掉"省/市"后缀、允许包含)

注意硬过滤只用求职者已经说过的条件——没说年龄就不按年龄过滤,不会因为信息不全就把岗位全筛光。

5.2 软打分:在合格的岗位里排先后

通过硬过滤的岗位再打分排序:

加减分项分值
薪资上限 ≥ 期望薪资+3
薪资区间覆盖期望薪资+2
薪资上限 < 期望薪资-2
区 / 县匹配+2
免费住宿+1
要住宿但岗位住宿未说明-1
偏好关键词命中岗位名(如"缝纫"“焊接”)+3
3 天内就能面试+1

同分时按最后面试日从近到远排——越快能面试的越优先,这正是蓝领求职者最关心的。

一家企业一条通知里往往有多个岗位(比如"普工"男女不限 23~55 岁,"切块工"只要 45 岁以下女性)。程序对每个岗位分别过滤打分,取最高分的岗位代表这家企业,其余合格岗位放进 other_positions;不合格的岗位(28 岁男性看不到"只要女性"的切块工)根本不会出现。

5.3 没匹配上:告诉模型"为什么"和"怎么接话"

匹配不到岗位是常事。如果工具只回一句"没有结果",大模型要么干巴巴地说"暂时没有合适的岗位",要么开始编。所以匹配结果里带了淘汰统计和建议:

RELAX_HINT = {
    "housing": "大多数岗位不提供住宿,可以问求职者能否自己解决住宿",
    "city": "该城市目前没有岗位,可以问求职者能否接受其他城市",
    ...
}
# 没有结果时:
out["suggestion"] = f"没有完全符合的岗位,淘汰最多的原因是「{...}」:{RELAX_HINT[...]}"

模型拿到"淘汰最多的原因是城市不同",就会自然地接一句"长春这边暂时没有合适的,天津有几个包吃住的厂,您考虑外地吗?"——这才像一个招聘专员在说话。

5.4 模型最终看到什么

工具返回给大模型的是排名前 N(默认 3,可配置)的精简结果:企业、岗位、薪资、年龄要求、住宿、餐食、工时、地址、面试时间、要带的材料……外加 matched_total、filtered_out 这几个统计数字。不管岗位库里有 30 个还是 300 个岗位,模型这一轮多看的只有这一两千字。

工具描述里还约束了模型的说法:“拿到结果后按序号向求职者介绍企业、岗位、薪资、住宿、面试时间和地点;不要把 J 开头的编号念给求职者,不要编造结果里没有的内容。”


六、多轮对话:服务端记忆 + 提示词注入

招聘对话从来不是一问一答。求职者会说:

  • “还有别的吗?”——要排除刚才推荐过的;
  • “第二个怎么样?”——要知道"第二个"是哪家;
  • “我要去那个包吃住的”——要把模糊指代对上岗位;
  • 隔了两天回来:“上次说的那个厂还招吗?”

大模型每轮都是无状态的,只能看到最近十几条聊天记录,上一轮工具结果里的岗位编号这一轮就没了。所以扩展把每位求职者的状态存在服务端(键 = 账号 ID + 会话名):

  • 画像累积:这轮说了年龄,下轮说了期望薪资,程序合并保存。后续调用 recruit_job_match 时参数都可以不传,“系统会记住这位求职者之前说过的条件,只传本次新了解到的即可”;
  • 推荐记录:最近一次推荐的岗位序号、编号、一行摘要;
  • 已展示列表:传 more=true(换一批)时排除;
  • 已报名记录:防止重复登记。

然后通过冰石机器人的提示词注入机制,每轮在提示词里追加一段"本求职者的招聘进度":

## 本求职者的招聘进度(系统记录,优先于聊天记录)
已了解:28岁 男 想在长春 需要住宿(这些不要再问)
上次推荐(求职者说"第一个/第二个"即指下面的序号):
1. [J0003] XX牧业 普工,前3天80/天,转正综合4300-6000,免费住宿,面试10月8日 上午11点
2. [J0007] XX线束北湖厂区 操作工,17+1/小时 日预支170,有偿住宿,面试10.8-10.16 早上8:20
3. [J0011] XX汽配 操作工,综合5000-6000,免费住宿,面试10月9日 上午9点
调用 recruit_job_detail / recruit_apply_record 时,job_ref 直接填上面的序号或 [ ] 里的编号;不要把编号念给求职者。

有了这段,模型知道哪些问题不用再问,求职者说"第二个"时直接把 job_ref 填 2。服务端的 resolve_job_ref 支持多种写法:序号(2、第二个、2号)、编号(J0007)、企业或岗位名关键词(先在本人推荐里找,再全库找),有歧义时返回候选列表让模型向求职者确认,引用的岗位已经过期就明确告诉模型"已失效,请重新匹配"。

这段注入只有几百字,比把聊天历史拉长、或者让模型每轮重新查一遍都划算得多。求职者多少天没再聊就自动清掉(默认 7 天,可配置)。


七、报名闭环:校验、去重、通知

求职者决定去面试后,模型用一条消息请求职者把"姓名、手机号、身份证号"一起发过来(工具描述里专门要求"不要一项一项地问"),然后调用 recruit_apply_record。程序这边负责模型做不好的事:

  • 格式校验:手机号必须 11 位以 1 开头;身份证号按国标算校验位——模型看着像身份证的数字串,程序一算就知道是不是抄错了一位;
  • 面试日期校验:"10月8日"能不能解析、是不是已经过去、是不是晚于这个岗位的最后面试日;
  • 去重:同一手机号报同一岗位视为更新,只覆盖非空字段;
  • 按需通知:只有新报名、或者面试时间 / 岗位 / 姓名真的变了,才给员工发通知——模型重复调用同样的参数不会刷屏;
  • 通知文案模板化:员工收到的消息由后台配置的模板渲染,支持 {姓名} {电话} {身份证} {企业} {岗位} {面试时间} {面试地址} {联系人} {微信昵称} {求职者情况} 等占位符,身份证默认脱敏为前 4 后 4。

校验失败时工具返回具体问题(“身份证号不是有效的 18 位号码(校验位不对)”),模型据此请求职者重新提供;成功后模型告知面试时间、地址、要带的材料,并说明"到时会有工作人员联系"。

员工通知的发送通道会按求职者所在的账号类型自动选择:个人微信账号直接按会话名发;企业微信账号的发送接口只认 ID,而运营只知道"小王""招聘内部群"这些名字,所以扩展会依次从聊天配置、联系人表、企业通讯录、群列表里把名字解析成 ID,同名多个时不瞎猜,提示运营改填 ID。每天的报名记录同样自动生成 Markdown 报告,后台可查。


八、这个扩展是怎么"插"进冰石机器人的

上面这些功能,没有改冰石机器人核心代码的一行。整个扩展就是 extensions/recruit_matcher/ 一个目录,14 个 Python 文件、约 2700 行,配 50 个单元测试用例。冰石启动时扫描 extensions/ 目录,调用每个扩展的 register(ctx);卸载时调用 unregister(ctx),支持热加载。

register 函数精简后是这样的:

def register(ctx):
    ext_config.ensure_file()            # 首次加载从模板生成 settings.yaml
    _service = RecruitService()
    _service.purge_expired()            # 启动先清一次过期岗位

    for tool in build_tools(_service):  # ① 注册 4 个大模型工具
        ctx.tool_registry.register_tool(tool)

    ctx.prompt_registry.register(       # ② 注册提示词注入 Provider
        RecruitSessionProvider(_service.sessions))

    _runner = SweepRunner(_service)     # ③ 启动每日清扫线程
    _runner.start()

    config_ui.register_config_resources(ctx, get_service, get_runner)  # ④ 注册后台配置页

它用到了冰石机器人提供的这些扩展点:

扩展点冰石提供的能力招聘扩展用它做什么
工具注册 tool_registry继承 BaseTool,声明名称、描述、JSON Schema 参数、支持的渠道,实现 execute;大模型即可调用4 个招聘工具,个人微信、企业微信两个渠道通用
提示词注入 prompt_registry实现 PromptSectionProvider.provide(ctx),按平台、会话、配置动态追加提示词片段,有优先级和长度上限每轮注入"本求职者的招聘进度",群聊不注入
扩展配置页 config_registry声明式描述 YAML 表单(整数、时间、文本、列表、开关),带校验器、保存回调、自定义操作按钮;也能挂一个只读 Markdown 目录推荐数量、有效期、通知对象与模板、脱敏开关;“重新生成报告”“立即清理过期岗位”"发送测试通知"三个按钮;岗位清单与报名记录查看
消息发送服务个人微信发送、企业微信运行时(通讯录、群列表、发消息)报名后给员工 / 内部群推通知
数据访问账号配置、联系人、聊天配置等数据表把员工名字解析成企业微信 ID

几个值得一说的点:

1. 工具描述就是协议。 冰石的工具集是渠道级共享的,招聘群和求职者私聊看到的是同一套工具。群和私聊各自的提示词约束"只调自己该调的",工具描述里也明确写了【招聘群监控专用】【求职者私聊专用】。业务规则(薪资怎么换算、更新只传改动字段、不要念编号)大量写在工具描述里,模型调用时自然遵守,不必堆在主提示词里。

2. 提示词注入是为"按人不同"的内容准备的。 冰石的聊天配置(提示词、知识库)是全员共享的,没有地方放"这个求职者已经说过 28 岁"这种按人不同的信息;工具调用又是"模型先决定调了才拿得到结果"。提示词注入正好补上这个缺口——这也是冰石平台为了支撑这类业务专门开出来的扩展点,Provider 必须快、出错不能拖垮回复链路,单段和总长都有上限。

3. 配置页零前端开发。 扩展只交了一份描述符,后台就自动渲染出表单:

{"key": "notify_targets", "label": "报名通知发给",
 "help": "一行一个;留空则只记录不通知。个人微信填会话名……企业微信填同事姓名、外部联系人备注名或群名……",
 "field": {"type": "string_list"}},

保存时先过扩展自己的校验器(比如通知模板里写了不存在的占位符会被拦下),保存后回调刷新缓存,改完即生效、不用重启。

4. 渠道扩展是增量的。 这个扩展最早只支持个人微信,后来加企业微信支持,只新增了一个 channels.py 负责"按账号类型选发送通道 + 名字解析成 ID",业务层 service.py 只改了一行:send_fn = make_sender(config_id)。这是因为冰石平台已经把个人微信、企业微信的收发都抽象好了,扩展只需要关心"发给谁、发什么"。

5. 业务逻辑可以被完整测试。 工具层只做参数整理,所有逻辑在 RecruitService 里;匹配是纯函数;发送函数、时间函数、配置加载都可以注入。50 个测试用例覆盖了日期解析、企业名归一化、增量更新、匹配排序、序号解析、身份证校验、报名去重、企业微信名字解析、文件损坏恢复……这对一个要长期跑在客户生产环境里的东西很重要。

冰石机器人的 extensions/ 目录里现在已经有二十来个这样的扩展:快递价格计算、轮胎价格查询、手机套餐推荐、ETC 维修台账、闲鱼自动发货、运单转发、汽服保养提醒……它们和招聘扩展是同一个套路:大模型负责理解和表达,扩展负责业务数据和规则,平台负责渠道、对话、配置和运行。


九、总结

回到开头的问题:一个手握上百个岗位的人力资源中心,怎么让招聘机器人给求职者推荐对的工作?

  • 岗位收集交给机器人:它就在招聘群里,看到通知就解析入库,更改自动合并,停招自动下架,过期自动清理;
  • 岗位匹配交给程序:五项硬过滤 + 加权打分,几百个岗位毫秒级出结果,没匹配上还能告诉模型原因;
  • 说话交给大模型:它只看排名前 3 的精简结果和几百字的进度摘要,把它们说成求职者听得懂、愿意接着聊的话;
  • 记忆交给服务端:画像、推荐记录、报名记录存在服务端,每轮注入,模型不必记编号。

这种"程序做确定性的事,大模型做不确定性的事"的分工,既把 token 成本控制在和岗位数量无关的水平,又把准确性交给了不会出错的代码。

而对冰石机器人来说,招聘岗位匹配只是一个新的扩展目录:注册几个工具、挂一个提示词 Provider、声明一份配置表单,就把一个垂直行业里最难的一环补上了。平台的价值不在于内置了多少功能,而在于新业务来的时候,能不能用最小的代价长出来。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐