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 18 | Vue 3 / Svelte | 生态成熟,Markdown 渲染库丰富,状态管理灵活 |
| 后端框架 | FastAPI | Flask / Django | 异步原生,Pydantic 校验,自动 API 文档 |
| 大模型 | DeepSeek | 其他 LLM | 中文简历语境理解好,API 兼容 OpenAI 格式,接入成本低 |
| 中间格式 | Markdown (GFM) | HTML / 纯文本 | 人机可读,转换生态成熟,易版本管理 |
| PDF 引擎 | Playwright (Chromium) | WeasyPrint / wkhtmltopdf | 对 CSS 支持最完整,中文字体渲染稳定 |
| 数据库 | SQLite | PostgreSQL | 单机部署零配置,简历数据量小,够用 |
这张表里最值得展开说的是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 润色和模板切换,别一上来就想着做全功能,那样容易烂尾。