【自学AI】11 在写代码之前,先把设计想清楚

5605 字
28 分钟
【自学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 流式推送进度

具体流程:

  1. 用户发起”生成题目”请求
  2. 后端立即返回一个 sessionId,告诉前端”我开始处理了”
  3. 后端同时通过 SSE 实时推送进度:
    • "正在解析简历..." → 20%
    • "正在分析技能..." → 40%
    • "正在生成题目..." → 60%
    • "正在评估匹配度..." → 80%
    • "完成!" → 100%
  4. 用户看着进度条动,知道系统在工作
  5. 100% 时,前端拉取最终结果展示

决策 2:为什么推进度,而不是推内容?

你可能会问:既然用了 SSE,为什么不直接流式推送题目内容,像打字机一样实时显示?

原因在于交互模式不同

打字机效果适合对话场景——用户问一句,AI 实时打出回答,这种交互很自然。

但简历押题不是对话,它的结果是一份完整的报告:10-15 道题、每题有难度分级、有参考答案、有出题理由,还有一份匹配度评估。这些内容是有结构的,需要完整展示,不适合一边生成一边显示。

如果用打字机效果,用户会看到题目一个字一个字地蹦出来,然后答案又一个字一个字地蹦出来……体验反而很割裂。

所以选择是:推进度,不推内容。让用户知道系统在工作,等全部生成完了,一次性展示完整报告。

打字机效果(流式推送内容)会在后面的专项面试功能里实现,那个场景是真正的对话,适合这种交互。


决策 3:简历怎样解析?

简历可能是各种格式:PDF、Word、纯文本。我们的方案是格式转换 + AI 理解,分两步走:

第一步,格式转换:

格式工具说明
PDFpdf-parse提取 PDF 中的文本内容
Wordmammoth.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 次生成 → ¥5
10,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 学习途中,把踩过的坑写下来 专注羽毛球,爱音乐,正在研究易经 🎵🏸

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

【自学AI】11 在写代码之前,先把设计想清楚
https://blog.fridolph.top/posts/2026-02-20__ai-design/
作者
Fridolph
发布于
2026-02-20
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录