资讯动态

用 Next.js + LangGraph.js 构建简历 AI Agent 实战

发布时间:2026/10/8 20:12:23 来源:尧图企业网站定制
1. 为什么简历工具值得用 AI Agent 重做一遍简历这个赛道看起来已经很拥挤了各种在线简历生成器、模板站、排版工具一抓一大把。但真正动手做过简历产品的人都知道传统简历工具的天花板非常明显它们本质上只是排版器用户还是得自己想内容、自己组织语言、自己判断写得好不好。一个工作三年的人和一个刚毕业的学生打开同一个简历工具得到的帮助几乎是一样的——这显然不合理。我这次要落地的项目就是用Next.js LangGraph.js搭一个真正意义上的简历 AI Agent。它和普通套模板工具的区别在于Agent 会主动追问你的经历、帮你把口语化的描述改写成专业表达、根据目标岗位动态调整内容侧重、甚至在你写完之后帮你做一轮HR 视角的挑刺。关键词里的AI Agent、LangGraph.js、Next.js三个词正好对应了这个项目的三个核心智能决策、流程编排、产品落地。为什么是 Agent 而不是简单的调一次大模型 API因为简历优化天然是一个多轮、有状态、需要分支判断的过程。比如用户说我想投后端开发岗Agent 需要判断用户有没有相关项目经验如果有追问技术栈和量化成果如果没有是不是要引导他挖掘可迁移的经历这种根据上一步结果决定下一步问什么的逻辑用单次 API 调用根本做不出来必须靠 Agent 的状态机来编排。这篇文章适合谁看如果你已经会写 React、了解 Next.js 的基本用法想找一个完整、能跑通、有真实业务价值的 AI Agent 项目来练手那这篇就是为你写的。我会把架构设计、LangGraph.js 的图怎么画、Next.js 的前后端怎么衔接、流式输出怎么处理、以及我在实测中踩过的坑全部摊开讲。不会只给你一个Hello World级别的 Demo而是能真正拿去改造成产品的完整方案。先说结论整个项目的技术栈是Next.js 14App Router LangGraph.js OpenAI 兼容接口 Tailwind CSS部署在支持 Node.js 运行时的平台上。LangGraph.js 负责 Agent 的流程编排Next.js 的 Route Handler 负责把 Agent 包装成流式 API前端用ReadableStream消费流式数据实现打字机效果。下面从架构开始拆。2. 简历 Agent 的整体架构与 LangGraph.js 的图设计2.1 为什么选 LangGraph.js 而不是自己写状态机很多人第一反应是Agent 不就是循环调用大模型 判断要不要调工具吗我自己写个 while 循环不就行了我一开始也是这么想的直到我把简历 Agent 的流程画出来才发现自己写状态机很快就会失控。简历 Agent 的真实流程是这样的先做意图识别用户是想新建简历、优化某段经历、还是针对岗位做匹配然后进入信息采集环节多轮追问采集够了进入内容生成生成后进入质量评估评估不通过要回退到采集或生成通过之后才输出。这里面有循环、有条件分支、有并行节点比如同时做专业度检查和岗位匹配度检查手写状态机维护起来非常痛苦。LangGraph.js 的核心价值就是把这种流程抽象成图Graph节点Node是处理单元边Edge是流转逻辑条件边Conditional Edge负责分支。它天然支持循环、支持状态在节点间传递、支持中断和恢复。对于简历这种多轮对话 分支判断的场景几乎是量身定做。提示LangGraph.js 和 Python 版的 LangGraph 概念基本一致但 JS 版在类型定义和流式处理上有些差异网上大部分教程是 Python 的迁移时要注意 API 命名。2.2 简历 Agent 的状态结构设计LangGraph 里最核心的概念是State状态它是在所有节点之间流转的共享数据。简历 Agent 的状态我设计成这样// lib/agent/state.ts import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ // 对话历史用 reducer 做追加 messages: AnnotationBaseMessage[]({ reducer: (prev, next) prev.concat(next), default: () [], }), // 当前阶段collect / generate / evaluate / done stage: Annotationstring({ reducer: (_, next) next, default: () collect, }), // 结构化的简历数据 resumeData: AnnotationResumeData({ reducer: (prev, next) ({ ...prev, ...next }), default: () ({}), }), // 目标岗位 targetRole: Annotationstring({ reducer: (_, next) next, default: () , }), // 评估得分与反馈 evaluation: AnnotationEvaluation({ reducer: (_, next) next, default: () ({ score: 0, feedback: [] }), }), // 重试次数防止死循环 retryCount: Annotationnumber({ reducer: (_, next) next, default: () 0, }), });这里有几个设计要点值得展开。第一messages用了concatreducer因为对话历史是只增不减的每次节点返回新消息时自动追加不用手动管理。第二resumeData用对象合并 reducer因为采集阶段可能分多轮填充不同字段先填基本信息再填项目经历合并比覆盖更合理。第三retryCount是我特意加的熔断机制——如果评估一直不通过不能让 Agent 无限循环下去超过 3 次就强制输出当前结果并提示用户手动调整。2.3 图的节点划分与流转逻辑整个 Agent 我拆成了 5 个节点每个节点职责单一节点名职责输入输出intentNode识别用户意图决定进入哪个分支最新用户消息更新 stagecollectNode多轮追问采集简历信息对话历史 当前 resumeData追问问题或更新 resumeDatagenerateNode根据采集信息生成简历内容resumeData targetRole生成的简历文本evaluateNode从 HR 视角评估简历质量生成的简历评分 改进建议finalizeNode整理输出结束流程所有状态最终简历流转逻辑用条件边控制。intentNode之后根据 stage 决定去collectNode还是generateNodegenerateNode之后固定去evaluateNodeevaluateNode之后是关键分支——如果评分达标或重试超限去finalizeNode否则回到collectNode补充信息。// lib/agent/graph.ts import { StateGraph, END } from langchain/langgraph; const workflow new StateGraph(ResumeState) .addNode(intent, intentNode) .addNode(collect, collectNode) .addNode(generate, generateNode) .addNode(evaluate, evaluateNode) .addNode(finalize, finalizeNode) .addEdge(__start__, intent) .addConditionalEdges(intent, routeByStage, { collect: collect, generate: generate, }) .addConditionalEdges(collect, routeAfterCollect, { generate: generate, collect: collect, // 继续追问 }) .addEdge(generate, evaluate) .addConditionalEdges(evaluate, routeAfterEvaluate, { finalize: finalize, collect: collect, }) .addEdge(finalize, END); export const resumeAgent workflow.compile();这套图跑起来之后整个简历优化过程就变成了一个有记忆、会判断、能回退的智能流程。用户不需要知道背后发生了什么他只会感觉这个工具好像真的懂我在写什么。3. Next.js 侧如何把 Agent 包装成可用的产品接口3.1 App Router 下的流式 Route HandlerAgent 跑起来只是第一步怎么把它接到前端才是产品化的关键。简历生成这种场景用户最讨厌的就是点一下按钮转圈 20 秒然后一次性蹦出一大段文字。所以流式输出是刚需让用户看到内容一个字一个字冒出来体验完全不同。Next.js 14 的 App Router 里Route Handler 可以直接返回ReadableStream。LangGraph.js 编译后的图支持.streamEvents()方法能按事件流式产出每个节点的中间结果。我把两者对接起来// app/api/chat/route.ts import { NextRequest } from next/server; import { resumeAgent } from /lib/agent/graph; import { HumanMessage } from langchain/core/messages; export const runtime nodejs; // 必须LangGraph 依赖 Node API export async function POST(req: NextRequest) { const { messages, threadId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { const eventStream resumeAgent.streamEvents( { messages: [new HumanMessage(messages.at(-1).content)] }, { 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: ${JSON.stringify({ done: true })}\n\n) ); } } } catch (err) { controller.enqueue( encoder.encode(data: ${JSON.stringify({ error: String(err) })}\n\n) ); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }这里用的是SSEServer-Sent Events格式每条消息以data:开头、\n\n结尾。前端用EventSource或者fetchReadableStream都能消费。我实测下来fetch方式更灵活因为可以带 POST body 和自定义 header。注意export const runtime nodejs这行千万别漏。LangGraph.js 内部用了一些 Node.js 特有的 API如果跑在 Edge Runtime 上会直接报错。我一开始图省事没加调试了半小时才发现是运行时的问题。3.2 会话持久化thread_id 与 Checkpointer简历优化不是一次对话能搞定的用户可能今天填一半明天接着填。LangGraph 提供了Checkpointer机制通过thread_id把每个会话的状态存下来下次带着同样的thread_id请求Agent 就能记得之前聊到哪了。开发阶段我用的是MemorySaver简单直接import { MemorySaver } from langchain/langgraph; const checkpointer new MemorySaver(); export const resumeAgent workflow.compile({ checkpointer });但MemorySaver是存在进程内存里的服务一重启就没了生产环境必须换成持久化方案。LangGraph.js 官方支持多种存储后端我选的是PostgresSaver因为简历数据本身就该存数据库顺手把会话状态也放进去运维成本最低。import { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer PostgresSaver.fromConnString(process.env.DATABASE_URL); await checkpointer.setup(); // 首次运行建表setup()会自动创建几张表来存 checkpoint 和写入记录跑一次就行。这里有个坑如果你用的是连接池比如 PgBouncersetup()可能会因为事务问题失败建议用直连跑一次初始化。3.3 前端如何优雅地消费流式数据前端这块我用了一个自定义 Hook 来封装流式消费逻辑把发消息、收流、更新 UI三件事解耦// hooks/useResumeAgent.ts export function useResumeAgent(threadId: string) { const [output, setOutput] useState(); const [loading, setLoading] useState(false); const send useCallback(async (content: string) { setLoading(true); setOutput(); const res await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: [{ role: user, content }], threadId }), }); const reader res.body!.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop() || ; // 最后一段可能不完整留到下次 for (const line of lines) { if (!line.startsWith(data: )) continue; const payload JSON.parse(line.slice(6)); if (payload.text) setOutput((prev) prev payload.text); if (payload.done) setLoading(false); } } }, [threadId]); return { output, loading, send }; }这里最关键的是buffer 的处理。SSE 的数据块在网络传输中可能被任意切分一次read()拿到的可能是一条完整消息也可能是半条。所以必须维护一个 buffer按\n\n分割最后一段不完整的留到下一轮拼接。这个细节如果处理不好会出现 JSON 解析报错或者文字乱码非常隐蔽。4. 让简历 Agent 真正聪明的几个关键实现4.1 结构化输出把大模型的自由文本变成可用数据简历 Agent 和普通聊天机器人最大的区别是它需要产出结构化数据而不是一段散文。比如采集阶段我需要把用户说的我在字节干了两年后端解析成{ company: 字节, duration: 2年, role: 后端 }这样的对象才能存进数据库、才能做后续的岗位匹配。LangChain.js 提供了withStructuredOutput方法配合 Zod schema 可以强制模型输出符合结构的数据import { z } from zod; const ResumeSchema z.object({ basic: z.object({ name: z.string().describe(姓名), years: z.number().describe(工作年限), targetRole: z.string().describe(目标岗位), }), experiences: z.array(z.object({ company: z.string(), role: z.string(), duration: z.string(), highlights: z.array(z.string()).describe(量化成果每条尽量带数字), })), }); const structuredModel model.withStructuredOutput(ResumeSchema); const result await structuredModel.invoke(prompt);实测下来withStructuredOutput比手动写 prompt 要求请输出 JSON靠谱得多因为它底层用的是 function calling 或者 JSON mode模型会严格遵守 schema。但有个坑字段的describe一定要写清楚尤其是highlights这种数组字段我会在 describe 里明确写每条尽量带数字模型输出的质量会明显提升。4.2 追问策略怎么让 Agent 问出有价值的问题简历采集最怕的就是 Agent 像个机器人一样问请描述你的项目经历用户回一句做了个电商系统就没了。好的追问应该是有引导性的能帮用户把模糊的经历具体化。我的做法是在collectNode里给模型一个追问清单让它根据当前已采集的信息找出信息密度最低的部分重点追问const COLLECT_PROMPT 你是一位资深简历顾问正在帮用户完善简历。 当前已采集信息 {currentData} 目标岗位{targetRole} 请判断哪些关键信息还缺失哪些描述过于笼统需要量化 追问时遵循 1. 一次只问 1-2 个问题不要一口气问一堆 2. 问题要具体比如这个项目你负责了哪部分有没有性能提升的数据 3. 如果用户描述里有优化了性能这类模糊表达追问具体数字 4. 语气专业但友好像朋友聊天 如果信息已经足够完整直接输出 [COLLECT_DONE];这个 prompt 的关键在于给了模型明确的判断标准和输出信号。[COLLECT_DONE]是一个约定好的标记routeAfterCollect函数检测到这个标记就流转到生成节点否则继续追问。这种用文本标记控制流程的做法在 Agent 开发里很常见比让模型输出 JSON 控制字段更稳定。4.3 评估节点用HR 视角做质量把关评估节点是我觉得整个项目最有价值的部分。它模拟 HR 筛简历的视角从几个维度给简历打分维度权重评估要点岗位匹配度30%技能、经历是否贴合目标岗位量化程度25%成果是否有具体数字支撑表达专业度20%用词是否专业、有无口语化表达结构清晰度15%逻辑是否清晰、重点是否突出亮点突出度10%是否有让人眼前一亮的经历评估节点同样用结构化输出返回{ score, feedback: [{ dimension, issue, suggestion }] }。如果总分低于阈值我设的是 75 分就带着 feedback 回到采集节点让 Agent 针对性地补充信息。这里有个经验评估标准要写进 prompt 里而且要写得足够细。我一开始只写请评估简历质量模型给的分永远在 80 分以上毫无区分度。后来我把上面这张表的维度、权重、评估要点全部塞进 prompt评分才变得有参考价值。4.4 防止 Agent 陷入死循环的三道保险Agent 开发最怕的就是无限循环——评估不通过、补充信息、再评估、还是不通过……烧 token 不说用户体验也极差。我加了三道保险第一道是retryCount每次从评估回到采集就 1超过 3 次强制流转到finalize。第二道是评估分数趋势检测如果连续两次评估分数没有提升说明补充的信息没起作用直接结束。第三道是超时控制在 Route Handler 层面给整个 Agent 执行加一个 60 秒的超时超时就返回当前已有结果。function routeAfterEvaluate(state: typeof ResumeState.State) { const { evaluation, retryCount } state; if (evaluation.score 75) return finalize; if (retryCount 3) return finalize; return collect; }这三道保险看起来简单但能避免 90% 的线上事故。我见过太多 Agent 项目因为没做熔断一个请求跑了几分钟还在循环最后把 API 额度烧光。5. 实测中踩过的坑与性能优化5.1 流式输出与结构化输出的冲突这是我在这个项目里踩的最大的坑。前面说了前端要流式输出但结构化输出withStructuredOutput是一次性返回完整对象的没法流式。这两个需求天然矛盾。我的解决方案是分节点处理collectNode和evaluateNode用结构化输出因为它们的产出是内部数据用户不需要实时看到generateNode用普通流式输出因为生成简历内容是用户最想实时看到的。这样既保证了数据结构的可靠性又保证了核心体验的流畅性。// generateNode 用流式 const stream await model.stream(prompt); let fullContent ; for await (const chunk of stream) { fullContent chunk.content; // 通过 config.writer 把 chunk 推给外层流 config.writer?.(chunk.content); }LangGraph.js 的节点函数可以接收config参数通过config.writer把中间结果推出去外层streamEvents就能捕获到。这个机制是打通节点内部流式和整体流式的关键。5.2 Token 消耗与上下文管理简历 Agent 是多轮对话对话历史会越来越长token 消耗是个大问题。我做了两件事来控制成本。第一是对话历史裁剪。只保留最近 10 轮对话更早的对话用一条摘要代替。摘要由模型生成压缩成 100 字以内。这样既保留了上下文又控制了长度。第二是状态与对话分离。resumeData是结构化的它本身就承载了核心信息不需要依赖完整对话历史。所以即使裁剪了对话Agent 依然知道用户填了哪些信息。这个设计让上下文管理变得简单很多。function trimMessages(messages: BaseMessage[], maxRounds 10) { if (messages.length maxRounds * 2) return messages; const recent messages.slice(-maxRounds * 2); const summary [早期对话摘要] 用户已提供基本信息正在完善项目经历。; return [new SystemMessage(summary), ...recent]; }5.3 并发场景下的状态隔离关键词里有个ai agent 怎么扛并发这确实是生产环境必须考虑的问题。LangGraph 的 Checkpointer 是按thread_id隔离的只要每个用户会话有独立的thread_id状态就不会串。但有几个细节要注意。第一thread_id的生成要保证唯一性我用的是crypto.randomUUID()前端首次进入时生成并存到 localStorage。第二如果用MemorySaver高并发下内存会暴涨必须换持久化存储。第三模型调用本身是 IO 密集型的Node.js 的单线程模型反而适合这种场景但要注意给模型调用加超时和重试避免某个慢请求拖垮整个服务。const model new ChatOpenAI({ modelName: gpt-4o-mini, timeout: 30000, maxRetries: 2, temperature: 0.7, });gpt-4o-mini是我实测下来性价比最高的选择简历这种任务不需要顶级模型mini 版本完全够用成本只有十分之一。temperature设 0.7 是因为简历生成需要一点创造性太低会显得死板。5.4 部署时的运行时选择Next.js 项目部署时Route Handler 的运行时选择很关键。我前面强调过必须用nodejs运行时但还有一个坑部分 Serverless 平台对响应时长有限制比如某些平台默认 10 秒超时而 Agent 跑一轮可能要 20-30 秒。解决方案有两个一是选支持长时运行的平台或者把超时调到 60 秒以上二是把 Agent 执行改成异步任务模式——请求立即返回一个 task_id前端轮询或者用 WebSocket 拿结果。我目前用的是第一种因为简历场景对实时性要求没那么高用户能接受等 30 秒只要能看到流式输出就不觉得慢。提示如果你的部署平台不支持长连接SSE 可能会被网关切断。这时候可以考虑用轮询方案或者把流式输出改成分段返回。6. 这套架构还能怎么扩展把简历 Agent 跑通之后我发现这套Next.js LangGraph.js的架构其实是个通用模板稍微改改就能用到很多场景。比如面试模拟 Agent把评估节点换成面试官提问节点采集节点换成回答评估节点就能做一个模拟面试工具。再比如岗位匹配 Agent把简历数据和岗位 JD 都作为输入让 Agent 做匹配度分析和差距建议。核心的图结构、流式接口、状态管理几乎不用改只需要替换节点内部的 prompt 和逻辑。从工程角度看还有几个可以深化的方向。一是引入 RAG把优质简历库、岗位 JD 库做成向量检索让 Agent 生成内容时有参考。二是多 Agent 协作比如一个 Agent 负责内容生成一个负责事实核查防止编造经历一个负责风格统一。LangGraph 天然支持多 Agent 编排这是它比简单 Chain 强大的地方。三是加入人工审核节点用 LangGraph 的interrupt机制在关键节点暂停等用户确认确认后再继续。我自己在实际操作中的体会是Agent 项目的难点从来不在调通模型而在流程设计和边界处理。模型能力是现成的但怎么把它的输出变成可靠的产品功能怎么处理各种异常和边界情况才是真正拉开差距的地方。简历 Agent 这个项目麻雀虽小但把多轮对话、结构化输出、流式响应、状态持久化、熔断控制这些 Agent 开发的核心问题都覆盖到了非常适合作为从会调 API到能做产品的进阶练手项目。最后分享一个小技巧调试 LangGraph 的时候把streamEvents的所有事件都打印出来你会看到每个节点的进入、退出、模型调用的开始和结束。这个日志比任何调试工具都直观能帮你快速定位是哪个节点出了问题。我一开始嫌日志吵后来发现没有它根本没法调试强烈建议在开发阶段打开。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑