ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LangGraph.js重构简历分析工具:Next.js全栈AI Agent落地实践

LangGraph.js重构简历分析工具:Next.js全栈AI Agent落地实践 上个月我把一个纯靠手写状态机的简历分析工具重构成了LangGraph.js驱动的AI Agent效果提升非常明显——这周又在整个流程的前端落地成了Next.js全栈应用。这篇文章把这次完整落地的思路、代码和踩坑记录都梳理一遍包括为什么在简历工具这个场景里不硬写if-else流程、图节点和工具调用怎么设计、流式输出在App Router里怎么配置不卡顿以及部署上线时绕了不少路才发现的几个隐蔽问题。如果你正准备在Next.js项目里接入一个正经的AI Agent或者已经在用LangGraph.js但想看看别人怎么落地这篇应该能省你不少时间。先放结论简历工具这个场景真正的核心不是让LLM自由发挥而是用可控的图结构把解析→分析→建议→改写四个步骤串起来LLM只负责每个节点内最擅长的判断和生成流程本身必须由代码兜底。1. 为什么选LangGraph.js而不是继续硬写状态机1.1 简历工具的真实痛点我之前那个工具的问题很典型用户上传一份简历填一个目标职位后端把文本拼接进Prompt一次性丢给模型让它输出匹配度分析优化建议。看起来简单实际用起来全是问题。首先模型一次输出的结果很不稳定有时候分析洋洋洒洒两千字建议却只有三行结构完全不可控。其次用户带着问题来追问第二点能展开说吗我改成这样行不行程序根本没法记住前两次分析的状态只能把整段历史当上下文重新塞进去既贵又容易把模型绕晕。最难受的是流程里几乎没有兜底机制简历是扫描件、岗位描述只有一句话、PDF解析出来是乱码任何一个异常情况都会导致整个请求翻车。本质上简历工具不是一个单次问答而是一个典型的短流程业务解析、评估、给建议、做改写每一环节都有明确输入输出环节之间还有依赖关系。写代码的人需要的是流程可控、状态可追踪、失败可回退而不是把一切交给模型自由发挥。1.2 LangGraph.js带来的核心变化LangGraph.js把Agent建模成一张图节点Node负责干活边Edge负责决定下一步状态State在整张图里流转并持久化。这个思路跟后端工程师熟悉的工作流引擎很像但它有一层独特的优势——节点内部可以调用LLM也可以调用普通的函数工具所以图结构LLM的组合让代码既保持确定性又保留智能性。落到简历场景里最明显的一个变化是我把分析简历这个整块Prompt换成了四个有明确输入输出的节点模型不再一次性吐出全部结果而是在每个节点只做一件事。这样我可以在节点之间做校验比如解析节点如果提取不到姓名和邮箱就直接走补充信息分支而不是让模型在后续步骤里继续硬猜。用一句大白话来说LangGraph给我的是轨道LLM是油门我不需要担心它跑到别的路上去只需要在关键路口决定往左还是往右。1.3 和其他方案的对比取舍动手之前我也考虑过其他方案直接调LangChain.js的AgentExecutor或者干脆自己在Next.js里用队列加回调硬写。最后选LangGraph.js的原因很简单它序列化友好、状态结构可控非序列化字段比如函数、流可以单独配置这在Next.js的Serverless环境下非常关键。LangChain.js的AgentExecutor简洁但过于魔法化调试起来不直观自己硬写状态机则要处理大量的历史记录拼接和分支跳转逻辑简历场景的多步流程用Graph描述更清晰。addEdge和addConditionalEdges这种方式一眼就能看出流程骨架对后期维护也友好得多。2. 整体架构前端、后端与Agent运行时如何协作2.1 Next.js全栈结构拆分整个项目是在Next.js App Router下搭的全栈应用。前端部分负责三件事文件上传与表单填写、调用后端API并流式接收消息、把Agent返回的结构化数据用组件渲染成可读的报告。后端在Route Handlers层开了两个接口POST /api/agent/resume接收简历文本、岗位描述和用户意图启动Agent流程并流式返回结果。POST /api/agent/resume/rewrite在Agent执行改写节点时专门处理对某一小节重写的请求。实际跑起来你会发现把Agent运行时放在API Route里好处是天然的隔离和无状态——每个请求都是独立的图执行实例Serverless环境里也很稳定。前端用fetch的ReadableStream读取流式响应逐段更新UI体验就是让简历工具一边思考一边输出而不是转圈圈等很久。2.2 数据流设计从上传到结果报告数据流是这样的前端上传PDF后先用pdf-parse把内容抽成纯文本。这一步我没放在Agent图里因为它是纯I/O和文件操作不需要LLM参与。抽出来的文本和用户填的岗位描述一起作为Agent图初始状态的一部分。进入图之后数据依次经过解析节点把简历文本结构化、匹配分析节点对比简历与岗位关键词、建议生成节点产出可执行的优化项、改写节点基于原生简历文本生成优化后的段落。每个节点的输出都会写回共享的State对象所以外层随时可以拿到中间产物来渲染进度条或做部分展示。2.3 为什么要保留一份原生简历副本这里有个经验值得分享改写节点必须基于原始简历的某一整段而不是基于LLM自己理解的分析结果来改写。原因很现实——模型把简历语义压缩再扩写的时候很容易丢掉细节比如起止时间、技术栈名称、项目里的量化指标。所以State里我同时存了resumeText原始全文和resumeSections解析后的分块改写时永远以分块原文为输入避免信息在多次传递中损耗。3. 核心实现图定义、节点与工具落地3.1 状态类型设计代码层面我用的LangGraph.js的Annotation方式来定义State关键的字段如下const AgentState Annotation.Root({ messages: AnnotationMessage[]({ reducer: (current, incoming) current.concat(incoming) }), resumeText: Annotationstring({ reducer: (_, incoming) incoming }), jobDescription: Annotationstring({ reducer: (_, incoming) incoming }), parsedResume: AnnotationResumeData | null({ reducer: (current, incoming) incoming ?? current }), analysis: AnnotationMatchAnalysis | null({ reducer: (current, incoming) incoming ?? current }), suggestions: AnnotationSuggestion[]({ reducer: (_, incoming) incoming }), rewrittenSections: AnnotationRecordstring, string({ reducer: (current, incoming) ({ ...current, ...incoming }) }) });注意这里messages的reducer是拼接而resumeText等业务字段是覆盖。这个设计决定了状态在整个图中的行为对话历史可以累积但业务数据始终以最新节点输出为准。如果你把所有字段都做成拼接图跑几轮之后State会越来越冗余prompt拼接时成本直接翻倍。3.2 节点函数一个节点只做一件事以核心的匹配分析节点为例它的输入是解析后的简历结构加岗位描述输出是一份结构化的匹配分析async function analyzeMatchNode(state: typeof AgentState.State) { const { parsedResume, jobDescription } state; const prompt 你是一位有8年经验的互联网行业HR同时熟悉技术岗位的能力模型。 请分析以下简历与目标职位的匹配程度。 目标职位${jobDescription} 简历信息 姓名${parsedResume.basics?.name} 技能${parsedResume.skills?.join(, ) ?? 未知} 工作经历${JSON.stringify(parsedResume.workExperiences ?? [])} 教育背景${JSON.stringify(parsedResume.education ?? [])} 请输出严格JSON格式如下 { matchScore: 0-100, matchedKeywords: [...], missingKeywords: [...], gapAnalysis: { experience: 一段分析文字, skills: 一段分析文字, highlights: 一段分析文字 }, prioritySuggestions: [..., ...] } 不要输出任何多余文字。; const response await model.invoke([ { role: system, content: 你只输出合法JSON。 }, { role: user, content: prompt } ]); const analysis safeExtractJson(response.content); return { analysis }; }这里有一个关键细节让模型输出JSON时不要寄希望于它一定会遵守格式。safeExtractJson里要处理Markdown代码块包裹、截断JSON、漏掉括号等情况。简历场景尤其容易触发这些问题因为简历文本里会出现各种分号、引号、中英文混排模型输出JSON时稍微抖动一下整个下游就崩了。所以在工具调用可用的情况下我更推荐用model.bindTools把OutputJSON做成一个固定工具模型通过tool call输出结构化结果根本不给你生成自由文本的机会。节点函数的返回值会通过reducer合并进State并自动传给下游节点。从这个角度看每个节点就是读老状态写新状态的纯函数非常好做单元测试。3.3 图构建与条件边图的构建是用Graph API来串联的const graph new StateGraph(AgentState) .addNode(parse_resume, parseResumeNode) .addNode(analyze_match, analyzeMatchNode) .addNode(generate_suggestions, generateSuggestionsNode) .addNode(rewrite_resume, rewriteResumeNode) .addNode(answer_followup, answerFollowupNode) .addEdge(START, parse_resume) .addEdge(parse_resume, analyze_match) .addEdge(analyze_match, generate_suggestions) .addConditionalEdges(generate_suggestions, routeAfterSuggestions, { rewrite: rewrite_resume, answer: answer_followup }) .addEdge(rewrite_resume, answer_followup) .addEdge(answer_followup, END) .compile();条件路由是LangGraph.js最有用的部分。routeAfterSuggestions检查用户意图如果用户当前的问题是帮我把工作经历那段改写得更突出就走rewrite_resume分支如果只是追问为什么你建议补充这个技能就走answer_followup分支。这个判断本身也是用LLM做一次轻量调用。我记得早期版本里我尝试过用内置的ToolNode来实现这个路由后来调整为普通节点后反而更清晰——工具毕竟是给模型动手干活用的跟选择流程分支是两码事不要混在一起。3.4 工具注册的边界图里也注册了工具我这里给了模型两个工具readFile读取解析后的原件段落注意是白名单路径不接受任意路径和writeDraft记录模型生成的改写段落。工具和普通节点的区别在于工具是模型自主决定调用的、粒度更细的动作而普通节点是代码里固定编排好的流程岗位。简历场景里我倾向于少工具、多用节点因为工具越多LLM的决策空间越大翻车的概率指数级上升。一句话工具是给模型玩的花样节点是项目底盘底盘要稳花样要少。4. 流式输出与前端交互4.1 在App Router里配置SSENext.js App Router的Route Handler天然支持ReadableStream流式输出这块其实不复杂但有三个坑值得记一下export const runtime nodejs; export const dynamic force-dynamic; export async function POST(req: Request) { const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { const payload await req.json(); const result await graph.stream( { resumeText: payload.resumeText, jobDescription: payload.jobDescription, messages: [{ role: user, content: payload.prompt }] }, { streamMode: updates } ); for await (const [nodeKey, update] of result) { if (nodeKey) { controller.enqueue( encoder.encode(data: ${JSON.stringify({ node: nodeKey, data: update })}\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 } }); }第一坑streamMode有两个常用值messages能拿到节点执行过程中的模型token流updates能拿到每个节点完成后的结构化数据。简历工具我用的是updates模式因为最重要的就是每个节点返回的结构化结果这些结果可以直接塞给前端组件渲染纯token流反而不好派用场。第二坑订阅的是nodeKey而不是数据本身注意做空值判断否则图中间步骤精确到哪些节点不产出数据时会报错。第三坑SSE响应头里Connection: keep-alive在Serverless平台可能被忽略但写上无妨关键是真的用ReadableStream逐块推送而不是攒到最后一次性返回。前端收到data:行后按空行切分解析JSON更新UI表现就是表格一行行蹦出来的效果。4.2 前端渲染结构化报告比聊天框更重要简历工具最终交付给用户的是报告不是聊天记录。所以状态里每个节点的结构化输出我都映射到单独的React组件匹配得分用进度环、技能关键词用标签云、建议列表用可勾选To-do、改写段落做成两栏对照。核心思路是以图的状态更新为数据源UI只做投射。这个设计和纯聊天式Agent有个很大的差异聊天式Agent把所有输出都塞进消息列表而结构化Agent把消息当审计日志、把节点产出当数据。把最终结果沉淀成数据这一点是项目从玩具变成工具的分水岭。5. 常见问题与排查实录5.1 结构化输出偶发解析失败这是简历Agent最容易踩的坑。模型在上下文复杂、输出长度偏长时会带出Markdown标记或者在JSON前后多打一行注释用了工具调用以后好很多但还是会有模型不按绑定工具输出、直接返回文本的情况。我的处理方案是在下游加一层解析兜底如果response.content里有{尝试截取从第一个{到最后一个}的子串再做JSON.parse。解析失败时不抛异常让整张图中断而是返回一个{ parseError: true }的占位状态下游节点检查到该字段后自动切到宽容模式用纯文本回答兜底。第二个方案是我觉得最值的一个决策——把解析失败也当成一个正常的分支走而不是当成异常退出。5.2 上游输入乱码或信息太少PDF解析出来全是乱码、或者岗位描述只写了开发工程师五个字这是简历业务的家常便饭。在解析节点结束后我会检查parsedResume.basics.name是否存在不存在就路由到一个询问补充节点用模型生成一段友好的追问比如我无法从你的简历中识别出你的姓名和联系方式请补充或重新上传清晰版本。这段逻辑极其简单但坚持走图分支而不是直接报错产品体验会好很多。用户感受到的是这个助手在试图解决问题而不是这个页面报错了。5.3 历史消息太长导致费用爆炸Agent跑了几轮追问之后messages字段会越来越长每次调用模型都要把全部历史塞进上下文。我试过两个办法一是用摘要节点把三页以上的旧消息压成一句摘要二是限定只传最近6条消息。实测下来简历场景用近6条最新节点结果就够了毕竟真正的业务数据都落在State的业务字段里并不依赖历史对话里的信息。成本敏感的项目这地方优化一次能省掉30%到40%的token开销。5.4 部署Serverless时的冷启动与超时LangGraph.js本身是个轻量运行时但初次调用要加载模型SDK、建立连接加上PDF解析整个接口可能超过一些平台的默认超时。我的做法是给Route Handler设置较长的maxDuration比如在Vercel上用60秒同时把PDF解析放到Graph之前让图执行只在纯文本上做LLM推理。另外图定义最好放在模块顶层复用避免每个请求都重新编译Graph冷启动耗时能明显降下来。6. 部署与成本优化实践6.1 运行时选择与包体积LangGraph.js可以跑在Node.js和Edge上但我的经验是优先Node.js runtime。原因有两点一是PDF解析和其他文件处理库通常依赖Node原生模块Edge环境根本跑不了二是Edge内置的模型SDK兼容性在流式输出和工具调用场景下偶尔会有边界问题调试成本不值得。包体积方面我只引入了langchain/langgraph和langchain/openai没有把整套LangChain.js拉进来打包后体积小了很多冷启动时间也下来了。这里建议用import直接引用类名尽量避免在文件顶部做全量重导出Tree Shaking效果会好一些。6.2 Prompt分层缓存与降级策略成本优化的核心是不让模型重复做已经做过的事。简历场景里岗位描述和简历解析结果基本不变我把这两段拼好的上下文写入缓存缓存的key用内容哈希用户追问时直接命中缓存不再重新调用模型生成本体分析。降级策略同样重要短时高并发时给模型配一个temperature: 0的备用配置输出更稳定再配合简单的队列重试整体成功率能上来。6.3 日志与可观测性Graph是个理想的可观测性单元。每个节点的入口和出口都打一条结构化日志记录节点名、耗时、关键产出字段的长度、是否命中缓存。排查用户问题的时候我只要点开一条流水号就能看到整张图走过哪几个节点、哪个节点耗时异常、哪段输出被截断。这个能力是纯手写if-else流程完全不具备的也是我推荐LangGraph.js的一个实际理由——它把Agent的决策过程透明化了。最后再说一点个人感受。这次落地让我最意外的不是LangGraph.js本身的功能而是它把一个看起来很AI的场景变得非常有工程感。以前调Agent像抽卡跑一次一个样现在我把流程摊开画成图每个节点厘清输入输出LLM反而被约束得老老实实的——该拆解就拆解、该改写就改写过程可审计、结果可复现。我劝想尝试AI Agent落地的朋友一句不要上来就搞一堆工具和一堆自由对话先把你这业务里最确定的流程画出来再把模型塞进去。简历工具这个方向目前整套代码已经跑在公司内部的招聘辅助流程里下一步我打算把简历转成标准JSON Schema的解析器单独抽成一个通用模块任何HR系统都可以直接复用。这篇就记录到这里有新踩坑经验再来补。
返回列表