news 2026/9/26 14:19:40

基于Jev与Vercel AI Gateway的AI简历匹配工具实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Jev与Vercel AI Gateway的AI简历匹配工具实战

招人最花时间的其实不是面试,是筛简历。我最近实在受不了人工过几百份简历的折磨,就动手做了个小工具,让 AI 先把简历和 JD 过一遍,输出匹配分数、关键点对齐情况和差距分析。模型选的是 Jev,接入层用了 Vercel AI Gateway。这套组合跑通之后,效果比我预期的好,中间也踩了不少文档里查不到的坑。这篇文章就把整个实战从头到尾拆一遍:为什么选 Jev + Vercel AI Gateway、网关怎么配、Prompt 怎么写、接口怎么调通,以及那些实际运行中才会遇到的问题。如果你正打算做类似的 AI 应用,或者想把多个模型统一接进一个网关来管理,这篇实战记录可以直接拿来抄作业。

1. 从需求到选型:为什么是 Jev + Vercel AI Gateway

1.1 简历匹配到底在解决什么问题

之前团队每轮招聘都要收几百份简历,初筛基本靠人工,几个人背对背看,标准很难统一。有人盯着学校背景,有人只看工作年限,有的人扫一眼技术栈就过了,导致最终进入面试的人选高度依赖筛简历那个人的主观偏好。我想要的不是"简单关键词命中",而是让模型真正理解两份文本之间的语义关系:候选人做过什么、JD 要求什么、哪些是强匹配、哪些有明显差距。

这个需求拆开来看其实很清晰。输入有两个:一份简历、一份职位描述。输出最好是一份结构化结果,包含总分、各维度评分、匹配项列表、缺失项列表和改进建议。整个工具要能批量跑,也能单条快速出结果。非功能需求同样重要:调用成本不能失控、延迟在可接受范围内、API 密钥不能暴露在前端、以后想换更好的模型时业务代码不用大改。

1.2 为什么用 Gateway 而不是直连模型

最初我也想过直接在前端或者一个 Node 脚本里调用模型 API,简单直接。但一旦考虑到"这可能是一个长期维护的内部工具",直连的问题就暴露了:模型厂商一换,所有调用代码都得跟着改;同一个模型来回传入相同的简历和 JD,每次都重新计费;请求一多还会触发限流,没有统一的兜底策略。Vercel AI Gateway 说白了就是在模型厂商和你之间加了一层"路由+管理"的代理,把上面这些问题集中解决掉。

对比项直连模型 API通过 Vercel AI Gateway
模型切换改代码、改依赖网关后台改配置,代码不动
缓存自己实现网关自带,相同请求直接命中
限流与重试每个供应商规则不同,要自己写统一配置
日志观测需要自己埋点网关侧有日志
密钥安全容易暴露在前端环境密钥都收在网关侧

网关的缓存机制是省钱的关键。同一份简历和同一份 JD 短时间内重复请求,网关会直接返回缓存结果,不会再往后端模型发起计费请求。内部工具里这类重复查询其实不少,比如同一个岗位多轮沟通时反复跑同一批简历,缓存一开,成本能明显降下来。

1.3 Jev 模型在这个场景里的定位

Jev 在整个系统里扮演的是推理引擎角色。简历匹配这种事,模型要能理解长文本、能按指令输出结构化内容,还要有足够稳定的指令遵循能力。我在选型时核心关注三点:一是能不能通过 API 访问,二是返回的 JSON 结构稳不稳定,三是中文简历和 JD 的理解能力。最终选了 Jev,主要是看中它在长上下文和中文语义理解上的表现,而且它提供了 OpenAI 兼容的 API 端点,接入成本很低。

先别纠结模型到底开不开源,只要它有可用的 API 就能接入到项目里。Jev 的具体开源信息可以去看官方仓库,这里我按"通过网关路由 Jev 模型"的思路来操作。把 Jev 配在网关后面,业务代码里不直接写死某一家的 SDK,而是统一走网关的 OpenAI 兼容接口,这样以后想换成其他模型,改网关配置就够了,代码一行不动。

2. 环境准备与网关配置

2.1 账号、密钥与项目初始化

先说准备工作。你需要一个 Vercel 账号,这是使用 AI Gateway 的前提。另外去 Jev 的官方控制台申请一个 API Key,这是模型侧的真实凭证。两个密钥都要保管好,后面一个配在网关里,一个用在网关调用上,别混。

项目我用 Next.js 来搭,App Router 模式。初始化命令很简单:

npx create-next-app@latest resume-matching --typescript --eslint --app

进入目录后安装依赖。核心就两个:OpenAI 客户端(用来调网关)和 pdf-parse(用来解析 PDF 简历)。顺手把 dotenv 也装上,方便本地跑的时候管理环境变量。

cd resume-matching npm install openai pdf-parse dotenv

如果你只需要处理纯文本或手动粘贴的简历,pdf-parse 可以暂时不装。但我强烈建议加上,因为实际场景里收到的简历八成是 PDF 格式。

2.2 在 Vercel 控制台创建 Gateway

打开 Vercel 项目,进入 Storage 或 AI 相关菜单,找到 AI Gateway。创建网关时会让你给它起个名字,这一步没什么讲究,叫resume-matcher或者cv-gateway都行。创建完之后,下一步是配置模型供应商 Provider,也就是把你申请的 Jev API Key 填进去,并设置路由规则。

关键点来了:Gateway 支持标准 OpenAI 兼容端点,所以我把 Jev 配成了一个类 OpenAI 的 Provider,模型名随便起一个内部别名,比如jev。调用的时候就是用这个名字来访问。配置完成后,Vercel 会给你分配一个专属的 Base URL 和 Gateway API Key,这两样就是业务代码里真正要用的。

注意:Gateway 的 Base URL 和 Gateway API Key 是访问代理层的凭证,它们和你最初申请的 Jev API Key 不是一回事。Jev 的原始 Key 只需要在网关后台保存,永远不要出现在业务代码或前端环境里。

2.3 环境变量梳理

在项目根目录创建.env.local,把网关信息填进去。我习惯把变量名拆成"模型名、Base URL、Key"三段,这样自己在代码里一目了然。

JEV_MODEL=jev JEV_BASE_URL=https://gateway.vercel.ai/v1 JEV_API_KEY=你的_gateway_key

这里有一个非常容易踩的坑:JEV_API_KEY填的是 Gateway 自己的 Key,不是 Jev 原始 Key。我一开始填反了,结果请求返回 401,排查了半小时才发现。把这两层密钥关系理清楚,后面的流程就顺了。

3. 简历匹配的核心设计与 Prompt 工程

3.1 简历文本抽取与预处理

简历输入这个环节看起来简单,实际上坑最多。PDF 简历格式五花八门:有的是两个栏位,有的是表格,有的是扫描版图片。pdf-parse只能处理文本型 PDF,遇到扫描件就是一堆空字符。所以我在工具里做了三层兜底:支持直接粘贴纯文本、支持上传 .txt、支持上传 PDF。PDF 解析失败时提示用户改用粘贴,不阻塞主流程。

import fs from 'fs'; import pdf from 'pdf-parse'; export async function extractTextFromPdf(buffer: Buffer): Promise<string> { const data = await pdf(buffer); const text = data.text.replace(/\n{3,}/g, '\n\n').trim(); if (text.length < 50) { throw new Error('PDF 无法提取文本,可能是扫描件,请改为粘贴文本'); } return text; }

文本预处理的核心是控制长度。简历动辄几千字,加上职位描述直接涌进模型,Token 数会迅速膨胀,成本上升而且响应变慢。我的做法是统一截断简历正文到 3000 字左右,职位描述保留完整,然后在 Prompt 里明确告诉模型"如果内容被截断,基于已有信息分析"。这样既能有效控制成本,也不会因为截断导致关键信息全丢。

3.2 Prompt 设计:如何让模型稳定输出结构化 JSON

简历匹配这种任务,Prompt 设计直接决定结果质量。我一开始用的是开放式提问,让模型"分析一下匹配度",结果输出的东西五花八门:有的写一大段散文,有的用 Markdown 列表,有的给了表格,根本没法程序化处理。后来我把 Prompt 改成严格的结构化约束,结果稳定了很多。

系统提示词的核心内容是这样的:

你是一名资深的简历筛选专家。你的任务是根据职位描述分析简历匹配度。 只输出 JSON,不要输出任何其他内容。JSON 结构如下: { "total_score": 0-100, "dimensions": { "experience": 0-100, "skills": 0-100, "education": 0-100 }, "matched_points": ["强匹配点1", "强匹配点2"], "missing_points": ["缺失或不足点1"], "suggestion": "一句话总结与建议" } 评分标准:技术栈直接匹配加分;项目经历与岗位职责高度相关加分; 候选人在相关领域有完整项目落地经验加分;学历信息缺失时不扣分, 但要在 missing_points 中注明。

用户提示词分两部分拼接。前半部分是职位描述,后半部分是简历原文。同时要求模型在给出分数时附带理由,比如total_score是 82,就要在matched_points或missing_points里体现依据。这个"分数+依据"的联动约束很重要,能防止模型乱给分。

温度参数我调到了 0.2。简历匹配不需要创造性,越低越稳定。另外我明确要求模型只输出 JSON,不开任何 Markdown 代码块包裹。实践下来,这个参数设置能显著减少后续 JSON 解析失败的次数。

3.3 匹配结果的解析与展示

模型返回的 JSON 字符串,我用JSON.parse直接解析。如果解析失败,我会做一个兜底:从返回文本中提取第一个{到最后一个}之间的子串再试一次。这个方案虽然有点粗暴,但在实际运行中命中率挺高,能扛住模型偶尔多输出一个解释性句子的情况。

export function parseJsonLoose(text: string): Record<string, unknown> { try { return JSON.parse(text); } catch { const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start !== -1 && end !== -1 && end > start) { return JSON.parse(text.slice(start, end + 1)); } throw new Error('Not JSON'); } }

展示层面我做了两个东西:一个直观的分数条和维度雷达图,以及一个"匹配点/缺失点"对照列表。分数的意义在于让 HR 能快速排序;匹配点和缺失点才是真正的价值,招聘的人一眼就能看出候选人强在哪、弱在哪。我还在结果页加了一行"直接给建议",方便 HR 决定是约面试还是婉拒。

4. 手写简历匹配接口与完整实现

4.1 项目结构与接口设计

我按业务功能把代码分成了几个模块,结构非常清晰:

resume-matching/ app/ page.tsx # 前端页面 api/ match/ route.ts # 简历匹配接口 lib/ prompt.ts # Prompt 拼接逻辑 pdf.ts # PDF 文本抽取 json.ts # JSON 宽松解析

API 路由设计成 POST 接口,接收 Multipart 表单数据,包含两个字段:job(职位描述)和resumeFile(简历文件,可选),同时也支持resumeText字段直接传文本。接口内部做四件事:解析上传文件、拼 Prompt、调网关拿模型结果、格式化返回。

4.2 API 路由的完整实现

先看核心接口代码。我用的是 OpenAI 客户端,指向 Vercel AI Gateway 的 Base URL。因为 Gateway 提供 OpenAI 兼容端点,所以不需要额外的 SDK 适配。

import { NextRequest, NextResponse } from 'next/server'; import OpenAI from 'openai'; import { extractTextFromPdf } from '@/lib/pdf'; import { buildPrompt } from '@/lib/prompt'; import { parseJsonLoose } from '@/lib/json'; const client = new OpenAI({ apiKey: process.env.JEV_API_KEY, baseURL: process.env.JEV_BASE_URL, }); export async function POST(req: NextRequest) { try { const form = await req.formData(); const job = form.get('job') as string; const resumeText = (form.get('resumeText') as string) || ''; const file = form.get('resumeFile') as File | null; if (!job || !job.trim()) { return NextResponse.json({ error: '职位描述不能为空' }, { status: 400 }); } let resume = resumeText; if (file && resume.length === 0) { const buffer = Buffer.from(await file.arrayBuffer()); resume = await extractTextFromPdf(buffer); } if (!resume || resume.trim().length < 50) { return NextResponse.json( { error: '简历内容太短,请提供完整的简历文本' }, { status: 400 } ); } const messages = buildPrompt({ job, resume }); const completion = await client.chat.completions.create({ model: process.env.JEV_MODEL || 'jev', messages, temperature: 0.2, max_tokens: 1500, response_format: { type: 'json_object' }, }); const content = completion.choices[0].message.content || '{}'; const result = parseJsonLoose(content); return NextResponse.json({ result }); } catch (error) { const msg = error instanceof Error ? error.message : 'unknown error'; return NextResponse.json({ error: msg }, { status: 500 }); } }

这个接口有三处细节值得展开说一下。

第一,response_format: { type: 'json_object' }必须开。虽然网关背后接的是 Jev,走 OpenAI 兼容协议时这个参数普遍有效,它会从底层约束模型输出合法 JSON。这个参数加上之后,模型的输出稳定性上了个台阶。

第二,max_tokens设成 1500 是经过考量的。一个完整匹配结果 JSON 一般在 300 到 800 Token 之间,留到 1500 是为了防止模型在matched_points和missing_points里过度展开写一大堆。如果要处理更长的分析,可以适当增加到 2000,但没必要无脑拉高。

第三,错误处理必须兜全。PDF 解析失败、网关超时、JSON 解析失败属于三类不同异常,接口统一返回结构化的错误体,前端才能准确展示错误原因。exposure 这些信息给用户,也能快速判断是模型问题还是输入问题。

4.3 Prompt 拼接逻辑

buildPrompt函数看起来简单,但有几行代码决定了结果的稳定程度。我的实现是:

import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions'; export function buildPrompt({ job, resume, }: { job: string; resume: string; }): ChatCompletionMessageParam[] { return [ { role: 'system', content: `你是一名资深简历筛选专家。请你严格根据职位描述和简历内容评估匹配度。 只输出 JSON,不要输出任何多余文字,不要用 Markdown 代码块包裹。 评分字段必须为数字 0-100。`, }, { role: 'user', content: `以下是职位描述:\n${job.slice(0, 2000)}\n\n以下是候选人简历:\n${resume.slice(0, 3000)}`, }, ]; }

这里我做了两个截断:职位描述最多 2000 字,简历最多 3000 字。对于绝大多数职位和简历,这个长度完全够用,而且能避免因输入过长导致成本飙升和响应时间拉长。如果截断后关键内容被切掉,模型也会因为忠实于文本而产生一定的信息缺失,所以截断的策略是在边界处做行的自然截断,不要硬切。

4.4 前端页面的简易实现

前端没有用太复杂的东西。核心就是一个表单:左边粘贴职位描述,右边上传简历文件或者粘贴简历文本,点击"开始匹配"按钮,页面请求/api/match接口,把结果渲染到卡片上。

'use client'; import { useState } from 'react'; export default function Home() { const [job, setJob] = useState(''); const [resumeText, setResumeText] = useState(''); const [file, setFile] = useState<File | null>(null); const [loading, setLoading] = useState(false); const [result, setResult] = useState<any>(null); const [error, setError] = useState(''); async function onMatch() { setLoading(true); setError(''); try { const form = new FormData(); form.append('job', job); form.append('resumeText', resumeText); if (file) form.append('resumeFile', file); const res = await fetch('/api/match', { method: 'POST', body: form }); const data = await res.json(); if (!res.ok) throw new Error(data.error || '请求失败'); setResult(data.result); } catch (e) { setError(e instanceof Error ? e.message : '请求失败'); } finally { setLoading(false); } } return ( <main style={{ maxWidth: 900, margin: '0 auto', padding: 32 }}> <h1>简历匹配工具</h1> <textarea value={job} onChange={(e) => setJob(e.target.value)} placeholder="粘贴职位描述" rows={6} style={{ width: '100%' }} /> <textarea value={resumeText} onChange={(e) => setResumeText(e.target.value)} placeholder="粘贴简历文本,或上传 PDF" rows={10} style={{ width: '100%' }} /> <input type="file" accept=".pdf,.txt" onChange={(e) => setFile(e.target.files?.[0] || null)} /> <button onClick={onMatch} disabled={loading}> {loading ? '匹配中...' : '开始匹配'} </button> {error && <p style={{ color: 'red' }}>{error}</p>} {result && ( <div> <h2>总分:{result.total_score}</h2> <p>经验:{result.dimensions?.experience},技能:{result.dimensions?.skills},学历:{result.dimensions?.education}</p> <h3>强匹配点</h3> <ul>{(result.matched_points || []).map((it: string, idx: number) => <li key={idx}>{it}</li>)}</ul> <h3>缺失点</h3> <ul>{(result.missing_points || []).map((it: string, idx: number) => <li key={idx}>{it}</li>)}</ul> <p>{result.suggestion}</p> </div> )} </main> ); }

页面设计完全面向 HR 使用习惯,不做花哨交互。提交后如果模型还在响应,按钮会禁掉,避免重复提交重复计费。这看起来是个小细节,实际用下来很有帮助——HR 在结果出来前反复点按钮的情况太常见了,网关有缓存还好,没缓存就是真金白银。

4.5 部署验证的完整流程

代码写完之后部署到 Vercel 很简单。先把我本地.env.local里的三个变量原样配到 Vercel 项目的 Environment Variables 里,然后 push 到 Git 仓库,Vercel 自动识别 Next.js 项目并构建。

部署完验证顺序建议这样走:先用 curl 直接测接口,确保后端通。

curl -X POST https://你的项目.vercel.app/api/match \ -F "job=招聘一名前端工程师,要求熟悉 React、TypeScript、有性能优化经验" \ -F "resumeText=我使用 React 开发过三个项目,熟悉 TypeScript,做过首屏性能优化"

预期返回结果里total_score应该偏高,matched_points里会列出 React 和 TypeScript 匹配。确认接口没问题后再用页面做真实文件上传测试,重点验证 PDF 解析环节是否稳定。

5. 实战中的问题与排查记录

5.1 高频问题速查表

现象原因解决方式
401 UnauthorizedGateway Key 填错或者填成 Jev 原始 Key检查环境变量,确认填的是网关自己的 Key
400 Bad Request入参缺失或简历文本过短检查前端是否完整传入 job 和 resumeText
模型返回非 JSONPrompt 约束不足或响应格式参数没开开启 response_format,温度调到 0.2 以下
JSON 解析失败模型在 JSON 外输出了解释文字使用宽松解析,提取首尾大括号
PDF 解析为空简历是扫描件或图片型 PDF提示用户改为粘贴文本
请求超时输入过长导致 Token 数过多在 Prompt 拼接时控制文本长度

5.2 三个最容易翻车的地方

第一个坑是环境变量错位。这个我已经说过了,但值得再强调一遍:JEV_API_KEY必须填网关的 Key,JEV_BASE_URL必须指向网关端点。如果你的前端代码里出现了 Jev 的原始 API Key,那就是重大安全隐患,网关的存在意义就没了。自查的标准很简单:登录 Vercel 后台,找到 Gateway 页面展示的那个 Key,和本地环境的比对一下。

第二个坑是响应格式不稳定。哪怕开了response_format: { type: 'json_object' },边界情况下模型依然可能给你一段破 JSON,比如某个字符串值里带了没转义的单引号。我的经验是双保险:既要开参数,又要在代码里做宽松解析。两件事分开看,前者提高正常情况的稳定性,后者兜住异常情况,缺一不可。

第三个坑是 PDF 解析质量参差。pdf-parse对纯文本型 PDF 效果不错,但遇到分栏简历的时候,读出来的文本顺序可能是乱的,第二栏的内容会混到第一栏后面。这直接影响模型理解简历的结构。我的方案是在 PDF 解析结果里做一个分行整理,尽量按空行分段清洗一遍,虽然不能根治分栏问题,但能显著降低混乱程度。

5.3 成本与性能的实测观察

整体跑下来的体感是:单次匹配请求平均耗时在 4 到 8 秒之间,其中大部分时间花在模型生成上,网关本身带来的额外延迟几乎可以忽略。开启缓存后,相同简历和 JD 的重复请求耗时直接降到几十毫秒级别,成本也趋近于零。

给个人开发者一个成本控制的建议:给网关配置一个偏保守的 Rate Limit,比如每分钟 30 次。内部工具完全够用,还能防止某个同事顺便写个脚本把你的 Key 当公共 API 刷。之前我就在日志里看到过短时间内几百次请求的异常记录,幸亏有网关的限流规则兜底,不然账单就难看了。

我个人实操下来最满意的一点是项目结构没有和特定模型 SDK 强绑定,全部通过网关走 OpenAI 兼容协议。下次如果发现 Jev 在某个任务上表现不佳,换模型只需要在网关后台加一个新的 Provider,然后改一个环境变量JEV_MODEL就完事。这也是我用 Vercel AI Gateway 来做这个项目最核心的原因:业务逻辑永远是稳定的,模型可以灵活替换。最近我还在考虑把网关这套方案用到另一个场景上,给团队的周报做自动摘要和待办提取,目前看架构完全可以复用,只需要换一套 Prompt 而已。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 14:18:22

Unity GC 卡顿全解析:从分配机制到性能优化实战

1. 这个系列要解决什么问题做 Unity 性能优化的朋友&#xff0c;多半都有过这种经历&#xff1a;游戏跑起来帧率看着还行&#xff0c;帧时间曲线也不算离谱&#xff0c;但就是在某些时刻——切技能、开背包、刷怪、甚至只是播了个动画——画面突然肉眼可见地"钝"了一…

作者头像 李华
网站建设 2026/9/26 14:17:19

260+国旗Sketch图标集:从Symbol库到SVG导出的完整实践指南

简介&#xff1a;这份Sketch格式图标库收录了260多个国家的国旗矢量素材&#xff0c;目标用户是界面设计师、前端工程师与品牌物料制作团队&#xff0c;可广泛用于移动应用、响应式网页、国际业务报表、在线地图及海外运营活动等。素材按Sketch源文件组织&#xff0c;所有图标均…

作者头像 李华
网站建设 2026/9/26 14:17:10

dbf文件转MySQL:从打开方式到数据迁移完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 14:16:34

AI代码生成的隐患排查与工程化治理实战

1. 这不是“写得快”的问题&#xff0c;是“写得对”的生死线我带过三支不同规模的开发团队&#xff0c;从初创公司到年营收过亿的SaaS厂商&#xff0c;过去两年里&#xff0c;所有团队都把AI代码助手纳入了标准开发流程——不是锦上添花&#xff0c;而是刚需。但去年Q3&#x…

作者头像 李华
网站建设 2026/9/26 14:16:29

AI服务器高速连接器为何吃紧?12亿扩产背后的信号完整性与选型逻辑

一台AI服务器里最贵的料&#xff0c;除了GPU就是HBM&#xff0c;这基本是行业共识。但今天我想聊一个大家平时很少正眼看、却在整机BOM里悄悄吃掉大几个百分点的环节——高速连接器。Molex&#xff08;莫仕/莫莱克斯&#xff09;宣布12亿增资东莞工厂&#xff0c;押注AI服务器高…

作者头像 李华
网站建设 2026/9/26 14:16:28

PPT卡顿提速实战指南:图片压缩、动画瘦身与性能优化全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华