Next.js + LangGraph.js 实战:构建 AI Agent 简历优化工具

发布时间:2026/10/7 13:45:16
Next.js + LangGraph.js 实战:构建 AI Agent 简历优化工具 简历工具这个赛道表面上看已经被做烂了但真正动手做一个能用的 AI Agent 版本你会发现坑远比想象中多。我最近用 Next.js 搭配 LangGraph.js 完整落地了一套简历优化工具从简历解析、岗位匹配、内容重写到多轮对话式修改整个链路跑通之后有不少值得记录的东西。这篇文章不讲空泛的概念而是把我在实际开发中遇到的架构选型、状态管理、流式输出、并发处理这些具体问题拆开来讲适合已经有一定前端基础、想往 AI Agent 方向落地的开发者参考。如果你正在纠结用什么样的技术栈来做这类工具或者已经动手但卡在某个环节下面的内容应该能帮你少走一些弯路。1. 为什么简历工具值得用 Agent 架构重做一遍1.1 传统简历工具的瓶颈在哪里市面上大多数简历工具的本质是模板填充器——你输入信息它套一个排版导出 PDF。稍微进阶一点的会做一些关键词匹配告诉你简历里缺了哪些技能词。但这类工具有一个根本性的问题它们不理解上下文。举个例子一个用户写负责公司核心业务系统的开发与维护传统工具只能判断这句话里有没有开发维护这类动词但它无法判断这句话放在应聘高级前端工程师的场景下是否足够有说服力。它不知道这个岗位更看重的是性能优化经验、组件库建设能力还是跨端方案落地。这种语义层面的判断恰恰是大语言模型擅长的事情。而 Agent 架构相比单纯的调一次 API 返回结果多出来的核心能力是多步骤推理和状态保持。简历优化不是一个单轮任务它需要先解析简历结构再分析目标岗位要求然后逐段对比找出差距接着生成修改建议最后还要根据用户反馈迭代调整。这个流程天然适合用图结构来编排。1.2 LangGraph.js 在浏览器端和 Node 端的定位差异LangGraph.js 是 LangChain 团队推出的图编排框架的 JavaScript 版本。它的核心抽象是状态图——你定义一个共享的 State 对象然后定义若干节点Node和边Edge每个节点读取 State、执行逻辑、返回 State 的增量更新。这里有一个很多人容易搞混的点LangGraph.js 可以跑在 Node.js 服务端也可以部分跑在浏览器端但涉及 API Key 调用的部分必须放在服务端。我的做法是把整个 Agent 编排放在 Next.js 的 Route Handler 里前端只负责收集用户输入和展示流式结果。这样既保证了密钥安全又能利用 Next.js 的边缘函数能力做低延迟响应。注意LangGraph.js 的StateGraph在服务端和客户端的行为基本一致但如果你用了checkpointer做持久化浏览器端需要额外处理存储适配。我建议初期直接用内存存储等流程稳定后再接入数据库。1.3 这套技术栈组合的实际收益用 Next.js LangGraph.js 做简历工具我实测下来最大的收益有三个第一流式体验天然契合。Next.js 的 Route Handler 支持 ReadableStreamLangGraph.js 的节点可以逐步产出结果两者结合可以让用户在等待过程中就看到 Agent 的思考过程而不是盯着一个 loading 转圈。第二状态管理清晰。简历优化涉及多个阶段的数据流转用图结构定义之后每个节点的输入输出一目了然调试的时候可以单独测试某个节点不用把整个链路跑一遍。第三扩展成本低。后面如果想加模拟面试薪资谈判建议这些功能只需要在图上加节点和边不用重构整个架构。2. 项目骨架搭建从零到能跑通第一个节点2.1 Next.js 项目初始化与目录约定我用的 Next.js 15 的 App Router 模式。初始化命令很标准npx create-next-applatest resume-agent --typescript --tailwind --app --src-dir目录结构上我做了这样的约定src/ app/ api/ agent/ route.ts # Agent 主入口处理流式响应 page.tsx # 前端交互页面 lib/ agent/ graph.ts # LangGraph 图定义 nodes/ # 各个节点实现 parse.ts analyze.ts rewrite.ts state.ts # State 类型定义 llm/ client.ts # 模型客户端封装这个结构的好处是 Agent 逻辑和 UI 逻辑完全分离。lib/agent下面的代码可以独立测试不依赖 Next.js 运行时。2.2 LangGraph.js 的安装与版本选择npm install langchain/langgraph langchain/openai langchain/core这里要特别注意版本兼容性。LangGraph.js 在 0.2.x 之后 API 有过一次比较大的调整StateGraph的泛型定义和addNode的签名都变了。我建议直接锁定较新的稳定版本不要混用不同大版本的langchain/core。{ langchain/langgraph: ^0.2.0, langchain/core: ^0.3.0, langchain/openai: ^0.3.0 }如果你用的是其他模型提供商把langchain/openai换成对应的包即可LangGraph.js 本身不绑定具体模型。2.3 定义第一个 State 和节点State 是整个图的血液。我最初设计的 State 是这样的import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ rawResume: Annotationstring(), jobDescription: Annotationstring(), parsedSections: AnnotationResumeSection[]({ reducer: (prev, next) next ?? prev, default: () [], }), suggestions: AnnotationSuggestion[]({ reducer: (prev, next) [...prev, ...next], default: () [], }), finalResume: Annotationstring(), });这里有个关键设计决策suggestions用了累加 reducer因为优化建议是逐步产生的每次节点执行都应该追加而不是覆盖。而parsedSections用覆盖 reducer因为解析结果应该是幂等的。第一个节点我写的是简历解析async function parseResume(state: typeof ResumeState.State) { const llm getLLM(); const result await llm.invoke([ { role: system, content: 你是一个简历解析助手请将简历拆分为结构化段落。 }, { role: user, content: state.rawResume }, ]); return { parsedSections: parseLLMOutput(result.content) }; }跑通这个节点之后你就有了一个最小可用的图。别急着加复杂逻辑先把输入→解析→输出这条线走通确认模型调用、状态传递、类型推断都没问题。3. 核心节点设计解析、匹配、重写三步走3.1 简历解析节点的结构化输出策略简历解析看起来简单实际上是最容易出问题的环节。用户的简历格式千奇百怪有 PDF 转出来的乱码有表格排版的还有中英文混排的。我试过几种方案方案优点缺点适用场景纯 Prompt 解析实现快格式不稳定快速验证Prompt JSON Schema结构可控需要模型支持生产环境分段落多次调用准确率高成本高、慢高价值场景我最终选了第二种用 OpenAI 的 Structured Output 能力强制模型返回符合 Schema 的 JSON。LangChain.js 里可以这样写const parser StructuredOutputParser.fromZodSchema( z.object({ sections: z.array(z.object({ type: z.enum([education, experience, skills, projects]), title: z.string(), content: z.string(), bullets: z.array(z.string()), })), }) );实测下来加了 Schema 约束之后解析成功率从大概 70% 提升到了 95% 以上。剩下 5% 的失败案例主要是简历内容太短或者格式太离谱这种我会在前端做一个兜底提示让用户手动补充。3.2 岗位匹配节点的对比逻辑岗位匹配的核心是找出简历里有什么和岗位要什么之间的差距。我的做法是让模型做两件事提取岗位的关键要求然后逐条对照简历内容打分。async function matchJob(state: typeof ResumeState.State) { const llm getLLM(); const prompt 岗位描述${state.jobDescription} 简历内容${JSON.stringify(state.parsedSections)} 请分析 1. 岗位的核心要求最多5条 2. 简历中对应的匹配情况完全匹配/部分匹配/缺失 3. 每条差距的具体改进建议 ; const result await llm.invoke(prompt); return { suggestions: parseMatchResult(result.content) }; }这里有个经验不要让模型直接输出匹配度百分比那个数字没有意义且不稳定。让模型输出具体的匹配条目和理由前端再根据条目数量做一个粗略的进度展示用户感知反而更好。3.3 内容重写节点的风格控制重写节点是最考验 Prompt 工程的地方。同一个意思写成负责了XX系统的开发和主导XX系统架构设计支撑日均百万级请求给人的感觉完全不同。我在重写节点里加了几个控制维度动词强度根据用户在团队中的实际角色选择合适的动词不要过度包装量化程度有数据的保留数据没数据的引导用户补充行业术语根据目标岗位所在行业调整用词const rewritePrompt 原始内容${section.content} 目标岗位${state.jobDescription} 重写要求 - 保持事实不变只优化表达 - 使用${verbStyle}风格的动词 - 如果原文有数字必须保留 - 不要编造任何原文没有的经历 ;提示重写节点一定要加不要编造的约束。我早期版本没加这条模型会给用户凭空加上带领10人团队这种经历用户如果直接用了面试时会非常尴尬。4. 流式输出与前端交互的工程细节4.1 Next.js Route Handler 中的流式响应实现LangGraph.js 支持streamEvents和stream两种流式模式。我推荐用streamEvents因为它能区分不同节点的事件前端可以据此展示正在解析简历正在匹配岗位这样的阶段提示。export async function POST(req: Request) { const { resume, jobDescription } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const events graph.streamEvents( { rawResume: resume, jobDescription }, { version: v2 } ); for await (const event of events) { if (event.event on_chain_end event.name parseResume) { controller.enqueue(encoder.encode(data: ${JSON.stringify({ stage: parsed, data: event.data.output })}\n\n)); } } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }这里用的是 SSEServer-Sent Events格式。相比 WebSocketSSE 在 Next.js 里实现更简单而且对于这种单向推送场景完全够用。4.2 前端如何优雅地展示 Agent 的中间状态前端我用了一个简单的状态机来管理展示const [stage, setStage] useStateidle | parsing | matching | rewriting | done(idle); const [partialResults, setPartialResults] useStateRecordstring, unknown({});每收到一个 SSE 事件就更新对应的 stage 和 partialResults。UI 上用一个步骤条展示当前进度已经完成的步骤可以点击查看中间结果。这个设计的好处是用户不会觉得卡住了。即使整个流程要跑 20 秒用户看到步骤在推进心理等待时间会短很多。4.3 错误处理与重试机制Agent 流程中最常见的错误是模型调用超时或者返回格式不符合预期。我的处理策略是节点级重试每个 LLM 调用包一层重试逻辑最多重试 2 次降级方案如果解析节点连续失败直接跳过结构化解析用原始文本进入后续流程用户可见的错误不要吞掉错误通过 SSE 推一个 error 事件给前端让用户知道哪一步出了问题async function withRetryT(fn: () PromiseT, maxRetries 2): PromiseT { for (let i 0; i maxRetries; i) { try { return await fn(); } catch (err) { if (i maxRetries) throw err; await new Promise((r) setTimeout(r, 1000 * (i 1))); } } throw new Error(unreachable); }5. 并发场景下的性能与成本控制5.1 多用户同时请求时的资源竞争问题简历工具的一个典型使用场景是招聘季大量用户同时上传简历。这时候如果每个请求都直接调模型会遇到两个问题API 速率限制和成本飙升。我的做法是在 Route Handler 层面加一个简单的队列控制const queue new PQueue({ concurrency: 5 }); export async function POST(req: Request) { return queue.add(async () { // 实际的 Agent 处理逻辑 }); }p-queue这个库很轻量能有效控制并发数。具体并发数设多少取决于你的模型 API 配额。我用的方案是并发 5实测在免费额度下也能稳定运行。5.2 Token 消耗的预估与优化一个完整的简历优化流程Token 消耗大概是这样节点输入 Token输出 Token说明解析~1500~800取决于简历长度匹配~2000~600简历岗位描述重写~1000/段~500/段按段落调用总计~6000~3000单次完整流程按这个量级如果用中等价位的模型单次成本大概在几分钱。但如果用户频繁修改成本会线性增长。我的优化策略是解析结果缓存同一份简历不重复解析重写节点只对用户选中的段落调用不批量处理匹配节点用较小的模型重写节点用较强的模型5.3 缓存策略哪些结果可以复用我在 State 之外加了一层 Redis 缓存key 是简历内容的 hash 值。解析结果和匹配结果都可以缓存因为这两个节点的输出只取决于输入内容不依赖用户交互。const cacheKey resume:parse:${hash(rawResume)}; const cached await redis.get(cacheKey); if (cached) return JSON.parse(cached); // ... 执行解析 await redis.set(cacheKey, JSON.stringify(result), EX, 3600);重写节点的结果不建议缓存因为用户可能会反复调整风格要求缓存命中率低且容易导致体验不一致。6. 踩坑记录那些文档里不会写的问题6.1 LangGraph.js 状态更新的常见误区我踩的第一个坑是 State 的 reducer 行为。默认情况下如果你在节点里返回{ parsedSections: newValue }LangGraph 会用新值覆盖旧值。但如果某个字段定义了累加 reducer返回数组时会追加而不是替换。我一开始没注意这个区别导致 suggestions 数组越跑越长同一个建议被重复添加了好几次。排查了半天才发现是 reducer 的问题。注意定义 State 时一定要想清楚每个字段的更新语义。覆盖还是追加这个决策会影响后续所有节点的行为。6.2 模型返回 JSON 格式不稳定的应对即使加了 Structured Output模型偶尔还是会返回带 markdown 代码块包裹的 JSON或者多一段解释文字。我的处理方式是写一个健壮的解析函数function safeParseJSON(text: string) { // 去掉 markdown 代码块标记 const cleaned text.replace(/json\n?|\n?/g, ).trim(); try { return JSON.parse(cleaned); } catch { // 尝试提取第一个完整的 JSON 对象 const match cleaned.match(/\{[\s\S]*\}/); if (match) return JSON.parse(match[0]); throw new Error(无法解析模型输出); } }这个函数看起来简单但能挡掉 90% 的格式问题。6.3 流式响应中断的排查过程有一次用户反馈说页面经常卡在正在重写这一步。我排查了很久最后发现是 Next.js 的 Route Handler 默认有超时限制长时间运行的流式响应会被中断。解决方案是在 Route Handler 里显式设置maxDurationexport const maxDuration 60; // 单位秒这个配置在 Vercel 部署时尤其重要免费版默认超时是 10 秒对于多节点的 Agent 流程完全不够用。6.4 前端 SSE 连接断开的自动重连SSE 连接在网络波动时会断开浏览器默认会自动重连但重连后之前的状态就丢了。我的做法是在前端记录已接收的事件序号重连时带上Last-Event-ID头服务端从对应位置继续推送。不过说实话对于简历工具这种单次会话时长在 30 秒以内的场景更简单的做法是检测到连接断开就直接提示用户重试而不是做复杂的断点续传。我最后选了这个方案实现成本低用户体验也能接受。7. 部署与后续扩展的几点思考7.1 Vercel 部署的注意事项Next.js 项目部署到 Vercel 是最顺滑的路径但有几个点需要注意环境变量模型 API Key 必须配置在服务端环境变量里不要用NEXT_PUBLIC_前缀函数超时前面提到的maxDuration要设置Pro 版可以到 300 秒边缘运行时LangGraph.js 依赖 Node.js 的一些 API不要用 Edge Runtime用默认的 Node.js Runtime7.2 从简历工具扩展到求职 Agent 的可能性这套架构跑通之后扩展方向其实很清晰。比如加一个模拟面试节点根据简历和岗位生成面试问题加一个薪资分析节点结合岗位描述给出谈判建议。因为整个流程是图结构加节点不会影响已有逻辑。我甚至考虑过把多个工具串成一个大的求职 Agent 图用户上传简历后自动走完优化→模拟面试→投递建议全流程。技术上完全可行主要成本在 Prompt 调优和结果质量控制上。7.3 个人开发者做这类工具的现实建议最后说点实在的。个人开发者做 AI Agent 工具最大的挑战不是技术而是找到真正有人愿意用的场景。简历工具看起来需求明确但竞争也激烈。我的建议是先做一个极简版本只解决一个具体问题比如帮我把简历里的项目描述改得更有说服力找 10 个真实用户试用收集反馈再决定加什么功能不要一上来就追求大而全Agent 的复杂度会随着节点数量指数级上升技术栈方面Next.js LangGraph.js 这个组合我个人用下来是顺手的。LangGraph.js 的图抽象让流程清晰Next.js 的全栈能力让部署简单。如果你已经在用 React 生态迁移成本很低。我在实际使用中发现Agent 类项目的调试时间远超编码时间。建议从一开始就把日志和可观测性做好每个节点的输入输出都记录下来后面排查问题会轻松很多。另外Prompt 一定要版本化管理每次调整都记录改了什么、效果如何否则很快就会忘记哪个版本效果最好。