1. 这个求职雷达到底解决了什么问题
求职这件事,最让人抓狂的从来不是“投简历”本身,而是信息筛选的效率。我身边不少朋友,包括我自己,都经历过这样的循环:打开招聘平台,输入关键词,翻十几页,发现一半岗位是重复的、三分之一是猎头挂的假岗、剩下几个看起来靠谱的,点进去发现薪资范围写得像谜语——“8k-20k”,你永远不知道自己值 8 还是 20。
更麻烦的是,岗位信息是动态的。今天看到的岗位,明天可能就关闭了;今天标 15k 的,下周可能变成 12k。你手动记录、手动对比,等你整理完,市场已经变了。
所以我用Seed-2.1-pro-0915搭了一个专用求职雷达。它的核心逻辑很简单:定时抓取目标岗位、结构化解析薪资与要求、生成可验证的报告。12 分钟出一份报告,每一条薪资数据都能点回原始岗位页面核对。不是那种“AI 帮你编一个薪资范围”的玩具,而是每一条数据都有来源、有链接、可追溯。
这个方案适合谁?如果你是正在找工作、想快速摸清某个岗位在市场里的真实薪资分布的人,这套东西可以直接抄作业。如果你是想学Agent 开发、SSE 流式推送、Next.js 全栈的开发者,这套架构也是一个完整的实战案例。下面我把整个设计思路、核心实现、踩过的坑,全部拆开讲。
2. 整体架构设计与技术选型思路
2.1 为什么选 Seed-2.1-pro-0915 做核心解析引擎
市面上能做大模型解析的方案很多,我选Seed-2.1-pro-0915的原因有三个,都是实际用下来的体会。
第一是结构化输出稳定。求职岗位的原始文本非常脏——有 HTML 标签、有平台自己的排版符号、有“急招”“高薪”这种干扰词。我需要模型把“岗位名称、公司、薪资下限、薪资上限、经验要求、学历要求、技能标签”这些字段干净地抽出来。Seed-2.1-pro-0915 在 JSON 模式下的字段遵循度很高,不会出现那种“让它输出 JSON,它给你输出一段解释文字”的情况。
第二是长上下文处理能力。一个岗位详情页加上公司信息,动辄三四千字。如果要做批量对比,一次要喂进去十几个岗位的文本。上下文不够的模型会截断,截断就意味着丢数据。Seed-2.1-pro-0915 在这块的表现让我比较放心,批量解析时没有出现明显的“后半段失忆”。
第三是成本可控。求职雷达是要定时跑的,一天跑几次,每次解析几十个岗位。如果单次调用成本太高,这个项目就没法长期跑。Seed-2.1-pro-0915 在解析质量与调用成本之间的平衡点,是我实测下来比较合适的。
提示:模型选型没有绝对的最优解。如果你的岗位文本以英文为主,或者需要更强的推理能力来做薪资预测,可以换其他模型。但如果你要的是“稳定抽取字段 + 批量处理 + 成本可控”,Seed-2.1-pro-0915 是一个很稳的选择。
2.2 Next.js + SSE 的前后端分工
整个系统分成三层:采集层、解析层、展示层。
采集层负责按关键词去目标平台拉取岗位列表和详情。这部分我用的是服务端定时任务,不放在前端,因为前端触发容易被反爬,也不稳定。
解析层就是 Seed-2.1-pro-0915 的工作区。采集层把原始文本丢过来,解析层输出结构化 JSON,存进数据库。
展示层用Next.js做。为什么用 Next.js 而不是纯前端框架?因为我要做SSE(Server-Sent Events)流式推送。求职雷达的运行过程是“边抓边解析边出结果”,用户不需要等 12 分钟才看到东西,而是可以实时看到“正在解析第 3 个岗位”“已发现 5 条薪资数据”。SSE 天然适合这种服务端主动推送的场景,比 WebSocket 轻,比轮询省资源。
Next.js 的 Route Handler 可以直接返回text/event-stream,配合 React 端的EventSource,整个流式链路非常干净。这也是为什么热词里Next.js和SSE会同时出现——它们在这个场景里是绝配。
2.3 Agent 编排:让雷达自己决定下一步
这个项目里我用了一个轻量的Agent编排逻辑。不是那种复杂的多智能体框架,而是一个“决策循环”:
- 采集 Agent 判断当前关键词下还有没有未抓取的岗位;
- 如果有,抓取并交给解析 Agent;
- 解析 Agent 输出结构化数据后,判断薪资字段是否完整;
- 如果不完整,触发一次“补全”调用,尝试从公司页面或岗位描述里二次提取;
- 全部完成后,报告 Agent 汇总生成可验证报告。
这个循环的价值在于容错。纯脚本的问题是,遇到一个字段缺失就断了。Agent 编排让系统能自己决定“这一步没拿到,我要不要再试一次”。热词里agent架构、agent框架与编排之所以火,就是因为大家发现,真正能落地的 Agent 不是炫技,而是把这种“判断-执行-再判断”的循环做稳。
3. 核心细节解析与实操要点
3.1 岗位文本清洗:别让脏数据污染解析结果
原始岗位文本有多脏?我举几个真实例子。有的平台会把薪资写成“1.5-2万/月”,有的写成“15K-20K·13薪”,还有的写成“面议”。技能标签有的是“Java,Spring,MySQL”,有的是“Java / Spring / MySQL”,还有的混在正文里“熟悉 Java、Spring 框架”。
如果你直接把这些丢给模型,模型也能解析,但字段一致性会很差。我的做法是先做一轮规则清洗,再交给模型。
清洗规则包括:
- 统一薪资单位:把“万/月”换算成“元/月”,把“K”换算成“千元”;
- 统一分隔符:把“、”“/”“,”统一成逗号;
- 剥离 HTML 标签和平台特有的装饰符号;
- 把“面议”“薪资面谈”单独标记,不参与数值统计。
这一步看起来不起眼,但实测下来,清洗后的解析准确率比直接丢原始文本高了至少两成。原因很简单:模型再强,也不该让它去处理本该用规则解决的格式问题。
注意:清洗规则不要写得太死。比如“13薪”这种信息,如果你直接删掉,就丢了重要数据。我的做法是把“13薪”“14薪”提取成单独字段,在报告里单独展示。
3.2 薪资字段的抽取与校验逻辑
薪资是这份报告的核心,也是最容易出错的地方。我的抽取逻辑分三步:
第一步,模型抽取。让 Seed-2.1-pro-0915 输出salary_min、salary_max、salary_unit、salary_months四个字段。
第二步,规则校验。检查salary_min是否小于salary_max,检查单位是否在允许范围内,检查月数是否合理(比如 12 到 16 之间)。
第三步,异常标记。如果校验不通过,比如salary_min大于salary_max,或者单位缺失,就把这条记录标记为“待人工确认”,不进入统计。
为什么要做校验?因为模型偶尔会把“15k-20k”解析成min=20, max=15,这种错误如果不拦,报告里的薪资分布就全乱了。校验规则就是最后一道防线。
3.3 SSE 流式推送的实现要点
SSE 这块我踩过坑,重点说三个。
第一个坑:连接超时。热词里有一条stream disconnected before completion: idle timeout waiting for sse,这个我太熟了。SSE 连接如果长时间没有数据推送,中间层会认为连接空闲并断开。解决办法是定期发送心跳。我的做法是每 15 秒发一个注释行: heartbeat,保持连接活跃。
第二个坑:数据格式。SSE 要求每条消息以data:开头,以两个换行结尾。如果你直接JSON.stringify一个对象丢过去,前端EventSource解析会出问题。正确做法是:
// Next.js Route Handler 中的 SSE 推送 const encoder = new TextEncoder(); function sendEvent(controller, eventName, data) { const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; controller.enqueue(encoder.encode(payload)); }第三个坑:错误处理。如果解析过程中某个岗位出错,不能让整个流断掉。我的做法是捕获错误后,推送一个event: error的消息,前端展示“该岗位解析失败”,然后继续处理下一个。
3.4 可验证报告的设计:每条薪资都能点回去
这是这个项目最核心的差异化点。报告里的每一条薪资数据,都带一个source_url字段,指向原始岗位页面。前端渲染时,薪资数字旁边有一个“验证”链接,点开就是原始页面。
这样做的好处是建立信任。AI 生成的报告,用户天然会怀疑“这数据是不是编的”。但如果你能点回去看到原始岗位,信任感就建立起来了。
实现上,采集层在抓取每个岗位时,必须把source_url一起存下来,解析层输出时带上这个字段,展示层渲染成链接。整条链路不能丢这个字段。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先说环境。我用的是 Node.js 20 以上版本,Next.js 14 的 App Router 模式。数据库用的是 PostgreSQL,因为要做结构化查询和统计。
依赖清单:
# 核心依赖 npm install next@14 react react-dom npm install @prisma/client prisma npm install seed-sdk # Seed-2.1-pro-0915 的调用 SDK # 采集与解析 npm install cheerio axios npm install zod # 用于校验模型输出的 JSON 结构这里重点说zod。模型输出的 JSON 虽然看起来对,但字段类型可能不对,比如salary_min本该是数字,模型给了字符串"15"。用 zod 定义 schema,解析后立即校验,不通过就触发重试。这一步能省掉后面大量的脏数据排查时间。
4.2 采集层的定时任务实现
采集层我用的是 Next.js 的 Route Handler 配合外部定时触发。核心逻辑是:
// app/api/crawl/route.js export async function POST(request) { const { keyword, pages } = await request.json(); const jobs = []; for (let page = 1; page <= pages; page++) { const list = await fetchJobList(keyword, page); for (const item of list) { const detail = await fetchJobDetail(item.url); jobs.push({ ...detail, source_url: item.url, crawled_at: new Date().toISOString(), }); } } // 存入数据库,状态标记为 pending await prisma.job.createMany({ data: jobs }); return Response.json({ count: jobs.length }); }这里的关键是每个岗位都带source_url。没有这个字段,后面的可验证报告就无从谈起。
4.3 解析层的 Agent 循环实现
解析层是整个系统的核心。我用了一个简化的 Agent 循环:
async function parseJobWithAgent(job) { let attempts = 0; const maxAttempts = 3; while (attempts < maxAttempts) { const raw = await callSeedModel(job.raw_text); const parsed = safeParseJSON(raw); const validation = jobSchema.safeParse(parsed); if (validation.success) { return validation.data; } // 校验失败,把错误信息反馈给模型,让它重试 attempts++; job.raw_text = `上次输出有以下问题:${validation.error.message},请修正后重新输出。\n\n${job.raw_text}`; } return { error: 'parse_failed', source_url: job.source_url }; }这个循环的价值在于自我修正。模型第一次输出格式不对,把错误信息反馈回去,第二次通常就能修正。实测下来,第一次成功率大概七成,加上重试后能到九成五以上。
4.4 SSE 推送与前端实时展示
后端推送这块,Next.js 的 Route Handler 返回一个ReadableStream:
// app/api/radar/route.js export async function GET(request) { const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); const send = (event, data) => { controller.enqueue( encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) ); }; // 心跳,防止空闲超时 const heartbeat = setInterval(() => { controller.enqueue(encoder.encode(': heartbeat\n\n')); }, 15000); try { const jobs = await prisma.job.findMany({ where: { status: 'pending' } }); send('start', { total: jobs.length }); for (let i = 0; i < jobs.length; i++) { const parsed = await parseJobWithAgent(jobs[i]); await prisma.job.update({ where: { id: jobs[i].id }, data: { parsed_data: parsed, status: 'done' }, }); send('progress', { current: i + 1, total: jobs.length, job: parsed }); } send('complete', { message: '报告生成完毕' }); } catch (err) { send('error', { message: err.message }); } finally { clearInterval(heartbeat); controller.close(); } }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); }前端用EventSource接收:
const es = new EventSource('/api/radar'); es.addEventListener('progress', (e) => { const data = JSON.parse(e.data); setProgress(data.current / data.total); setJobs((prev) => [...prev, data.job]); }); es.addEventListener('complete', () => { es.close(); });这套链路跑通后,用户打开页面就能看到岗位一条条被解析出来,体验比“等 12 分钟看一个静态报告”好太多。
4.5 报告生成与薪资统计
所有岗位解析完成后,报告 Agent 做汇总统计:
- 薪资中位数、平均值、分位数(25%、75%);
- 按经验要求分组的薪资对比;
- 按技能标签分组的岗位数量;
- 每条数据的来源链接。
统计逻辑用 SQL 就能做,不需要再调模型。模型只负责抽取,统计交给数据库,这样结果更可控。
5. 常见问题与排查技巧实录
5.1 SSE 连接频繁断开怎么办
这是最高频的问题。热词里stream disconnected before completion说的就是这个。排查顺序:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 连接几秒就断 | 中间层空闲超时 | 加心跳,每 15 秒发一次 |
| 连接建立后立即断 | 响应头不对 | 检查Content-Type是否为text/event-stream |
| 部分消息丢失 | 数据格式错误 | 检查是否以\n\n结尾 |
| 长时间无数据后断 | 服务端处理太慢 | 先推送“处理中”状态,再推送结果 |
我踩过最坑的一次是:中间层有 60 秒空闲超时,而我的解析逻辑有时候一个岗位要处理 40 秒,中间没有推送任何数据,连接就被断了。后来加了心跳,问题解决。
5.2 模型输出 JSON 解析失败
这个问题的根源通常是模型在 JSON 外面包了一层解释文字,比如“好的,以下是解析结果:{...}”。解决办法有两个:
一是用response_format参数强制 JSON 模式(如果模型支持);二是在解析前用正则提取第一个{到最后一个}之间的内容。
function safeParseJSON(text) { try { return JSON.parse(text); } catch { const match = text.match(/\{[\s\S]*\}/); if (match) { try { return JSON.parse(match[0]); } catch { return null; } } return null; } }5.3 薪资数据出现异常值
异常值通常来自两种情况:一是模型解析错误,二是原始岗位本身写的就是异常值(比如“100k-200k”其实是年薪,但被当成月薪)。
我的处理方式是双重校验:先检查数值范围是否合理(月薪超过 100k 的标记为可疑),再检查单位是否明确。可疑数据不进入统计,但在报告里单独列出,标注“待确认”。
5.4 批量解析时速度太慢
如果串行解析,几十个岗位要跑很久。我的优化是并发解析,但并发数要控制。实测下来,并发 5 到 8 个比较稳,再高容易触发模型端的限流。
async function parseBatch(jobs, concurrency = 5) { const results = []; for (let i = 0; i < jobs.length; i += concurrency) { const batch = jobs.slice(i, i + concurrency); const batchResults = await Promise.all(batch.map(parseJobWithAgent)); results.push(...batchResults); } return results; }5.5 采集层被目标平台限制
这个问题的处理原则是控制频率、模拟正常访问。具体做法包括:每次请求之间加随机延迟、使用合理的请求头、不要短时间内重复抓同一个页面。这部分不展开太多,核心思路是“像正常用户一样访问”。
6. 这套雷达还能怎么扩展
跑通基础版本后,我做了几个扩展,效果不错。
第一个扩展是薪资趋势追踪。每次运行都把结果存一份快照,这样过一段时间就能看到同一个岗位的薪资变化,或者同一个关键词下的薪资中位数走势。这个功能对判断“现在是不是跳槽好时机”很有参考价值。
第二个扩展是技能热度分析。把所有岗位的技能标签抽出来做词频统计,能看出当前市场最缺什么技能。我实测下来,这个数据比很多行业报告都及时。
第三个扩展是订阅推送。当雷达发现符合条件的新岗位时,通过邮件或站内通知推送给用户。这部分用 SSE 做实时推送,或者用定时任务做批量推送都可以。
第四个扩展是多关键词并行。现在支持同时跑多个关键词,比如“前端”“后端”“全栈”一起跑,最后生成一份综合报告。并发控制还是老规矩,别贪多。
7. 一些实操心得
最后分享几个我在这个项目里踩过的坑和总结的经验。
关于模型调用:不要迷信“一次调用解决所有问题”。把任务拆细,抽取字段是一个调用,校验是规则,统计是 SQL。模型只做它最擅长的事——从非结构化文本里抽信息。
关于 SSE:心跳是必须的,错误处理是必须的,数据格式是必须严格的。这三点做到,SSE 就很稳。
关于可验证性:source_url这个字段从采集层到展示层,全程不能丢。这是整个报告可信度的基石。
关于 Agent 编排:不要为了 Agent 而 Agent。我这个项目里的 Agent 循环很简单,就是“解析-校验-重试”。但就是这三步,把成功率从七成提到了九成五。复杂的多智能体框架不一定适合每个人,先把这种小循环做稳,价值就已经很大了。
关于并发:并发数不是越高越好。模型端有限流,数据库有连接数限制,采集端有频率限制。找到那个平衡点,比盲目调高并发更重要。
这套东西我从零搭到跑通,大概花了两个周末。中间踩的坑主要集中在 SSE 连接稳定性和模型输出校验上。但跑通之后,每次求职季都能用,而且数据是自己的、可验证的,比看别人的报告踏实多了。如果你也在做类似的东西,希望这些经验能帮你少走点弯路。