【自学AI】13 实现一个生产级 AI 简历押题系统的完整复盘
写在前面
学 AI 这几个月,一直在看教程、跑 Demo、调 API。
但有一个问题始终没解决:怎样从”能跑的 Demo”到”能上线的产品”?
教程里的例子都很简单——调个 API,拿到结果,打印出来,完事。 但真实场景里,你要考虑:用户输入不规范怎么办?AI 超时了怎么办?扣费扣错了怎么办?
这次做 AI 简历押题系统,从需求分析到架构设计,从 Prompt 工程到代码实现,从成本控制到错误处理—— 完整走了一遍”从零到一”的过程。
这篇不是代码教程,是复盘,是工程思维的提炼。
如果你只想学”怎么调 AI API”,这篇可能不适合你。 但如果你想知道”怎样设计一个靠谱的 AI 应用”,这篇会对你有用。
一、七节课的完整地图
先看全景图,我们经历的完整旅程:
| 章节 | 核心问题 | 关键产出 |
|---|---|---|
| 1 功能设计 | 我们要做什么? | 需求分析 + 架构设计 + 数据结构 |
| 2 Prompt 设计 | 怎样让 AI 做得更好? | 三个专用 Prompt:简历分析 / 题目生成 / 匹配度评估 |
| 3 基础 Controller | 怎样搭建框架? | SSE 流式推送 + RxJS Subject 事件总线 |
| 4 简历解析 | 怎样处理多种输入? | 三种方式:直接文本 / 上传文件 / 简历库 |
| 5 扣费与退费 | 怎样确保财务安全? | 原子操作 + 自动退款 + 消费记录审计 |
| 6 简历解析详解 | 怎样让解析更鲁棒? | 验证 + 截断 + 边界情况处理 |
| 7 AI 集成与生成 | 怎样生成高质量结果? | 两步法 + 进度模拟 + JSON 格式约束 |
这不是线性的”写完一个功能再写下一个”,而是增量开发—— 每一步都是完整的、可测试的、有价值的。
《易经·系辞》:“易则易知,简则易从。” 把大系统拆成小模块,每个模块独立可测,整个系统自然清晰。
二、五个关键技术决策
做这个系统的时候,有五个地方停下来想了很久—— 不是因为技术难,是因为每个选择背后,都有成本、体验、可靠性的权衡。
决策 1:为什么用 SSE 而不是轮询?
AI 生成至少需要 5 分钟,用户盯着白屏等 5 分钟是什么体验?
一开始想用轮询——前端每秒发一个请求问”好了没?” 但算了一下:5 分钟 = 300 秒 = 300 个请求。
服务器要处理 300 个重复请求,用户要等 2-3 秒才看到更新—— 这不是好的解法。
SSE 的优势:
- 后端主动推送,前端被动接收
- 一个连接,持续推送
- 实时性好(100 毫秒内看到更新),资源消耗少
核心代码很简单:
const subject = new Subject<ProgressEvent>();
subject.subscribe({ next: (event) => { res.write(`data: ${JSON.stringify(event)}\n\n`); // 推送给前端 },});后端每次有进度更新,就往 subject 里扔一个事件,前端自动收到。
这个决策的本质: 对于长时间运行的异步任务,主动推送比被动轮询更高效。
决策 2:为什么分两步而不是一步?
这是整个系统最重要的架构决策。
一开始想一步到位——让 AI 一次性生成”题目 + 分析”。 但实际测试发现,一步法有几个问题:
| 问题 | 原因 |
|---|---|
| Prompt 太长 | 要同时说清楚”怎么生成题目”和”怎么分析简历”,Prompt 超过 2000 tokens |
| 输出太长 | 一次性输出 5000+ tokens,生成时间超过 10 分钟 |
| 注意力分散 | AI 同时做两件事,质量都下降 |
| 失败后果严重 | 如果第 9 分钟失败了,前面 9 分钟全白费 |
改成两步之后:
// 第一步:生成题目(7 分钟)const questionsResult = await aiService.generateResumeQuizQuestionsOnly({...});
// 第二步:生成分析(2 分钟)const analysisResult = await aiService.generateResumeQuizAnalysisOnly({...});数据对比:
| 方式 | tokens | 等待时间 | 失败后果 |
|---|---|---|---|
| 一步法 | 5000-8000 | 10 分钟以上 | 全部白费 |
| 两步法 | 6000-9000(分两次) | 约 9 分钟 | 至少有一半结果 |
两步法看起来多了一次 API 调用,但总时间反而更短—— 因为两个中等请求比一个超长请求更快。
而且用户体验更好:第一步完成后,用户已经看到”题目已生成”,心里有数。
《易经·履卦》:“履道坦坦,幽人贞吉。” 一步一步踩实,比一次性跳过去更稳。
决策 3:为什么简历有三种方式而不是只有一种?
最简单的做法是:只支持”粘贴文本”,用户自己复制粘贴简历。
但这样用户体验很差——大部分人的简历是 PDF 或 DOCX 文件, 让他们先打开文件、复制内容、再粘贴,多了两步。
所以我们支持了三种方式:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 直接粘贴文本 | 用户手头有纯文本简历 | 最简单,无需处理 | 需要手动复制粘贴 |
| 上传文件 | 用户有 PDF/DOCX 文件 | 最常见,符合习惯 | 需要下载+解析,复杂度高 |
| 从简历库选择 | 用户之前在简历汪保存过 | 最快,直接查库 | 依赖外部系统 |
这是优雅的降级设计——让用户选择最方便的方式,而不是强制一种。
文件解析的技术栈:
// 依赖三个包pnpm add axios@1.13.2 pdf-parse@1.1.1 mammoth@1.11.0
// 根据文件类型选择解析方式switch (fileType) { case 'PDF': text = await this.parsePdf(buffer); break; case 'DOCX': text = await this.parseDocx(buffer); break;}这个决策的本质: 支持多种输入方式是产品体验的基础,不要偷懒只做一种。
决策 4:为什么扣费要提前,失败后再退费?
扣费时机有三种选择:
| 时机 | 问题 |
|---|---|
| 等生成成功了再扣费 | 中间 5 分钟,系统其他部分是否在计数?是否会超配额?状态不一致 |
| 用后付费 | 万一余额不足怎么办?生成完了才发现没钱,用户白等了 |
| 先扣费,失败则退费(我们的选择) | 快速检查余额 + 失败自动退还 + 逻辑清晰 |
完整的扣费生命周期:
原子扣费(resumeRemainingCount - 1) ↓创建消费记录(status: PENDING) ↓AI 生成(可能失败) ↓保存结果 ↓消费记录 → SUCCESS
↕(任何一步失败)
退款(resumeRemainingCount + 1)消费记录 → FAILED + isRefunded: true原子操作的关键代码:
// ✅ 正确方式(原子操作)const user = await this.userModel.findOneAndUpdate( { _id: userId, resumeRemainingCount: { $gt: 0 }, // 条件检查 }, { $inc: { resumeRemainingCount: -1 }, // 原子递减 }, { new: false }, // 返回更新前的文档);
if (!user) { throw new BadRequestException('次数不足');}为什么要原子操作?
假设两个请求同时进来,用户只剩 1 次:
- 请求 A 读到余额 = 1,扣费,余额变成 0
- 请求 B 也读到余额 = 1(因为 A 还没写回去),也扣费,余额变成 -1
这就是并发问题。
原子操作把”检查余额”和”扣费”合并成一个不可分割的动作, 数据库保证同一时间只有一个请求能成功。
这个决策的本质: 先扣费、后生成、失败退款,是最清晰、最可靠的方式。
决策 5:为什么要进度条?
AI 生成至少需要 5 分钟,用户盯着白屏等 5 分钟是什么体验?
进度条的核心问题是:AI 生成是异步的,我们不知道它什么时候完成,没办法给出真实的进度百分比。
解决方案:模拟进度。
private getStagePrompt(progressSubject: Subject<ProgressEvent>): void { const progressMessages = [ { progress: 0.05, message: '🤖 AI 正在深度理解您的简历内容...' }, { progress: 0.1, message: '📊 AI 正在分析您的技术栈和项目经验...' }, { progress: 0.15, message: '🔍 AI 正在识别您的核心竞争力...' }, // ... 共 18 条消息,覆盖 0-90% ];
let progress = 0; const interval = setInterval(() => { progress += 1; this.emitProgress(progressSubject, progress, progressMessages[progress].message);
if (progress === progressMessages.length - 1) { clearInterval(interval); } }, Math.floor(Math.random() * (2000 - 800 + 1)) + 800); // 每 0.8-2 秒随机更新}关键设计:
- 随机间隔(0.8-2 秒):比固定 1 秒更自然
- 消息内容有变化:从”理解简历”到”设计问题”到”质量检查”
- 进度只到 90%:最后 10% 留给真实完成事件
这样用户看到的是:进度条在动,消息在变,AI 真的在干活—— 虽然我们不知道真实进度,但用户感受到的是”有进展”。
这个决策的本质: 不确定性的进度条,用模拟 + 随机间隔 + 有意义的消息,让用户感到”AI 真的在干活”。
三、贯穿始终的工程思维
如果你只记住代码,这章就浪费了。 我要让你记住的,是背后的工程思维。
思维 1:先设计,后编码
我们没有上来就写代码,而是先花了大量时间做设计。
为什么?
- 代码是容易改的,但如果架构错了,代价很高
- 需求理解不清楚,代码写出来也是错的
- 数据结构设计不好,后续会很痛苦
正确的顺序:
需求分析 → 架构设计 → 数据库设计 → 代码实现《易经·系辞》:“形而上者谓之道,形而下者谓之器。” 道是架构,器是代码。道不清楚,器再精致也没用。
思维 2:每一步都能独立测试
我们没有说”全部写完再测试”,而是:
- 框架搭好后,能调用接口吗?能收到 SSE 响应吗?
- 简历能解析吗?能验证格式吗?
- 扣费能成功吗?退费能成功吗?
- 复杂的文件解析对吗?边界情况处理了吗?
- AI 能生成内容吗?JSON 格式正确吗?
这就是增量开发——每一步都是完整的、可测试的、有价值的。
不是等到全部写完才发现”哦,这里有个 Bug”, 而是每写完一个模块,立刻测试,立刻发现问题。
思维 3:用户体验很重要
为什么要有进度条?为什么要分两步?为什么要支持三种方式?
都是为了用户体验。
站在用户的角度想:
- “我点了生成,然后什么都没发生,5 分钟都没动静,我以为系统死了”(没有进度条)
- “我只支持上传文件,但我想直接粘贴简历”(方式太少)
- “生成成功了,但配额没了,也没有提示”(体验差)
好的产品,永远是从用户的角度出发。
思维 4:失败处理和回滚
我们在扣费那一节讲了很多关于”失败怎么办”的问题。
本质思想是:在分布式系统中,失败是常见的,关键是怎样恢复。
| 场景 | 处理方式 |
|---|---|
| 扣费成功了,但 AI 超时了 | 自动退费 |
| AI 生成完了,但数据库连接失败了 | 自动退费 |
| 退费本身失败了(最严重) | 告警 + 人工介入 |
这不是说”万一出错,用户就认了”, 而是说”有任何错误,我们都有备选方案”。
嵌套 try-catch 的价值:
try { // 扣费 + 生成} catch (error) { try { await refundCount(userId, 'resume'); // 退款 } catch (refundError) { // ⚠️ 退款失败是严重问题,必须告警 this.logger.error(`🚨 退款流程失败!需要人工介入!`); // TODO: 发送告警通知 }}这就是健壮的系统设计。
四、从这章能学到什么
如果我问你”这个项目学到了什么”,可能有几种回答:
| 回答类型 | 内容 |
|---|---|
| ❌ 表面的回答 | ”我学会了怎样调用 AI API、怎样生成面试题” |
| ✅ 深层的回答 | ”我学到了怎样设计一个完整的 AI 应用,从需求分析到架构设计,从 Prompt 工程到代码实现,从成本控制到错误处理” |
这两个回答差别很大。
第一个,你只是会用 API。 第二个,你能独立设计和实现类似的系统。
具体来说,你学到了:
| 能力 | 体现在哪里 |
|---|---|
| 产品思维 | 理解用户需求,设计好的功能流程 |
| 架构思维 | 把大功能拆成小模块,让每个模块独立运作 |
| Prompt 工程 | 写好的 Prompt,让 AI 输出更高质量的结果 |
| 成本意识 | 估算 Token、优化成本 |
| 可靠性设计 | 处理失败、自动恢复、确保用户数据安全 |
| 渐进式开发 | 每一步都能测试、持续集成用户反馈 |
这些,都是超越代码的能力。
五、完整技术栈
| 类别 | 技术 | 用途 |
|---|---|---|
| 后端框架 | NestJS 11.0.1 | 后端框架 |
| 数据库 | MongoDB + Mongoose | 数据存储 |
| 异步处理 | RxJS | 流处理和事件总线 |
| 数据验证 | class-validator | 输入验证 |
| 文件处理 | axios + pdf-parse + mammoth | 下载和解析文件 |
| AI 集成 | LangChain 1.1.1 + @langchain/deepseek | AI 应用框架 |
| 实时通信 | SSE + RxJS Subject | 流式推送进度 |
| 架构模式 | MVC + DI + Repository + Strategy | 分层架构 |
六、下一步能做什么
到这里,你已经实现了一个完整的 AI 应用。但这只是开始。
1. 优化质量
- 收集用户反馈,迭代 Prompt
- 做 A/B 测试,对比不同 Prompt 的效果
- 加入人工审核,确保质量
2. 优化成本
- 分析 Token 消耗,找到优化空间
- 考虑用更便宜的模型(但要测试质量)
- 加入缓存,避免重复计算
3. 优化体验
- 加入实时反馈(用户可以标记”这道题不好”)
- 支持导出(用户可以下载为 PDF)
- 支持分享(用户可以分享给朋友)
4. 扩展功能
- “专项面试”模块(针对某个特定技术深度)
- “模拟面试”模块(AI 作为面试官)
- “薪资谈判”模块(怎样谈出高薪)
这些都是基于现有基础,可以逐步建立。
学完这章,我的几个感悟
架构设计比代码实现更重要
代码是容易改的,但架构错了,代价很高。
这次做系统,最花时间的不是写代码,是想清楚:
- 扣费时机怎么设计?
- 两步法还是一步法?
- 失败了怎么回滚?
想清楚之后,代码反而写得很快。
用户体验是产品的灵魂
技术再好,用户体验差,也没人用。
进度条、三种输入方式、友好的错误提示—— 这些看起来是”细节”,但恰恰是用户感受最深的地方。
失败处理和成功处理一样重要
很多教程只讲”成功的路径”,不讲”失败了怎么办”。
但真实系统里,失败是常见的——网络超时、数据库连接失败、AI 返回格式错误……
怎样优雅地处理失败、怎样自动恢复、怎样保护用户数据—— 这些才是系统是否靠谱的关键。
工程思维是可以迁移的
这次学到的思维方式——先设计后编码、增量开发、原子操作、失败回滚—— 不只适用于 AI 应用,适用于任何复杂系统。
技术会变,但工程思维不会变。
写在最后
从”能跑的 Demo”到”能上线的产品”,中间隔着的不是代码量,是工程思维。
这个系统,代码可能只有 2000 行,但背后的思考—— 架构设计、成本控制、失败处理、用户体验—— 是这 2000 行代码的地基。
地基稳了,房子才能盖得高。
遇到问题时,先想想背后的原因,再想怎样解决。 很多时候,理解原因比找到解决方案更重要。
昇哥 · 2026年4月 学 AI 简历押题项目途中,把踩过的坑和想清楚的事写下来
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!