【自学AI】11 在写代码之前,先把设计想清楚
我们把理论基础和工具集成都搞定了——LangChain 四个核心组件、DeepSeek 接入、多轮对话的会话管理。 概念有了,工具有了,现在该做点真实的东西了。 这篇开始进入实战阶段。第一个功能:简历押题。
开头:一个让我真实困扰过的问题
我自己找工作的时候,遇到过这样的情况——
简历投出去了,面试通知来了。然后就开始焦虑:面试官会问什么?我的简历里哪些地方是雷点?
我做 JS 全栈,简历上写了 Vue、React、NestJS、Node.js,还有几个项目经历。但我不知道面试官会从哪个角度切入,不知道他会不会深挖某个我不太熟的技术点,也不知道我的项目经历在他眼里是加分项还是减分项。
这种感觉很被动。
简历押题要解决的,就是这个问题。
用户上传简历,系统分析内容,生成一套针对这个人定制的面试题目。不是从题库里抽的通用题,而是真正根据你的技能、经验、项目经历量身定制——你简历上写了什么,AI 就从哪里出题。
这篇文章很重要,但我们不写一行代码。
因为在写代码之前,有更重要的事:把设计想清楚。需求是什么、用户怎么用、数据怎么存、API 怎么设计、有哪些技术坑——这些都想好了,代码才好写,以后维护也更容易。
代码,下一篇再说。
一、需求分析
用户的真实场景
具体说一下用户是谁、在什么情况下用这个功能。
我是一个 90 后 JS 全栈,工作了好几年,技术栈是 Vue、React、NestJS、Node.js,做过电商系统、推荐系统,还有一些 AI 相关的项目。
现在要去面试一家公司,职位是”高级全栈开发工程师”,JD 上写着要求熟悉微服务架构、有大型系统设计经验。
心里有几个问题:
- 我的背景和这个岗位的匹配度有多高?
- 面试官会从哪些技术点切入?
- 简历上哪些地方可能是雷点,需要提前准备?
这就是真实需求。不是想要一套通用的前端面试题,而是基于我自己的背景,告诉我这场面试可能会发生什么。
系统要为用户做什么
用户上传简历后,系统要完成 6 件事:
1. 理解简历
提取关键信息:工作年限、技能栈、项目经历、教育背景。这是后面所有分析的基础。
2. 分析岗位
不同公司、不同岗位、不同职级,面试问题天差地别。同样是”前端开发”,初创公司和大厂问的完全不一样。系统要结合简历和 JD,才能准确判断面试可能问什么。
3. 生成定制题目
生成 10-15 个面试题。不是从题库里抽的,是 AI 根据用户背景生成的。我简历上写了 NestJS,AI 就出 NestJS 相关的题;我做过推荐系统,AI 就问推荐系统的架构设计。
4. 题目分级
题目分三个难度,比例大概是 30% 基础、50% 中等、20% 高级。
为什么这样分?因为面试通常是这个节奏——先用基础题热身,再用中等题考察实际能力,最后用高级题探探深度。
5. 评估匹配度
系统评估用户技能和岗位要求的匹配程度,给出一个得分。比如我的 JS 全栈背景 vs 要求微服务架构经验的岗位,匹配度可能是 75 分——基础能力够,但系统设计经验偏弱。
6. 给出学习建议
针对不足之处,给出具体建议。不是”建议你加强学习”这种废话,而是”你缺少微服务架构经验,建议从 Docker + Kubernetes 入手,重点看服务拆分和通信机制”这样的具体方向。
核心价值:个性化
市面上有很多面试题库,但那些题是给所有人的。
一个刚入行的前端和一个做了 8 年的全栈,看到的是同一套题。这对两个人都不好——新人会被吓到,老手会觉得太简单。
我们的系统不一样。题是为你量身定制的。 系统知道你有什么技能、缺什么技能,就针对性地出题。
不是更多的题,而是更准的题。
二、业务流程设计
完整的业务流程
从用户登录到最后看到结果,完整流程如下:
用户登录系统 ↓[前端] 选择上传简历(PDF、Word 或纯文本) ↓[验证] 检查文件格式、大小是否符合要求 ↓[解析] 把 PDF/Word 转成文本 ↓[分析] AI 分析简历,提取关键信息 - 工作年限:N 年 - 技能:Vue、React、NestJS、Node.js... - 项目:电商系统、推荐系统... - 教育:计算机本科 ↓[生成] AI 根据分析结果,生成 10-15 个题目 - 30% 基础题:考察基本概念 - 50% 中等题:考察实际应用和项目经验 - 20% 高级题:考察深度理解和系统设计 ↓[评估] 生成技能匹配度评估 - 综合得分:XX 分 - 优势:前端基础扎实,有实际项目经验 - 不足:缺少大型系统设计经验 - 建议:深入学习微服务架构 ↓[保存] 所有结果写入数据库 ↓[推送] 后端通过 SSE 实时推送进度到前端 ↓用户查看题目、评估报告、学习建议四个关键设计决策
流程图看起来简单,但里面有几个决策,直接影响用户体验和系统实现。
决策 1:实时生成,还是异步生成?
这是一个很现实的问题。调用 AI 生成题目,可能需要 5-10 秒。用户能等吗?
如果用户发起请求,然后盯着空白页面等 10 秒,体验会很差——他不知道系统是在处理还是卡死了。
我们的方案是:实时生成 + SSE 流式推送进度。
具体流程:
- 用户发起”生成题目”请求
- 后端立即返回一个
sessionId,告诉前端”我开始处理了” - 后端同时通过 SSE 实时推送进度:
"正在解析简历..."→ 20%"正在分析技能..."→ 40%"正在生成题目..."→ 60%"正在评估匹配度..."→ 80%"完成!"→ 100%
- 用户看着进度条动,知道系统在工作
- 100% 时,前端拉取最终结果展示
决策 2:为什么推进度,而不是推内容?
你可能会问:既然用了 SSE,为什么不直接流式推送题目内容,像打字机一样实时显示?
原因在于交互模式不同。
打字机效果适合对话场景——用户问一句,AI 实时打出回答,这种交互很自然。
但简历押题不是对话,它的结果是一份完整的报告:10-15 道题、每题有难度分级、有参考答案、有出题理由,还有一份匹配度评估。这些内容是有结构的,需要完整展示,不适合一边生成一边显示。
如果用打字机效果,用户会看到题目一个字一个字地蹦出来,然后答案又一个字一个字地蹦出来……体验反而很割裂。
所以选择是:推进度,不推内容。让用户知道系统在工作,等全部生成完了,一次性展示完整报告。
打字机效果(流式推送内容)会在后面的专项面试功能里实现,那个场景是真正的对话,适合这种交互。
决策 3:简历怎样解析?
简历可能是各种格式:PDF、Word、纯文本。我们的方案是格式转换 + AI 理解,分两步走:
第一步,格式转换:
| 格式 | 工具 | 说明 |
|---|---|---|
pdf-parse | 提取 PDF 中的文本内容 | |
| Word | mammoth | 把 .docx 转成纯文本 |
| 纯文本 | 直接使用 | 不需要转换 |
第二步,AI 结构化提取:
转成文本后,把文本发给 AI,让它提取结构化信息:
你是一个 HR 专家。请分析以下简历,提取关键信息。返回一个 JSON 对象,包含以下字段:{ "years_of_experience": 数字, "skills": ["技能1", "技能2"], "projects": ["项目1", "项目2"], "education": "学历信息"}AI 理解简历内容,返回机器可读的结构化数据。后续的题目生成就有了可靠的输入。
决策 4:配额管理怎样做?
生成题目要调用 AI API,API 是要钱的。怎样控制成本、同时让用户有好的体验?
我们的方案是用户配额制:
- 新用户注册,赠送 1 次免费配额
- 每生成一次题目,消耗 1 个配额
- 配额用完,用户可以升级会员或单独购买
- 生成前检查配额,生成后扣减配额,失败后自动退还
最后一条很重要——如果 AI API 超时或者生成失败,用户的配额要退回来,不能让用户白白损失。
三、数据库设计
明确了流程和决策,现在设计数据库。我们需要两个核心集合。
Schema 1:InterviewQuizResult(押题结果)
这是最重要的集合。每当用户生成一次题目,就在这里创建一条记录,保存这次生成的所有信息——简历、分析结果、题目列表、匹配度评估,全部在一起。
创建 src/interview/schemas/interview-quiz-result.schema.ts:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';import { Document, SchemaTypes, Types } from 'mongoose';
export type ResumeQuizResultDocument = ResumeQuizResult & Document;
/** * 题目难度枚举 * 三个难度对应面试的三个阶段:热身 → 考察 → 探深度 */export enum QuestionDifficulty { EASY = 'easy', // 基础题,约占 30% MEDIUM = 'medium', // 中等题,约占 50% HARD = 'hard', // 高级题,约占 20%}
/** * 题目类别枚举 * 覆盖面试中常见的几种题型 */export enum QuestionCategory { TECHNICAL = 'technical', // 技术能力(考察具体技术栈) PROJECT = 'project', // 项目经验(基于简历中的项目) PROBLEM_SOLVING = 'problem-solving', // 问题解决(算法、设计等) SOFT_SKILL = 'soft-skill', // 软技能(沟通、协作等) BEHAVIORAL = 'behavioral', // 行为面试(STAR 法则类) SCENARIO = 'scenario', // 场景题(如果遇到 XX 情况你怎么做)}
/** * 单个面试题目 * _id: false 表示 MongoDB 不为子文档自动生成 _id */@Schema({ _id: false })export class InterviewQuestion { @Prop({ required: true }) question: string; // 题目内容
@Prop({ required: true }) answer: string; // 参考答案
@Prop({ enum: QuestionCategory, required: true }) category: QuestionCategory; // 题目类别
@Prop({ enum: QuestionDifficulty, required: true }) difficulty: QuestionDifficulty; // 难度
@Prop() tips?: string; // 回答技巧提示
@Prop({ type: [String], default: [] }) keywords?: string[]; // 关键词(方便前端高亮显示)
@Prop() reasoning?: string; // 出题理由(帮助用户理解考察点)
@Prop({ default: false }) isFavorite?: boolean; // 用户是否收藏了这道题
@Prop({ default: false }) isPracticed?: boolean; // 用户是否已练习
@Prop() practicedAt?: Date; // 练习时间
@Prop() userNote?: string; // 用户自己的笔记}
export const InterviewQuestionSchema = SchemaFactory.createForClass(InterviewQuestion);
/** * 技能匹配项 * 用于展示用户技能和岗位要求的对比 */@Schema({ _id: false })export class SkillMatch { @Prop({ required: true }) skill: string; // 技能名称(如 "NestJS"、"微服务架构")
@Prop({ required: true }) matched: boolean; // 用户简历中是否有这个技能
@Prop() proficiency?: string; // 熟练度描述(如 "熟练"、"了解"、"缺失")}
export const SkillMatchSchema = SchemaFactory.createForClass(SkillMatch);这个 Schema 就像一个”信息容器”。用户生成一次题目,所有的信息——简历原文、AI 分析结果、题目列表、匹配度评估——都放在这一条记录里。查询时一次拿到所有数据,不需要多表关联。
Schema 2:ConsumptionRecord(消费记录)
你可能会问:消费记录为什么要单独建一个集合,直接放在 InterviewQuizResult 里不行吗?
原因是:消费记录是跨功能的。
简历押题会消费配额,后面要做的专项面试、行测面试、AI 模拟面试也会消费配额。如果把消费记录分散在各个功能的 Schema 里,想统计用户总消费、分析各功能的使用比例,就得跨多个集合查询,很麻烦。
单独建一个 ConsumptionRecord 集合,所有功能的消费都往这里写,统一管理,数据分析和成本控制都方便很多。
创建 src/interview/schemas/consumption-record.schema.ts:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';import { Document, SchemaTypes, Types } from 'mongoose';
export type ConsumptionRecordDocument = ConsumptionRecord & Document;
/** * 消费类型枚举 * 每种功能对应一个类型,方便按功能统计消费 */export enum ConsumptionType { RESUME_QUIZ = 'resume_quiz', // 简历押题(本篇实现) SPECIAL_INTERVIEW = 'special_interview', // 专项面试(后续实现) BEHAVIOR_INTERVIEW = 'behavior_interview', // 行测+HR 面试(后续实现) AI_INTERVIEW = 'ai_interview', // AI 模拟面试(后续实现)}
/** * 消费状态枚举 * 这是一个状态机:pending → success 或 failed * 失败时自动退还配额 */export enum ConsumptionStatus { PENDING = 'pending', // 处理中(已扣费,AI 正在生成) SUCCESS = 'success', // 成功(生成完成,配额正常扣除) FAILED = 'failed', // 失败(已自动退款,配额已退还) CANCELLED = 'cancelled', // 用户主动取消}
/** * 消费记录 Schema */@Schema({ timestamps: true })export class ConsumptionRecord { @Prop({ required: true, unique: true }) recordId: string; // 消费记录唯一 ID(用于幂等性检查)
@Prop({ type: SchemaTypes.ObjectId, ref: 'User', required: true, index: true, }) user: Types.ObjectId;
@Prop({ required: true, index: true }) userId: string; // 字符串形式,便于直接查询,不需要 populate
@Prop({ required: true, enum: ConsumptionType, index: true }) type: ConsumptionType;
@Prop({ required: true, enum: ConsumptionStatus, default: ConsumptionStatus.PENDING, }) status: ConsumptionStatus;
@Prop({ required: true }) consumedCount: number; // 消费的次数(通常为 1)
@Prop() description?: string; // 消费描述(如 "生成简历押题 - Java 后端开发")
@Prop() createdAt: Date;}
export const ConsumptionRecordSchema = SchemaFactory.createForClass(ConsumptionRecord);四、关键技术点
Schema 设计好了,现在讲几个实现时容易踩坑的技术细节。
技术点 1:幂等性保证
你有没有遇到过这种情况:提交表单时,网络慢,按钮没有反应,不确定有没有提交成功,于是又点了一次——结果提交了两次,出现了重复数据。
这叫非幂等操作。对于我们的系统,后果更严重:用户不小心重复点击”生成题目”,就会扣两次配额。
解决方案是 Request ID(请求 ID):
1. 前端生成一个 UUID(如 550e8400-e29b-41d4-a716-446655440000)2. 把这个 UUID 和请求数据一起发给后端3. 后端收到请求,先查这个 UUID 是否已经处理过4. 如果处理过 → 直接返回之前的结果,不重新生成5. 如果没处理过 → 继续处理,并记录这个 UUID代码大概是这样:
// 后端收到请求,先做幂等性检查const existing = await this.quizResultModel.findOne({ requestId });if (existing) { // 这个 requestId 已经处理过了,直接返回之前的结果 // 不会重新扣费,不会重新调用 AI return existing;}
// 没有处理过,继续生成新结果...即使用户重复提交,后端也只处理一次。
技术点 2:异常处理与自动退款
如果生成过程中出错了(AI API 超时、网络中断、解析失败),怎么处理?
原则:不让用户白白损失配额。
整个流程是一个状态机:
用户发起请求 ↓扣减 1 个配额 ↓创建记录,状态设为 PENDING(处理中) ↓ ├── 成功 → 状态改为 SUCCESS,配额正常扣除 └── 失败 → 状态改为 FAILED,自动退还配额代码实现:
try { const record = await this.quizModel.create({ userId, status: 'pending', // ... });
const result = await this.aiService.generate(...);
await this.quizModel.updateOne( { _id: record._id }, { status: 'completed', ...result } );
} catch (error) { await this.quizModel.updateOne( { _id: record._id }, { status: 'failed', error: error.message } );
// 自动退还配额,用户不会损失 await this.quotaService.refund(userId, 1);
throw error;}技术点 3:SSE 流式推送进度
怎样实现”进度条实时更新”?这里要用到 RxJS 的 Subject。
简单理解:Subject 就像一个”消息广播站”。可以不断往里面发消息,所有订阅者都能实时收到。
@Sse('resume/quiz/stream')async generateStream(@Body() dto: GenerateQuizDto) { const subject = new Subject();
this.quizService .generateWithProgress(dto, (event) => { subject.next({ data: event, id: Date.now() }); }) .then(() => { subject.complete(); }) .catch((error) => { subject.error(error); });
return subject;}前端连接这个 SSE 接口,就能实时收到进度更新,驱动进度条动起来。
技术点 4:成本计算
生成一次题目,大概要花多少钱?算一下。
Token 估算:
| 部分 | Token 数 |
|---|---|
| 输入:简历文本 | ~500-1000 |
| 输入:Prompt 模板 | ~1000 |
| 输出:10-15 道题目 | ~2000 |
| 输出:匹配度评估 | ~1000 |
| 合计 | ~4500 tokens |
按 DeepSeek 当前价格估算(具体以官网为准):
输入 token 费用 = 1500 / 1,000,000 × ¥0.5 = ¥0.00075输出 token 费用 = 3000 / 1,000,000 × ¥1.5 = ¥0.00450单次总成本 ≈ ¥0.005规模成本预估:
1,000 次生成 → ¥510,000 次生成 → ¥50如果有 10,000 个用户,每人生成 5 次:
10,000 × 5 × ¥0.005 = ¥250成本很低。但如果用户量继续增长,配额管理就变得很重要——它不只是商业模型,也是成本控制的手段。
五、完整架构图解读
┌─────────────────────────────────────┐│ 前端(浏览器) ││ · 上传简历 ││ · 生成 requestId(幂等性保证) ││ · 实时显示进度条(SSE) ││ · 展示题目和评估报告 │└──────────────┬──────────────────────┘ │ HTTP / SSE ▼┌─────────────────────────────────────┐│ Controller(quizController)││ · generateStream() — SSE 推送进度 ││ · getResult() — 查询详情 ││ · getHistory() — 查询历史 │└──────────────┬──────────────────────┘ │ ▼┌─────────────────────────────────────┐│ Service(quizService) ││ · 检查配额 / 记录消费日志 ││ · 调用 AI 分析简历 ││ · 调用 AI 生成题目 ││ · 调用 AI 评估匹配度 ││ · 保存结果到数据库 │└──────────────┬──────────────────────┘ │ ▼┌─────────────────────────────────────┐│ AI Service(aiService) ││ · 调用 LangChain ││ · 管理 Prompt 模板 ││ · 解析 AI 输出(JSON 校验) ││ · 处理重试和超时 │└──────────────┬──────────────────────┘ │ ┌───────┴───────┐ ▼ ▼┌────────────┐ ┌──────────────────┐│ MongoDB │ │ DeepSeek API ││ │ │ ││ · InterviewQuizResult ││ · ConsumptionRecord │ · 分析简历││ · User(配额) │ · 生成题目│└────────────┘ │ · 评估匹配度 │ └──────────────────┘每一层的职责:
- 前端层:负责用户交互,生成
requestId保证幂等性,通过 SSE 接收进度更新 - Controller 层:接收 HTTP 请求,参数校验,路由分发。不写业务逻辑
- Service 层:业务核心。配额检查、流程编排、数据库操作都在这里
- AI Service 层:专门处理 AI 相关的事情。Prompt 管理、LangChain 调用、输出解析、重试逻辑
- Database 层:持久化存储。三个核心集合:押题结果、消费记录、用户配额
- AI API 层:DeepSeek 提供的能力,通过 HTTP API 调用
这种分层的好处是:每一层只做自己的事。想换 AI 模型,只改 AI Service 层;想改业务逻辑,只改 Service 层;想改数据库,只改 Schema。各层互不干扰。
用易经的话说,这有点像既济——水火各归其位,各司其职,系统才能稳定运转。
六、常见问题
Q1:简历里有很多个人隐私,怎样保护?
简历包含姓名、手机号、工作经历等敏感信息。我们的方案:
- 简历文本在数据库中加密存储,防止数据库泄露时直接暴露明文
- 定期清理:30 天后自动删除简历原文(题目和评估结果保留)
- 权限控制:API 层加权限检查,只有用户自己能访问自己的简历数据
Q2:怎样确保 AI 生成的题目质量?
AI 生成的内容不一定每次都好。我们的质量保证方案:
- Prompt 设计要精确:给出清晰的格式要求和例子,减少 AI 的发挥空间
- Zod Schema 验证输出:如果 AI 返回的 JSON 格式不对,直接报错重试,不把脏数据存进库
- 用户反馈机制:用户可以标记”这道题不好”,积累反馈后用来迭代 Prompt
- 定期人工抽查:每周抽查一批生成结果,发现问题及时调整
Q3:用户的简历是英文的,或者中英混写,怎么处理?
AI 本身支持多语言理解,这不是问题。但有几个地方要注意:
- Prompt 要支持多语言:告诉 AI “请用和简历相同的语言生成题目”
- 中英混写的处理:让 AI 自行判断主要语言,统一输出语言
- 特殊字符:某些 PDF 解析出来可能有乱码,要在预处理阶段做清洗
结尾
这篇文章没有写一行代码,但我们做了最重要的事:把设计想清楚了。
回顾一下我们做了什么:
- ✅ 需求分析:用户要什么、系统做什么、核心价值是个性化
- ✅ 业务流程:从上传简历到展示报告的完整链路
- ✅ 四个设计决策:实时推送、推进度不推内容、简历解析方案、配额管理
- ✅ 数据库设计:两个核心 Schema,以及为什么这样设计
- ✅ 技术细节:幂等性、异常处理与退款、SSE 推送、成本计算
- ✅ 架构分层:六层架构,每层职责清晰,互不干扰
- ✅ 常见问题:隐私保护、质量保证、多语言支持
最重要的认识:在写代码之前,一定要把设计想清楚。
需求、流程、数据、API、技术方案,都想好了,代码才好写。这不是在浪费时间,这是在节省时间——设计阶段发现的问题,改起来只需要几分钟;代码写完了再发现设计有问题,可能要重构几天。
有点像易经里说的谋定而后动——不是不动,是动之前,先想清楚往哪里动。
下一篇,我们基于这个设计,开始写代码。
从 Prompt 设计开始,然后逐步实现每个功能:AI 分析简历、生成题目、评估匹配度、SSE 推送进度……
设计图已经画好了,代码只是把它翻译成机器语言。
昇哥 · 2026年3月 90后 JS 全栈 × AI 学习途中,把踩过的坑写下来 专注羽毛球,爱音乐,正在研究易经 🎵🏸
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!