用 LLM 做简历分析,我踩过的坑比写过的代码还多

上一篇我写了收藏和错题本的数据建模。这一篇换一个赛道------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 包含三部分:

  1. 角色设定:告诉 AI 它是一个专业招聘顾问
  2. 提取规则:技术栈、工作年限、项目经验、教育背景分别怎么提取
  3. 输出格式:严格 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 面试链的第一环。做完这一步,后面两步就顺了:

  1. ✅ 分析简历 → 拿到结构化画像
  2. ⏳ 基于简历出题 → 读 skills 和 projects,生成针对性题目
  3. ⏳ 评估回答 → 判断用户是否答到要点,决定是否追问

AI 集成最难的其实不是调 API,而是:

  • Prompt 设计:怎么让 AI 稳定输出结构化数据
  • 缓存策略:怎么避免重复调用,控制成本
  • 错误处理:AI 返回异常格式时怎么办
  • 分层架构:怎么把 AI 逻辑和业务逻辑解耦

这些都是在真实项目里才能学到的工程经验。下一篇我会写出题的实现。


我是小ao,欢迎在评论区交流你的 AI 应用开发经验~

相关推荐
用户8314550980311 小时前
如一 Agent 架构解读(十九):错误的修养——工具报错是 Agent 唯一的自纠教材
人工智能·llm
用户721746588261 小时前
Qwen-Image 2.1 的四条宣称拆成调用路径:透明挂在 edit 侧,164,919 与 301,400 两组像素读数
llm·aigc·agent
Mr_Mao1 小时前
8 月我曾说过要让 DSH 像 Codex 一样好用,现在怎么样了?
app·deepseek
goehou3 小时前
System Prompt 实战:从一段话到一份规格(附回归测试)
ai·llm·提示词·评测·system prompt
goehou4 小时前
模型网关实战:多模型路由、降级和成本对账的统一入口
网关·ai·llm·路由·成本
流浪0014 小时前
大模型技术全景(二十四):RAG 发展的四种基础范式与未来趋势
llm·rag·advanced rag·naive rag·graph rag·modular rag
AINative软件工程4 小时前
MCP Server 错误处理工程实践:5 层防护让你的 AI Agent 不再因工具报错而崩溃
llm
goehou15 小时前
Prompt 注入实战:直接注入、间接注入和四道防线(附测试样例)
安全·ai·llm·agent·prompt注入
虎鲸不是鱼17 小时前
【RTX Pro 4500 Blackwell】Qwen3.8 27B NVFP4 MTP在dsh的真实表现
大模型·llm·qwen·lm studio·harness