☰
用 Vercel AI Gateway 和 Jev 模型做简历匹配:从文本抽取到评分落地
2026/9/26 7:55:00 网站建设 项目流程

今年帮团队做招聘系统改造,最让我头疼的不是写管理后台,而是简历初筛环节。几百份 PDF 扔过来,靠人一份份看,效率低到让人怀疑人生。项目最终落地的方案,是用 Jev 模型配合 Vercel AI Gateway 做简历匹配:上传简历和职位描述,服务端自动抽取出技能、工作年限、项目经历,给出分维度匹配分和参考理由,HR 只需要看 Top 10。这篇实战记录没有高深的算法,全是工程细节——怎么接模型、怎么设计提示词、怎么让输出稳定可靠,以及我在正式环境里踩过的坑。适合正在做 AI 简历解析、智能招聘应用,或者想上手 Vercel AI Gateway 的开发者参考。

1. 项目整体设计与思路拆解

1.1 为什么选择 Vercel AI Gateway 作为接入层

先说说模型接入这件事。团队的业务代码部署在 Vercel 上,后端用 TypeScript。早期我图省事,想过直接在前端页面里调模型接口,毕竟 Jev 对中文的理解能力不错,传一段提示词就能出结果。但简历是敏感数据,把模型密钥暴露在浏览器里等于把大门敞开,而且每次调用都要写死一个 API 地址,后续想换模型完全没有退路。

Vercel AI Gateway 解决的就是这类问题。它做了一层统一代理,业务后端只面对一个稳定的网关切点和一把密钥,模型供应商的变化被挡在外面。我在网关里配置了 Jev 的路由之后,系统提示词、用户提示词、输出格式这三层逻辑全部收口到后端统一管理。即便某天 Jev 的服务需要切换,前端和业务层一行代码都不用动,只改网关注册表里的目标模型就行。

网关还有一个容易被忽略的好处是请求级缓存。同一份简历经过规范化处理后,哈希值基本是稳定的。HR 反复以不同职位描述去匹配同一批候选人时,命中缓存的次数非常多,能省下一大笔 token 成本。我在生产环境跑了两周,发现缓存命中率接近三成,这对预算敏感的小团队来说不是小数目。

1.2 简历匹配的业务流程设计

简历匹配听着像是一个自然语言处理任务,落到工程上其实是清晰的数据流水线。我把它拆成五步:上传文件、抽取文本、清洗文本、模型解析、评分融合。

第一步是上传原始文件,支持 PDF 和 DOCX。第二步用解析库把文件变成纯文本,这一步看起来简单,实际上最费时间,后面会展开讲。第三步是清洗,把身份证号、手机号、邮箱这类隐私字段打码,把空行合并,把过长的无意义片段切掉,避免大段冗余文本占用上下文。第四步才是调用模型,让 Jev 按固定 JSON Schema 抽取候选人画像并做匹配评分。第五步是评分融合,模型回传的是技能、经验、项目、文化四个维度的得分,我再按权重算出一个总分,排序后给 HR 展示。

核心决策是“让模型只做理解,不做计算”。模型给出结构化结果,加权求和这种确定性计算放在本地代码里。这样分数是透明的、可解释的,HR 能看到为什么这个人得 78 分,而不是面对一个黑盒总分。调试时也能定位到底是模型理解错了,还是权重参数不合适。

1.3 整体技术栈与请求链路

  • 运行时:Node.js 18+,部署在 Vercel 的 Serverless Function
  • 框架:Next.js API Route,方便复用前端工程
  • 模型接入:AI SDK + Vercel AI Gateway,模型标识为 Jev
  • 文件解析:pdf-parse 处理 PDF,mammoth 处理 DOCX
  • 结构化输出:zod 定义 Schema,配合 AI SDK 的 generateObject

请求链路是这样的:浏览器上传文件到 API Route,服务端完成文本抽取和清洗,随后调用网关里配置好的 Jev 模型,拿到结构化 JSON 后做加权评分,返回给前端。整个链路没有额外的数据库依赖,简历文本临时落盘后即可删除,隐私风险也小一些。

2. 核心细节解析与实操要点

2.1 简历文本抽取:被低估的第一个坑

简历匹配的准确率,一半取决于模型,另一半取决于输入文本的干净程度。我用 pdf-parse 解析 PDF 时,第一版代码只跑了三份简历就翻车了。解析出的文本里有大量乱码、空格错位、换行丢失,中文句子被截成单词碎片。原因主要有三个:PDF 字体子集化、扫描版 PDF 完全没有文本层、以及部分简历做了复杂排版。

对于扫描版简历,唯一有效的办法是 OCR。我在项目里接入了云 OCR 服务作为兜底,识别出的文本再经过一轮纠错。对于普通 PDF,我加了一层启发式清洗:连续的空白字符替换成单个空格,段落之间用空行分隔,页眉页脚里的“第 1 页”“简历”这类噪声直接过滤。DOCX 用 mammoth 提取相对稳定,但也要注意模板自带的目录区域,需要从解压后的 HTML 中把无关节点删除。

从实战结果看,文本抽取阶段最值得投入时间的是“如何保留信息的层级结构”。简历的格式本身就是语义:工作经历的时间段、公司、岗位、职责是有层级关系的。我用缩进和分隔符把这些层级保留下来,而不是拍平成一段话。模型看到的是类似“2019-2023|某某科技公司|高级前端工程师|负责……”这样的结构,抽取效果比纯文本好很多。

2.2 提示词的结构化设计

简历匹配的提示词不是写一段“请你分析一下”就完事。我试过自由输出,模型给出的结果随榜单漂移,同样简历换个措辞分数能差出 15 分。后来我改用三段式:系统角色、任务约束、输出格式。

系统角色告诉模型“你是资深招聘专家”。任务约束列出评分维度和每个维度的行为定义,比如“技能匹配不仅看关键词,也要看技能掌握的深度是否与职位要求一致”。输出格式用 JSON Schema 固定,model 只负责填值,不负责发挥。

实践中最有效的一个操作是“事实先于评分”。我要求模型先列出简历中的客观事实,比如工作年限、技术栈、项目规模,再基于事实给分数。这相当于把思考过程显式化,既方便人工复核,也明显降低了分数虚高的问题。没有事实铺垫的评分,模型往往会往中间值或高分偏。

下面是精简后的提示词模板,可以直接抄。

你是一名资深招聘专员。请根据职位描述与候选人简历,完成结构化分析。 要求: 1. 先提取候选人的客观事实,再基于事实给出评分。 2. 技能匹配维度同时考察关键词重合度与技能熟练度。 3. 经验匹配维度重点比较行业背景、岗位序列、职级年限。 4. 项目匹配维度评估项目复杂度、技术栈与业务价值的重合程度。 5. 文化匹配维度无法明确判断时,给中性分并注明“信息不足”。 6. 不要根据候选人自我评价中的溢美之词拉高分数。 7. 只输出 JSON,不要输出分析过程。 职位描述: {jd_text} 候选人简历: {resume_text} 输出 JSON 结构: {json_schema}

在提示词中把职位描述放在简历前面,模型对岗位要求的关注度会更高。这是我在若干版本的排列实验里验证过的,顺序对结果的影响在简历匹配场景里不能忽略。

2.3 结构化输出与参数设置

Jev 这类大语言模型默认输出自由文本,简历匹配却需要机器可读的 JSON。我在项目里用 zod 定义 Schema,然后交给 AI SDK 的 generateObject 方法调用,由框架层面保证输出结构,而不是靠提示词碰运气。

zod 的 Schema 大概长这样:

import { z } from 'zod'; const ResumeMatchSchema = z.object({ candidate: z.object({ name: z.string(), years_of_experience: z.number(), skills: z.array(z.string()), education: z.string().optional(), }), facts: z.array(z.string()), matching: z.object({ dimension_scores: z.object({ skills_match: z.number().min(0).max(100), experience_match: z.number().min(0).max(100), project_match: z.number().min(0).max(100), culture_match: z.number().min(0).max(100), }), matching_reasons: z.array(z.string()), gaps: z.array(z.string()), }), });

Schema 里故意把每个维度分数限制在 0 到 100 之间,小数也可以接受。模型一旦给出 120 这样的数值,框架会触发校验失败,这时我做一次重试,提示模型“上次输出不合法,请重新生成”。这个机制比事后手工清洗数据可靠得多。

参数方面,temperature 我设为 0.2。简历匹配是确定性任务,不需要创造性,温度越低输出越稳定。maxTokens 我设置得比较保守,因为结构化输出本身很短,多余的 token 消耗没有意义。网关层我开启了缓存,同一份简历和同一职位描述的组合重复请求时会直接命中缓存,这能把每次请求的耗时从十几秒降到毫秒级。

3. 实操过程与核心环节实现

3.1 环境准备与项目初始化

我用的方式是新建一个 Next.js 项目,因为 Vercel 对 Next.js 的部署最省事,API Route 天然能跑服务端逻辑。初始化命令是常规操作,这里不展开。重点说依赖安装:

npm install ai @ai-sdk/openai pdf-parse mammoth zod npm install -D @types/pdf-parse

随后在 Vercel 项目控制台里配置环境变量。网关的地址和密钥不要写成明文,统一走环境变量读取:

GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1 GATEWAY_API_KEY=your_gateway_key_here JEV_MODEL=jev

其中 JEV_MODEL 对应你在网关注册的模型路由标识。如果模型供应商的接入文档给了不同的路由名,以那个为准。

3.2 简历文本抽取模块实现

文本抽取模块放在独立的文件里,便于后续维护。核心代码大概是这样的:

import fs from 'node:fs/promises'; import pdf from 'pdf-parse'; import mammoth from 'mammoth'; async function extractTextFromPdf(filePath: string): Promise<string> { const buffer = await fs.readFile(filePath); const data = await pdf(buffer); return data.text; } async function extractTextFromDocx(filePath: string): Promise<string> { const buffer = await fs.readFile(filePath); const result = await mammoth.extractRawText({ buffer }); return result.value; }

这里有一个细节:pdf-parse 的返回结构在不同版本里有差异,有些版本返回的 text 在 data 对象下,有些直接在顶层。接入时先打印一次返回对象确认字段,避免生产环境出现 undefined TypeError。抽取完成后的文本我还会做一轮清洗,把所有连续空白字符替换成单一空格,再把过长的段落按句号拆行,控制每次送入模型的文本体积。

3.3 核心匹配逻辑与代码落地

模型调用的封装是全项目最核心的部分。我用 createOpenAI 指定自定义网关地址,把 Jev 当成一个 OpenAI 兼容模型来调用:

import { createOpenAI } from '@ai-sdk/openai'; import { generateObject } from 'ai'; const gateway = createOpenAI({ baseURL: process.env.GATEWAY_BASE_URL, apiKey: process.env.GATEWAY_API_KEY, }); const jevModel = gateway(process.env.JEV_MODEL || 'jev'); export async function matchResume(jdText: string, resumeText: string) { const systemPrompt = buildPrompt(jdText, resumeText); const result = await generateObject({ model: jevModel, schema: ResumeMatchSchema, system: systemPrompt, temperature: 0.2, }); return result.object; }

返回结果是带类型的对象,直接可以参与后续计算。由于 generateObject 内部会做 Schema 校验,我这里没有再写额外的 parse 逻辑。

评分融合模块相对简单,全部是确定性的计算。我在四个维度上设定了权重:技能 40%、经验 30%、项目 20%、文化 10%。

const weights = { skills_match: 0.4, experience_match: 0.3, project_match: 0.2, culture_match: 0.1, }; function computeTotalScore(scores: Record<keyof typeof weights, number>) { return Math.round( scores.skills_match * weights.skills_match + scores.experience_match * weights.experience_match + scores.project_match * weights.project_match + scores.culture_match * weights.culture_match ); }

权重是可配置的,不同行业不同岗位可以微调。电商公司可能更看重项目匹配,外包团队可能更看重经验匹配。把权重提出来做成配置后,HR 不用改代码也能调出符合业务偏好的排序。

3.4 部署到 Vercel 与联调

本地完成模拟测试后,我直接在项目根目录运行部署命令:

vercel deploy --prod

环境变量在 Vercel Dashboard 的 Project Settings 里提前配好,部署完成后看日志。第一次上线时我用一份包含大量专有名词的简历做冒烟测试,确认返回的 JSON 字段和前端组件能对上。

这里提醒一个问题:简历文件的临时存储。Vercel Serverless Function 有临时文件系统,但回收机制不确定,而且多个并发请求之间不能相互读写文件。我采用的做法是把上传的文件在读入内存后立即删除,所有逻辑都基于内存里的 Buffer 操作,不依赖磁盘。

4. 常见问题与排查技巧实录

4.1 高频问题速查表

下面的表格是项目运行以来遇到频率最高的几类问题,每一条都是真实踩过的,不是从文档里抄来的说法。

现象可能原因排查与解决
解析出的简历文本乱码PDF 无文本层或字体子集化改用 OCR 兜底,扫描识别前先放大预处理
模型返回分数全部偏高模型倾向给正面评价提示词要求“先列事实再评分”,并加入反拔高约束
JSON 校验偶尔失败模型输出缺失字段或类型不符使用 generateObject + zod Schema,失败时自动重试一次
网关报 429超出速率限制打开网关缓存,对重复请求做本地 Redis 或内存缓存
中文姓名识别为乱字符OCR 对特殊字体支持差将姓名识别拆成独立字段,允许人工编辑兜底
长简历超时上下文过长导致推理变慢截断策略:保留最近三段经历,压缩自我评价段落

排查这类问题有一个共同心法:先确定问题发生在哪一层。先把抽取后的纯文本打印出来看,如果文本本身是乱的,就不要去调提示词,那是前处理问题。如果文本正常但模型输出不合理,再检查提示词和参数。层与层之间不要混着排查,否则只会越查越乱。

4.2 提升匹配准确率的几个实战技巧

第一,让模型做两次抽样再取均值。temperature 0.2 的情况下,每次推理结果仍有细微波动。我在二进制流程里对同一组合连续调用两次,取两个分数的平均分作为最终结果。多花一倍 token,换来的是更稳定的排序,值得。

第二,把隐私字段从文本里提前移除。手机号、身份证、薪资期望这些字段,模型并不需要用来做匹配,留着反而可能干扰判断,也可能被回显到日志里。我在清洗阶段用正则表达式先抹掉,比如手机号替换成“手机已隐藏”。这既是为了隐私,也是为了让模型注意力集中在能力相关文本上。

第三,提示词里的职位描述优先。简历匹配的判断基准是职位要求,所以职位描述一定放在简历之前。我在测试中发现,顺序颠倒后模型的注意力明显偏移,开始从简历里找客观亮点,而不是对着岗位缺口去找匹配度,分数分布会整体失真。

第四,恼人的长简历问题,不要简单截断。无脑截掉后半段会把最新项目经验丢掉。我的做法是优先保留最后三段工作经历的完整信息,其它经历只留公司和年限摘要。这样模型看到的永远是候选人能力最强的部分,节点权重也更贴近 HR 的真实判断轨迹。

第五,小规模灰度验证。上线第一周,我在后台加了一个“人工复核按钮”,HR 可以给系统评分打差分。这些差分数据是调试提示词的黄金样本,比任何抽象指标都直观。我记得有一个版本系统过分看中“知名公司”背景,导致大厂边缘岗位的候选人分数虚高,就是靠 HR 反馈才暴露出来的。

从我自己的使用反馈来看,这个项目最难的不是模型选择,而是把不可控的大模型输出变成可信赖的产品功能。每一步都在做约束:文本清洗约束输入,提示词约束行为,Schema 约束输出格式,权重配置约束结果解释。Jev 模型的能力是下限定,Vercel AI Gateway 让接入和管理变得顺手,真正决定体验的,还是工程上这些不起眼的细节。如果你也要做类似的匹配工具,我建议先把一份简历从上传到出分全链路跑通,再考虑扩展批量处理。细节问题越早暴露,后面返工的成本就越低。

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

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

立即咨询