☰
Vercel AI Gateway实战:构建简历匹配工具的完整指南
2026/9/28 7:19:28 网站建设 项目流程

最近在做一个内部招聘场景的小工具:候选人投递简历后,HR 希望先用系统把简历和岗位 JD 做一轮自动匹配,筛掉明显不合适的人,再让 Recruiter 做人工复核。项目名就叫“Jev 实战:用 Vercel AI Gateway 做简历匹配”。听起来是个很垂直的 AI 小应用,但真正动手之后才发现,从模型接口、网关路由、提示词设计、简历文本清洗到结果结构化,每一步都有很多细节坑。这篇文章把我实际跑通的一条完整链路记录下来,包括踩过的坑,以及最后沉淀下来的配置和代码,希望能给正在做类似 AI 工具的团队一个可参考的模板。

1. 项目现状与架构设计思路

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

很多团队早期做简历筛选,方案都是朴素的规则匹配:正则表达式拉学历、工作年限,再对技能词表做交集统计。比如 JD 里写了“熟悉 Kubernetes”,简历里出现“K8s”,那就是命中;如果只写了“容器编排经验”,关键词规则就完全失联。这种硬编码规则的问题在于,招聘领域里同一个技能有大量同义表达,再加上“熟悉”“精通”“了解”这类程度词,规则表越维护越长,覆盖率和准确率却始终上不去。

后来有人引入向量检索:把简历和 JD 都转成 embedding,再算余弦相似度。这个方案能解决部分同义词问题,但本质上还是在做“文本相似”,不是“岗位匹配”。候选人过往经历里做过类似的业务,但简历措辞完全不同,向量相似度照样不高;简历里堆了很多热门技术名词,语义上跟岗位没有实际关系,相似度可能反而很高。

我这次做简历匹配,目标不是搞一个完美的 AI 筛选系统,而是把规则匹配和模型推理结合起来,解决三个实际问题:

  • 第一,把简历和 JD 的语义相关性量化成一个可解释的分数,而不是黑盒相似度。
  • 第二,自动列出“候选人满足什么”“缺少什么”“面试官重点考察什么”,给 HR 提供决策依据。
  • 第三,通过 Vercel AI Gateway 统一管理模型调用链路,让业务代码不用关心底层模型由谁提供、被限流怎么办、同样的请求能不能走缓存。

这些需求听起来不复杂,但要把 AI 模型稳定地用起来,架构上就得先搭好一个可靠的调用层。

1.2 为什么是 Jev + Vercel AI Gateway 这套组合

选型时我对比过好几套方案:直接用原生模型 API、自建网关、用云厂商聚合服务,最后选择了 Jev 模型配合 Vercel AI Gateway。下面把 Jev 当作一个已经拿到 API 访问权限的模型代号来用,它承担简历理解、匹配推理、结果生成的任务;Vercel AI Gateway 则负责所有和模型交互有关的横切能力。

Vercel AI Gateway 最吸引我的有三点:

  • 统一入口。业务侧只面向一个网关地址,所有模型请求都从同一个入口走,模型更换、服务商切换不需要改业务代码。
  • 缓存和重试。相同请求可以在网关层直接命中缓存,省掉的 token 费用非常可观;临时性的 429、5xx 错误也可以由网关策略重试。
  • 可观测性。每次请求的延迟、token 消耗、错误码都能在后台看到,排查线上问题比直接翻模型服务商日志方便得多。

而 Jev 模型这边,推理质量比很多我试过的轻量模型更稳,尤其是在“长文本理解 + 结构化输出”这种场景下,它不会轻易丢信息,也能按照指令输出稳定的 JSON 结构。两者放在一起,整个链路就是“业务代码 — Vercel AI Gateway — Jev 模型”,既不绑定单一供应商,又保留了部署运维的简单性。

2. 环境准备:把网关和模型串起来

2.1 需要准备的四样东西

动手前先列个清单,避免做到一半发现缺东西:

  • 一个 Vercel 账号,用于创建 AI Gateway 路由。
  • Jev 模型的 API Key,申请后至少要有基础调用权限。
  • 一个 Node.js 项目,本地环境 Node 18 以上即可。
  • 测试数据:脱敏后的简历文本和一段岗位 JD。我直接用了自己构造的样例数据,没有碰真实候选人隐私。

工具链上用 pnpm 管理依赖,核心依赖就两个:pdf-parse用于解析 PDF 简历,zod用于校验模型返回的 JSON 结构。如果你只处理纯文本简历,pdf-parse也可以省掉。

2.2 初始化 Vercel AI Gateway 路由

创建网关的流程不复杂,在 Vercel 控制台找到 AI Gateway 入口,新建一个 Gateway,然后在 Provider 配置里把 Jev 模型的 Base URL 和 API Key 填进去。保存之后,Vercel 会分配一个网关地址,形如:

https://gateway.vercel.ai/v1/chat/completions

这个地址就是业务侧唯一需要记住的模型入口。我习惯把相关配置统一放到.env.local文件里,项目根目录创建如下内容:

AI_GATEWAY_URL=https://gateway.vercel.ai/v1/chat/completions AI_GATEWAY_TOKEN=your_vercel_gateway_token JEV_MODEL_NAME=jev

注意,AI_GATEWAY_TOKEN和 Jev 模型的 API Key 不是一回事。网关的 Token 相当于你访问网关的凭证,而 Jev 的 API Key 配置在 Vercel 控制台里,业务代码不需要也不应该接触到模型侧密钥。这样做还有一个好处:如果以后网关后面挂了多个模型,业务代码里只需要切换JEV_MODEL_NAME这个字段,密钥体系完全不用动。

2.3 第一次模型调用的完整请求样例

环境变量准备好之后,先用 curl 做一次最基础的连通性验证。请求体里带上模型名和消息内容,写的就是一个非常简单的系统提示词加用户问题:

curl -X POST "$AI_GATEWAY_URL" \ -H "Authorization: Bearer $AI_GATEWAY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "model": "jev", "messages": [ {"role": "system", "content": "你是一个招聘助手,只回答JSON。"}, {"role": "user", "content": "请输出当前时间。"} ], "temperature": 0.2 }'

如果网关和模型配置都正常,你会得到一个 Chat Completions 风格的标准响应,核心内容在choices[0].message.content里。我第一次测试时在这里踩了个小坑:网关地址配成了之前某个旧项目的地址,请求直接 404。排查方式也比较笨,先确认.env.local里的地址和 Token 对应的是同一个 Gateway,别出现地址是新网关、Token 是旧网关这种低级错误。

第一发请求通过之后,整个链路算是通了。但这只是起点,真正的困难在后面:怎么让模型稳定地输出我们想要的 JSON,以及怎么处理不同格式的简历文本。

3. 简历匹配的核心逻辑设计

3.1 简历文本清洗:先解决“脏数据”

模型能理解的是文本,但你拿到的简历往往是 PDF、DOCX、甚至是图片扫描件。图片扫描件需要 OCR,这个工程量大,我建议第一阶段先不碰,只处理 PDF 和纯文本。PDF 解析我用的是pdf-parse,但解析出来的内容质量真的很随机,经常出现这种情况:

  • 表格内容顺序错乱,技能栏跑到工作经历前面。
  • 英文和中文混排时,换行符位置奇怪。
  • 页眉页脚混入正文,导致“第 1 页共 5 页”这种噪声进入提示词。

所以我加了一个简单的预处理步骤,按优先级做了几件事:

  1. 把多个连续空白符压缩成单个空格,统一换行符为\n。
  2. 删掉明显的页眉页脚行,比如“第 X 页”“Page X of X”。
  3. 按行长度过滤,单行小于 3 个字符的行直接丢弃。
  4. 把解析后的文本截断到 8000 个字符以内,超过部分从尾部裁掉。

这个截断策略看起来很粗暴,但实际效果不错。简历核心信息通常集中在前三分之二,尾部一般只剩证书列表和自我评价,丢失信息的影响相对小。后面我还会在提示词里明确告诉模型:如果简历被截断了,只基于已有文本做判断,不要擅自假设缺失内容。

3.2 提示词设计:把评分标准写进系统指令

简历匹配提示词是整个项目最核心的部分。我一开始写得很笼统,就是“请根据简历和 JD 打分”,结果模型给的分数忽高忽低,同一个候选人换个顺序问,评分能差十几分。

后来我把评分标准拆解成三个维度,直接写进系统提示词里:

  • 硬性条件匹配度:学历、工作年限、核心技能栈是否满足。
  • 项目经验相关度:候选人做过的项目是否涉及 JD 要求的关键词和能力。
  • 软技能与潜力信号:沟通、协作、自我驱动这类无法直接考量的信息。

同时给模型一个明确的评分刻度:0 到 100 分,60 分以下不推荐进入面试;60 到 79 分建议储备;80 分以上优先推荐。这些规则没有量化到具体权重,但是给模型提供了足够强的约束。

我的系统提示词最后定型成这样:

你是一位资深技术招聘顾问,请根据岗位JD评估候选人简历。 评估维度: 1. 硬性条件:学历、工作年限、关键技术栈是否满足JD。 2. 项目经验:候选人项目经历与JD所需能力的重合度。 3. 附加价值:候选人可能给团队带来的额外经验。 打分规则: - 90~100:高度匹配,可直接进入面试。 - 70~89:整体匹配,有明显可培养空间。 - 50~69:部分匹配,存在结构性短板。 - 0~49:明显不匹配,不建议推进。 输出要求: 始终输出JSON对象,格式如下: { "score": 整数, "summary": "两到三句话的总体评价", "matched_skills": ["技能1", "技能2"], "missing_skills": ["技能1", "技能2"], "risk_level": "low | medium | high", "suggestions": ["面试考察点或建议"] }

用户提示词里,我先把简历和 JD 放进去,再强调一句“你只能基于给出的简历文本做判断,不要编造候选人经历”。这里有一个细节很关键:简历和 JD 之间一定要用清晰的标记分隔,否则模型可能把两者混在一起。我用的模板是:

===== 岗位JD ===== {jdText} ===== 候选人简历 ===== {resumeText} ===== 请开始评估,只输出JSON。

3.3 结构化输出与后端校验

大模型输出再智能也是概率性的,不能直接把JSON.parse的结果扔给业务系统。我做了两层防护:

第一层,在请求参数里加上response_format: { "type": "json_object" },这是模型能力范围内的 JSON 模式,能显著减少输出解释性文字的情况。

第二层,用 Zod 对模型返回的 JSON 做校验,字段类型和取值范围都严格约束。比如score必须是整数且在 0 到 100 之间,matched_skills必须是字符串数组。校验失败时,我会自动重试一次,重试时把错误信息反馈给模型,让它重新修正输出。这段逻辑比较机械,但稳定输出全靠它兜底。

import { z } from "zod"; export const MatchResultSchema = z.object({ score: z.number().int().min(0).max(100), summary: z.string().min(1), matched_skills: z.array(z.string()), missing_skills: z.array(z.string()), risk_level: z.enum(["low", "medium", "high"]), suggestions: z.array(z.string()), });

校验通过之后,再把结果转成结构化的匹配报告返回给前端。整个流程看起来不复杂,但每一层都会遇到实际问题,下面我展开讲讲完整实操。

4. 完整实操:从上传简历到拿到匹配报告

4.1 工程目录结构

项目我直接部署在 Vercel 上,用 Next.js 做了一层薄薄的壳。目录结构如下:

resume-match/ ├── app/ │ ├── api/ │ │ └── match/ │ │ └── route.ts # 简历匹配接口 │ ├── layout.tsx │ └── page.tsx # 前端页面 ├── lib/ │ ├── prompt.ts # 提示词模板 │ ├── parser.ts # 简历文本预处理 │ └── gateway.ts # 网关调用封装 ├── .env.local └── package.json

前后端分离程度不高,但是对这个体量的工具来说足够清晰。核心逻辑都在lib目录下,route.ts只负责 HTTP 参数解析和结果返回。

4.2 后端接口的实现与关键代码

网关调用的封装是核心,我把它写成一单向方法,入参是简历文本和 JD 文本,出参是校验后的匹配结果。关键代码在这里:

import { MatchResultSchema } from "./schema"; const AI_GATEWAY_URL = process.env.AI_GATEWAY_URL!; const AI_GATEWAY_TOKEN = process.env.AI_GATEWAY_TOKEN!; const JEV_MODEL_NAME = process.env.JEV_MODEL_NAME ?? "jev"; export async function matchResume(resumeText: string, jdText: string) { const systemPrompt = SYSTEM_PROMPT; const userPrompt = buildUserPrompt(resumeText, jdText); for (let attempt = 0; attempt < 2; attempt++) { const res = await fetch(AI_GATEWAY_URL, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${AI_GATEWAY_TOKEN}`, }, body: JSON.stringify({ model: JEV_MODEL_NAME, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ], temperature: 0.2, max_tokens: 1200, response_format: { type: "json_object" }, }), signal: AbortSignal.timeout(20000), }); if (!res.ok) { const errText = await res.text(); throw new Error(`Gateway request failed: ${res.status} ${errText}`); } const data = await res.json(); const content = data.choices?.[0]?.message?.content ?? ""; try { const parsed = JSON.parse(content); return MatchResultSchema.parse(parsed); } catch (err) { if (attempt === 1) { throw err; } // 第二次尝试时,把错误信息加入提示词,让模型修正输出 userPrompt += `\n上次输出无法通过JSON校验,错误:${err.message}\n请重新输出合法JSON。`; } } throw new Error("模型输出无法解析"); }

几个设计点值得解释:

  • temperature设成 0.2,是为了让模型在每次调用中尽量稳定,减少评分波动。如果你希望每次结果有一定随机性,可以调高到 0.5 左右,但我不建议在招聘筛选场景这么做。
  • AbortSignal.timeout(20000)是硬性兜底。模型推理慢的时候可能拖到 30 秒以上,但 Vercel Functions 的免费额度限制请求不能太久,20 秒是比较合理的平衡点。
  • 重试次数只做两次。除了 JSON 校验失败,其他异常直接抛出,不在这层做无限重试,把重试策略交给网关层统一处理。

parseRestxt函数提取简历文本。如果上传的是纯文本,直接读字符串;如果是 PDF,用pdf-parse解析。为了减少网关请求体体积,我在解析后调用一个cleanResumeText方法:

export function cleanResumeText(raw: string): string { return raw .replace(/\r/g, "\n") .replace(/[ \t]+/g, " ") .replace(/\n{3,}/g, "\n\n") .replace(/第.{0,3}页/g, "") .split("\n") .filter((line) => line.trim().length >= 3) .join("\n") .slice(0, 8000); }

这段代码虽然简单,但解决了我后续遇到的一大半脏数据问题。

4.3 前端页面的最小可用版本

前端我只做了一个最简单的新页面:左侧文本框粘贴简历,右侧文本框粘贴 JD,下面一个“开始匹配”按钮和一个结果展示区。没有做文件上传,因为第一阶段重点是流程验证,先把模型链路跑通再考虑交互体验。

核心调用就是一个fetch:

const response = await fetch("/api/match", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ resumeText, jdText }), }); const result = await response.json(); if (response.ok) { setResult(result); } else { setError(result.error ?? "匹配失败,请稍后重试"); }

后端route.ts里我也做了基本入参校验:简历和 JD 都不能为空,简历最长 2 万字符。这样即使前端漏处理,后端也不会把超长文本直接打给模型。

跑通这版之后,整个工具已经可以用了。我拿了几份脱敏简历测试,匹配结果基本能反映人工筛选的判断。但线上使用和本地测试完全是两回事,下面这部分是实战中最容易被忽视的问题。

5. 线上运行中的常见问题与排查实录

5.1 网关 429 和 504:别把重试逻辑写进业务代码

上线后最怕的不是模型答错,而是模型服务商限流。第一次压测时,我连续发了 20 个并发请求,结果一大半返回 429。我的第一反应是在业务代码里加指数退避重试,后来发现 Vercel AI Gateway 控制台本身就提供了重试配置,而且做得比我手动写好得多。

网关层的重试策略是:对 5xx 和 429 自动重试,可配置最大重试次数,默认 3 次。我建议业务侧只处理最终失败,也就是网关重试之后仍然返回错误的情况。这比我用 JavaScript 循环里的 sleep 要省心得多,也避免把 Fetch 请求占满。

如果你非要自己写重试,注意两点:退避间隔至少从 500 毫秒开始,上限不要超过 5 秒;重试次数最多 3 次。不要忽略 429 响应头里的Retry-After字段,那才是服务商告诉你的准确等待时间。

5.2 模型输出不稳定:JSON 解析失败怎么办

即使开了response_format,偶尔还是会遇到 JSON 解析失败。原因通常是模型生成的 JSON 里带了大段解释,或者括号匹配错误。我的处理方式是引入“修正-重试”机制:

  1. 第一次解析失败时,不直接报错。
  2. 把失败信息连同上一次生成内容一起回传给模型,在用户提示词末尾追加一句:“上次输出无法通过JSON校验,错误信息:{错误}。请重新输出合法JSON。”

实测下来,第二次成功率极高。如果第二次还是失败,说明模型当前状态不佳,我会直接返回一个“模型暂时不可用”的响应,而不是给用户看一个半截 JSON。这个拒绝逻辑很重要,宁可让用户重试,也不能让错误结构污染数据库。

5.3 简历太长导致上下文超限

简历清洗时我已经截断了 8000 字符,但岗位 JD 可能本身就很长,一些大厂的 JD 光福利介绍就能写 3000 字。两个加在一起很容易超过模型上下文限制,或者导致处理时间太长。

我的解决思路是先把 JD 里跟评估无关的内容去掉。JD 通常包含公司介绍、团队介绍、福利待遇、职位描述、任职要求五部分,真正对匹配有用的只有职位描述和任职要求。我写了一个简单规则来做粗切分:按“福利待遇”“关于我们”“公司介绍”等关键词切割,把这些段落直接丢弃。这个方法不优雅,但实现简单,效果显著,能让请求体缩小一半以上。

如果你不想写规则,也可以在进入模型之前先让 Gateway 路由过去请模型做一轮文本摘要,但那样会额外消耗一次调用,降低吞吐。我建议规则优先,模型兜底。

5.4 缓存策略:相同 JD 不需要反复调模型

Vercel AI Gateway 自带缓存,默认情况下对完全相同的请求会直接返回缓存结果。这个能力在简历匹配场景里非常有用:同一个岗位 JD 但要评估多份简历,简历文本不同,请求就不会完全一致;但如果误把 JD 和简历拼错了 JSON 结构,请求完全一致,反而会造成缓存异常。

我提三个缓存建议:

  • 开启网关缓存,但 TTL 不要设太长,我设置的 10 分钟,方便切换提示词后快速生效。
  • 在业务侧不要自己再做一层文件缓存,否则提示词更新后无法即时验证效果。
  • 给提示词模板加一个version字段,比如prompt_v3,每次改版后缓存自然失效,避免网络缓存和网关缓存双重叠加的脏数据问题。

我上线后就吃过亏:改了评分标准,但网关缓存里还残留旧提示词的结果,导致同一份简历匹配出的分数忽高忽低。加上prompt_version之后问题彻底消失。

6. 后续优化方向与我的实战体会

6.1 从批量跑分到决策辅助的演进

第一阶段跑通之后,我的目标从“算出分数”变成了“让 HR 真正愿意用”。算法分数再准,如果 HR 看不懂为什么是这个分数,她们还是会把工具当摆设。

所以我后来做了一件事:在匹配报告里增加“证据引用”。模型输出matched_skills和missing_skills只是结论,HR 更想看到“候选人简历第 2 段提到的订单系统重构经验,正好对应 JD 里的高并发场景”。添加证据需要提示词里补充一句:每个suggestion必须引用简历原文关键词,并放在括号里返回。结果呈现出来,HR 的信任度一下子提高不少。

另外,我建议把单个候选人的评分做成历史记录。同一份简历在不同岗位下评分不同,这很正常;但同一岗位下重复匹配的分数应该稳定。记录历史有助于发现模型波动,也能用来回归测试提示词改动的影响。

6.2 我最后想说的几个小技巧

这个项目给我最大的收获不是“会用某个模型了”,而是理解了 AI 应用工程化的核心:模型能力是一部分,调用链路的稳定性、输出结构的可控性、可观测性,才决定一个功能能不能从 Demo 走到生产。

几个实操经验沉淀如下:

  • 定期导出 Vercel AI Gateway 的调用日志,里面每次请求的 token 消耗和延迟数据很有价值。我每周看一次,用来判断是否该调大网关缓存 TTL,或者排查哪个时段模型延迟异常偏高。
  • 提示词版本管理一定要做。我用 Git 管理一个prompts.md文件,每次修改都记录版本号和效果说明。
  • 不要迷信单一模型。我在网关上同时挂了 Jev 和另一个备选模型,一旦 Jev 服务状态出现波动,只需要改环境变量里的模型名,整个业务代码零改动切换。这种容灾能力是自建模型服务很难替代的。
  • 简历数据涉及候选人隐私,记得在展示和存储前做脱敏处理。我这边只保留必要字段,超过 30 天的原始文本定期清理。

这套链路跑到现在,简历匹配工具已成为团队内部招聘流程中一个稳定的辅助模块。如果你也需要做类似的 AI 应用,建议按“网关封装 — 提示词约束 — 输出校验 — 日志复盘”的顺序推进,先跑通最小闭环再优化细节。模型更新换代很快,但只要框架搭得干净,后续替换模型、升级提示词的成本都很低。

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

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

立即咨询