上一篇我写了收藏和错题本的数据建模。这一篇换一个赛道------AI 集成。
「面试克星」这个项目最大的亮点是 AI 模拟面试。但完整的链路不是"一上来就问问题",而是: 分析简历 → 基于简历出题 → 评估回答 → 决定是否追问
第一步就是简历分析。用户上传 PDF 简历,系统解析出纯文本,AI 从文本里提取结构化的技能栈、项目经历、教育背景。
这篇文章记录我从设计到调通的完整过程。
一、为什么先做简历分析
AI 面试官的核心能力是"个性化出题"。如果一个用户写了 React,另一个用户写了 Vue,他们应该被问到不同的问题。
要做到这一点,AI 必须先知道用户会什么。而简历里正好有这些信息。
但简历是非结构化文本 ,机器没法直接用。所以第一步是用 AI 把它转成结构化 JSON:
json
{
"skills": ["Vue3", "React", "Node.js", "MongoDB"],
"yearsOfExperience": "(大三)",
"projects": [
{ "name": "江农论坛", "description": "Vue3全栈校园社区..." }
],
"education": { "degree": "本科", "school": "江西农业大学", "major": "计算机科学与技术" }
}
这个结构是后续所有 AI 功能的"上下文"。出题时读 skills,调难度时读 yearsOfExperience,出项目题时读 projects。
二、数据模型:缓存是关键
分析简历要调 AI,一次几百到上千 token。如果每次面试都重新分析,成本会积少成多。
所以我决定把分析结果缓存起来。
在 Resume 模型里加一个可选字段:
typescript
export interface IResume extends Document {
userId: mongoose.Types.ObjectId
rawText: string
analysis?: {
skills: string[]
yearsOfExperience: string
projects: { name: string, description: string }[]
education: { degree: string, school: string, major: string }
}
}
analysis 是可选的。用户刚上传简历时它是空的,第一次调 AI 后才有值。
判断缓存的逻辑:
typescript
if (resume.analysis && resume.analysis.skills?.length > 0) {
return res.json({ analysis: resume.analysis, cached: true })
}
注意这里有个坑 :一开始我写的是 if (resume.analysis),但空对象 {} 也满足这个条件。结果就是------AI 第一次返回了空结果,被存进数据库,之后每次调用都命中"缓存",永远返回空。
判断缓存时一定要检查关键字段是否有值,而不是只检查字段是否存在。
三、Prompt 设计:system 和 user 的分工
调 LLM 时的消息结构是:
typescript
messages: [
{ role: "system", content: "(角色设定 + 提取规则 + 输出格式)" },
{ role: "user", content: rawText }
]
system 放规则,user 放数据。
system 里的 Prompt 包含三部分:
- 角色设定:告诉 AI 它是一个专业招聘顾问
- 提取规则:技术栈、工作年限、项目经验、教育背景分别怎么提取
- 输出格式:严格 JSON,字段名和结构都要写死
Prompt 里我特意加了几条"注意事项":
- 如果某项信息未找到,填 "未提供"
- skills 不要包含过于基础的技能(如 "Microsoft Office")
- 在校生标记为 "(大三)",应届生标记为 "(应届生)"
这几条大幅提升了输出质量。不加的话,AI 会把"会用电脑"都写进 skills。
四、JSON 清洗:比 replace 更稳的方法
AI 返回的内容不一定是纯 JSON,可能是:
text
```json
{
"skills": ["Vue3", "React"],
...
}
或者:
text
好的,以下是分析结果:
{
...
}
如果用 replace 去掉代码块标记,很容易漏掉某种情况。更稳的方法是用 indexOf 和 lastIndexOf 精准截取:
typescript
const jsonStartIndex = content.indexOf('{')
const jsonEndIndex = content.lastIndexOf('}') + 1
const jsonString = content.substring(jsonStartIndex, jsonEndIndex)
const analysis = JSON.parse(jsonString)
不管 AI 在前面加了什么废话、后面加了什么解释,只要 JSON 本身是完整的,这种方法都能正确截取。
五、调用链路的完整实现
把 AI 调用封装成一个独立的 service 文件,路由只管 HTTP 逻辑,不混入 Prompt 拼接:
typescript
// services/aiService.ts
export const analyzeResume = async (rawText: string) => {
const response = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: rawText }
]
})
const content = response.choices[0].message.content || ''
const jsonStartIndex = content.indexOf('{')
const jsonEndIndex = content.lastIndexOf('}') + 1
const jsonString = content.substring(jsonStartIndex, jsonEndIndex)
return JSON.parse(jsonString)
}
路由里只做四件事:
typescript
// 1. 查简历
const resume = await Resume.findOne({ userId: req.user!._id })
if (!resume) return res.status(404).json({ message: '请先上传简历' })
// 2. 判断缓存
if (resume.analysis && resume.analysis.skills?.length > 0) {
return res.json({ analysis: resume.analysis, cached: true })
}
// 3. 调 AI
const analysis = await analyzeResume(resume.rawText)
// 4. 存回数据库
resume.analysis = analysis
await resume.save()
res.json({ analysis, cached: false })
分层的好处 :如果以后要换模型、改 Prompt、加错误重试,只需要改 aiService.ts,路由完全不用动。
六、踩坑清单
坑1:PDF 解析出来的文本是乱序的
用户上传的 PDF 简历,用 pdf2json 解析出来的文本可能是这样的:
text
江西农业 大学
V i t e
R ender
字符被空格拆开了,AI 识别时会受影响。
解决方向:可以在解析后做文本清洗,或者换一个更好的解析库。我目前先用原始文本直接喂给 AI,大部分情况下还能正确提取。
坑2:空对象被当成有效缓存
前面提过------if (resume.analysis) 对 {} 也生效。判断条件要加上关键字段检查。
坑3:改代码后没重启
我在调试时一度以为代码没生效,反复检查逻辑。最后发现是 nodemon 没触发重启,跑的是旧代码。调试时先看终端是否打印了新日志。
坑4:缓存命中导致误判
返回 cached: true 时接口根本不调 AI,终端也不会打印任何日志。如果一直看不到日志,先确认是不是走了缓存。
七、效果验证
调通之后,AI 从我那份格式混乱的 PDF 简历里提取出了:
- 25 个技能点(前端、后端、数据库、部署、安全)
- 2 个项目,描述准确
- 教育背景:江西农业大学 · 计算机科学与技术 · 本科
- 经验年限 :
(大二)(从简历里提取的,虽然现在我已经大三了)
这个准确度超出我的预期。关键在 Prompt 设计,不在模型选择。 用 DeepSeek 这种高性价比的模型,配合好的 Prompt,完全能做出可用的产品。
八、总结
简历分析是 AI 面试链的第一环。做完这一步,后面两步就顺了:
- ✅ 分析简历 → 拿到结构化画像
- ⏳ 基于简历出题 → 读 skills 和 projects,生成针对性题目
- ⏳ 评估回答 → 判断用户是否答到要点,决定是否追问
AI 集成最难的其实不是调 API,而是:
- Prompt 设计:怎么让 AI 稳定输出结构化数据
- 缓存策略:怎么避免重复调用,控制成本
- 错误处理:AI 返回异常格式时怎么办
- 分层架构:怎么把 AI 逻辑和业务逻辑解耦
这些都是在真实项目里才能学到的工程经验。下一篇我会写出题的实现。
我是小ao,欢迎在评论区交流你的 AI 应用开发经验~