每天爬取 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 的内容周一不是刚抽过吗? 为什么还要花钱再抽一遍?
通俗比喻:认人名 vs 认相貌指纹为什么数据库主键加标记管不住版本变化? 想象你要给全校学生办借书证:
- 认名字打勾(岗位表布尔标记):你只记着“小明今天换了件新外套,他的证件作废,得花 100 块重新体检拍照办证”。过两天小明把旧外套穿回来了,你发现他又和昨天不同,于是又花 100 块拉他去体检办证;
- 认指纹特征(内容哈希):你不管他穿什么衣服、换不换名字,只要按一下指纹(计算文本哈希),发现指纹库里早就存过这个人的体检档案,直接 0 成本把老档案拉出来用。
这让我猛然意识到:“是否处理过”根本不是一个岗位的属性,而是“某一段具体内容版本”的属性。
一个简单的布尔值,根本承载不了内容版本的演进。
什么是内容哈希版本?真正的去重必须脱离数据库主键 ID。应该把岗位正文、核心要求提取出来做规范化(去除无关空格),然后计算 SHA-256 哈希值。只要哈希值相同,不管是在哪天、哪个岗位 ID 上出现,它对应的都是同一份内容实体。
采集里的“假变化”
既然要按内容哈希来做,事情是不是就解决了?
并没有,我很快发现爬虫每天都在“报假警”——明明内容没变,哈希却天天在跳。
抓包看了实际爬下来的 JSON,主要有两个隐蔽的捣乱分子:
- 自动前移的更新时间: 不少招聘官网哪怕 JD 一个字没改,页面底部的
updated_at时间戳每天也会自动更新(为了让职位显得活跃); - 数组顺序随机抖动: 城市列表
["北京", "上海"]今天返回这个顺序,明天变成["上海", "北京"],文本一序列化,哈希直接失效。
因此,哈希提取必须做一层前置清洗:时间戳字段统统剔除,集合型字段必须先排序再序列化。 只有当标题、职责和要求发生真正语义变化时,才生成新的内容版本。
这样一来,前面的 A → B → A 就有了完美的解法:周四爬虫虽然看到了改动,但一算哈希发现历史库里早就躺着版本 A 的画像,直接拿来复用,一分钱不用花。
Prompt 只要改一个字,5 万数据全得作废?
内容哈希搞定了,另一个更要命的问题接踵而至:抽取的规则本身是会迭代的。
今天我觉得 Prompt 里对“校招”的定义不够准,加了一句补充说明;明天模型出了新版本,我想换成更强的新模型。
按照最朴素的工程思维:既然 Prompt 变了,提取规则变了,那历史结果就“过期”了,是不是所有数据都得推倒重跑?
如果每调一次 Prompt 就重跑 4.7 万条数据,任何团队的 API 预算都扛不住。
“规则发生了迭代”,绝不等于“以前跑出来的结果就不能用了”。
为了解耦这个矛盾,我把版本拆成了两层:
| 维度概念 | 职责与回答的问题 | 变动的影响 |
|---|---|---|
| 提取配置版本 (Config Version) | 这份画像是用哪个 Prompt、哪个模型、什么温度跑出来的? | 用于历史回溯和审计,频繁变动也不触发历史数据作废 |
| 输出契约版本 (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 变更”与“前端契约变更”解耦,绝不让日常提示词调优引发全量数据的雪崩重跑;
- 视图防线: 彻底丢弃“最新批次覆盖历史”的全量思维,建立基线增量合并视图;
- 语义防线: 区分“网络失败”与“合法的业务空值”,避免无意义的死循环重试。
把这几道防线扎稳之后,每天 4.7 万个岗位的日常扫描,实际触发大模型调用的通常只有寥寥几百条,整个系统的运行成本被死死压在了几块钱以内。面对大模型这种按量付费的基础设施,在架构上多花一小时做增量抽象,往往能为你在未来的每个月省下成千上万的账单。