AI / LLM

每天爬取 5 万个岗位,如何让大模型只处理新增与变动的内容

2365 字约 8 分钟

摘要全网招聘岗位接近 5 万个,如果每天都全量交给大模型处理,成本会很高。本文从最简单的“已处理标记”出发,介绍增量抽取过程中遇到的问题和解决方法。

Job Market Monitor 是我维护的一个全网招聘信息追踪项目:爬虫每天抓取各大互联网公司官网上的职位描述(JD),记录职位的上下线、薪资变化和招聘要求。

随着数据不断积累,单纯的文本检索已经不够用了。我们需要更结构化的“岗位画像”:这个岗位是否面向应届生?技术栈是 Java 还是 Go?最低学历是否限定全日制本科?

这些信息分散在各家公司的自然语言描述中,正则和词表很难覆盖全部情况,适合交给 LLM 做结构化抽取。

接口写好了,Prompt 也调通了。但在准备把它加入每日定时任务时,我意识到了一个问题:

线上在招的活跃岗位接近 4.7 万个。如果每天爬取后都把它们全部交给大模型处理,即使用的是最便宜的 Flash 模型,成本也会很高。

大部分在招岗位,连续挂上一两个月,正文都不会有任何改动。那么,怎样才能让大模型只处理真正变化或新增的岗位?

“加一个布尔标记”的方案为什么行不通

很多人的第一反应(包括我最初的想法)都很简单:在岗位表里加一个布尔字段 is_processed。

  • 刚爬下来时,值为 false;
  • 交给模型抽取完成后,改为 true;
  • 第二天爬虫发现内容变了,再改回 false。

这个方案看起来没有问题。但我在草稿上推演了一个真实场景,发现它不可行:

A → B → A 的版本回滚
  • 周一(版本 A): 某个算法岗位首次出现,调用 LLM 抽取成功,生成画像,标记为 true。
  • 周二(版本 A): 正文没有变化,跳过。
  • 周三(版本 B): HR 修改了两个字,系统判定为变化,重新调用 LLM 抽取,标记为 true。
  • 周四(版本 A): HR 觉得改得不妥,又改回了周一的内容。

周四的时候,系统该怎么处理?

如果按“字段发生了变化”来判断,系统会再次调用大模型并产生费用。但版本 A 的内容在周一已经抽取过了,为什么还要再抽取一次?

通俗比喻:按名字做记号,还是按指纹识别

为什么数据库主键上的标记管不住版本变化? 假设学校要给学生办借书证:

  • 按名字做记号:只记住“小明今天换了一件新外套,证件作废,需要重新体检拍照办证”。两天后小明穿回旧外套,你又判定他“和昨天不同”,于是又办了一次证;
  • 按指纹识别:不管他穿什么衣服,只要比对指纹,发现系统里已经有他的体检记录,就直接调出旧记录使用,不需要重新办证。

这让我意识到:“是否处理过”并不是岗位本身的属性,而是某一段具体内容版本的属性。

一个布尔值无法记录内容版本的变化。

什么是内容哈希版本?

去重应该脱离数据库主键。把岗位的正文和核心要求提取出来,做规范化处理(例如去掉多余的空格),再计算 SHA-256 哈希值。只要哈希值相同,无论是哪一天、哪个岗位 ID 上出现的内容,都可以视为同一份内容。

采集过程中的“假变化”

既然按内容哈希来判断,问题是不是就解决了?

并没有。我很快发现,爬虫每天都会误报变化:内容没有改动,哈希却每天都在变。

抓包检查了实际爬取的 JSON,发现主要有两个原因:

  1. 更新时间自动前移: 不少招聘官网即使没有修改 JD 的一个字,页面底部的 updated_at 时间戳每天也会自动更新,以便让职位显得活跃;
  2. 数组顺序不稳定: 城市列表 ["北京", "上海"] 今天返回这个顺序,明天可能变成 ["上海", "北京"]。文本序列化之后,哈希就不一样了。

因此,计算哈希之前必须先做清洗:剔除时间戳字段,集合类字段先排序再序列化。 只有标题、职责和要求发生了实质变化时,才生成新的内容版本。

这样,前面的 A → B → A 问题就解决了:周四虽然检测到了改动,但计算哈希后发现历史记录中已经有版本 A 的画像,直接复用即可,不产生任何费用。

修改 Prompt 后,是否要重跑全部数据?

内容哈希解决之后,又遇到了一个问题:抽取规则本身也会迭代。

比如,我觉得 Prompt 中对“校招”的定义不够准确,加了一句补充说明;或者模型有了新版本,我想换成更强的模型。

按照最直接的思路,Prompt 和规则都变了,旧结果就应该失效,所有数据都要重跑吗?

如果每次调整 Prompt 都要重跑 4.7 万条数据,任何团队的 API 预算都难以承受。

规则迭代不等于旧结果失效。

为此,我把版本拆成了两层:

维度记录的内容变动的影响
提取配置版本(Config Version)这份画像用的是哪个 Prompt、哪个模型、什么 temperature用于回溯和审计,修改时不会使历史数据失效
输出契约版本(Contract Version)输出的 JSON 结构和枚举值,前端页面能否正确读取只有破坏性改动(如删除字段、改变字段含义)时才升级
契约向下兼容

日常调整 Prompt、修正措辞、换一个更便宜的模型,只要输出字段的含义没有变化,就属于内部实现的优化,沿用同一个契约版本。线上直接复用已有画像,只有新爬取的岗位才使用新的 Prompt。只有在前端画像组件需要改动时(例如把“工作经验”从纯文本拆分为“最低年限”和“最高年限”),才发布新的契约,并按需逐步重跑。

小批次导致的数据“消失”

配置梳理清楚之后,我又在数据展示层遇到了问题。

在传统的全量批处理系统中,我们习惯使用“当前批次”的概念:批次 100 完成后,整体替换批次 99,前端只查询批次 100 的数据。

但在增量系统中,这种方式会导致问题:

  • 周一:首次运行,完成了全量 40,000 条画像,标记为批次 batch_001;
  • 周二:只增量处理了 300 个有变化的岗位,标记为批次 batch_002。

如果前端仍然查询“最新完成的批次(batch_002)”,用户打开网站时会发现:昨天还有 4 万个岗位的画像,今天只剩 300 个了。其余 39,700 个岗位的状态都变成了“未处理”。

批次是执行记录,不是数据视图

批次(Batch Run)只应该记录某一次 Worker 运行的日志和审计信息,不能用它来判断数据是否有效。线上查询应当直接按“岗位 ID + 当前兼容的最新契约”关联结果,而不是按批次筛选。

合法的空结果也是一种成功

在增量测试中,后台有时会出现一些奇怪的抽取记录:模型返回了合法的 JSON,但其中的 skills 和 experience 都是空的。

我最初怀疑模型偷懒,没有提取出来。但查看原始 JD 后发现,该公司的官网只写了两句话:“欢迎加入我们,待遇面议”,没有任何技术栈或学历要求。

如果把这种空结果当作抽取失败,程序第二天会重试,第三天还会重试。同一个没有信息的 JD,每天都会白白调用一次大模型。

合法的空值本身就是一条有效的画像事实。 只要 JSON 结构校验通过,空值也应该标记为成功并入库。只有遇到接口超时、500 错误或 JSON 格式损坏时,才算真正失败。

增量设计中的四道防线

回头看,“避免重复调用模型”这个需求背后,串联着一整套系统设计上的取舍:

  1. 内容层: 用清洗后内容的 SHA-256 代替数据库主键,排除时间戳和列表顺序造成的假变化,并让版本回滚时可以直接复用旧结果;
  2. 规则层: 将 Prompt 的变更与前端契约的变更分开,日常调整 Prompt 不会引发全量重跑;
  3. 视图层: 放弃“最新批次覆盖历史”的做法,改为基于基线的增量合并视图;
  4. 语义层: 区分网络失败与合法的空结果,避免无意义的重试循环。

有了这四道防线,每天 4.7 万个岗位的扫描中,真正触发大模型调用的通常只有几百条,整个系统的运行成本能控制在几块钱以内。大模型是按量计费的基础设施,在架构上多花一些时间做增量设计,往往能在之后的每个月节省大量费用。

评论