Next.js + LangGraph.js 实战:构建智能简历优化 AI Agent
1. 为什么我要用 Next.js LangGraph.js 做简历工具 Agent先说说这个项目的来龙去脉。我在日常工作中经常帮朋友看简历也帮团队筛过不少候选人慢慢发现一个很现实的问题大部分人写简历不是能力不行而是不知道怎么把能力“翻译”成招聘方想看的样子。市面上的简历工具我几乎试了个遍要么是纯模板填充要么是套个通用大模型的壳做润色改出来的东西千篇一律甚至会把真实经历改得面目全非。所以我决定自己动手做一个真正能“读懂”简历、能跟用户来回对话、能按目标岗位定向优化的 AI Agent。技术选型上前端用Next.jsAgent 编排用LangGraph.js。这两个组合不是拍脑袋定的下面我会把每个选择背后的理由讲透。这个项目适合谁看如果你已经会一点 React 或者 Node.js想搞清楚 AI Agent 到底怎么从 Demo 落到一个真实可用的产品里那这篇就是写给你的。如果你只是想找个简历模板那可能帮助不大。全文我会围绕架构设计、核心实现、踩坑记录三条线展开把能直接抄的代码和配置都放出来。先说清楚这个 Agent 到底要干什么不然架构无从谈起。我给它定义了四个核心能力第一解析用户上传的简历PDF/Word结构化提取教育、经历、技能等信息第二跟用户多轮对话追问缺失的关键信息比如“你在这个项目里具体负责哪一块”第三根据目标岗位 JD 做定向匹配和改写建议第四输出一份排版规范、可直接投递的简历。这四个能力不是线性的中间会有大量分支和回退这正是我选 LangGraph.js 而不是简单 Chain 的根本原因。2. 整体架构设计与技术选型拆解2.1 为什么前端选 Next.js 而不是纯 SPA简历工具这个场景有个很特殊的需求既要处理文件上传又要做流式对话还要考虑 SEO 和首屏速度。我一开始用 Vite React 搭了个纯前端 SPA结果发现两个问题。一是文件解析如果放前端做PDF 解析库体积大得离谱首屏加载直接奔着 3MB 去了二是流式输出对话内容时纯前端方案要么用 WebSocket 自己维护连接状态要么用 SSE 但跨域和重连处理很烦。Next.js 的 App Router 天然解决了这些。我把文件解析放在 Route Handler 里跑前端只负责上传和展示首屏体积压到了 400KB 以内。流式对话直接用 Route Handler 返回ReadableStream前端用fetch加getReader()消费不需要额外维护 WebSocket 连接。另外简历工具天然有分享需求比如“把优化后的简历生成一个链接发给朋友看”Next.js 的服务端渲染让这类页面可以直接被搜索引擎收录这是纯 SPA 做不到的。具体版本我用的是 Next.js 14 的 App RouterNode 运行时。这里有个细节要注意LangGraph.js 目前对 Edge Runtime 的支持还不完整尤其是涉及一些 Node 原生模块的时候会报错所以 Route Handler 里我显式声明了export const runtime nodejs。这个坑我踩过当时流式接口在本地跑得好好的一部署到 Edge 环境就 500排查了半天才发现是运行时的问题。2.2 LangGraph.js 相比普通 Chain 的核心优势很多人做 AI Agent 第一反应是用 LangChain 的AgentExecutor或者简单的SequentialChain。我一开始也是这么干的但很快就撞墙了。简历优化这个流程有个特点它不是一条直线而是一张有环的图。比如用户上传简历后Agent 发现工作经历缺失要追问用户回答后Agent 可能觉得还不够具体要再追问一轮追问够了才进入改写环节。改写完之后用户可能不满意要回到追问环节补充信息。这种“带条件分支 可回退”的流程用 Chain 写会变成一堆 if-else 嵌套维护起来是灾难。LangGraph.js 的核心抽象就是状态图StateGraph。你把整个流程定义成节点Node和边Edge每个节点是一个处理函数边决定下一步走哪里。状态State在所有节点之间共享和累积。这跟简历优化的心智模型完全吻合。我定义的 State 大概长这样// lib/agent/state.ts import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ // 原始简历文本 rawResume: Annotationstring({ reducer: (_, update) update, default: () , }), // 结构化后的简历数据 parsedResume: AnnotationParsedResume | null({ reducer: (_, update) update, default: () null, }), // 目标岗位 JD targetJD: Annotationstring({ reducer: (_, update) update, default: () , }), // 对话历史 messages: AnnotationBaseMessage[]({ reducer: (current, update) current.concat(update), default: () [], }), // 缺失信息清单 missingFields: Annotationstring[]({ reducer: (_, update) update, default: () [], }), // 当前阶段 stage: Annotationparse | interview | optimize | done({ reducer: (_, update) update, default: () parse, }), });这里messages用了concat作为 reducer意味着每次节点返回新消息时会追加而不是覆盖这是 LangGraph.js 里处理对话历史的标准做法。其他字段用覆盖式 reducer因为它们是“当前最新状态”。2.3 整体流程图与节点职责划分整个 Agent 我拆成了五个核心节点用一张图串起来parseNode接收原始简历文本调用大模型做结构化提取输出parsedResume和missingFields。interviewNode根据missingFields生成追问问题等待用户回答把回答合并回parsedResume。optimizeNode结合targetJD和parsedResume生成优化后的简历内容。reviewNode让用户确认优化结果不满意则回到 interview。finalizeNode生成最终排版好的简历标记stage为 done。条件边的逻辑是parse 之后如果missingFields非空就去 interview否则直接去 optimizeinterview 之后回到 parse 做一次重新提取因为用户补充了信息optimize 之后去 reviewreview 根据用户反馈决定回 interview 还是去 finalize。这套设计的好处是每个节点职责单一测试的时候可以单独 mock 状态来跑。我实测下来把节点拆细之后调试时间至少省了一半因为出问题能快速定位是哪个环节的锅。3. 核心细节解析与实操要点3.1 简历解析怎么让大模型稳定输出结构化数据简历解析是整个流程的地基如果这里提取的信息不准后面全白搭。我试过三种方案纯 Prompt 让模型输出 JSON、用 Function Calling、用 Zod Schema 约束输出。最后选了第三种也就是 LangChain 的withStructuredOutput配合 Zod。原因很直接纯 Prompt 输出 JSON 的稳定性太差模型经常在 JSON 外面包一层解释文字或者字段名拼错。Function Calling 稳定一些但不同模型厂商的实现差异大换模型就要改代码。Zod Schema 方案是 LangChain 提供的统一抽象底层会自动适配不同模型的工具调用能力而且 Zod 本身能在运行时校验字段类型不对会直接报错而不是悄悄塞个错误值进去。// lib/agent/schemas.ts import { z } from zod; export const ParsedResumeSchema z.object({ basicInfo: z.object({ name: z.string().describe(候选人姓名), email: z.string().optional().describe(邮箱), phone: z.string().optional().describe(电话), location: z.string().optional().describe(所在城市), }), education: z.array(z.object({ school: z.string(), degree: z.string(), major: z.string(), startDate: z.string(), endDate: z.string(), })), workExperience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string(), responsibilities: z.array(z.string()).describe(具体职责每条一句话), achievements: z.array(z.string()).describe(量化成果尽量带数字), })), skills: z.array(z.string()), projects: z.array(z.object({ name: z.string(), role: z.string(), description: z.string(), techStack: z.array(z.string()), })), });这里有个关键技巧在 describe 里写清楚期望的格式。比如achievements我特意注明“尽量带数字”模型输出的质量会明显提升。另外responsibilities和achievements用数组而不是长字符串是为了后续做逐条优化颗粒度更细。调用的时候这样写// lib/agent/nodes/parse.ts import { ChatOpenAI } from langchain/openai; import { ParsedResumeSchema } from ../schemas; const model new ChatOpenAI({ modelName: gpt-4o-mini, temperature: 0, }); const structuredModel model.withStructuredOutput(ParsedResumeSchema, { name: extract_resume, }); export async function parseNode(state: typeof ResumeState.State) { const result await structuredModel.invoke([ { role: system, content: 你是一个专业的简历解析助手。请从用户提供的简历文本中提取结构化信息。 如果某个字段在原文中找不到不要编造留空或返回空数组。 对于工作经历中的成果优先提取带数字的量化描述。, }, { role: user, content: state.rawResume }, ]); // 计算缺失字段 const missing: string[] []; if (!result.basicInfo.email) missing.push(邮箱); if (!result.basicInfo.phone) missing.push(电话); if (result.workExperience.length 0) missing.push(工作经历); result.workExperience.forEach((exp, i) { if (exp.achievements.length 0) { missing.push(第${i 1}段工作经历的量化成果); } }); return { parsedResume: result, missingFields: missing, stage: missing.length 0 ? interview : optimize, }; }temperature: 0是必须的简历解析要的是稳定复现不是创意。我试过用默认温度同一份简历两次解析出来的字段顺序和措辞都不一样做 diff 的时候很痛苦。3.2 多轮追问怎么让 Agent 问得聪明而不是像查户口追问环节是最容易做砸的地方。我见过很多工具一上来就是“请填写你的邮箱、电话、工作经历”跟填表没区别用户直接就跑了。好的追问应该是基于已有信息做推理只问真正缺失且关键的内容。我的做法是在 interviewNode 里把parsedResume和missingFields一起喂给模型让它生成一个自然的追问。关键是 Prompt 里要强调“一次只问一到两个问题”“用对话的口吻而不是问卷的口吻”。// lib/agent/nodes/interview.ts export async function interviewNode(state: typeof ResumeState.State) { const missing state.missingFields; // 如果缺失项太多优先问最重要的 const priorityOrder [工作经历, 量化成果, 电话, 邮箱]; const sorted missing.sort( (a, b) priorityOrder.findIndex(p a.includes(p)) - priorityOrder.findIndex(p b.includes(p)) ); const toAsk sorted.slice(0, 2); const response await model.invoke([ { role: system, content: 你是一个友好的简历顾问。用户正在完善简历目前缺少以下信息${toAsk.join(、)}。 请用自然对话的方式追问一次最多问两个问题。 不要用请填写这种命令式语气要像朋友聊天一样。 如果用户之前已经回答过类似问题不要重复问。, }, ...state.messages, ]); return { messages: [response], stage: interview, }; }这里有个细节state.messages里包含了完整对话历史所以模型知道之前问过什么。但要注意消息不能无限增长否则 token 会爆。我的做法是在 messages 超过 20 条时用一个 summarize 节点把早期对话压缩成摘要。这个优化后面在并发那节会详细讲。用户回答之后怎么把回答合并回parsedResume我的做法是回到 parseNode 重新跑一遍但这次把用户的补充信息作为额外上下文传进去。这样比手动写合并逻辑可靠得多因为模型能理解“用户说的‘那个项目’指的是哪个项目”这种指代关系。3.3 定向优化怎么让改写有针对性而不是泛泛而谈优化环节的核心是把 JD 和简历做匹配。我的做法分两步先让模型分析 JD 的关键要求再让模型针对每条要求去简历里找对应证据找不到的就标记为“需要补充”。// lib/agent/nodes/optimize.ts const JDAnalysisSchema z.object({ keyRequirements: z.array(z.object({ requirement: z.string(), importance: z.enum([high, medium, low]), })), keywords: z.array(z.string()).describe(JD 中出现的高频技术词和业务词), }); export async function optimizeNode(state: typeof ResumeState.State) { const jdAnalysis await model .withStructuredOutput(JDAnalysisSchema) .invoke(分析以下 JD提取核心要求和关键词\n${state.targetJD}); const optimizationPrompt 你是一个资深简历优化师。请根据以下 JD 分析结果优化候选人的简历。 JD 核心要求 ${jdAnalysis.keyRequirements.map(r - [${r.importance}] ${r.requirement}).join(\n)} JD 关键词${jdAnalysis.keywords.join(、)} 候选人当前简历 ${JSON.stringify(state.parsedResume, null, 2)} 优化要求 1. 对于每条 high 重要性的要求在简历中找到对应证据如果没有明确标注缺少相关经历。 2. 改写工作经历描述使用 JD 中的关键词但不要编造未发生的事实。 3. 量化成果要保留原始数字不要夸大。 4. 技能列表按与 JD 的相关度排序。 ; const optimized await model.invoke(optimizationPrompt); return { messages: [optimized], stage: optimize, }; }这里最重要的原则是不编造。我在 Prompt 里反复强调这一点因为简历造假是红线。模型有时候会“好心”帮你补一些听起来合理的经历这在简历场景里是致命的。我的做法是在 reviewNode 里让用户逐条确认任何模型新增的内容都要用户明确点头才能保留。4. 实操过程与核心环节实现4.1 项目初始化与依赖安装先把项目搭起来。我用的是create-next-app选 TypeScript、App Router、Tailwind。npx create-next-applatest resume-agent --typescript --tailwind --app --no-src-dir cd resume-agent然后装核心依赖npm install langchain/langgraph langchain/openai langchain/core npm install zod pdf-parse mammoth npm install -D types/pdf-parse这里解释下每个包的用途。langchain/langgraph是状态图核心langchain/openai是模型接入langchain/core提供消息类型等基础抽象。pdf-parse和mammoth分别处理 PDF 和 Word 文件。注意pdf-parse有个坑它默认会尝试读取一个测试文件在 Next.js 的打包环境里会报错需要在next.config.js里配置// next.config.js module.exports { webpack: (config) { config.resolve.alias.canvas false; config.resolve.alias.encoding false; return config; }, };这个坑我卡了整整一个下午报错信息是ENOENT: no such file or directory完全看不出跟 pdf-parse 有关最后是在 GitHub issue 里翻到的。4.2 文件上传与解析接口实现文件上传我用的是 Next.js 的 Route Handler配合 FormData。这里要注意 Next.js 14 里request.formData()对文件大小的限制默认是 4MB简历一般够用但如果要放宽可以在 Route Handler 配置里改。// app/api/upload/route.ts import { NextRequest, NextResponse } from next/server; import pdf from pdf-parse; import mammoth from mammoth; export const runtime nodejs; export const maxDuration 60; export async function POST(req: NextRequest) { const formData await req.formData(); const file formData.get(file) as File; if (!file) { return NextResponse.json({ error: 没有文件 }, { status: 400 }); } 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 application/vnd.openxmlformats-officedocument.wordprocessingml.document ) { const result await mammoth.extractRawText({ buffer }); text result.value; } else { return NextResponse.json({ error: 不支持的文件格式 }, { status: 400 }); } // 简单清洗去掉多余空行 text text.replace(/\n{3,}/g, \n\n).trim(); return NextResponse.json({ text, charCount: text.length }); }maxDuration 60是给 Vercel 部署用的PDF 解析大文件可能超过默认的 10 秒。本地开发无所谓但部署上去一定要加。4.3 LangGraph 图的组装与流式输出图组装是整个项目的核心我把所有节点和边在一个文件里定义清楚// lib/agent/graph.ts import { StateGraph, END } from langchain/langgraph; import { ResumeState } from ./state; import { parseNode } from ./nodes/parse; import { interviewNode } from ./nodes/interview; import { optimizeNode } from ./nodes/optimize; import { reviewNode } from ./nodes/review; import { finalizeNode } from ./nodes/finalize; const workflow new StateGraph(ResumeState) .addNode(parse, parseNode) .addNode(interview, interviewNode) .addNode(optimize, optimizeNode) .addNode(review, reviewNode) .addNode(finalize, finalizeNode) .addEdge(__start__, parse) .addConditionalEdges(parse, (state) { return state.missingFields.length 0 ? interview : optimize; }) .addEdge(interview, parse) .addEdge(optimize, review) .addConditionalEdges(review, (state) { const lastMessage state.messages[state.messages.length - 1]; const content lastMessage.content as string; return content.includes(确认) ? finalize : interview; }) .addEdge(finalize, END); export const resumeAgent workflow.compile();流式输出这块LangGraph.js 提供了streamEvents方法可以拿到每个节点的执行事件。我在 Route Handler 里把它转成 SSE 格式返回给前端// app/api/chat/route.ts export async function POST(req: NextRequest) { const { message, threadId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const eventStream resumeAgent.streamEvents( { messages: [new HumanMessage(message)] }, { version: v2, configurable: { thread_id: threadId } } ); for await (const event of eventStream) { if (event.event on_chat_model_stream) { const chunk event.data.chunk?.content; if (chunk) { controller.enqueue( encoder.encode(data: ${JSON.stringify({ text: chunk })}\n\n) ); } } if (event.event on_chain_end event.name finalize) { controller.enqueue(encoder.encode(data: [DONE]\n\n)); } } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端消费的时候用fetch加 reader逐块解析data:行。这里有个细节SSE 的每条消息必须以\n\n结尾少一个换行前端就收不到。我调试的时候因为这个问题浪费了不少时间。4.4 状态持久化与多轮对话多轮对话的关键是状态要能跨请求保持。LangGraph.js 提供了 Checkpointer 机制我用的是MemorySaver做开发生产环境换成了基于 Postgres 的持久化。// lib/agent/graph.ts import { MemorySaver } from langchain/langgraph; const checkpointer new MemorySaver(); export const resumeAgent workflow.compile({ checkpointer });调用的时候通过configurable.thread_id来区分不同用户的会话。同一个 thread_id 的多次调用会共享状态这样用户刷新页面后对话还能继续。生产环境我用的是langchain/langgraph-checkpoint-postgres把状态存到数据库里好处是服务重启不丢数据而且可以做会话历史查询。这里有个性能上的考量状态里存了完整的messages数组如果对话很长每次读写都是全量。我的做法是在 messages 超过 30 条时触发一个压缩节点把前 20 条总结成一段摘要只保留最近 10 条原文。这样既保留了上下文又控制了状态体积。5. 并发处理与性能优化实录5.1 AI Agent 怎么扛并发从单机到队列“AI Agent 怎么扛并发”是最近被问得最多的问题我拿这个项目的实际数据来说。单机 Node 进程每个请求要调用 3 到 5 次大模型 API每次调用平均 2 到 4 秒。如果不做任何处理并发 10 个请求就会明显卡顿因为 Node 虽然是异步的但大模型 API 的响应时间摆在那里连接池和内存都会吃紧。我的方案分三层。第一层是请求队列用p-queue控制同时进行的 Agent 执行数量超过的排队等待。第二层是模型调用缓存对于相同的输入比如同一份简历的解析用哈希做 key 缓存结果避免重复调用。第三层是流式输出让用户感知到的等待时间大幅缩短因为第一个 token 通常 1 秒内就回来了。// lib/queue.ts import PQueue from p-queue; export const agentQueue new PQueue({ concurrency: 5, // 同时最多 5 个 Agent 执行 interval: 1000, // 每秒最多 intervalCap: 10, // 10 个新任务 });concurrency: 5这个数字是压测出来的。我模拟了 50 个并发请求分别测了 concurrency 为 3、5、10、20 的情况。结果是 5 的时候吞吐量和响应时间的平衡最好10 以上开始出现 API 限流错误3 则吞吐量上不去。当然这个数字跟你的模型供应商配额有关需要自己压测调整。5.2 缓存策略哪些能缓存哪些不能缓存这块要非常小心因为简历是敏感数据。我的原则是只缓存模型调用结果不缓存用户数据本身而且缓存 key 用输入内容的哈希不存原始文本。// lib/cache.ts import { createHash } from crypto; const cache new Mapstring, { value: any; expireAt: number }(); export function cacheKey(input: string): string { return createHash(sha256).update(input).digest(hex); } export async function withCacheT( key: string, ttlMs: number, fn: () PromiseT ): PromiseT { const cached cache.get(key); if (cached cached.expireAt Date.now()) { return cached.value; } const value await fn(); cache.set(key, { value, expireAt: Date.now() ttlMs }); return value; }解析节点和 JD 分析节点适合缓存因为同样的输入结果应该一致。但追问节点和优化节点不适合因为它们依赖对话历史每次输入都不同。TTL 我设的是 1 小时太长了内存吃不消太短了命中率低。5.3 超时与重试大模型调用不稳定怎么办大模型 API 偶尔超时或返回 5xx 是常态必须做重试。我用的是指数退避第一次失败等 1 秒第二次等 2 秒第三次等 4 秒最多重试 3 次。// lib/retry.ts export async function withRetryT( fn: () PromiseT, maxRetries 3, baseDelay 1000 ): PromiseT { let lastError: Error; for (let i 0; i maxRetries; i) { try { return await fn(); } catch (err) { lastError err as Error; // 4xx 错误不重试因为重试也没用 if (lastError.message.includes(400)) throw lastError; await new Promise(r setTimeout(r, baseDelay * Math.pow(2, i))); } } throw lastError!; }这里有个经验4xx 错误不要重试。我一开始无脑重试所有错误结果遇到 API key 无效的情况白白等了 7 秒才报错。后来加了判断4xx 直接抛5xx 和网络错误才重试。6. 常见问题与排查技巧实录6.1 问题速查表问题现象可能原因排查方法解决方案PDF 解析报 ENOENTpdf-parse 测试文件问题看报错堆栈是否指向 pdf-parsenext.config.js 里配 canvas/encoding alias流式输出前端收不到SSE 格式不对浏览器 Network 面板看响应确保每条消息以\n\n结尾状态跨请求丢失没配 checkpointer检查 compile 参数加 MemorySaver 或 Postgres checkpointer模型输出 JSON 解析失败没用 structuredOutput打印原始输出改用 withStructuredOutput Zod并发高时 API 限流没有队列控制看 API 返回 429加 p-queue 控制并发数对话历史 token 爆了messages 无限增长看单次请求 token 数加压缩节点保留最近 N 条Edge Runtime 报错LangGraph 不支持看部署环境Route Handler 声明 nodejs runtime6.2 几个我踩过的深坑第一个坑是消息 reducer 写错导致历史丢失。我一开始把messages的 reducer 写成了(_, update) update结果每次节点返回新消息都会覆盖历史对话到第二轮就失忆了。正确的写法是(current, update) current.concat(update)。这个 bug 很隐蔽因为单轮测试完全正常只有多轮才暴露。第二个坑是条件边的返回值必须是节点名。我一开始在条件函数里返回了true或false想着用布尔值控制分支结果 LangGraph 直接报错说找不到名为true的节点。条件边必须返回字符串对应addNode时注册的节点名。第三个坑是流式输出和结构化输出不能同时用。withStructuredOutput返回的是完整对象不支持流式。所以 parseNode 和 optimizeNode 里的 JD 分析用的是非流式只有 interviewNode 和 optimizeNode 的最终输出用流式。这个限制在 LangChain 文档里没明说是我试出来的。6.3 独家避坑技巧关于简历解析的准确率我有个小技巧在 Prompt 里给一两个 few-shot 示例。不用多一两个就够模型对字段格式的理解会准确很多。我加了一个示例之后achievements字段的提取准确率从大概 70% 提到了 90% 以上。关于追问的自然度我的经验是在系统 Prompt 里明确禁止某些句式。比如“请提供”“请填写”“请补充”这类词一旦出现就很不像人。我直接在 Prompt 里写“不要使用‘请提供’‘请填写’这类词”效果立竿见影。关于成本控制gpt-4o-mini 在简历场景完全够用。我对比过 gpt-4o 和 mini 的输出质量在结构化提取和改写这两个任务上差距很小但成本差了十几倍。只有最终的润色环节我用了稍好的模型因为那个环节用户对文字质量最敏感。7. 后续可以怎么扩展这个项目目前跑通的是核心流程但还有不少可以深挖的方向。我最近在试的是多简历版本管理就是同一个用户针对不同岗位生成不同版本的简历用 LangGraph 的 subgraph 来做隔离。另外简历评分也是个有意思的方向用一套规则加模型打分给用户一个直观的优化前后对比。还有一个我比较看好的扩展是接入真实岗位数据。现在用户要手动粘贴 JD如果能对接招聘平台的公开岗位信息用户输入目标岗位名称就能自动拉取相关 JD 做匹配体验会好很多。不过这块涉及数据合规要谨慎处理。最后分享一个我在实际使用中的体会AI Agent 的价值不在于模型多强而在于流程设计得多顺。同样的模型流程设计得好用户感觉像有个专业顾问在帮忙流程设计得差就像在跟一个答非所问的机器人较劲。LangGraph.js 给我的最大帮助就是让我能把精力放在流程设计上而不是纠结于状态怎么传递、分支怎么控制这些底层细节。