ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Next.js + LangGraph.js实战:从零搭建简历优化AI Agent

Next.js + LangGraph.js实战:从零搭建简历优化AI Agent 如果你也遇到过“简历改了十几版还是被HR一眼跳过”的痛点或者你是个想入门AI Agent开发却总卡在“不知道从哪下手”的工程师那我今天分享的这个项目可能会给你一点启发我用Next.js加LangGraph.js从零落地了一个简历优化AI Agent。它不只是一个简单的ChatBot而是一个能接收岗位JD和简历原文、自动拆解岗位需求、逐条比对匹配度、给出针对性修改建议、甚至直接生成优化后简历的完整智能体应用。这篇文章会把整个项目的设计思路、架构选型、核心代码、踩坑记录都拆开讲清楚代码片段直接给你照着抄也能跑起来。1. 项目整体设计与技术选型复盘1.1 这个AI Agent到底做什么先把这个简历工具Agent的能力边界划清楚避免做着做着变成一个“什么都能聊”的玩具。我最终落地的Agent核心流程是接收两份输入目标岗位的JD文本Job Description和用户简历正文纯文本或Markdown。自动拆解JD中的关键信息岗位职责、硬性技能要求、加分项、年限要求、学历要求等。把简历内容按技能点、项目经历、工作年限、教育背景等维度结构化。执行匹配度分析逐项比对JD要求与简历内容输出匹配评分和差距清单。决策如果差距可以通过修改简历表达方式来弥补直接进入“简历改写”节点如果关键信息缺失比如没有项目细节则生成追问问题向用户收集信息。生成优化版简历保持原有事实不编造但重写项目描述、技能关键词、自我评价等段落使简历更贴合JD。最终以Markdown格式输出优化后的简历并附上一份差异说明。这个流程本质上是一个有状态、有条件分支、可能需要多轮交互的工作流。如果只用单纯的一次LLM调用很难稳定地完成“分析→决策→改写→追问”这一串动作。这也是我选择LangGraph.js的根本原因。1.2 为什么是Next.js加LangGraph.js而不是别的组合对于“简历工具AI Agent”这个场景我其实考虑过三条技术路线这里做一个真实对比方案优势劣势结论纯Python后端FastAPI LangGraph Python版生态最成熟LangGraph Python功能最全社区案例多前端要另起一套前后端联调成本高部署要维护两个服务适合后端团队但作为全栈项目太重了Next.js 直接手写状态机无额外框架依赖逻辑完全可控状态管理、条件分支、循环回退都是自己造轮子Agent的“图”特性没法直观表达适合教学不适合快速落地Next.js LangGraph.js前后端统一TypeScriptAPI Routes天然可以作为Agent服务端Vercel部署零配置LangGraph.js可以在JS/TS全栈项目里直接表达状态图LangGraph.js的文档和示例比Python版少一些需要自己趟坑这就是我最终的选择前后端统一语言省下的时间远超想象。简历解析、Agent状态流转、LLM流式输出、前端渲染全部用TypeScript一套搞定不需要维护两套模型定义和两套类型系统。而且Next.js的API Routes可以非常自然地承接SSEServer-Sent Events流式响应前端直接用fetch读取流体验很顺滑。1.3 从热搜词里读出的行业信号顺便说一下最近“AI Agent主流架构”“AI Agent开发”“AI Agent部署”这些词的搜索热度非常高说明大量前端/全栈工程师正在从“调API”往“编排智能体”这个方向迁移。我的一个直观感受是LangGraph这类“图状态编排框架”正在成为AI Agent开发的事实标准。它不是取代LangChain而是把LangChain里原本靠一堆链式调用堆积起来的逻辑升级成了一张可维护、可暂停、可恢复、可回退的状态图。这个项目用的LangGraph.js正好踩在这个趋势上。2. LangGraph.js核心概念与Agent状态图设计2.1 用状态图思维替代线性调用链传统LLM应用的处理模式是“调用→拿结果→再调用”。比如简历助手最常见的错误写法是const analysis await llm.invoke(analyzePrompt); const suggestion await llm.invoke(suggestPrompt); const rewritten await llm.invoke(rewritePrompt);这种线性写法应付固定流程还行但你一旦遇到“分析结果不达标就需要回去追问用户”“简历信息不足需要等待外部补充”这类场景代码会迅速腐化成一堆if-else套娃。LangGraph.js的核心思想是把Agent执行过程抽象成一张有向图节点是“动作”边是“状态转移规则”。每个节点做一件事并更新全局状态下一个节点根据最新状态决定做什么。整个过程中状态是所有节点唯一的共享数据源。我用生活化类比来解释传统链式调用像去餐厅按固定套餐吃菜一道道端上来你没法换菜LangGraph的图状态机更像自助餐你拿着一个餐盘状态在每个档口节点决定要不要取菜、取多少、下一站去哪临时想折回去加菜也完全没问题。简历Agent恰好需要这种灵活性。2.2 状态类型定义先设计好“餐盘”使用LangGraph.js第一步是定义State类型。这不是随意拍脑袋的事每个字段都对应Agent执行过程中的关键产物。我的简历Agent状态定义如下import { z } from zod; export const JDRequirementSchema z.object({ responsibilities: z.array(z.string()), requiredSkills: z.array(z.string()), preferredSkills: z.array(z.string()), experienceYears: z.string(), education: z.string(), keywords: z.array(z.string()), }); export const ResumeSchema z.object({ summary: z.string(), skills: z.array(z.string()), projects: z.array(z.object({ name: z.string(), role: z.string(), highlights: z.array(z.string()), techStack: z.array(z.string()), })), experience: z.array(z.object({ company: z.string(), title: z.string(), duration: z.string(), achievements: z.array(z.string()), })), education: z.string(), }); export type AgentState { jobDescription: string; resumeText: string; jdParsed?: z.infertypeof JDRequirementSchema; resumeParsed?: z.infertypeof ResumeSchema; matchAnalysis?: { score: number; strengths: string[]; gaps: string[]; criticalMissing: string[]; }; routeDecision?: rewrite | ask_more; followUpQuestions?: string[]; rewrittenResume?: string; changeLog?: string[]; };注意我特地加了criticalMissing这个字段它是决策节点的核心判断依据。什么算关键缺失比如JD明确要求“主导过端上架构升级”简历项目描述里完全没有体现任何“架构”“设计”“主导”这类词汇这就是criticalMissing。出现这类情况Agent就不应该硬着头皮改写简历因为简历是事实依据编造经历是绝对不能碰的雷区。更好的做法是向用户追问让用户补信息后再继续。2.3 节点函数与条件边的实现LangGraph.js的节点就是一个普通函数接收当前状态返回部分更新后的状态。这个设计我很喜欢因为每个节点可以独立测试不需要启动整个图就能单点验证。简历Agent我设计了六个核心节点parseJD把用户粘贴的JD原文交给LLM做结构化抽取使用zod schema约束输出格式。parseResume同样用结构化输出解析简历但这里我会额外做一步“去噪”例如去掉简历里“个人照片”“籍贯”这类对技术岗匹配分析无用的信息。analyzeMatch将解析后的JD要求和简历结构化数据进行综合打分生成strengths/gaps/criticalMissing。decideRoute根据criticalMissing数量决定下一步去向。rewriteResume按JD要求重写简历中的项目描述、技能列表、自我评价同时严格禁止编造事实。askFollowUp生成针对关键缺失信息的追问问题。节点之间的条件边是这样组织的import { StateGraph, START, END } from langchain/langgraph; const graph new StateGraphAgentState() .addNode(parseJD, parseJDNode) .addNode(parseResume, parseResumeNode) .addNode(analyzeMatch, analyzeMatchNode) .addNode(decideRoute, decideRouteNode) .addNode(rewriteResume, rewriteResumeNode) .addNode(askFollowUp, askFollowUpNode) .addEdge(START, parseJD) .addEdge(parseJD, parseResume) .addEdge(parseResume, analyzeMatch) .addEdge(analyzeMatch, decideRoute) .addConditionalEdges( decideRoute, (state) state.routeDecision, { rewrite: rewriteResume, ask_more: askFollowUp, } ) .addEdge(rewriteResume, END) .addEdge(askFollowUp, END); export const resumeAgent graph.compile();这段代码是整个Agent的骨架。addConditionalEdges是LangGraph.js最具代表的能力它让图的走向在运行时动态决定。我在debug阶段经常只用中间节点测试比如单独把parseResume拎出来跑一遍看看LLM结构化输出稳定性这也得益于每个节点都是独立函数的架构。2.4 Compile编译与图执行形态graph.compile()会生成一个可执行的CompiledStateGraph实例你可以对它调用invoke一次性执行、stream分步执行、streamEvents带事件详细信息的流式执行。我在前端展示时用的就是streamEvents因为可以拿到每个节点的开始/结束事件在UI上实时展示类似“正在解析JD…”“正在分析匹配度…”这样的过程进度。这里要提醒一个容易踩的坑LangGraph.js目前提供的streamEvents事件名和Python版有所不同Python里习惯用on_chat_model_streamJS里也有类似的事件名但建议不要硬记直接在代码里打印一次所有事件类型跑一遍后你就知道哪些事件对你是有用的。调试阶段多打log反而是最快的路径。3. 结构化输出、工具调用与LLM配置细节3.1 用withStructuredOutput稳定解析JD和简历简历Agent的整条链路中最不稳定的环节就是“把用户粘贴的杂七杂八简历文本变成结构化数据”。用户可能直接粘贴Word排版的内容也可能从PDF复制出全是换行的乱码甚至中英文混排。直接让LLM自由输出JSON大概率会出现字段缺失、key命名漂移等问题。我采用的方案是Runnable.withStructuredOutput()配合zod。LangChain.js对OpenAI等模型会自动把zod schema转换成JSON Schema传给模型然后对输出做校验。核心代码大概是import { ChatOpenAI } from langchain/openai; import { z } from zod; const model new ChatOpenAI({ model: gpt-4o-mini, apiKey: process.env.OPENAI_API_KEY, temperature: 0.1, }); const jdParser model.withStructuredOutput(JDRequirementSchema, { name: extract_jd_requirements, }); const resumeParser model.withStructuredOutput(ResumeSchema, { name: extract_resume_data, });我把温度调到0.1甚至可以是0。因为简历解析这事不需要创造力越稳定越好。JD抽取和简历解析节点的实现就是直接await jdParser.invoke(state.jobDescription)返回值已经通过了zod校验类型准确后面用到时不需要再做runtime断言。这比让LLM自己“发挥”靠谱太多了。3.2 什么情况下choose工具调用而不是结构化输出有读者可能会问LangGraph.js不是支持tool calling吗为什么这里不定义一个extract_jd工具让模型自己调用我的经验是如果LLM的输出目标是“数据本身”优先用结构化输出如果目标是“让LLM决定是否需要触发某个动作”才用工具调用。简历Agent里的JD解析和简历解析属于前者因为模型没有选择权它必须把文本抽成结构。而“判断是否追问用户”由decideRoute节点用普通prompt就能完成也没必要工具调用。一个真正使用工具调用的场景是Agent在改写简历时需要查阅一个外部技能库或者调用搜索接口来确认某个技术名词的标准写法这时定义一个search_skill_reference工具才有意义。这个判断逻辑是我做多个Agent项目后总结出来的。工具调用在LangGraph里确实很酷但别为了炫技而滥用。每个工具调用都会增加一次LLM推理开销还会引入“模型选错工具”的失败模式。能用结构化输出解决的就不用工具。3.3 节点Prompt设计要点每个节点的prompt我都维护成独立常量放在prompts/目录下面。这里分享一个关键心得给LLM的提示里必须定义“行为边界”。简历改写节点的系统提示词我大概是这样写的你是一名资深的简历优化顾问。你的任务是在不编造任何事实的前提下优化简历内容使其更匹配给定的岗位JD。 硬性规则 1. 不得新增原简历中不存在的项目经历、公司、职位头衔。 2. 不得夸大原简历中未提及的技术栈。 3. 可以调整语序、补充量化描述中用到的逻辑推导如从“负责后端开发”改写成“独立负责订单模块的后端开发涵盖接口设计、性能优化与线上问题排查”。 4. 技能列表可以按JD要求重新排序但只能使用简历中已出现的技能词。 5. 项目描述的改写必须参照STAR法则。这五条规则不是拍脑袋写的。第一二条是为了守住伦理底线第三四条是为了让结果对用户真的有帮助第五条是保证改写后的内容符合招聘方筛选习惯。我见过很多简历Agent的翻车案例都是让模型“自由润色”结果模型会脑补出不存在的项目或者编造学历这种输出作为工具是不可接受的。3.4 响应格式与模型选择模型选择上我用了GPT-4o-mini理由是它在中文简历场景下结构化输出表现稳定且成本低。解析一份简历加JD大概消耗2K到4K token跑完整Agent含改写大概消耗6K到10K token用4o-mini处理下来单次成本不到一毛钱。如果你预算更紧也可以用国内的开源模型或者便宜的长文本模型LangGraph.js通过LangChain的模型抽象层可以很方便地替换。这里给一个省钱技巧解析JD和简历用gpt-4o-mini改写简历这种需要更强表达能力的步骤可以切换到一个更强的模型。你的每个节点函数里可以持有不同的模型实例不必全Agent绑定一个。4. 实操过程从初始化到跑通完整Agent4.1 项目初始化和依赖安装前置条件Node.js 18一个OpenAI或兼容API的Key。项目我直接用Next.js官方脚手架npx create-next-applatest resume-ai-agent --typescript --tailwind --app cd resume-ai-agent npm install langchain/langgraph langchain/openai langchain zod这里有个小建议TypeScript Tailwind直接选上因为后面写前端交互界面时Tailwind能让页面快速逼近可用状态不用为样式反复折腾。zod是必须的LangChain.js的withStructuredOutput依赖它来做类型校验。4.2 按目录组织Agent代码建议按下面这样组织目录结构别把所有代码堆进一个文件里resume-ai-agent/ ├── app/ │ ├── api/ │ │ └── agent/ │ │ ├── route.ts # SSE接口入口 │ │ └── run.ts # Agent编排核心 │ └── page.tsx # 前端页面 ├── lib/ │ ├── state.ts # Agent状态定义/zod schema │ ├── nodes.ts # 所有节点函数 │ ├── graph.ts # 图构建 │ ├── models.ts # LLM模型实例 │ └── prompts.ts # Prompt常量把模型实例单独放一个文件是有意为之。后面如果想把模型从OpenAI切换成本地部署模型只需要改models.ts一个文件。节点和Graph分开写也方便做单元测试。4.3 在Next.js API Route中实现SSE流式响应前端体验上我选择了SSE流式输出因为Agent跑完全流程可能要十几秒如果接口一次性返回用户会以为页面卡死了。用流式响应前端可以实时展示“每个节点执行中”的状态像是看着Agent一步步干活体验好很多。API Route核心代码import { NextRequest } from next/server; import { resumeAgent } from /lib/graph; export const runtime nodejs; export async function POST(req: NextRequest) { const { jobDescription, resumeText } await req.json(); if (!jobDescription || !resumeText) { return Response.json( { error: jobDescription and resumeText are required }, { status: 400 } ); } const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { const events resumeAgent.streamEvents( { jobDescription, resumeText }, { version: v2 } ); for await (const event of events) { const { event: eventType, name, data } event; if (eventType on_chain_start) { const payload data: ${JSON.stringify({ type: node_start, name, })}\n\n; controller.enqueue(encoder.encode(payload)); } if (eventType on_chain_end) { const payload data: ${JSON.stringify({ type: node_end, name, })}\n\n; controller.enqueue(encoder.encode(payload)); } if (eventType on_chat_model_stream) { const token data?.chunk?.content; if (typeof token string) { const payload data: ${JSON.stringify({ type: token, token, })}\n\n; controller.enqueue(encoder.encode(payload)); } } } controller.enqueue( encoder.encode(data: ${JSON.stringify({ type: done })}\n\n) ); controller.close(); } catch (err) { controller.enqueue( encoder.encode( data: ${JSON.stringify({ type: error, error: err instanceof Error ? err.message : Unknown error, })}\n\n ) ); controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }resumeAgent.streamEvents()返回的是一个异步迭代器天然适配ReadableStream。这里我把三种事件分别处理节点开始事件用于更新进度条节点结束事件用于标记步骤完成模型token事件用于实时展示LLM的响应文本。有个细节会被很多人忽略version: v2不传的话LangGraph.js可能用老版本事件协议字段结构不一样解析会出错。这是官方文档里写得不清楚的地方我自己踩过这个坑群里也有不少人问过。4.4 前端页面流式读取与状态展示前端我用了一个简洁的useAgentStreamHook来消费SSE流。核心逻辑是fetch ReadableStream TextDecoder逐段解析type StreamEvent | { type: node_start; name: string } | { type: node_end; name: string } | { type: token; token: string } | { type: done } | { type: error; error: string }; export function useAgentStream() { const [status, setStatus] useStateidle | running | done | error(idle); const [nodesStatus, setNodesStatus] useStateRecordstring, running | done({}); const [tokens, setTokens] useState(); const run useCallback(async (jobDescription: string, resumeText: string) { setStatus(running); setTokens(); setNodesStatus({}); try { const res await fetch(/api/agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ jobDescription, resumeText }), }); const reader res.body!.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { value, done } 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 parsed JSON.parse(line.slice(6)) as StreamEvent; if (parsed.type node_start) { setNodesStatus((prev) ({ ...prev, [parsed.name]: running })); } else if (parsed.type node_end) { setNodesStatus((prev) ({ ...prev, [parsed.name]: done })); } else if (parsed.type token) { setTokens((prev) prev parsed.token); } else if (parsed.type error) { setStatus(error); console.error(parsed.error); } else if (parsed.type done) { setStatus(done); } } } } catch (err) { setStatus(error); console.error(err); } }, []); return { run, status, nodesStatus, tokens }; }页面上我放了一个两栏布局左侧是两个文本框JD和简历右侧是实时流输出面板和节点进度指示。每次node_start时高亮节点名称node_end时打上绿色对勾。用户看着“parseJD → parseResume → analyzeMatch → rewriteResume”一步步跑完对Agent的信任感会明显强于“等待一个白屏”。4.5 完整跑通的调试顺序我建议新人在本地调试时务必按“从简到繁”的顺序来先只跑parseJDNode打印结构化输出确认JSON Schema和模型配合正常。再单独跑parseResumeNode用一份真实简历测试解析覆盖度。接着把analyzeMatchNode挂上去重点观察gap识别的准确率。最后再把条件边和完整图串起来。全流程跑通后再接SSE和前端。这一步帮我省了大量排查时间。如果你是第一次接触LangGraph.js不要一上来就急着把所有节点和图都写完再调试分支越多排查越痛苦。5. 常见问题与排查技巧实录5.1 图不结束导致接口超时这是最典型的LangGraph新坑图跑起来之后像死循环一样不结束Vercel上直接504超时。排查思路很简单先确认每个通向END的路径是否都被条件边覆盖。我第一次把条件边的映射值写成了rewriteResume和ask_more但实际上askFollowUp节点没接END结果图在执行完askFollowUp后没有可走的边导致事件流挂起。解决办法在addEdge(askFollowUp, END)之后重新跑。还有一招很实用给streamEvents外面套一个超时控制比如Promise.race方式超过30秒就强制返回错误至少不会让用户无限等待。5.2 结构化输出偶发字段丢失即使用了withStructuredOutput偶尔还是会出现zod校验失败导致抛错。比如简历里没有教育经历字段模型可能直接不给education但schema里它是必填的。我的解法是在schema里把一些字段设成可选然后在代码里给默认值const ResumeSchema z.object({ summary: z.string().default(), skills: z.array(z.string()).default([]), projects: z.array(z.object({ name: z.string(), role: z.string(), highlights: z.array(z.string()), techStack: z.array(z.string()), })).default([]), education: z.string().default(未提供), });5.3 中文token流式输出乱码SSE场景下中文乱码问题通常不是模型的问题而是编码处理不当。如果你没有用TextDecoder解码而是直接JSON.parse二进制转字符串很容易出现半个字符的情况。正确做法是维护一个buffer只解析完整的事件块不要把\n\n分割出来的半个json塞给JSON.parse。上面useAgentStream里的写法处理了这个问题建议直接复制使用。5.4 LangGraph.js升级导致的API差异LangGraph.js版本迭代很快我在开发过程中就遇到了StateGraph的构造函数签名变化。建议锁定版本号不要直接装latestnpm install langchain/langgraph0.2.20记录你项目里实际可用的版本等稳定后再统一升级。社区里如果你看到某个示例跑不起来大概率就是版本差异问题先把依赖版本对齐再排查别的。5.5 Vercel部署时的函数超时限制如果用Vercel的免费版部署Serverless Function默认最长执行时间是10秒。Agent全流程跑完通常需要15到30秒直接线上运行必然超时。处理方式有几种在vercel.json里调大maxDuration到60秒这是付费功能但额度很低个人项目也能用。部署到自己的Node.js服务器用Docker或PM2守护彻底摆脱Serverless的时间限制。把Agent拆成异步任务用队列处理前端轮询结果但这会牺牲实时流式体验简历场景不推荐。我个人建议是演示项目直接用Vercel Pro或者部署到一台2核4G的小服务器上把Next.js作为独立Node进程跑这样耗时不是问题。结尾这个Agent还能怎么延伸这次做简历Agent最大的收获不是“会用了LangGraph.js”而是理解了AI Agent工程化落地的完整链条需求拆解 → 状态设计 → 图编排 → 结构化输出 → 流式交互 → 稳定性兜底。LangGraph.js把编排复杂度控制在了很低的水平但真正决定一个Agent能不能用的还是你对业务场景的拆解能力和对LLM输出稳定性的控制能力。如果你也想做个类似的工具我建议先别想着“做大”就做“单点突破”比如只做一个“JD技能匹配度分析Agent”不做改写数据模型的复杂度和风险都会小很多跑通后再往上加节点。实际上所有复杂的Agent都是从简单的节点生长出来的先把最小闭环跑通比什么都重要。最后分享一个小技巧把一些必要信息留出来。我准备把这次的简历Agent再加一个“根据目标公司风格调整语气”的节点比如投外企和投国企对简历风格要求完全不同。这类需求本质上也是加一个节点的事但正因为用的是图状态编排我可以不破坏现有流程直接插一个新的分支进去。这就是LangGraph这类框架比手写状态机强的地方——扩展New Node出来一切回归原位。
返回列表