每天爬取 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,发现主要有两个原因:
- 更新时间自动前移: 不少招聘官网即使没有修改 JD 的一个字,页面底部的
updated_at时间戳每天也会自动更新,以便让职位显得活跃; - 数组顺序不稳定: 城市列表
["北京", "上海"]今天返回这个顺序,明天可能变成["上海", "北京"]。文本序列化之后,哈希就不一样了。
因此,计算哈希之前必须先做清洗:剔除时间戳字段,集合类字段先排序再序列化。 只有标题、职责和要求发生了实质变化时,才生成新的内容版本。
这样,前面的 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 格式损坏时,才算真正失败。
增量设计中的四道防线
回头看,“避免重复调用模型”这个需求背后,串联着一整套系统设计上的取舍:
- 内容层: 用清洗后内容的 SHA-256 代替数据库主键,排除时间戳和列表顺序造成的假变化,并让版本回滚时可以直接复用旧结果;
- 规则层: 将 Prompt 的变更与前端契约的变更分开,日常调整 Prompt 不会引发全量重跑;
- 视图层: 放弃“最新批次覆盖历史”的做法,改为基于基线的增量合并视图;
- 语义层: 区分网络失败与合法的空结果,避免无意义的重试循环。
有了这四道防线,每天 4.7 万个岗位的扫描中,真正触发大模型调用的通常只有几百条,整个系统的运行成本能控制在几块钱以内。大模型是按量计费的基础设施,在架构上多花一些时间做增量设计,往往能在之后的每个月节省大量费用。