
最近在做一个内部招聘场景的小工具候选人投递简历后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_URLhttps://gateway.vercel.ai/v1/chat/completions AI_GATEWAY_TOKENyour_vercel_gateway_token JEV_MODEL_NAMEjev注意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 页”这种噪声进入提示词。所以我加了一个简单的预处理步骤按优先级做了几件事把多个连续空白符压缩成单个空格统一换行符为\n。删掉明显的页眉页脚行比如“第 X 页”“Page X of X”。按行长度过滤单行小于 3 个字符的行直接丢弃。把解析后的文本截断到 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下面一个“开始匹配”按钮和一个结果展示区。没有做文件上传因为第一阶段重点是流程验证先把模型链路跑通再考虑交互体验。核心调用就是一个fetchconst 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 里带了大段解释或者括号匹配错误。我的处理方式是引入“修正-重试”机制第一次解析失败时不直接报错。把失败信息连同上一次生成内容一起回传给模型在用户提示词末尾追加一句“上次输出无法通过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 应用建议按“网关封装 — 提示词约束 — 输出校验 — 日志复盘”的顺序推进先跑通最小闭环再优化细节。模型更新换代很快但只要框架搭得干净后续替换模型、升级提示词的成本都很低。