☰
Vercel AI Gateway接入Jev模型:简历智能匹配全流程实现
2026/9/26 18:36:55 网站建设 项目流程

1. 为什么我看上Vercel AI Gateway:简历匹配这个场景的"中间人"

1.1 直接调 Jev API 不行吗?行,但会很痛

先说结论:如果只是写一个"给自己用两天的脚本",完全可以直接拿着 Jev 的 API Key 去调,根本不用绕网关。但我这次要做的是一个正经的简历匹配服务,接完 Jev 之后还要接别的模型对比效果,还要让同事也能用、还要看日志排错,这就不是"能调通"的问题了。

直接调 Jev API 最烦的地方在于:每次想对比模型效果,都要改代码里的一堆常量。简历匹配这个场景尤其明显——你可能觉得 Jev 评分比较合理,但想要看一眼另一个模型跑出来的分数差异,那就得把 baseURL、请求头、错误码处理逻辑全部改一遍。如果你接触过几家不同风格的大模型 API,还会发现它们所谓"OpenAI 兼容"只不过是大方向兼容,真到了超时时间、错误码字段、流式返回格式这些细节上,各家的脾气完全不同。业务代码里塞满了这种适配逻辑,项目很快就会变得又脏又难维护。

所以我把接入层抽了出来,让所有模型请求统一走 Vercel AI Gateway。请求先到网关,再由网关转发到真实的 Jev 模型服务。业务代码里只需要维护一个 OpenAI 风格的客户端、一个 baseURL、一个网关 Token,模型切换变成了配置项而不是代码改动。后面我会具体写这个切换过程。

1.2 网关替你兜住的脏活:路由、重试、可观测性

Vercel AI Gateway 在我这个项目里承担了三件脏活,这三件事单独拎出来自己做都不难,但全堆在业务代码里就很恶心。

第一是路由。网关后面可以挂多个模型服务商,每个 Provider 都有自己的地址和密钥。调用方不需要关心目标模型到底部署在哪,只需要在 model 字段里带上一个配置好的标识,网关会自己去找对应的后端。我这次配好 Jev 之后,业务侧完全不用保存 Jev 的真实 API 地址,自然也就不存在"地址散落在各个文件里"的问题。

第二是重试和限流。模型服务经常返回 429、5xx,或者干脆连接超时。网关层可以做统一的退避重试策略,业务侧收到失败响应后不用自己写一坨超时重试逻辑。简历匹配这种请求动辄几秒甚至几十秒,中间偶发一次网络抖动要是全让上层代码去处理,那每个接口都得套一个重试框架,想想就头大。

第三是可观测性。网关注定每个请求的耗时、token 消耗、错误码,还有一个统一的面板可以看。简历匹配上线之后,你一定会被问到"今天跑了多少份?平均耗时多少?成本多少?"这种问题。有了网关日志,直接截图导出就行,不用自己在数据库里埋一堆打点。

1.3 网关不是银弹:什么场景不建议绕这一层

把网关说得这么好,也该泼点冷水。如果你的场景是一次性的离线脚本,数据量只有十几份简历,跑完就完事,那直接调 Jev API 就好,多一层网关反而多一个故障点。如果你的企业对数据隐私要求极高,要求请求链路不能经过任何第三方转发层,那网关也可能不合适——虽然网关本身不保存模型内容,但"多经手一层"这件事在某些合规场景下就是要额外论证。

我的判断标准很简单:凡是"要持续迭代、要多人使用、要切换模型做对比"的项目,值得上网关;凡是"跑一次就扔、追求最小依赖"的活,别上。简历匹配恰好是前者,所以我选了网关方案。

2. 开工之前:从Jev密钥到网关Provider配置

2.1 拿到Jev API密钥:注册、建Key、权限最小化

接入的第一步当然是先拿到 Jev 模型的访问凭证。去模型官方站点注册账号之后,在控制台里找到 API Keys / 密钥管理页面,创建一个新的密钥。

这里有几个实操细节值得多说一句。第一,生产环境和本地调试最好用两个不同的密钥,不要图省事共用一个。原因很简单:本地环境变量文件有可能会被误提交到仓库里,如果密钥是专用的,泄露后只需要吊销这一把,不影响线上。第二,大多数模型平台允许给密钥设置额度上限,建议新建密钥时顺手设一个月额度,防止测试阶段被某个死循环脚本把余额刷光。第三,别把密钥填到任何前端代码里,这一点后面踩坑部分我专门会讲。

顺便回应一个很多人关心的问题:Jev 模型是否开源。对做应用层的我们来说,模型开不开源其实不太影响接入方式——只要它的 API 是 OpenAI 兼容格式,你把 baseURL 和 apiKey 一换,代码就能跑。如果哪一天你想自托管,那也只是把 baseURL 指向你自己的推理服务地址,业务代码不需要变化。所以"开源吗"这个问题,在你决定走云 API 的路线上,基本不影响决策。

2.2 在AI Gateway面板添加Provider

登录 Vercel 控制台,找到 AI Gateway 相关入口,新建 Provider。填三样东西:Provider 名称、Jev 模型 API 的基础地址、Jev API Key。Provider 名称其实是个别名,我填的是jev,后面在请求里就是用这个名字来路由的。

配置完成之后,网关会给你一个统一的调用地址,通常形如https://gateway.vercel.ai/v1。如果面板支持创建项目级端点,我也建议用项目级地址,后续可以按项目区分调用量、分别配置限额,多环境管理会舒服很多。

因为各家网关面板的界面时不时会改,字段名称可能略有出入,但核心概念都一样:你把自己的模型 Provider 挂到网关下面,得到一个统一的 OpenAI 兼容 endpoint,之后所有调用都打到这个 endpoint 上。遇到配置细节不确定的时候,对照面板里的 Provider 配置说明,把 Jev 的 API 地址和密钥填进去,基本都不会错。

2.3 本地开发环境:必要的变量、依赖和目录结构

我这次用的是 Next.js 项目,主要是图它 API Route 写起来方便,部署到 Vercel 也顺。不过网关接入这层其实跟框架无关,你用 Express、Fastify 甚至一个 Python FastAPI 服务都完全没问题,核心思路是一样的:服务端持有网关 Token,通过 OpenAI 兼容客户端完成请求。

项目里需要装的东西不多:

npm install openai pdf-parse mammoth zod
  • openai:官方 OpenAI SDK,用来调用 OpenAI 兼容接口,走网关时把 baseURL 换成网关地址即可。
  • pdf-parse:解析 PDF 简历。
  • mammoth:把 docx 转成纯文本。
  • zod:校验模型返回的 JSON 结构,防止评分字段缺失或类型不对。

环境变量这样配:

# .env.local JEV_GATEWAY_URL=https://gateway.vercel.ai/v1 JEV_GATEWAY_TOKEN=你的网关Token JEV_MODEL_ID=jev:模型标识

目录结构保持简单:

app/api/match/route.ts # 匹配接口 lib/parser.ts # 简历文档解析 lib/matcher.ts # 网关调用与评分逻辑 lib/schema.ts # 输出JSON校验

3. 简历匹配的主流程:从简历文本到结构化评分

3.1 文档解析:PDF和Word怎么变成干净的文本

很多人觉得简历匹配的重点是"选个聪明的模型",实际上接到的简历里 PDF、Word、纯文本乱七八糟什么都有,第一步反而是怎么把文件内容变成干净的文本。

PDF 我用pdf-parse,Word 用mammoth,实现都不复杂:

// lib/parser.ts import pdf from "pdf-parse"; import mammoth from "mammoth"; export async function extractText(file: { name: string; buffer: Buffer }) { if (file.name.endsWith(".pdf")) { const result = await pdf(file.buffer); return result.text.replace(/\n{3,}/g, "\n\n").trim(); } if (file.name.endsWith(".docx") || file.name.endsWith(".doc")) { const result = await mammoth.extractRawText({ buffer: file.buffer }); return result.value.trim(); } return file.buffer.toString("utf-8").trim(); }

这里有个容易忽略的问题:pdf-parse对扫描版 PDF 完全无能为力,它会返回一堆空白或者乱码。正常电子签名的简历没问题,但如果候选人发的是拍照件,就必须先做 OCR,这一步在 MVP 阶段可以先不处理,我后面会再提。另一个经验是,解析完之后要把多余的空行压缩掉,简历文档经常有各种格式残留,全塞进 Prompt 里既浪费 token 又会干扰模型判断。

3.2 Prompt设计:让模型同时扮演招聘专家和岗位剖析师

解析文本只是准备工作,真正决定匹配质量的是 Prompt。我第一次写的 Prompt 特别简陋,就一句"请评估这份简历和职位的匹配度",结果模型给出来的评分没有维度拆解,也没有解释理由,根本没法用。

后来我把 Prompt 调整成了"招聘专家 + 岗位剖析师"的双重角色设定,核心要求是:

  • 明确区分"岗位职责"和"任职要求"。这两种内容对匹配评估的权重完全不同,岗位职责描述的是日常做什么,任职要求才是硬性筛选条件。如果模型把两者混在一起,很容易把"负责团队管理"这种职责内容当成"候选人需要具备管理经验"来打分,导致误判。
  • 每个评分维度都必须给出依据。不能光给一个分数,还要说明是根据简历里哪一段经历、哪一项技能判断的。
  • 必须输出 JSON,字段应符合我在下一节定义的 schema。

系统提示词我最终写成这样:

你是一位资深招聘专家,擅长分析候选人简历与职位描述(JD)的匹配度。 你被给定两份内容:候选人简历文本、职位描述文本。 请从以下五个维度分别打分,每个维度0到100分: - experience:工作经验与岗位所需经验的匹配程度 - skills:硬技能与岗位技能的覆盖程度 - education:学历与专业背景的匹配程度 - stability:任职稳定性与职业路径的合理程度 - culture:从简历内容推断的协作风格、行业背景契合度 同时输出: - matched_skills:候选简历中命中JD要求的技能列表,必须是简历原文中出现过的技能 - missing_skills:JD中明确要求但简历中缺乏的技能列表,必须从JD原文提取,不要推测 - summary:150字以内的匹配总结 - recommendation:建议(强烈推荐/推荐/待定/不推荐) 只输出JSON对象,不要输出Markdown代码块,不要加任何解释。

这个 Prompt 看起来长,但每句话都在约束模型的行为边界。尤其是"必须是简历原文中出现过的技能"和"不要推测"这两句,是我经历了幻觉问题之后加上去的,后面踩坑部分会仔细展开。

3.3 输出约束:JSON Schema与字段说明

模型输出自由文本容易,稳定输出结构难。我在lib/schema.ts里定义了完整的 zod schema:

// lib/schema.ts import { z } from "zod"; export const MatchResultSchema = z.object({ scores: z.object({ experience: z.number().min(0).max(100), skills: z.number().min(0).max(100), education: z.number().min(0).max(100), stability: z.number().min(0).max(100), culture: z.number().min(0).max(100), }), matched_skills: z.array(z.string()), missing_skills: z.array(z.string()), summary: z.string().min(10), recommendation: z.enum(["强烈推荐", "推荐", "待定", "不推荐"]), }); export type MatchResult = z.infer<typeof MatchResultSchema>;

字段这么设计是有原因的。scores里五个维度全部用 0 到 100 的整数,而不是"高/中/低"或者 1 到 5 星,是因为后续做批量排序、画雷达图、算加权总分,全部需要数值化数据。1 到 5 星粒度太粗,两份明显不同的简历可能都打 4 星,没法排序。matched_skills和missing_skills是为了可解释性——HR 拿到一份自动评分报告,总得知道为什么是这个分数,技能清单就是最直观的佐证。

4. 接入代码实战:Jev通过网关调用的完整写法

4.1 统一客户端初始化

网关的 endpoint 是 OpenAI 兼容的,所以直接复用 OpenAI SDK,只改 baseURL 和 apiKey:

// lib/matcher.ts import OpenAI from "openai"; const gatewayClient = new OpenAI({ baseURL: process.env.JEV_GATEWAY_URL, apiKey: process.env.JEV_GATEWAY_TOKEN, });

这里有一个关键认知:SDK 里的apiKey字段,在这里填的其实是 Vercel AI Gateway 的 Token,而不是 Jev 平台的原始密钥。Jev 的真实密钥只存在于网关口配置里,业务代码完全接触不到。这就很舒服——即使代码仓库泄露,攻击者拿到的也只是网关 Token,而网关 Token 的权限范围和撤销方式都是在你的掌控之下的。

4.2 带重试的匹配函数

下面是一个完整可用的匹配函数,包含了构建 messages、调用模型、容错解析、重试几个部分:

// lib/matcher.ts import OpenAI from "openai"; import { MatchResultSchema, type MatchResult } from "./schema"; export async function matchResume(resumeText: string, jdText: string): Promise<MatchResult> { const systemPrompt = "你是一位资深招聘专家,擅长分析候选人简历与职位描述(JD)的匹配度。...只输出JSON对象,不要输出Markdown代码块,不要加任何解释。"; const userPrompt = `候选人简历:\n${resumeText.slice(0, 8000)}\n\n职位描述:\n${jdText.slice(0, 3000)}`; let lastError: unknown; // 第一遍用标准结构化输出模式 for (let attempt = 0; attempt < 2; attempt++) { try { const response = await gatewayClient.chat.completions.create({ model: process.env.JEV_MODEL_ID as string, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], temperature: attempt === 0 ? 0.2 : 0, max_tokens: 1500, response_format: { type: "json_object" }, }); const content = response.choices[0]?.message?.content ?? ""; const parsed = extractJson(content); return MatchResultSchema.parse(parsed); } catch (err) { lastError = err; // 短暂等待后重试,第二次把 temperature 降到 0,减少随机性 await new Promise((resolve) => setTimeout(resolve, 1500)); } } throw lastError; } function extractJson(content: string) { // 有些模型兼容 openai 的 response_format,有些会返回 markdown 代码块 const fenced = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/); if (fenced) return JSON.parse(fenced[1]); return JSON.parse(content); }

关于response_format有一点要特别注意:并不是所有 OpenAI 兼容模型都支持这个参数。Jev 这块你在实测时最好先试一下,如果传了报错,就把这个参数去掉,然后依赖上面的extractJson对 Markdown 代码块做兜底解析。这是兼容各种 OpenAI 兼容模型的关键小技巧。

4.3 返回结果给前端时的格式化处理

API Route 里把匹配结果再包一层统一结构返回给前端,方便调用方处理:

// app/api/match/route.ts import { NextResponse } from "next/server"; import { extractText } from "@/lib/parser"; import { matchResume } from "@/lib/matcher"; export async function POST(req: Request) { const startedAt = Date.now(); const formData = await req.formData(); const file = formData.get("resume") as File; const jd = formData.get("jd") as string; if (!file || !jd) { return NextResponse.json({ error: "缺少简历文件或JD文本" }, { status: 400 }); } const buffer = Buffer.from(await file.arrayBuffer()); const resumeText = await extractText({ name: file.name, buffer }); if (resumeText.length < 50) { return NextResponse.json({ error: "简历解析结果为空,请确认文件不是扫描件" }, { status: 422 }); } try { const result = await matchResume(resumeText, jd); return NextResponse.json({ data: result, meta: { elapsedMs: Date.now() - startedAt }, }); } catch (err) { console.error("匹配失败:", err); return NextResponse.json({ error: "匹配服务暂时不可用,请稍后重试" }, { status: 502 }); } }

这里还顺手做了一个最小鲁棒性校验:如果解析出来的简历文本不足 50 字,直接拒绝,避免把空文本送进模型返回一堆瞎评分。前端拿到elapsedMs也能在界面上显示"本次匹配耗时 xx 秒",让用户有个合理预期。

5. 踩坑记录:密钥暴露、响应解析和长文档超时

5.1 前端环境变量里的密钥差点裸奔

这是我在项目最初期差点犯下的错误。当时为了快速出 Demo,我把JEV_GATEWAY_TOKEN用NEXT_PUBLIC_前缀的形式放在了环境变量里,然后在一个客户端组件里直接调用网关。从功能上讲,浏览器确实能发请求拿到匹配结果,但代价是网关 Token 被完整打包进了浏览器端 JavaScript 文件里,任何人打开控制台都能看到。

这种做法的危险在于:你原本指望通过网关把真实模型密钥藏起来,结果网关 Token 又在前端裸奔了,等于绕了一圈又回到了起点。正确做法是严格的"服务端代理"——前端只把文件传给自己的 API Route,由 Route 持有 Token 调用网关,然后把结果返回给前端。这样浏览器永远只跟自己的服务端打交道,跟 Vercel AI Gateway、跟 Jev 平台之间都没有直接联系。

5.2 长简历超时:截断、拆块与并发

第一次真实测试,我丢进去一份 6 页纸的资深技术负责人简历,还带了一堆项目描述。结果等了将近一分钟,接口直接超时。问题出在简历文本太长,模型需要在超长上下文里做分析,生成时间也跟着拉长。

我用的第一个修复手段是截断。把简历文本 slice 到 8000 字符,JD slice 到 3000 字符,保证 Prompt 总量可控。实测下来,对于绝大多数候选人简历,保留前 8000 字符基本能覆盖最近三段工作经历和技能列表——这也是 HR 最看重的部分。

截断毕竟会丢信息,所以后来我又做了第二层优化:拆块评估。把简历按"基本信息+技能列表""工作经历""项目经历"切成三个块,分别让模型打一次分,最后再让模型基于三个子评估汇总出一个总分。这样每个子任务更聚焦,也不容易超时。代价是调用次数翻了好几倍、成本翻倍。我的建议是:MVP 阶段先截断,跑通了再考虑拆块;如果每天处理的简历量不大,截断带来的精度损失完全可以接受。

5.3 对话中的"幻觉关键词"怎么识别和抑制

在评估一份没有提到任何容器化经验的简历时,Jev 生成的missing_skills里居然出现了"Kubernetes"和"Docker"——这俩词在职位描述原文里根本不存在。这就不是评分问题了,而是模型在脑补技能清单,如果照单全收给 HR 看,会让人怀疑你的系统在胡编。

这个问题我做了两层防御。

第一层是 Prompt 层面约束,这也是最有效的:在系统提示词里明确写"matched_skills 必须是简历原文中出现过的技能""missing_skills 必须从 JD 原文提取,不要推测"。大多数情况下模型会遵守这个约束。

第二层是代码层面的后置过滤:从模型返回的matched_skills和missing_skills里,过滤掉那些既不出现在简历原文、也不出现在 JD 原文里的词。注意这个过滤不能做得太机械,因为简历写"熟悉消息队列",JD 写"RabbitMQ",俩词在字面上完全不同但语义相关。所以我会保留原始结果给前端展示,同时在后端计算一个"strictMatch"列表作为参考字段,让审核的人有据可查。

这个问题的根因在于:模型在生成 JSON 时会在概率分布里选择"最像技能列表的词",而不会时刻记得"我从哪句话得到这个结论"。唯一的解法就是反复提示它"基于原文",再加一个程序化过滤器作为最终防线。

6. 批量筛人的进阶思路:实测评估与扩展方向

6.1 我实测下来的效果、耗时和成本

用 30 份真实简历做了一轮测试(当然脱敏了),这里记录一下我的体感数据,不是严谨评测,仅供参考。

单份简历的匹配耗时大概在 20 到 40 秒之间,主要取决于简历长度和模型服务的实时负载。Prompt 平均 token 消耗在 4000 到 6000 之间,其中大部分是简历文本。如果按模型平台的 token 单价粗略估算,单份简历的成本约在几美分这个量级,考虑到它省下的人工阅读时间,性价比非常高。

和人工评估的一致性方面:我拿 10 份简历让 HR 同事人工打分,再用服务跑一遍,差额基本控制在 ±10 分以内。不能说模型比人工更准,但它最大的价值是标准统一——同一个 JD 下所有简历都按同一套维度打分,不存在人工审阅时"前面严格后面宽松"的疲劳效应。

6.2 从单份匹配到批量初筛:Embedding 可以怎么介入

上面的实现是"单份简历 + JD → 评分"。如果简历量变成几百份,每一份都走完整的大模型调用,耗时和成本都会线性增长。这时候我建议先做一层基于向量的初筛。

思路是把 JD 和每份简历分别向量化,计算语义相似度,先召回最相关的前 20 份到 30 份,再对这部分候选走前面写的 Jev 精审评分。Embedding 初筛这一步非常便宜且快速,虽然精度不如大模型精评,但足以把明显不相关的简历挡在门外。像pgvector或者本地的向量库都能做这个召回;如果不想引入向量数据库,也可以退而求其次用关键词倒排过滤,但召回质量会明显差一截。

6.3 如果继续迭代,我下一步会做这几件事

第一,把 JD 也结构化。现在 JD 是直接粘进去的纯文本,但同一家公司不同岗位的 JD 结构差异很大。如果先把 JD 拆成"职责""硬性要求""加分项"三个字段再喂给模型,评分维度的解释会更清晰。

第二,做成异步批处理。在线同步匹配只适合单份试用,真正的批量筛选应该走任务队列:上传一批简历 → 服务端逐份处理 → 每份处理完通过 Webhook 通知前端。这样就算处理一百份简历要一两个小时,用户也不用一直开着页面等。

第三,增加一个人工复核出口。模型给的分数和技能清单,应该允许 HR 在界面上修改,修改后的结果沉淀下来,未来可以反哺 Prompt 或做成反馈数据。简历匹配这种决策场景,模型当助手、人做最终判断,才是最务实的产品形态。

最后分享一个我自己踩过之后总结出来的习惯:做 AI 应用,宁可让用户多等三秒,也不要返回一份半截 JSON。稳定的输出结构、完善的容错解析、严格的后端校验,这三件事的重要性排序,永远排在"选哪个模型"前面。模型可以随时换,坏了结构就会让你的一系列下游逻辑全部崩盘。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询