1. 为什么我要用 Next.js + LangGraph.js 重写简历工具
简历工具这个赛道,表面上看已经被做烂了。市面上一抓一大把的“简历生成器”,本质上就是个表单加模板渲染,用户填完信息,选个模板,导出 PDF,完事。我一开始也是这么想的,直到我自己帮朋友改了几十份简历之后才发现,真正难的根本不是排版,而是内容本身。
一份简历能不能过筛,80% 取决于内容匹配度,而不是模板好不好看。比如一个后端工程师投递某大厂的云原生岗位,他的简历里写的是“负责公司内部管理系统的开发”,这句话放在招聘方眼里等于什么都没说。但如果改成“基于 Kubernetes 搭建内部微服务治理平台,支撑日均 200 万次调用,P99 延迟从 800ms 降到 120ms”,性质就完全不一样了。问题在于,绝大多数求职者根本不知道自己该写什么、怎么写。
这就是我想做这个 AI Agent 简历工具的出发点。它不是帮你排版,而是帮你诊断简历内容、匹配目标岗位、生成改写建议。整个项目我用 Next.js 做全栈框架,用 LangGraph.js 做 Agent 编排,前后端一把梭,部署在 Vercel 上,个人开发者完全可以跑起来。
这篇文章我会把整个项目的设计思路、技术选型、核心代码、踩过的坑全部摊开讲。适合有 React 基础、想入门 AI Agent 开发、或者想做一个真正能用的 AI 产品的朋友。如果你只是想看个 Demo 截图,那这篇文章可能不太适合你。
2. 整体架构设计与技术选型思路
2.1 为什么是 Next.js 而不是前后端分离
我一开始其实想过用 FastAPI 做后端、React 做前端,毕竟 Python 生态在 AI 这块确实更成熟。但后来算了一笔账:这个项目的核心逻辑是“用户上传简历 → Agent 分析 → 返回结构化建议”,整个链路是请求-响应式的,没有长连接、没有复杂的后台任务调度。这种情况下,前后端分离带来的收益非常有限,反而增加了部署复杂度和跨域调试成本。
Next.js 的 App Router 提供了 Route Handlers,可以直接在app/api/目录下写服务端逻辑,和前端代码在同一个仓库、同一套类型系统里。我可以用 TypeScript 定义好 ResumeSchema,前端表单和后端 Agent 输入共用同一个类型,改一个字段两边都跟着变,这种体验在前后端分离的项目里是很难做到的。
另一个关键考虑是流式输出。AI Agent 的分析过程通常需要 10-30 秒,如果等全部生成完再返回,用户会以为页面卡死了。Next.js 的 Route Handler 支持 ReadableStream,配合 Vercel 的 Edge Runtime,可以做到边生成边推送到前端。这个能力对于 AI 产品的用户体验来说是刚需。
2.2 LangGraph.js 到底解决了什么问题
很多人第一次听到 LangGraph 会以为它是 LangChain 的替代品,其实不是。LangChain 解决的是“怎么调用 LLM 和工具”,LangGraph 解决的是“多个 LLM 调用之间怎么编排”。
举个具体例子。我的简历分析 Agent 需要做四件事:解析简历文本、提取关键信息、匹配岗位 JD、生成改写建议。如果用传统的链式调用,代码大概是这样:
const parsed = await parseResume(text); const extracted = await extractInfo(parsed); const matched = await matchJD(extracted, jd); const suggestions = await generateSuggestions(matched);这种写法能跑,但有几个致命问题。第一,如果matchJD发现简历和岗位完全不匹配,后面的generateSuggestions就没必要跑了,但链式调用没法中途退出。第二,如果我想在提取信息之后加一个“人工确认”环节,链式调用很难插入。第三,如果某个环节失败了想重试,得自己写一堆 try-catch。
LangGraph 的核心概念是状态图。你把整个流程定义成一张图,每个节点是一个处理步骤,边是流转条件。状态在节点之间传递,每个节点可以读取和修改状态。这样一来,条件分支、循环重试、人工介入都变成了图上的自然表达。
import { StateGraph, END } from "@langchain/langgraph"; const workflow = new StateGraph(ResumeState) .addNode("parse", parseNode) .addNode("extract", extractNode) .addNode("match", matchNode) .addNode("generate", generateNode) .addEdge("parse", "extract") .addEdge("extract", "match") .addConditionalEdges("match", shouldGenerate, { generate: "generate", end: END, }) .addEdge("generate", END); const app = workflow.compile();这段代码的可读性比链式调用高了一个量级。更重要的是,LangGraph 内置了 checkpoint 机制,每个节点的状态可以持久化,这意味着如果用户在生成建议的过程中刷新了页面,下次回来还能从断点继续。这个能力对于长流程的 AI 应用来说非常关键。
2.3 技术栈全景与选型理由
| 层级 | 技术选型 | 选型理由 |
|---|---|---|
| 前端框架 | Next.js 14 (App Router) | 全栈一体、流式支持、部署简单 |
| UI 组件 | shadcn/ui + Tailwind | 复制粘贴即用、样式可控 |
| Agent 编排 | LangGraph.js | 状态图模型、支持条件分支和循环 |
| LLM 接入 | OpenAI SDK / 兼容接口 | 生态成熟、切换模型成本低 |
| 状态管理 | Zustand | 轻量、适合中小型应用 |
| 数据校验 | Zod | 和 TypeScript 无缝集成 |
| 部署 | Vercel | 零配置、Edge Function 支持 |
这里重点说一下为什么选 Zustand 而不是 Redux。这个项目的状态其实不复杂,主要是简历文本、分析结果、当前步骤这几项。Redux 的 action/reducer 模式在这种场景下属于过度设计,写起来啰嗦。Zustand 用一个 store 文件就能搞定,而且支持在 React 组件外读取状态,这在 Agent 回调里更新 UI 状态时非常方便。
3. 核心模块拆解与关键实现细节
3.1 简历解析:从 PDF 到结构化文本
简历文件格式五花八门,PDF、Word、图片都有。我的策略是先统一转成纯文本,再做结构化提取。PDF 解析用的是pdf-parse,Word 用mammoth,图片走 OCR 接口。这一步的关键是不要试图在解析阶段做太多事情,先把文本拿到手,后面的 Agent 会处理脏数据。
// app/api/parse/route.ts import pdf from "pdf-parse"; import mammoth from "mammoth"; export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get("file") as File; const buffer = Buffer.from(await file.arrayBuffer()); let text = ""; if (file.type === "application/pdf") { const result = await pdf(buffer); text = result.text; } else if (file.type.includes("word")) { const result = await mammoth.extractRawText({ buffer }); text = result.value; } return Response.json({ text }); }这里有个坑我踩过:pdf-parse在 Next.js 的 Edge Runtime 里跑不起来,因为它依赖 Node.js 的fs模块。解决办法是把解析逻辑放在 Node.js Runtime 的 Route Handler 里,在文件顶部加一行export const runtime = "nodejs"。这个细节官方文档里没写清楚,我调试了两个小时才发现。
3.2 Agent 状态设计:让数据在节点间流动
LangGraph 的状态定义是整个 Agent 的骨架。我用 Zod 定义了状态的结构,这样每个节点读写状态时都有类型提示,不会出现拼写错误。
import { z } from "zod"; export const ResumeState = z.object({ rawText: z.string(), parsedInfo: z.object({ name: z.string().optional(), skills: z.array(z.string()), experiences: z.array(z.object({ company: z.string(), role: z.string(), duration: z.string(), description: z.string(), })), education: z.array(z.string()), }).optional(), targetJD: z.string().optional(), matchScore: z.number().optional(), suggestions: z.array(z.object({ section: z.string(), original: z.string(), improved: z.string(), reason: z.string(), })).optional(), error: z.string().optional(), });状态设计有个原则:只放必要的数据,不要把中间过程的临时变量塞进去。我一开始把 LLM 的原始返回也放进状态里,结果状态对象越来越大,checkpoint 序列化的时候性能明显下降。后来改成每个节点处理完就把原始返回丢掉,只保留结构化结果,状态大小控制在了 10KB 以内。
3.3 岗位匹配节点:条件分支的实际应用
岗位匹配是整个 Agent 里最有意思的部分。它的逻辑是:把简历里的技能、经历和岗位 JD 做对比,算出一个匹配度分数。如果分数低于阈值,直接返回“不匹配”并结束流程,不再浪费 token 去生成建议。
async function matchNode(state: ResumeState) { const prompt = `你是一个资深 HR,请对比以下简历和岗位要求, 输出一个 0-100 的匹配度分数,以及不匹配的主要原因。 简历:${JSON.stringify(state.parsedInfo)} 岗位:${state.targetJD}`; const response = await llm.invoke(prompt); const { score, reasons } = JSON.parse(response.content); return { matchScore: score, matchReasons: reasons }; } function shouldGenerate(state: ResumeState) { if (state.matchScore && state.matchScore >= 60) { return "generate"; } return "end"; }这个阈值 60 不是拍脑袋定的。我拿 50 份真实简历和 20 个岗位 JD 做了测试,发现匹配度低于 60 的简历,即使强行生成改写建议,质量也很差,因为根本方向就不对。这种情况下,与其给用户一堆没用的建议,不如直接告诉他“这个岗位不适合你,建议看看其他方向”。这个判断逻辑后来成了用户反馈最好的功能之一。
3.4 流式输出:让用户看到 Agent 在思考
AI 产品最忌讳的就是“转圈圈”。用户点了按钮之后,如果 20 秒内没有任何反馈,大概率会关掉页面。我的做法是把 Agent 的每个节点执行状态都推送到前端,让用户看到“正在解析简历 → 正在提取信息 → 正在匹配岗位 → 正在生成建议”这样的进度。
// app/api/analyze/route.ts export async function POST(req: Request) { const { text, jd } = await req.json(); const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); const send = (event: string, data: any) => { controller.enqueue( encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) ); }; const app = workflow.compile(); const events = app.stream({ rawText: text, targetJD: jd }); for await (const event of events) { const [nodeName, nodeState] = Object.entries(event)[0]; send("progress", { node: nodeName, state: nodeState }); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", }, }); }前端用EventSource接收这些事件,每收到一个就更新进度条。实测下来,用户看到进度条在动,即使总耗时 30 秒,也不会觉得卡。这个体验上的提升比优化模型速度来得更直接。
4. 完整实操流程与部署落地
4.1 从零搭建项目骨架
第一步是初始化 Next.js 项目。我用的命令是:
npx create-next-app@latest resume-agent --typescript --tailwind --app cd resume-agent npm install @langchain/langgraph @langchain/openai zod zustand npm install pdf-parse mammoth npm install -D @types/pdf-parse这里注意pdf-parse的类型定义要单独装,不然 TypeScript 会报错。另外@langchain/langgraph的版本更新很快,建议锁定版本号,我用的0.0.30版本比较稳定。
目录结构我这样组织:
app/ api/ parse/route.ts # 文件解析 analyze/route.ts # Agent 分析 page.tsx # 主页面 components/ UploadZone.tsx # 上传区域 ProgressBar.tsx # 进度条 SuggestionCard.tsx # 建议卡片 lib/ agent/ state.ts # 状态定义 nodes.ts # 各节点实现 graph.ts # 图编排 store.ts # Zustand store4.2 环境变量与模型配置
模型接入这块我做了个抽象层,方便切换不同的 LLM 服务商。核心思路是不把模型调用写死在节点里,而是通过一个工厂函数创建。
// lib/agent/llm.ts import { ChatOpenAI } from "@langchain/openai"; export function createLLM(options?: { temperature?: number }) { return new ChatOpenAI({ modelName: process.env.LLM_MODEL || "gpt-4o-mini", temperature: options?.temperature ?? 0.3, configuration: { baseURL: process.env.LLM_BASE_URL, }, }); }环境变量配置:
# .env.local LLM_API_KEY=your_api_key_here LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini温度参数我设的是 0.3,因为简历分析需要的是稳定、可复现的输出,不需要创意。如果温度太高,同一个简历每次分析结果都不一样,用户会觉得这个工具不靠谱。这个细节很多教程不会提,但实际产品里很重要。
4.3 前端交互与状态管理
Zustand store 的设计要围绕用户操作流程来。我把整个流程拆成四个状态:idle、parsing、analyzing、done。
// lib/store.ts import { create } from "zustand"; interface AppState { step: "idle" | "parsing" | "analyzing" | "done"; resumeText: string; jd: string; progress: { node: string; label: string }[]; suggestions: Suggestion[]; setStep: (step: AppState["step"]) => void; addProgress: (node: string, label: string) => void; setSuggestions: (s: Suggestion[]) => void; } export const useAppStore = create<AppState>((set) => ({ step: "idle", resumeText: "", jd: "", progress: [], suggestions: [], setStep: (step) => set({ step }), addProgress: (node, label) => set((state) => ({ progress: [...state.progress, { node, label }] })), setSuggestions: (suggestions) => set({ suggestions, step: "done" }), }));这里有个小技巧:addProgress用函数式更新,避免闭包捕获旧状态。我一开始写成set({ progress: [...progress, ...] }),结果快速连续调用时进度会丢失,改成函数式之后就正常了。
4.4 部署到 Vercel 的注意事项
Vercel 部署 Next.js 项目基本是零配置,但有几个坑要注意。
第一,函数执行超时。Vercel 免费版 Serverless Function 默认 10 秒超时,Hobby 版可以调到 60 秒。Agent 分析通常需要 20-40 秒,所以必须在vercel.json里配置:
{ "functions": { "app/api/analyze/route.ts": { "maxDuration": 60 } } }第二,文件大小限制。Vercel 的请求体限制是 4.5MB,大部分简历 PDF 都在这个范围内,但如果用户上传的是扫描版多页 PDF,可能会超。我的做法是在前端先做一次文件大小检查,超过 4MB 直接提示用户压缩。
第三,冷启动问题。Serverless 函数冷启动时,LangGraph 的初始化会额外耗时 1-2 秒。我的优化是把 workflow 的编译结果缓存在模块级别,避免每次请求都重新编译。
let cachedApp: any = null; function getApp() { if (!cachedApp) { cachedApp = workflow.compile(); } return cachedApp; }这个改动让冷启动时间从 3 秒降到了 1.5 秒左右,效果很明显。
5. 常见问题排查与避坑经验实录
5.1 Agent 输出格式不稳定怎么办
这是最常见的问题。LLM 有时候返回 JSON,有时候返回带 markdown 代码块的 JSON,有时候还会加一段解释文字。我试过三种方案:
第一种是纯 prompt 约束,在提示词里写“只返回 JSON,不要任何其他内容”。实测下来,GPT-4 级别模型遵守率大概 90%,小模型只有 70%。
第二种是用response_format: { type: "json_object" }。这个方案稳定性高很多,但要求 prompt 里必须出现 “JSON” 这个词,否则会报错。
第三种是用 Zod schema 做校验加自动重试。这是我现在用的方案:
async function invokeWithRetry<T>( llm: ChatOpenAI, prompt: string, schema: z.ZodSchema<T>, maxRetries = 3 ): Promise<T> { for (let i = 0; i < maxRetries; i++) { const response = await llm.invoke(prompt); try { const parsed = JSON.parse(response.content as string); return schema.parse(parsed); } catch (e) { if (i === maxRetries - 1) throw e; prompt += "\n\n上次输出格式错误,请严格按照 schema 返回。"; } } throw new Error("unreachable"); }这个方案的好处是类型安全,返回值一定是符合 schema 的。坏处是失败时会多消耗 token,所以 maxRetries 不要设太大,3 次足够了。
5.2 长简历导致 token 超限怎么处理
有些用户的简历写了五六页,转成文本后超过 8000 token。直接塞给模型会超限,而且成本也高。我的处理策略是分段摘要:
先把简历按章节切分(教育、工作经历、项目经历、技能),每个章节单独做一次摘要,然后把摘要合并后再做匹配分析。这样既控制了单次请求的 token 量,又保留了关键信息。
async function summarizeSections(text: string) { const sections = splitBySections(text); const summaries = await Promise.all( sections.map((s) => llm.invoke(`用 100 字总结以下内容:\n${s}`)) ); return summaries.map((s) => s.content).join("\n"); }实测下来,一份 8000 token 的简历压缩到 1500 token 左右,关键信息保留率在 95% 以上。这个方案比直接截断要靠谱得多。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| PDF 解析返回空文本 | 扫描版 PDF 无文字层 | 接入 OCR 或提示用户 |
| Agent 卡在某节点不返回 | LLM 请求超时 | 设置 timeout 和重试 |
| 流式输出中断 | Vercel 函数超时 | 调大 maxDuration |
| 状态丢失 | checkpoint 未配置 | 接入持久化存储 |
| 匹配分数波动大 | 温度参数过高 | 降到 0.3 以下 |
| 部署后报模块找不到 | Edge Runtime 不兼容 | 改用 Node.js Runtime |
5.4 几个我踩过的坑
坑一:不要在 Edge Runtime 里用 Node.js 模块。我一开始把解析逻辑放在 Edge Function 里,结果pdf-parse直接报错。Edge Runtime 只支持 Web Standard API,任何依赖fs、path、buffer的库都跑不了。解决办法就是加export const runtime = "nodejs"。
坑二:LangGraph 的状态更新是浅合并。如果你在节点里返回{ parsedInfo: { skills: [...] } },它不会和原有的parsedInfo合并,而是直接覆盖。我一开始没注意,导致 education 字段被清空了。正确做法是返回完整对象,或者用自定义的 reducer。
坑三:流式输出要注意背压。如果前端消费速度跟不上,controller.enqueue会堆积内存。我的做法是在前端加一个节流,每 100ms 最多更新一次 UI,避免频繁重渲染。
坑四:API Key 绝对不能暴露在前端。所有 LLM 调用必须走 Route Handler,前端只和自家 API 通信。这个是最基本的安全原则,但我在 review 别人代码时经常看到有人把 key 写在NEXT_PUBLIC_开头的环境变量里,这是大忌。
6. 后续可以扩展的方向
这个项目目前只做了简历分析和建议生成,但 LangGraph 的状态图模型让扩展变得很容易。我接下来打算加两个功能。
一个是多轮对话式改写。用户对某条建议不满意,可以直接在卡片上回复“这条太正式了,我想要更口语化的表达”,Agent 根据反馈重新生成。这个功能只需要在图上加一个humanFeedback节点,用条件边连回generate节点就行。
另一个是岗位推荐。根据简历内容反向匹配适合的岗位方向,这个需要接入岗位数据库,但 Agent 的匹配逻辑可以复用现有的matchNode。
如果你也在做类似的 AI Agent 项目,我的建议是先把单条链路跑通,再考虑扩展。我见过太多人一上来就设计一个包含十几个节点的复杂图,结果每个节点都调不通,最后项目烂尾。先用三四个节点把核心流程跑起来,验证了价值之后再往上加,这个节奏比较稳。
代码我已经整理成模板放在仓库里,核心逻辑都在lib/agent/目录下,想复现的朋友可以直接参考。实际跑下来,整个项目的开发周期大概两周,其中一半时间花在调试 Agent 的输出稳定性上,这部分是最耗精力但也最值得投入的。