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

简历这件事,几乎每个职场人都绕不开。我见过太多朋友,技术能力过硬、项目经历扎实,结果简历排版一塌糊涂,投出去石沉大海。市面上的简历工具要么模板千篇一律,要么导出 PDF 时格式乱飞,要么收费才能去掉水印。索性自己动手,用 React 做前端交互、FastAPI 做后端服务、DeepSeek 负责内容生成,再配合 Markdown 到 PDF 的转换链路,搭一个完全可控的 AI 简历生成器。整套方案跑下来,从填写基本信息到拿到一份排版干净的专业 PDF,全程不超过两分钟,而且所有环节都掌握在自己手里,想改哪里改哪里。

这篇文章我会把整个项目的设计思路、技术选型理由、核心代码实现、踩过的坑和优化经验全部摊开讲。适合有 React 和 Python 基础、想做一个完整全栈项目的开发者,也适合正在准备面试、想拿一个真实项目练手的同学。我不会只贴代码,更重要的是讲清楚每个决策背后的逻辑——为什么选 FastAPI 而不是 Flask,为什么用 DeepSeek 而不是别的模型,Markdown 转 PDF 为什么不能直接一把梭。

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

1.1 现有工具的痛点在哪里

先说清楚动机。市面上简历工具大致分三类:在线编辑器、Word 模板、LaTeX 模板。在线编辑器的问题是模板同质化严重,HR 一眼就能看出你用的是哪家,而且免费版导出带水印、字体受限、排版微调几乎不可能。Word 模板看似灵活,但版本兼容性是个大坑——你在这台电脑上排好的版,换台机器打开可能就错位了,导出 PDF 时中文字体嵌入问题更是老生常谈。LaTeX 模板排版确实漂亮,但学习曲线陡峭,改一个字段要翻半天文档,对非科班出身的人不友好。

更深层的痛点是:简历内容本身需要反复打磨。不同岗位、不同公司,简历的侧重点应该不一样。投后端开发岗,项目经历要突出架构设计和高并发处理;投前端岗,要强调组件化和性能优化。手动改一版简历至少半小时,改十版就是五小时。如果有一个工具能根据目标岗位自动调整措辞和重点,效率提升是数量级的。

1.2 这套方案的整体思路

我的设计思路很直接:把简历拆成"结构化数据"和"呈现层"两部分。用户在前端填写或粘贴原始经历,后端调用 DeepSeek 对内容进行润色和结构化,输出一份标准 Markdown,最后把 Markdown 渲染成排版精美的 PDF。这样做的好处是,内容与格式彻底解耦——同一份数据可以生成不同风格的 PDF,也可以随时切换模板而不影响内容。

技术栈的选择上,前端用 React 是因为它的组件化模型天然适合表单和实时预览场景,状态管理清晰,配合 SSE 还能做流式渲染。后端选 FastAPI,核心原因是它对异步的支持非常自然,调用 DeepSeek API 这种 IO 密集型操作,异步能显著提升并发能力,而且 FastAPI 自带 OpenAPI 文档,调试接口非常方便。DeepSeek 负责内容生成,它的中文理解和结构化输出能力在这个场景下表现很好,API 调用成本也低。Markdown 到 PDF 的转换,我最终选了 WeasyPrint 而不是 Puppeteer,原因后面细说。

1.3 最终效果长什么样

整个应用的工作流是这样的:用户在左侧表单填写基本信息、教育背景、工作经历、项目经验、技能标签;点击"AI 润色"按钮后,后端把原始内容发给 DeepSeek,模型返回润色后的结构化 JSON;前端把 JSON 转成 Markdown 实时预览;用户确认无误后点击"导出 PDF",后端把 Markdown 渲染成 HTML,再用 WeasyPrint 转成 PDF 返回下载链接。整个过程支持流式输出,用户能看到 AI 逐字生成内容,体验比等待整段返回好很多。

最终产出的 PDF 支持自定义主题色、字体大小、页边距,中文字体嵌入完整,不会出现乱码。实测下来,一份两页的简历从填写到导出,熟练后一分半钟搞定。

2. 前端用 React 搭建表单与实时预览的完整思路

2.1 表单状态管理的取舍

简历表单字段多、嵌套深,状态管理如果设计不好,代码会迅速膨胀成一团乱麻。我一开始想用 Redux Toolkit,但仔细评估后发现没必要——这个场景的状态更新频率高但结构相对固定,用 React 自带的 useReducer 配合 Context 就够了,引入 Redux 反而增加样板代码。

具体做法是定义一个 resumeReducer,管理整个简历数据树。每个字段的更新通过 dispatch 一个 action 来完成,action 的 payload 包含字段路径和新值。这样做的优势是,所有状态变更都走同一条路径,方便做撤销重做和持久化。我额外加了一个 localStorage 的中间层,每次状态变更后自动保存草稿,用户刷新页面不会丢数据。

const resumeReducer = (state, action) => { switch (action.type) { case 'UPDATE_FIELD': return setNestedValue(state, action.path, action.value); case 'SET_AI_RESULT': return { ...state, ...action.payload, aiGenerated: true }; case 'RESET': return initialState; default: return state; } };

这里有个细节值得说:setNestedValue 用路径数组来定位字段,比如['workExperience', 0, 'description'],这样无论嵌套多深都能精确更新,避免了写一堆展开运算符的丑陋代码。

2.2 实时预览的性能优化

实时预览是这个项目的核心体验。用户每改一个字,右侧预览就要同步更新。如果每次更新都重新渲染整个 Markdown 组件,输入框会明显卡顿。我的优化方案是分两层:第一层用防抖控制更新频率,输入停止 300ms 后才触发预览更新;第二层用 React.memo 包裹预览组件,只有 Markdown 字符串真正变化时才重新渲染。

Markdown 渲染我选了 react-markdown 配合 remark-gfm 插件,支持表格、任务列表等 GitHub 风格语法。代码高亮用 rehype-highlight,虽然简历里代码块不多,但技能展示部分偶尔会用到。实测下来,即使简历内容很长,输入延迟也在可接受范围内。

2.3 流式输出的前端处理

调用 DeepSeek 生成内容时,如果等整段返回再显示,用户会盯着空白屏幕好几秒,体验很差。我用 SSE 做流式输出,后端每生成一个 token 就推给前端,前端逐字追加到预览区。这里的关键是处理好连接的生命周期——用户中途修改内容或关闭页面时,要主动断开 SSE 连接,否则会留下僵尸连接。

const startGeneration = () => { const eventSource = new EventSource(`/api/generate?session=${sessionId}`); eventSource.onmessage = (e) => { const data = JSON.parse(e.data); if (data.done) { eventSource.close(); return; } setStreamContent(prev => prev + data.token); }; eventSource.onerror = () => eventSource.close(); return () => eventSource.close(); };

注意:SSE 在 HTTP/1.1 下有连接数限制,浏览器对同一域名的并发连接通常限制在 6 个。如果你的应用同时有多个流式请求,建议升级到 HTTP/2,或者用 WebSocket 替代。

3. FastAPI 后端的目录结构与异步设计

3.1 项目目录怎么划分才合理

FastAPI 项目最容易犯的错误是把所有路由塞进一个 main.py。项目稍微大一点,维护成本就爆炸。我采用的目录结构是按职责分层,兼顾清晰和不过度设计:

backend/ ├── app/ │ ├── main.py # 应用入口,注册路由和中间件 │ ├── config.py # 配置管理,读取环境变量 │ ├── models/ # Pydantic 数据模型 │ │ ├── resume.py │ │ └── ai.py │ ├── routers/ # 路由层 │ │ ├── resume.py │ │ └── ai.py │ ├── services/ # 业务逻辑层 │ │ ├── deepseek.py │ │ └── pdf.py │ └── utils/ # 工具函数 │ └── markdown.py ├── templates/ # PDF 渲染模板 ├── requirements.txt └── .env

这个结构的核心原则是:路由层只负责参数校验和响应组装,业务逻辑全部下沉到 services。这样做的好处是,业务逻辑可以独立测试,不依赖 HTTP 上下文。比如 DeepSeek 的调用逻辑,我可以直接写单元测试,不用启动服务器。

3.2 异步调用 DeepSeek 的正确姿势

FastAPI 的异步能力是它最大的卖点之一,但很多人用不对。调用 DeepSeek API 是典型的 IO 密集型操作,必须用 async/await 配合 httpx 的 AsyncClient,否则会阻塞事件循环,并发能力还不如同步框架。

import httpx from app.config import settings class DeepSeekService: def __init__(self): self.client = httpx.AsyncClient( base_url=settings.DEEPSEEK_BASE_URL, headers={"Authorization": f"Bearer {settings.DEEPSEEK_API_KEY}"}, timeout=60.0 ) async def polish_resume(self, raw_content: str, target_role: str): prompt = self._build_prompt(raw_content, target_role) response = await self.client.post( "/chat/completions", json={ "model": "deepseek-chat", "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt} ], "temperature": 0.7, "stream": True } ) async for line in response.aiter_lines(): if line.startswith("data: "): yield line[6:]

这里有个关键点:httpx.AsyncClient 应该复用而不是每次请求都新建。我在应用启动时创建全局实例,通过依赖注入传给各个路由。这样能复用连接池,减少 TCP 握手开销。实测下来,复用连接后单次请求的延迟降低了约 30%。

3.3 配置管理与密钥安全

API 密钥绝对不能硬编码在代码里。我用 pydantic-settings 管理配置,从环境变量读取,本地开发用 .env 文件,生产环境用容器注入。这样既方便本地调试,又保证了生产安全。

from pydantic_settings import BaseSettings class Settings(BaseSettings): DEEPSEEK_API_KEY: str DEEPSEEK_BASE_URL: str = "https://api.deepseek.com" MAX_TOKENS: int = 4096 PDF_FONT_PATH: str = "/usr/share/fonts/truetype/custom" class Config: env_file = ".env" settings = Settings()

提示:.env 文件一定要加入 .gitignore,我见过太多人把密钥提交到仓库的惨案。另外建议在 CI 流程里加一个密钥扫描步骤,防止意外泄露。

4. DeepSeek 提示词工程:让 AI 输出结构化简历内容

4.1 提示词设计的核心原则

让 AI 生成简历内容,最大的挑战是输出不稳定——同样的输入,两次生成的结构可能完全不同。解决这个问题的关键是提示词要足够约束。我的做法是:在 system prompt 里明确定义输出格式为 JSON,给出完整的字段说明和示例,并强调"只输出 JSON,不要任何额外解释"。

SYSTEM_PROMPT = """你是一位资深简历顾问,擅长将零散的经历描述转化为专业、有说服力的简历语言。 输出要求: 1. 严格输出 JSON 格式,不要包含任何 markdown 代码块标记 2. JSON 结构必须包含以下字段: - summary: 个人简介,2-3句话,突出核心竞争力 - workExperience: 数组,每项包含 company, position, duration, highlights(字符串数组) - projects: 数组,每项包含 name, role, techStack(数组), description, achievements(数组) - skills: 对象,按类别分组 3. 工作经历和项目描述使用 STAR 法则,量化成果优先 4. 语言风格专业但不浮夸,避免"精通""熟练掌握"等空洞词汇 5. 如果原始信息不足,基于合理推断补充,但不要编造具体数字 """

这里有个经验:temperature 不要设太高。我试过 0.9,生成的内容确实有创意,但经常偏离原始信息,甚至编造不存在的项目。最终定在 0.7,在稳定性和多样性之间取得平衡。

4.2 处理 AI 返回的 JSON 解析问题

即使提示词约束得再好,DeepSeek 偶尔还是会在 JSON 前后加一些解释性文字,或者用 markdown 代码块包裹。直接 json.loads 会报错。我的处理方案是写一个健壮的解析函数,先尝试直接解析,失败则用正则提取 JSON 部分,再失败则返回错误让用户重试。

import json import re def parse_ai_response(text: str) -> dict: # 尝试直接解析 try: return json.loads(text) except json.JSONDecodeError: pass # 提取 markdown 代码块中的 JSON match = re.search(r'```(?:json)?\s*([\s\S]*?)```', text) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 提取第一个完整的 JSON 对象 match = re.search(r'\{[\s\S]*\}', text) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass raise ValueError("无法解析 AI 返回的内容")

这个函数我迭代了三个版本才稳定下来。第一版只做了直接解析,线上跑了两天就收到用户反馈说偶尔报错。加上代码块提取后,错误率降到 1% 以下。最后加上大括号匹配,基本覆盖了所有情况。

4.3 针对不同岗位的提示词微调

同一个人的经历,投不同岗位应该有不同的侧重点。我在提示词里加了一个 target_role 参数,根据目标岗位动态调整生成策略。比如投后端岗,强调系统设计、性能优化、高并发;投前端岗,强调组件化、用户体验、工程化。

ROLE_HINTS = { "backend": "重点突出系统架构设计、数据库优化、高并发处理经验", "frontend": "重点突出组件化设计、性能优化、跨端适配经验", "fullstack": "兼顾前后端,突出全链路交付能力和技术广度", "algorithm": "重点突出模型调优、数据处理、论文或竞赛成果" }

实测下来,加了岗位定向后,生成内容的匹配度明显提升。用户反馈说"AI 好像知道我要投什么岗位",其实就是提示词里多了一句话的事。

5. Markdown 转 PDF 的完整链路与踩坑记录

5.1 为什么选 WeasyPrint 而不是 Puppeteer

Markdown 转 PDF 有两条主流路线:一是用 Puppeteer 或无头浏览器渲染 HTML 再打印成 PDF,二是用 WeasyPrint 这类纯 Python 的 HTML/CSS 渲染引擎。我最终选了 WeasyPrint,原因有三:第一,Puppeteer 需要装 Chromium,镜像体积大,部署麻烦;第二,无头浏览器在容器里跑经常遇到字体和沙箱问题;第三,WeasyPrint 对 CSS 打印样式的支持足够好,分页控制、页眉页脚、页码都能搞定。

当然 WeasyPrint 也有坑,最大的问题是中文字体。默认情况下它找不到系统中文字体,导出的 PDF 全是方块。解决方案是在 CSS 里用 @font-face 显式指定字体文件路径,并确保字体文件在容器里存在。

5.2 Markdown 到 HTML 的转换细节

Markdown 转 HTML 我用 markdown-it-py,它比 Python-Markdown 更快,对 CommonMark 标准的支持也更完整。转换时要注意几个细节:表格需要开启 tables 插件,代码块需要开启 fenced_code,否则渲染出来是纯文本。

from markdown_it import MarkdownIt md = MarkdownIt("commonmark", {"breaks": True, "html": True}) \ .enable("table") \ .enable("strikethrough") def markdown_to_html(md_text: str) -> str: return md.render(md_text)

这里有个容易忽略的点:Markdown 里的换行。标准 CommonMark 里,单个换行符不会产生<br>,但简历场景下用户往往希望换行生效。所以我把 breaks 选项设为 True,让单个换行也渲染成<br>。这个细节看似小,但直接影响最终 PDF 的排版效果。

5.3 PDF 样式模板的设计

PDF 的样式全部写在 CSS 里,通过 @page 规则控制页面尺寸、边距和页眉页脚。我设计了一套简洁专业的模板,支持主题色变量,用户可以在前端选择。

@page { size: A4; margin: 15mm 12mm; @bottom-center { content: counter(page) " / " counter(pages); font-size: 9pt; color: #999; } } @font-face { font-family: 'CustomChinese'; src: url('file:///app/templates/fonts/SourceHanSans.ttf'); } body { font-family: 'CustomChinese', sans-serif; font-size: 10.5pt; line-height: 1.6; color: #333; } .section-title { color: var(--theme-color, #2c3e50); border-bottom: 2px solid var(--theme-color, #2c3e50); padding-bottom: 4px; margin-top: 16px; }

注意:WeasyPrint 对 CSS 变量的支持有限,如果发现主题色不生效,改用内联样式或服务端字符串替换。我在项目里用的是服务端替换方案,生成 HTML 时把主题色直接写进去,最稳妥。

5.4 分页控制的实战经验

简历最怕的就是分页把一段经历从中间截断。WeasyPrint 支持 page-break-inside: avoid,可以防止元素内部被分页。我给每个工作经历和项目卡片都加上这个属性,确保内容完整。

.experience-item, .project-item { page-break-inside: avoid; margin-bottom: 12px; } .section { page-break-inside: auto; } h2 { page-break-after: avoid; }

page-break-after: avoid 用在标题上,防止标题出现在页面底部而内容跑到下一页。这几个属性组合下来,分页效果基本符合预期。但要注意,如果单个经历内容超过一页,page-break-inside: avoid 会导致内容溢出,这时候需要手动拆分或调整字号。

6. 项目部署与性能优化的实战心得

6.1 容器化部署的关键配置

整个项目用 Docker Compose 编排,前端 Nginx 静态服务,后端 FastAPI 用 Uvicorn 跑。Dockerfile 里最容易出问题的是字体安装,WeasyPrint 依赖系统字体库,基础镜像里往往没有中文字体。

FROM python:3.11-slim RUN apt-get update && apt-get install -y \ libpango-1.0-0 \ libpangoft2-1.0-0 \ libharfbuzz0b \ libffi-dev \ fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . /app WORKDIR /app CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

fonts-noto-cjk 这个包提供了完整的中日韩字体支持,装上之后 WeasyPrint 就能正确渲染中文了。如果不装,导出的 PDF 中文全是方块,这个坑我踩过,排查了半天才定位到是字体缺失。

6.2 接口性能的实测数据

我对几个核心接口做了压测,用 locust 模拟并发请求。测试环境是 2 核 4G 的云服务器,结果如下:

接口并发数平均响应时间成功率
/api/resume/save5045ms100%
/api/ai/polish (流式)20首字节 800ms100%
/api/pdf/export102.3s100%

PDF 导出是最耗时的环节,因为 WeasyPrint 渲染本身是 CPU 密集型操作。优化方案是把 PDF 生成放到后台任务队列,用户提交后立即返回任务 ID,前端轮询任务状态,生成完成后返回下载链接。这样接口响应时间从 2.3 秒降到 50ms 以内。

6.3 缓存策略与成本控制

DeepSeek API 调用是有成本的,虽然单价不高,但用户反复点击"重新生成"会累积不少费用。我加了两层缓存:第一层是内容哈希缓存,如果用户输入的内容没变,直接返回上次的生成结果;第二层是 Redis 缓存,缓存 24 小时,跨用户共享。

import hashlib import redis.asyncio as redis async def get_cached_result(content: str, role: str): key = hashlib.md5(f"{content}:{role}".encode()).hexdigest() cached = await redis_client.get(f"resume:{key}") if cached: return json.loads(cached) return None

实测下来,缓存命中率在 40% 左右,因为很多用户会反复调整同一份简历。这部分节省的 API 调用成本相当可观。

6.4 几个值得注意的边界情况

最后分享几个实际运行中遇到的边界问题。第一,超长简历的处理——如果用户粘贴了几万字的原始内容,DeepSeek 的上下文窗口可能装不下。我的方案是在前端限制输入长度,超过 8000 字时提示用户精简,后端也做二次校验。第二,特殊字符转义——用户输入的内容里可能包含 Markdown 特殊字符,直接渲染会破坏格式,需要在转换前做转义处理。第三,并发导出冲突——同一用户短时间内多次点击导出,会生成多个 PDF 文件,我用文件锁加任务去重解决。

这套系统我自己用了大半年,帮朋友改简历也一直在用,整体稳定性不错。从技术角度看,React + FastAPI + DeepSeek 这个组合的扩展性很好,后续想加模板市场、多语言支持、简历评分等功能,架构上都不用大改。如果你也在做类似的项目,希望这些经验能帮你少走点弯路。

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

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

立即咨询