☰
React + FastAPI + DeepSeek:从零构建 AI 简历生成器实战
2026/11/4 18:08:12 网站建设 项目流程

1. 为什么我要自己撸一个 AI 简历生成器

投过简历的人都知道那种折磨:一份内容改八遍,每次都要在 Word 里跟行距、页边距、字体搏斗,导出 PDF 还经常跑版。市面上的在线简历工具要么模板丑,要么导出带水印,要么改一个字段要重新排版半小时。我前阵子帮几个朋友内推,顺手看了他们发来的简历 PDF,排版问题一抓一大把——标题和正文挤在一起、项目符号对不齐、中英文混排字距诡异。那一刻我就想,这事本质上是个「结构化数据 + 模板渲染」的问题,为什么不用代码解决?

于是就有了这个项目:用 React 做前端编辑界面,FastAPI 做后端服务,接 DeepSeek 大模型做内容润色和生成,最终把 Markdown 一键渲染成专业排版的 PDF。核心链路很清晰:用户在网页上填基本信息或者直接粘贴一段粗糙的经历描述,DeepSeek 负责把它改写成简历语言,前端实时预览 Markdown 渲染效果,点一下导出,后端用无头浏览器把 HTML 转成 PDF 返回下载。

这套东西适合谁?如果你是会写点代码的求职者,想给自己做一个可版本管理的简历工具,那直接抄这套架构就行;如果你是前端或全栈开发者,想找一个「React + FastAPI + LLM」的完整实战项目练手,这个场景的复杂度刚刚好——有表单状态管理、有流式输出、有文件生成、有跨端渲染,麻雀虽小五脏俱全。我把它拆开讲,重点不是给你一份能跑的代码,而是把每个技术选型背后的「为什么」讲透,让你能按自己的需求改造。

2. 整体架构设计与技术选型拆解

2.1 三层架构的职责划分

这个项目我刻意做成了前后端分离的三层结构,不是为了炫技,而是每一层都有明确的不可替代的职责。

前端层(React)负责三件事:表单数据收集、Markdown 实时预览、导出触发。为什么用 React 而不是 Vue 或原生?因为简历编辑器本质上是一个「高频状态更新 + 局部重渲染」的场景。用户在左边输入框敲字,右边预览区要实时跟着变,这种双向绑定如果用手写 DOM 操作会非常痛苦。React 的受控组件模型配合useState或者useReducer,能把「数据源唯一」这件事做得很干净。我实测下来,用react-markdown做预览渲染,配合remark-gfm插件支持表格和任务列表,输入延迟基本感知不到。

后端层(FastAPI)承担三个关键任务:调用 DeepSeek API 做内容生成、接收 Markdown 并转换成 HTML、调用无头浏览器生成 PDF。选 FastAPI 而不是 Flask 或 Django,核心原因是它的异步原生支持和自动生成的交互式文档。调用大模型 API 是典型的 IO 密集型操作,一个请求可能要等好几秒,如果用同步框架,并发几个用户就会把 worker 占满。FastAPI 基于 Starlette,用async def写接口,配合httpx发异步请求,单进程就能扛住不错的并发。另外它用 Pydantic 做请求体校验,简历字段的必填、类型、长度约束直接声明在模型里,省掉一大堆手写 if-else。

模型层(DeepSeek)负责把「人话」翻译成「简历话」。比如用户输入「我负责了一个后台系统,用 Python 写的,性能还行」,模型要输出「主导基于 Python 的后台系统架构设计与性能优化,支撑日均 XX 万请求」。这一步是整个工具的灵魂,也是为什么不用规则模板的原因——规则模板写死了句式,换个行业就废了,而大模型能理解语义。

2.2 为什么 Markdown 是中间格式的最优解

很多人会问:为什么不直接让模型输出 HTML,或者直接输出 PDF?我试过几条路线,最后锁定 Markdown 作为中间层,理由有三条。

第一,Markdown 是人和机器都能读的格式。用户可以在预览区直接看到## 工作经历这样的标记,想手动微调随时能改。如果中间层是 HTML,用户看到一堆<div class="section-title">会直接懵掉。如果中间层是 PDF,那根本没法编辑,改一个字要重新生成整份文件。

第二,Markdown 到 HTML 的转换生态极其成熟。Python 侧有markdown-it-py,JS 侧有marked,两边渲染结果基本一致,这意味着前端预览和后端导出可以用同一套语义。我踩过一个坑:早期前端用一套渲染器、后端用另一套,结果预览里表格是好的,导出的 PDF 里表格塌了。后来统一用 GFM(GitHub Flavored Markdown)规范,问题消失。

第三,Markdown 天然适合版本管理。简历内容存成.md文件,扔进 Git 仓库,每次改动能看到 diff,想回滚到上一版随时可以。这比存数据库里一个 JSON 字段优雅太多。我现在的做法是:用户数据存 SQLite(结构化字段),同时把渲染用的 Markdown 快照存一份到文件系统,双保险。

2.3 技术选型对照表

为了让你一眼看清每个环节的选择逻辑,我整理了一张对照表:

环节选型备选方案选择理由
前端框架React 18Vue 3 / Svelte生态成熟,Markdown 渲染库丰富,状态管理灵活
后端框架FastAPIFlask / Django异步原生,Pydantic 校验,自动 API 文档
大模型DeepSeek其他 LLM中文简历语境理解好,API 兼容 OpenAI 格式,接入成本低
中间格式Markdown (GFM)HTML / 纯文本人机可读,转换生态成熟,易版本管理
PDF 引擎Playwright (Chromium)WeasyPrint / wkhtmltopdf对 CSS 支持最完整,中文字体渲染稳定
数据库SQLitePostgreSQL单机部署零配置,简历数据量小,够用

这张表里最值得展开说的是PDF 引擎的选择。我最初用的是 WeasyPrint,纯 Python,安装简单,但它对 CSS 的支持停留在比较老的版本,flex布局支持不完整,中文字体嵌入也经常出问题。后来换成 Playwright 驱动 Chromium,本质上是「用浏览器打印网页」,CSS 支持度和 Chrome 完全一致,中文字体只要系统装了就能正确嵌入。代价是镜像体积大了不少,但换来的排版稳定性完全值得。

3. 核心细节解析与实操要点

3.1 DeepSeek 接入的关键配置

接入 DeepSeek 这块,我用的是它兼容 OpenAI 格式的接口,所以可以直接用openai这个 Python SDK,把base_url指过去就行。这里有个细节很多人会踩坑:API Key 绝对不能写在前端。我见过有人图省事,在 React 里直接fetch大模型接口,Key 暴露在浏览器网络面板里,等于把钱包敞开。正确做法是前端只跟自己的 FastAPI 通信,由后端持有 Key 去调模型。

后端调用我封装了一个LLMClient类,核心参数有三个需要重点调:

  • temperature:简历润色场景我设成0.3。太高了模型会自由发挥,把你的「负责登录模块」改写成「缔造了用户身份认证的宏伟蓝图」,HR 看了想打人。低温度保证输出稳定、克制。
  • max_tokens:设成2048足够。简历单段经历一般不会超过 500 字,留足余量防止截断。
  • stream:我开了流式输出。用户点「AI 润色」后,文字一个字一个字蹦出来,体验上比转圈等 5 秒好太多。FastAPI 侧用StreamingResponse配合text/event-stream把 chunk 推给前端。
# 后端调用 DeepSeek 的核心逻辑(简化版) import httpx from fastapi import FastAPI from fastapi.responses import StreamingResponse app = FastAPI() async def stream_polish(raw_text: str): payload = { "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是资深简历顾问,把用户的口语化描述改写成专业简历语言,保持事实不变,不要编造数据。"}, {"role": "user", "content": raw_text} ], "temperature": 0.3, "stream": True } async with httpx.AsyncClient(timeout=60) as client: async with client.stream("POST", "https://api.deepseek.com/v1/chat/completions", json=payload, headers={"Authorization": f"Bearer {API_KEY}"}) as resp: async for line in resp.aiter_lines(): if line.startswith("data: "): yield line[6:] + "\n\n" @app.post("/api/polish") async def polish(body: dict): return StreamingResponse(stream_polish(body["text"]), media_type="text/event-stream")

注意:系统提示词里一定要加「不要编造数据」这句。我早期没加,模型自作主张给用户编了「提升转化率 37%」这种数字,用户差点就这么投出去了。简历里每一个数字都必须是真实的,这是底线。

3.2 Markdown 渲染的一致性问题

前端预览和后端导出必须用同一套 Markdown 语义,否则就会出现「预览好好的,导出就崩」的经典问题。我的解决方案是:前端用react-markdown+remark-gfm,后端用markdown-it-py并开启 GFM 插件,两边都遵循同一套规范。

这里有个中文用户特有的坑:Markdown 换行。标准 Markdown 里,单个换行符会被忽略,两个段落之间必须空一行。但中文用户习惯是敲一下回车就换行,结果预览里全挤成一坨。解决办法是开启breaks选项(react-markdown里是remark-breaks插件,markdown-it-py里是breaks=True),让单个换行也渲染成<br>。这个设置一开,用户体验立刻正常。

另一个坑是表格转换。简历里经常要放技能熟练度表格,Markdown 表格语法对对齐要求严格,少一个竖线就渲染失败。我在前端加了一个「插入表格」按钮,自动生成标准语法骨架,用户只填内容,避免手写出错。

3.3 PDF 导出的样式控制

PDF 导出的核心是「用 CSS 控制打印样式」。Playwright 生成 PDF 时,我通过page.pdf()传入几个关键参数:

await page.pdf( path=output_path, format="A4", print_background=True, # 打印背景色,否则技能条、分割线全没了 margin={"top": "15mm", "bottom": "15mm", "left": "18mm", "right": "18mm"}, prefer_css_page_size=False )

print_background=True这个参数极其重要,默认是False,导致所有背景色、边框都不打印,简历看起来像半成品。我第一次导出时纳闷为什么技能进度条不见了,查了半天文档才发现是这个开关。

字体方面,我推荐在 CSS 里显式声明中文字体栈:

body { font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; font-size: 10.5pt; line-height: 1.6; }

字号用pt而不是px,因为打印场景下pt是物理单位,更可控。正文10.5pt是中文简历的黄金尺寸,标题用14pt加粗,层级分明又不占地方。

4. 实操过程与核心环节实现

4.1 从零搭建项目骨架

先说目录结构,这是 FastAPI 项目最容易乱的地方。我采用的是按职责分层,而不是按文件类型分层:

resume-ai/ ├── backend/ │ ├── app/ │ │ ├── main.py # 应用入口,注册路由 │ │ ├── api/ │ │ │ ├── polish.py # AI 润色接口 │ │ │ └── export.py # PDF 导出接口 │ │ ├── services/ │ │ │ ├── llm_client.py # 大模型调用封装 │ │ │ └── pdf_renderer.py # PDF 渲染逻辑 │ │ ├── schemas/ │ │ │ └── resume.py # Pydantic 数据模型 │ │ └── templates/ │ │ └── resume.html # PDF 用的 HTML 模板 │ ├── requirements.txt │ └── .env # 存 API Key,绝不提交 ├── frontend/ │ ├── src/ │ │ ├── App.jsx │ │ ├── components/ │ │ │ ├── Editor.jsx # 左侧编辑区 │ │ │ ├── Preview.jsx # 右侧预览区 │ │ │ └── Toolbar.jsx # 顶部操作栏 │ │ └── api/client.js # 后端接口封装 │ └── package.json └── README.md

这个结构的好处是:想找「AI 润色」相关代码,直接进api/polish.py和services/llm_client.py,不用在几十个文件里翻。schemas单独放,因为 Pydantic 模型会被多个模块引用,集中管理避免循环导入。

4.2 前端编辑器的状态设计

前端我用了最简单的useState管理 Markdown 文本,没有上 Redux 或 Zustand。原因很简单:这个应用的状态就是「一份 Markdown 字符串 + 一个加载标志」,复杂度根本不需要状态管理库。过度设计反而增加维护成本。

核心交互是这样的:左边一个<textarea>,右边一个<ReactMarkdown>。用户敲字,onChange更新 state,React 自动重渲染预览区。为了防止大文档卡顿,我加了一个useDeferredValue把预览渲染降级为低优先级更新,输入框始终保持丝滑。

import { useState, useDeferredValue } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; export default function Editor() { const [md, setMd] = useState('# 张三\n\n## 工作经历\n\n- 负责...'); const deferredMd = useDeferredValue(md); return ( <div className="grid grid-cols-2 gap-4"> <textarea value={md} onChange={(e) => setMd(e.target.value)} className="h-screen p-4 font-mono text-sm border-r" /> <div className="h-screen overflow-auto p-6 prose"> <ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]}> {deferredMd} </ReactMarkdown> </div> </div> ); }

useDeferredValue这个 Hook 是 React 18 引入的,专门解决「输入框卡顿」问题。它让预览区的更新变成可中断的低优先级任务,用户快速打字时不会因为渲染 Markdown 而掉帧。实测下来,即使文档长到几千字,输入依然跟手。

4.3 PDF 生成的完整链路

导出 PDF 的链路是这样的:前端把 Markdown 字符串 POST 给/api/export,后端先把它转成 HTML,套进一个带样式的 HTML 模板,然后用 Playwright 打开这个 HTML 并打印成 PDF,最后以文件流返回。

这里有个性能优化点:Playwright 的浏览器实例要复用。如果每次请求都chromium.launch()启动一个新浏览器,一次要一两秒,并发高了直接崩。我的做法是在 FastAPI 的lifespan里启动一个全局浏览器实例,每个请求开一个新page,用完关掉 page 但保留 browser。

from contextlib import asynccontextmanager from playwright.async_api import async_playwright browser = None @asynccontextmanager async def lifespan(app): global browser pw = await async_playwright().start() browser = await pw.chromium.launch() yield await browser.close() await pw.stop() app = FastAPI(lifespan=lifespan) @app.post("/api/export") async def export_pdf(body: dict): html = render_markdown_to_html(body["markdown"]) page = await browser.new_page() await page.set_content(html, wait_until="networkidle") pdf_bytes = await page.pdf(format="A4", print_background=True) await page.close() return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": "attachment; filename=resume.pdf"})

wait_until="networkidle"这个参数保证页面里所有资源(字体、图片)都加载完再打印,否则可能出现字体没加载完就导出、PDF 里中文变方块的情况。

4.4 中文字体嵌入的实战处理

中文字体是 PDF 导出最容易翻车的地方。服务器上如果没装中文字体,Chromium 渲染时就会用默认字体,导出后中文全是方框。我的解决方案是在 Dockerfile 里显式安装字体:

FROM python:3.11-slim RUN apt-get update && apt-get install -y \ fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/*

fonts-noto-cjk是 Google 的开源中文字体,覆盖简繁日韩,体积适中。装完之后,CSS 里声明font-family: "Noto Sans SC"就能正确命中。如果你想要更好看的简历字体,可以自己下载思源黑体或霞鹜文楷,COPY 进镜像再fc-cache -f刷新字体缓存。

提示:本地开发时如果 Mac 上导出正常、部署到 Linux 服务器就乱码,九成是字体问题。先fc-list :lang=zh看看系统里有没有中文字体,没有就装。

5. 常见问题与排查技巧实录

5.1 问题速查表

我把开发过程中踩过的坑整理成了一张表,你遇到类似症状可以直接对号入座:

症状可能原因排查方向解决方案
导出 PDF 中文变方块服务器缺中文字体fc-list :lang=zh安装 fonts-noto-cjk
预览正常导出跑版前后端 Markdown 渲染器不一致对比两边渲染结果统一用 GFM 规范
背景色/分割线消失print_background未开启检查 page.pdf 参数设为 True
输入框打字卡顿预览渲染阻塞主线程React DevTools Profiler用 useDeferredValue
AI 输出编造数据系统提示词约束不足检查 prompt加「不得编造」指令
并发导出时服务卡死每次请求新建浏览器看内存和 CPU复用 browser 实例
流式输出前端收不到SSE 响应头不对看 Network 面板设 text/event-stream
表格在 PDF 里塌陷Markdown 表格语法错误检查竖线数量用表格生成按钮

5.2 三个最坑的问题深度复盘

第一个坑:SSE 流式输出的缓冲问题。我一开始用 FastAPI 的StreamingResponse推流,本地测试好好的,部署到 Nginx 后面就变成「等全部生成完才一次性吐出来」。查了半天发现是 Nginx 默认开启了proxy_buffering,把流式响应缓冲了。解决办法是在 Nginx 配置里加proxy_buffering off;和X-Accel-Buffering: no响应头。这个坑不踩一次根本想不到,因为代码逻辑完全正确,问题出在中间层。

第二个坑:Playwright 在容器里的沙箱问题。在 Docker 里跑 Chromium 默认会启用沙箱,但容器环境往往没有足够的权限,导致启动失败。报错信息是No usable sandbox。解决办法是启动时加--no-sandbox参数。但要注意,这个参数降低了安全性,所以只在受控的容器环境里用,别在共享服务器上随便开。

browser = await pw.chromium.launch(args=["--no-sandbox", "--disable-dev-shm-usage"])

--disable-dev-shm-usage是另一个容器场景的必备参数,因为容器默认的/dev/shm只有 64MB,Chromium 渲染大页面时会因为共享内存不足而崩溃。

第三个坑:大模型返回的 Markdown 带多余代码块包裹。有时候你让模型输出 Markdown,它会很「贴心」地给你包一层```markdown代码块,结果渲染出来是一坨代码而不是格式化文本。我的处理是在后端加一道清洗:如果返回内容以```开头、以```结尾,就剥掉外层。这个清洗逻辑要写得保守一点,只剥最外层,别把用户内容里正常的代码块也误伤。

5.3 独家避坑心得

分享几条文档里不会写、但实际开发中特别有用的经验。

关于 API Key 管理:用python-dotenv加载.env文件,并且把.env写进.gitignore。我还会在代码启动时检查 Key 是否存在,不存在就直接报错退出,而不是等到用户点了按钮才在运行时炸掉。早失败早发现。

关于简历数据的持久化:我一开始没做自动保存,用户刷新页面内容全丢,被朋友骂了一顿。后来加了localStorage自动草稿,每 3 秒存一次,刷新自动恢复。这个功能实现成本极低,但体验提升巨大。再进阶一点可以存到后端,但单机自用的话localStorage完全够。

关于 PDF 文件命名:别用resume.pdf这种通用名,用户下载一堆简历分不清谁是谁。我的做法是用「姓名_岗位_日期」的格式,比如张三_前端工程师_20260115.pdf。后端在Content-Disposition头里设置文件名时,中文需要 URL 编码,否则某些浏览器会乱码:

from urllib.parse import quote filename = quote("张三_前端工程师.pdf") headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"}

注意这里用的是filename*=UTF-8''语法,这是 RFC 5987 规定的国际化文件名写法,比直接filename=兼容性好得多。

关于模型输出的稳定性:即使 temperature 设得很低,模型偶尔还是会抽风。我的做法是在后端加一层「输出校验」:检查返回内容是否为空、是否包含明显的拒绝性话术、长度是否异常。任何一项不通过就重试一次,重试还失败就返回原文并提示用户手动修改。宁可降级,也不要给用户一个半成品。

6. 这套架构还能怎么扩展

聊完实现,说几个我后续打算加的方向,也给你一些改造灵感。

多模板切换是最容易加的功能。现在只有一套 HTML 模板,其实只要准备多套 CSS,导出时让用户选一个风格(简约、商务、创意),后端根据参数加载不同样式表就行。模板和逻辑解耦,加模板不用动一行 Python 代码。

简历评分是个有意思的玩法。把生成的 Markdown 再喂给 DeepSeek,让它从「量化成果」「动词使用」「篇幅控制」几个维度打分并给改进建议。这个功能对求职者价值很高,实现上就是多一次模型调用。

多语言版本也很实用。同一份简历内容,让模型翻译成英文版,导出时生成两份 PDF。注意翻译不是直译,英文简历的句式习惯和中文差别很大,提示词里要明确要求「按英文简历惯例重写」而不是「逐句翻译」。

版本对比适合长期维护简历的人。每次导出时把 Markdown 快照存一份,加个 diff 视图,能看到「这次投递相比上次改了哪些内容」。这个用difflib就能实现,前端渲染一下差异高亮即可。

我自己用这套工具改简历,从打开到导出 PDF 大概三分钟,比在 Word 里折腾快了一个数量级。更重要的是,简历内容变成了纯文本,可以进 Git 管理,每次针对不同岗位微调都能追溯。如果你也在频繁投简历,或者想找个 React + FastAPI + LLM 的练手项目,这套东西的投入产出比相当高。先把最小链路跑通——一个输入框、一个预览区、一个导出按钮——然后再慢慢加 AI 润色和模板切换,别一上来就想着做全功能,那样容易烂尾。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询