用Next.js和LangGraph.js构建简历优化AI Agent的完整实践
这两年我一直在帮团队搭各种内部工具从简单的表单收集到复杂的业务流自动化都做过。前段时间接到一个需求做一个简历优化工具不是那种套模板的排版工具而是能真正读懂用户简历内容、针对目标岗位给出量化评估和修改建议的AI工具。试了好几版方案最后选了Next.js LangGraph.js组合把整个Agent完整落地了。这篇文章就聊聊这个项目的设计思路、关键实现以及我踩过的一些坑。先说结论简历工具是特别适合用Agent架构来做的一类场景。原因很简单简历优化不是一个“一次提问一次回答”的简单对话它包含了拆解简历、理解岗位需求、分维度评估、生成修改建议、甚至生成定制化简历这一整条链路的多个环节。如果用普通的prompt工程硬写代码会变成一坨互相嵌套的if-else而且每加一个新功能就要动一遍主流程。用了LangGraph.js之后整条链路被拆成了图上的一个个节点状态流转清晰每个节点可以单独调试后续加功能也只是在图上加节点的事。1. 这类简历工具为什么值得用Agent来做1.1 简历优化的本质是一连串决策很多人觉得简历工具就是“把简历丢给大模型让它给点建议”这想法太天真了。真实场景里一个完整的简历优化需求至少包含以下步骤把用户粘贴的原始文本或上传的PDF解析成结构化数据这需要识别个人信息、工作经历、项目经历、技能清单还要把时间线对齐。结合用户选择的目标岗位比如“后端开发工程师”对简历进行打分判断是否匹配。这里要拆好几个维度包括结构完整性、量化成果数量、关键词覆盖、STAR法则使用情况。基于评分结果生成具体的修改建议。注意修改建议不是泛泛而谈的“请补充量化数据”而是要结合用户的原文指出具体哪一条工作经历描述太平淡、哪些技能关键词缺失。用户确认后生成一份优化后的简历全文保留原格式风格同时替换掉有问题的描述。这四步之间存在明确的数据依赖关系第二步依赖第一步的结构化数据第三步依赖第二步的评分结果第四步又依赖前三个步骤的所有产出。用传统的Service层写法每一步都要手动串起来中间状态需要自己定义一堆DTO来传递。而LangGraph.js的核心机制就是把这一步一步封装成图节点靠共享的State对象自动传递数据代码结构会干净非常多。1.2 LangGraph.js到底解决了什么问题LangGraph.js是LangChain.js生态里的图编排框架核心思想是让开发者用“图”的方式定义Agent的行为路径。每个节点就是一个函数或者一个封装好的组件节点之间用边来连接边上还可以定义条件路由。我之前用LangChain.js做Agent时最大的痛点就是复杂的多轮工具调用流程很难控制。LangChain.js的Agent executor虽然能循环调用工具但只要你需要中断流程、插入人工确认、或者在特定条件下走不同的分支代码复杂度立刻爆炸。LangGraph.js把这些问题变成了图上的“条件边”比如“如果评分低于60分就走‘深度优化’分支如果评分高于85分就直接跳到‘生成简历’分支”这种写法完全跟着业务逻辑走不需要自己去维护状态机。另外LangGraph.js有一个很有用的机制叫持久化检查点checkpointer。图每次执行到某个节点时会把状态快照保存下来。中断之后可以从指定的检查点继续执行不需要重头跑一遍。我之前觉得这个功能没用直到做简历生成这个场景才真正体会到它的价值大模型的中间输出可能很慢如果用户在第三步调整了目标岗位Agent可以从“评分”节点重新跑而不是重新解析一遍简历。1.3 这个项目适合谁参考如果你也在做内容生成、文档处理、数据分析这类偏“重流程”的AI应用这篇文章的架构思路可以直接抄。哪怕你完全没接触过LangGraph.js只要会一点TypeScript和React跟着后面的代码走一遍也能搭出一个可运行的简历Agent。2. 技术选型Next.js与LangGraph.js的组合逻辑2.1 为什么用Next.js而不是纯前端方案最初我在两个方案之间犹豫过方案A是纯React SPA 后端单独起一个Node服务方案B是直接用Next.js全栈搞定。最后选了Next.js核心原因有三个。第一是API Route的天然整合。LangGraph.js需要跑在Node环境而Next.js的Route Handler可以直接挂载Graph服务前端页面和Agent接口在同一个项目里维护不需要为Agent的API单独开一个Git仓库也不存在跨域问题。对于我这种经常单兵作战、维护多个内部工具的开发者来说少一个服务就少一倍的运维成本。第二是流式渲染体验。简历优化这个场景大模型生成建议动不动就要十几秒如果做成一次性返回用户会以为网站卡死了。Next.js的App Router对流式响应支持得很好Route Handler里可以直接写Web标准流前端配合ReadableStream逐个字展示生成结果。这套体验跟现在主流AI产品完全一致。第三是团队协作的边界清晰。前端组件和Agent的Graph定义虽然在一个仓库里但目录结构上分开前端的人不用关心Agent内部长什么样只需要对“启动任务”和“获取结果”两个API。这个边界在项目中后期特别重要不然每个人都在改Agent的代码方案会越搞越乱。2.2 为什么Agent层选了LangGraph.js而不是LangChain.jsLangChain.js的Agent执行器也能实现多工具调用但我做了个对比实验发现两个核心差异状态模型不同。LangChain.js的Agent本质上是一个循环把当前prompt传给模型模型决定调用哪个工具拿到结果再传回模型直到模型认为可以回答了。整个循环里没有“阶段”的概念很难表达“先解析再评分再生成”这种明显有先后顺序的流程。LangGraph.js可以显式定义节点和边的顺序执行顺序完全可控。人机交互的时机不同。简历优化流程里我希望在“解析简历”完成后暂停让用户确认一下解析出的关键信息有没有错比如“工作年限识别对了没”。如果在LangChain.js里做人工确认要么得写复杂的回调要么得把整个流程拆成多个接口手动串联。在LangGraph.js里一个interrupt_before参数就能实现中途暂停等用户确认后调用invoke恢复执行。这两个差异基本决定了选型后面所有开发都围绕LangGraph.js展开。另外提一嘴版本问题现在LangChain.js的官方包里已经直接集成了langgraph安装langchain/langgraph包就能用不需要单独引一个框架。官方文档里最新的例子也都是基于这个包在做社区生态已经比较成熟了。2.3 整体技术栈清单项目最终用到的核心依赖如下模块选型说明前端框架Next.js 14App Router路由、服务端渲染、API RouteAgent编排langchain/langgraph定义状态图、节点、条件路由LLM接入langchain/openai兼容OpenAI接口的模型都可用结构化输出zod校验Agent节点返回的数据结构状态持久化langchain/langgraph-checkpoint支持中断续跑和任务恢复部署Vercel / Node服务器取决于是否需要长时任务模型层面生产环境我是接了兼容OpenAI协议的自建服务开发调试直接用DeepSeek的API也能跑通因为LangChain.js的OpenAI封装支持自定义baseURL。这一层灵活性很大别被“必须用GPT”这种想法框住。3. 核心实现简历Agent的状态图与节点3.1 图结构设计四个节点加一条人工确认边整个Agent的图结构很清晰总共四个核心节点parse_resume解析简历 → evaluate_resume评分诊断 → optimize_suggestions生成修改建议 → generate_resume生成定制简历其中parse_resume之后会暂停等前端把解析结果返回给用户确认确认通过才继续执行evaluate_resume。这样设计的原因很实际如果模型第一轮就把工作时长解析错了后面所有评分和建议都建立在错误数据上整份优化结果会跑偏。图定义的核心代码如下import { StateGraph, Annotation, START, END } from langchain/langgraph; // 定义Agent的全局状态 const AgentState Annotation.Root({ rawResume: Annotationstring({ reducer: (a: string, b: string) b ?? a ?? , }), targetRole: Annotationstring({ reducer: (a: string, b: string) b ?? a ?? , }), parsedResume: Annotationany({ reducer: (a: any, b: any) b ?? a, }), evaluation: Annotationany({ reducer: (a: any, b: any) b ?? a, }), suggestions: Annotationany[]({ reducer: (a: any[], b: any[]) (b ?? a) as any[], }), generatedResume: Annotationstring({ reducer: (a: string, b: string) b ?? a ?? , }), messages: Annotationany[]({ reducer: (a: any[], b: any[]) (a ?? []).concat(b ?? []), }), }); async function parseResumeNode(state: typeof AgentState.State) { const llm getLLM(); const parser structuredParser(); // zod定义的JSON Schema const result await llm.invoke([ { role: system, content: RESUME_PARSE_PROMPT }, { role: user, content: state.rawResume }, ]); const parsed parser.parse(result.content); return { parsedResume: parsed }; } async function evaluateResumeNode(state: typeof AgentState.State) { // 基于解析后的简历 目标岗位生成评分 } async function optimizeSuggestionsNode(state: typeof AgentState.State) { // 基于评分结果生成逐条建议 } async function generateResumeNode(state: typeof AgentState.State) { // 生成完整优化版简历 } const graph new StateGraph(AgentState) .addNode(parse_resume, parseResumeNode) .addNode(evaluate_resume, evaluateResumeNode) .addNode(optimize_suggestions, optimizeSuggestionsNode) .addNode(generate_resume, generateResumeNode) .addEdge(START, parse_resume) .addEdge(parse_resume, evaluate_resume) .addEdge(evaluate_resume, optimize_suggestions) .addEdge(optimize_suggestions, generate_resume) .addEdge(generate_resume, END) .compile({ checkpointer: memorySaver });这个图的可读性非常好后面任何人接手都能一眼看懂整个Agent的执行链路。3.2 解析节点的结构化输出实现简历解析是整个Agent里最容易出错也最影响体验的环节。大模型直接输出一段话很容易但要让后续节点能编程式地访问“工作经历列表”“技能关键词”就必须要求解析结果是一个严格的JSON结构。我的做法是定义一个zod Schema把它作为JSON Schema传给模型import { z } from zod; import { ChatPromptTemplate } from langchain/core/prompts; import { JsonOutputFunctionsParser } from langchain/output_parsers; const ResumeSchema z.object({ basicInfo: z.object({ name: z.string().describe(候选人姓名), email: z.string().describe(邮箱地址), phone: z.string().describe(联系电话), yearsOfExperience: z.number().describe(工作年限总和按年计算), }), workExperience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string(), achievements: z.array(z.string()), })), projects: z.array(z.object({ name: z.string(), description: z.string(), techStack: z.array(z.string()), })), skills: z.array(z.string()), });这里有个特别重要的经验一定要求模型把“工作年限总和”作为显式字段输出而不是让后续节点自己推断。因为简历文本里的时间描述经常不完整比如只写了“2020 - 2023”没写月份模型被迫推断时会有很大误差但把它当成一个独立字段让模型认真数一遍准确率会高很多。3.3 评分节点的分维度诊断逻辑评分节点的Prompt是整个Agent里最需要反复调优的部分。我测试过好几种写法最后稳定下来的是让它按五个维度打分结构完整性、量化成果、关键词匹配、语言质量、STAR法则使用。每个维度满分20分总分100分。在写这个节点的Prompt时有个小技巧就是让它先输出自己的推理过程再给出分数。比如“我看到简历中项目部分有两个项目用到了React、TypeScript与目标岗位要求的关键词匹配度较高我认为该维度得分较高”。有了推理过程之后再打分分数会稳定很多。这是我在实际测试里发现的——如果直接让模型打分它经常给每个维度都打差不多的分数得不到有区分度的诊断结果。评分节点的伪代码async function evaluateResumeNode(state: typeof AgentState.State) { const llm getLLM(); const prompt 你是一名资深技术招聘专家。请根据以下目标岗位和简历结构化内容对简历进行分维度评估。 目标岗位${state.targetRole} 简历内容${JSON.stringify(state.parsedResume)} 评估要求 1. 先逐维度分析说明理由 2. 再给出每个维度的分数0-20分 3. 最后输出总分0-100分 输出格式必须为JSON。; const result await llm.invoke([ { role: system, content: EVALUATE_SYSTEM_PROMPT }, { role: user, content: prompt }, ]); return { evaluation: JSON.parse(stripCodeFence(result.content)) }; }这里stripCodeFence是我自己写的一个小工具函数专门处理模型把JSON输出包在json代码块里的情况。这个问题出现的频率比想象的高后面排查章节会专门聊。3.4 生成定制简历时的流程控制最后一个节点是生成完整简历这个节点跟前几个不太一样它不是一次性输出因为一份完整的优化简历可能超过三千字大模型一次生成的token长度有限而且生成不稳定。我在这里引入了流式输出通过回调用ReadableStream把内容逐步推给前端。在LangGraph.js里节点内部可以直接调用模型的stream方法async function generateResumeNode(state: typeof AgentState.State, config) { const llm getLLM(); const stream await llm.stream([ { role: system, content: GENERATE_RESUME_PROMPT }, { role: user, content: buildGenerateInput(state) }, ], { signal: config.signal }); let fullText ; for await (const chunk of stream) { fullText chunk.content; // 通过回调把chunk推给前端SSE } return { generatedResume: fullText }; }前端接流的写法我放在后面“实操环节”统一说。3.5 人工确认节点的中断恢复这个机制是LangGraph.js最值回票价的功能。我用interruptBefore实现“解析完成后暂停等用户确认”const compiledGraph graph.compile({ checkpointer: memorySaver, interruptBefore: [evaluate_resume], });当图执行到evaluate_resume之前框架会自动暂停状态保存在checkpointer里。前端收到解析结果后把thread_id和用户的确认/修正信息传回后端后端调用graph.invoke(updatedState, { thread_id })图会从暂停的地方继续往下执行。我建议给每个用户的任务创建一个唯一的thread_id用UUID就行。后续无论用户刷新页面还是隔几个小时再回来只要thread_id不变任务状态就能无缝恢复。这体验比重新填一遍表单强太多了。4. 前端接入与流式交互的落地细节4.1 Next.js Route Handler封装Agent接口在Next.js的App Router里我新建了app/api/resume-agent/route.ts来承接Agent调用。这个路由处理两种请求POST启动任务GET获取任务状态另外SSE流式推送生成结果。启动任务的简化实现import { NextRequest } from next/server; export async function POST(req: NextRequest) { const { rawResume, targetRole, threadId } await req.json(); const initialState { rawResume, targetRole, messages: [], }; const result await compiledGraph.invoke(initialState, { thread_id: threadId, }); return Response.json({ threadId, parsedResume: result.parsedResume, status: NEED_CONFIRM, // 表示需要用户确认解析结果 }); }流式生成简历的端点单独走SSEexport async function GET(req: NextRequest) { const threadId req.nextUrl.searchParams.get(threadId); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const config { thread_id: threadId, streamMode: messages, callbacks: [ { handleLLMNewToken(token: string) { controller.enqueue(encoder.encode(data: ${token}\n\n)); }, handleLLMEnd() { controller.close(); }, }, ], }; await compiledGraph.invoke(null, config); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }这里需要注意我给invoke传的initialState是null因为图会从checkpointer里恢复之前保存的状态不需要再传一遍。这是LangGraph.js的checkpointer机制带来的好处。4.2 前端实时展示token流前端我用了一个自定义hook来管理SSE连接核心逻辑是把fetch返回的response.body转成reader循环读取decoded chunk并拼接到state里async function streamAgentOutput(threadId: string) { const response await fetch(/api/resume-agent?threadId${threadId}); const reader response.body?.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader!.read(); if (done) break; const text decoder.decode(value); const tokens text .split(\n) .filter((line) line.startsWith(data: )) .map((line) line.replace(data: , )); setGeneratedText((prev) prev tokens.join()); } }这里有个坑TextDecoder默认不会自动处理跨chunk的多字节字符。如果流式输出的是中文一个汉字被拆成两个chunk传输时用TextDecoder单独decode每个chunk会偶尔出现乱码。解决办法很简单——用TextDecoder的stream选项让它保持内部状态const decoder new TextDecoder(utf-8, { stream: false });实际测试下来比较稳妥的是在每次decode时传{stream: true}然后最后一次读取时再decode(undefined, {stream: false})收尾。这个细节很容易被忽略但会导致部分用户偶发看到乱码排查起来还特别费力。4.3 组件状态机的设计简历工具的整个交互流程我用了一个简单的状态机来管理IDLE、PARSING、NEED_CONFIRM、EVALUATING、SUGGESTING、GENERATING、DONE。每个状态对应页面上不同的UI区块。比如NEED_CONFIRM状态时页面展示一个表格让用户核对解析出的姓名、工作年限、技能列表旁边有“确认无误”和“修正”两个按钮。这种边做边让用户纠错的交互模式比全部自动化但结果不准确要好很多因为用户对AI的容忍度很低但对“自己确认过的结果”接受度很高。5. 实操过程中遇到的问题与排查实录5.1 模型输出的JSON永远被Markdown代码块包裹这个是出现频率最高的问题。即使是明确要求“只输出JSON”的Prompt模型也经常输出json开头和结尾的代码块。直接用JSON.parse会抛异常。我的处理思路分两层。第一层写一个健壮的提取函数function stripCodeFence(text: string): string { const jsonMatch text.match(/(?:json)?\s*([\s\S]*?)/); if (jsonMatch) return jsonMatch[1].trim(); const braceMatch text.match(/\{[\s\S]*\}/); if (braceMatch) return braceMatch[0]; return text.trim(); }第二层是在解析失败时走“重试节点”。LangGraph.js里可以在节点内部catch异常然后重新调用一次模型并额外加一句“上次的回复格式不是有效JSON请只输出JSON对象”。实测重试一次的成功率接近98%。这两层兜底加上之后解析节点基本稳了。5.2 流式输出到一半断了前端永久loading这个问题排查了很久。症状是前端收到一段内容后SSE连接突然中断页面一直转圈。排查后发现两个原因叠加产生第一个原因是Vercel部署时路由默认有超时时间长任务跑太久会被强制掐断。解决方法是给该路由单独配置export const maxDuration 60;并且不要使用默认的Edge Runtime因为LangGraph.js需要Node API。第二个原因是我的Node版本太低ReadableStream的某些API行为不一致。后来把项目Node版本升到20.x就稳定了。我的建议是如果你用LangGraph.js做这种长链路生成本地调试没问题后上生产环境前一定要用真实的慢模型比如带上较长的简历文本跑一遍完整流程。别用Mock数据测Mock测不出超时问题。5.3 Agent状态被“污染”多个用户共用同一个thread_id我在联调阶段发生过一个很尴尬的bug用户A填了一份简历生成了建议用户B进来不用填简历直接看到了用户A的生成结果。查了日志才发现前端没有正确为每个新会话生成threadId导致所有用户都共用了一个默认的thread_id。解决办法是前端每次进入页面时用crypto.randomUUID()生成一个全新的threadId而不是放在全局变量里。这个其实是Next.js开发者很容易掉进去的坑因为本地开发时组件默认不会重新mount看起来一切正常但生产环境并发请求一上来就暴露了。5.4 recursionLimit导致Agent执行失败LangGraph.js有一个默认的recursionLimit默认25意思是整个图最多执行25个节点。对大多数Agent来说25次足够用但我的简历Agent有个重试节点如果模型连续输出坏格式触发了多次重试再加上主链路的4个节点偶尔会超出限制报错。处理方式有两种一是在compile()时调大recursionLimit二是控制重试次数超过3次就用固定的兜底JSON返回。我最终用的是第二种因为它保证响应时间可控不会因为模型反复出错无限拖下去。5.5 成本控制别让大模型反复解析同一份简历简历解析这个节点调用的模型如果用的是高精度大参数模型成本并不低。一份典型简历大约1500字解析一次对应几千个token的输入输出。如果用户在“解析确认”环节修改了简历原文比如发现AI漏了一段经历重新跑整个图会导致解析节点再跑一次成本直接翻倍。我的优化方案是把rawResume的哈希值作为缓存key如果用户没有改动原始文本直接复用之前的parsedResume跳过解析节点。在LangGraph.js里可以在节点入口判断缓存命中就直接返回已有结果不调用模型。这个优化在长期运行后能省下不少钱。6. 部署与性能调优的实战建议6.1 把LangGraph.js的状态存进数据库而不是内存项目早期我用的checkpointer是MemorySaver所有状态都存在进程内存里。测试环境没问题但生产环境有两个隐患一是重启后所有任务状态丢失二是多实例部署时用户可能被负载均衡打到不同实例导致invoke时找不到对应的thread_id。后来换成了数据库支持的checkpointer。LangGraph.js官方提供了多种checkpointer实现可以直接接入Postgres。核心改动只有一行import { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer PostgresSaver.fromConnString(process.env.DATABASE_URL!); const compiledGraph graph.compile({ checkpointer });换成数据库持久化之后任务状态跨实例、跨重启都稳稳的。这是我认为“从Demo到生产”最关键的一步很多人忽略了这个上线第二天就因为进程重启丢了一堆用户任务。6.2 长任务用队列还是直接同步等待简历生成任务耗时通常15到30秒如果直接在Route Handler里同步等待用户在请求期间要保持HTTP连接不中断一方面体验不够好另一方面在Serverless环境比如Vercel很容易触发超时限制。我的落地方式是生成类长任务启动后立即返回task_id前端轮询状态只有SSE流式返回是在一个持续连接里做。这样既兼顾了实时性也规避了同步等待的部署限制。如果你不想自己写轮询也可以用Trigger.dev或者Inngest这类任务队列服务把生成任务塞进队列再回调通知架构上更干净。6.3 模型选型与prompt调优的参数建议我调试过程中对比了几组模型参数最终稳定的配置是解析和评分用temperature 0.1建议生成和简历生成用temperature 0.5。原因很简单解析和评分要的是稳定和准确温度越低越好建议生成要有一定的发散性温度太低会导致所有建议都长得很像千篇一律。另外所有节点的Prompt我都显式声明了“以JSON格式返回”并且把zod schema转换成JSON Schema塞进Prompt里。这比让模型自由发挥返回格式的效果好太多也大大减少了前面提到的“JSON被代码块包裹”问题的出现概率。7. 复盘LangGraph.js简历Agent的架构价值与可扩展性这个项目做完之后我最大的体会是Agent类应用不要一上来就写代码先把“图”画出来。LangGraph.js的真正价值不是帮你会用大模型而是逼着你去梳理业务链路里的节点、状态、条件分支和人工干预点。梳理清楚之后实现就是按图施工后面加需求也是按图加节点思路会特别清晰。这个架构的可扩展性也体现在多个方面。比如现在只支持“按目标岗位优化简历”后续完全可以加一个“面试题预测”节点在optimize_suggestions之后基于简历内容和目标岗位生成一份模拟面试题。只需要在图上加一个节点再加一条边不需要动现有的任何代码。再比如可以做“简历版本对比”把generate_resume节点备份一份原始文本然后生成多个风格版本的简历让用户选择。这也是在图上加并行分支的事情LangGraph.js天然支持并行节点。我现在已经把同一套图架构复用到了部门里的其他文档处理工具上只是换了节点内容状态的流转逻辑完全不用动。这就是图编排框架带来的架构红利——先在逻辑层面把流程梳理清楚再落到框架里开发效率会大幅提升。最后再分享一个小技巧调试LangGraph.js的图时可以用它官方提供的drawMermaid()方法把图直接生成Mermaid格式代码然后拿到任意Mermaid渲染器里可视化。我看了一眼生成的图整个Agent的执行链路一目了然给同事讲代码的时候用它做图示比自己画半天流程图高效多了。不过生产环境记得关掉这个调试输出避免暴露内部结构。