ARTICLE DETAIL

资讯详情

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

Next.js + LangGraph.js:打造有状态简历Agent的工程实践

Next.js + LangGraph.js:打造有状态简历Agent的工程实践 简历工具这个品类我见过太多人做过但大多数都做浅了。无非是接一个大模型API把用户简历和岗位描述拼到一起一个Prompt打过去出来一堆泛泛而谈的建议——没有状态、没有流程、没有工具调用连用户中途想改一下期望岗位这种基本需求都处理不了。这个项目里我尝试用Next.js做全栈界面用LangGraph.js把解析简历→提取结构化信息→岗位匹配分析→生成改写建议→按需自动改写这一整条链路变成一个有状态、可中断、可恢复的Agent流程落地成一个真正能用的简历工具。这篇文章不聊概念直接围绕项目本身拆解为什么选LangGraph.js、图怎么设计、节点怎么写、以及我在Vercel上部署和应对并发时踩过的那些坑。1. 简历工具为什么需要状态编排而不是一次Prompt1.1 用户需求拆解简历工具的核心场景先把这个产品要解决的场景说清楚。我调研了一圈身边HR和求职者的使用习惯发现核心诉求其实集中在三块快速看懂一份简历HR收到一份PDF想快速得到候选人基本信息、工作经历、技能标签、教育背景的结构化摘要不用自己逐行读评估岗位匹配度求职者把心仪岗位的JD贴进来让工具给出匹配百分比、短板项、以及差异化的改进建议自动改写简历片段针对具体岗位把项目经验里平淡的描述改写成有量化指标、有关键词命中的版本。这三件事看起来简单但天然是多步骤的。重要的是步骤之间存在依赖关系没解析出结构化信息就无法做岗位匹配匹配结果不好改写才有的放矢。而且用户的需求常常不是一次性的——他可能看完第一版建议后说我其实更想投产品经理岗这时候整个分析链路就需要基于新的JD重新跑但简历解析结果应该保留复用。这类场景用传统的无状态Prompt调用硬糊会越写越乱。1.2 从单次调用到多步编排的痛点直接调LLM API做这件事最明显的痛点有三个Prompt不可控把解析简历评估匹配生成建议全塞进一个PromptLLM一旦在中间环节理解偏了后面全错而且你无法知道错在哪一步。输出格式稍微复杂点JSON解析就会频繁失败。无法调用外部工具解析PDF需要文件解析库查询岗位信息可能需要联网搜索或者调招聘网站API如果Agent只具备文本进-文本出的能力这些工具根本没法接入流程。没有状态记忆用户中途说换一个岗位JD再分析一遍无状态API只能把整段历史重新塞进上下文既浪费Token又容易超出上下文窗口。LangGraph.js解决的就是这个问题。它把Agent执行过程建模成一个有向图每个节点是一个具体动作解析、提取、分析、改写节点之间通过共享状态对象传递数据支持条件跳转、循环、中断和恢复。你可以把它理解为给LLM套了一层可控的执行编排层流程里哪些环节该调模型哪些环节该走代码逻辑全由你决定。这种感觉更像是用代码在规划Agent的工作流而不是把一切交给模型的即兴发挥。2. 整体架构设计Next.js全栈 LangGraph.js编排 LLM大脑2.1 Next.js承担的角色交互层与API边界这个项目我选择Next.js作为骨架核心原因是它的App Router可以直接把API Route和前端页面放在同一个项目里部署到Vercel上时每个路由天然就是一个Serverless函数。对于简历工具这类轻交互、重后端的应用这个模式非常合适前端页面处理用户上传简历、粘贴JD、展示Agent执行进度和流式结果app/api/agent/route.ts作为Agent的入口接收到请求后初始化LangGraph.js的图实例并执行执行过程中的状态快照、进度信息通过流式响应推给前端用户能实时看到解析中→分析中→生成建议中的进度条。这样的分工让LangGraph.js只关注Agent编排本身不掺合HTTP协议、页面渲染这些事。同时Next.js的Server Action或Route Handler天然支持ReadableStream我们可以很方便地把LangGraph.js的流式事件转发给前端体验非常顺滑。2.2 LangGraph.js的图结构设计LangGraph.js的核心API是StateGraph。我们需要先定义一个全局的AgentState类型它会在所有节点之间传递和累积。对于简历工具我设计了如下状态字段字段类型说明rawTextstring从PDF或粘贴文本中解析出的原始文本parsedResumeobject结构化简历信息技能、经历、项目等jobDescriptionstring用户输入的岗位JDmatchScorenumber匹配度评分0-100issuesIssue[]分析出的简历问题清单suggestionsSuggestion[]针对问题的改进建议rewritingQueuestring[]等待改写的简历片段ID列表rewrittenSectionsRecordstring, string改写后的片段内容userApprovalboolean用户是否确认自动改写状态定义好了之后图的设计关键在于节点的边界。我的原则是一个节点只干一件事节点之间通过状态解耦。最终图的结构如下import { StateGraph, END } from langchain/langgraph; const graph new StateGraphAgentState() .addNode(parseResume, parseResumeNode) .addNode(extractInfo, extractInfoNode) .addNode(analyzeMatch, analyzeMatchNode) .addNode(generateSuggestions, generateSuggestionsNode) .addNode(rewriteResume, rewriteResumeNode) .addEdge(__START__, parseResume) .addEdge(parseResume, extractInfo) .addEdge(extractInfo, analyzeMatch) .addEdge(analyzeMatch, generateSuggestions) .addConditionalEdges(generateSuggestions, shouldAskUser, { yes: rewriteResume, no: END, }) .addEdge(rewriteResume, END) .compile();这里shouldAskUser是一个条件函数当生成的建议里包含需要用户确认的改写动作时图会继续走向rewriteResume节点否则直接结束。实际项目中$rewriteResume节点内部还可以通过interrupt机制挂起等用户在网页上点击确认后再用Command(resume)恢复执行。这一小段设计就是我选择LangGraph.js而非简单prompt链的根本原因——它把人机协作变成了一等公民。3. LangGraph.js核心节点实现从表单上传到建议输出3.1 定义Agent状态的TypeScript类型代码永远是落地过程中最诚实的部分。先看状态类型的完整定义这决定了所有节点能访问什么数据type ParsedResume { name?: string; contact?: string; skills: string[]; experience: ExperienceItem[]; education: string[]; }; type AgentState { rawText: string; parsedResume: PartialParsedResume; jobDescription: string; matchScore: number; issues: string[]; suggestions: Array{ section: string; original: string; suggestion: string; }; rewritingQueue: string[]; rewrittenSections: Recordstring, string; userApproval: boolean; };这几个字段在节点间传递时要注意LangGraph.js的StateGraph默认使用Annotation来定义状态如何合并。比如issues如果是数组默认写入是直接覆盖还是追加必须显式声明。我建议使用Annotation的reducer来控制数组追加否则容易出现前一个节点的输出被后一个节点覆盖的诡异问题import { Annotation } from langchain/langgraph; const AgentStateAnnotation Annotation.Root({ rawText: Annotationstring(), parsedResume: AnnotationPartialParsedResume(), issues: Annotationstring[]({ reducer: (prev, next) [...(prev ?? []), ...(next ?? [])], }), suggestions: AnnotationArray{ section: string; original: string; suggestion: string }({ reducer: (prev, next) [...(prev ?? []), ...(next ?? [])], }), });这样当多个节点都向状态里追加issues时最终会是一个累积数组而不是互相覆盖。3.2 简历解析节点PDF/文本的处理与结构化提取parseResume节点的职责是把用户上传的PDF变成纯文本。我是在Node.js的route handler里做文件解析的因为直接给LangGraph.js传Promise也是可以的它支持异步节点。核心技术栈用的是pdf-parse同时兼容用户直接粘贴的纯文本。async function parseResumeNode(state: AgentState): PromisePartialAgentState { // PDF或文本已在上传时转为原始文本这里只需要清洗 const cleaned state.rawText .replace(/\r/g, ) .replace(/[ \t]/g, ) .replace(/\n{3,}/g, \n\n); return { rawText: cleaned }; }真正的重头戏在extractInfo节点。我试过用LangChain的createStructuredOutput方法但后来还是改回了手动构造Prompt 解析JSON的方式。原因有两个一是结构化输出方法在不同模型上的兼容性参差不齐二是简历文本格式千奇百怪我需要对LLM输出的JSON先做一次JSON.parse异常处理解析失败时能降级走正则提取。这个兜底逻辑必须由我自己控制不能依赖框架的黑盒。async function extractInfoNode(state: AgentState): PromisePartialAgentState { const prompt 根据以下简历文本提取结构化信息。输出JSON格式包含name、skills数组、experience数组每个元素有title, company, period, description、education数组。只输出JSON不要多余文字。简历文本 ${state.rawText}; const response await model.invoke(prompt); const content response.content.toString(); const cleanedJson content.replace(/json|/g, ).trim(); try { const parsed JSON.parse(cleanedJson); return { parsedResume: parsed }; } catch { // 兜底粗粒度正则提取 const skillsMatch state.rawText.match(/(?:技能|熟悉|掌握)[:](.)/); return { parsedResume: { skills: skillsMatch ? skillsMatch[1].split(/[,、]/) : [], }, }; } }这一段的经验是LLM输出JSON时永远不要把解析结果直接交给下游节点。你必须在你自己的代码里做一次解析、清洗和类型校验否则一个多余的json会让下一步的Prompt直接崩掉。3.3 岗位匹配与评分节点结构化输出与工具调用analyzeMatch节点要做的事是把简历的结构化信息parsedResume和岗位JDjobDescription放到一起让LLM给出匹配分和差距分析。这里的关键是让LLM返回高度结构化的结果这样前端才能直接渲染成评分卡片而不是一段散文。async function analyzeMatchNode(state: AgentState): PromisePartialAgentState { const prompt 你是一个资深HR。根据简历信息和岗位JD分析匹配度。 简历信息${JSON.stringify(state.parsedResume)} 岗位JD${state.jobDescription} 输出JSON格式包含 - matchScore: 0-100的整数 - issues: 字符串数组每个是一条简历短板 - suggestions: 数组每项包含section简历中的区块标识、original原文、suggestion具体改进建议 只输出JSON。; const response await model.invoke(prompt); const result safeParseJson(response.content.toString()); return { matchScore: result.matchScore ?? 0, issues: result.issues ?? [], suggestions: result.suggestions ?? [], }; }我在实际使用中发现matchScore这个数字不能只生成一次。同一份简历用户先后给了两份不同JD会得到两个分数。为了不让用户困惑我在前端会记录对比曲线但这块不是本文重点重点是analyzeMatch节点的输出必须是可验证的JSON因为这后面会簇拥其他节点逻辑。3.4 简历改写节点调用多工具按需修改rewriteResume是最后一个核心节点。它会遍历state.suggestions中标记为需要改写的项调用一个内部工具函数rewriteSection让LLM针对每一段原文生成改写版本。这个工具调用模式正好是LangGraph.js相对LangChain的一个优势点节点内部可以定义多个工具图引擎负责路由。const rewriteSectionTool async ({ section, original, keyword }: { section: string; original: string; keyword: string }) { const prompt 请改写以下简历片段突出关键词${keyword}增加量化成果保持真实不超过100字。 ### 原文 ${original} ### 改写后; const response await model.invoke(prompt); return response.content.toString(); }; async function rewriteResumeNode(state: AgentState): PromisePartialAgentState { const updatedSections: Recordstring, string {}; for (const item of state.suggestions) { if (!item.original) continue; const keyword extractKeyword(item.suggestion); updatedSections[item.section] await rewriteSectionTool({ section: item.section, original: item.original, keyword, }); } return { rewrittenSections: updatedSections }; }注意这个循环里每个片段都单独调一次LLM别把多个片段一次性塞进一个Prompt。原因参看后文Token成本控制部分——简历文本一旦超长LLM对长文本尾部的注意力会显著下降分段处理的效果比一次性处理稳定得多。3.5 条件分支与人工确认让Agent停下来上面提到shouldAskUser这个条件函数。落地时它承担一个关键职责判断是否有建议项需要用户确认后才能改动。因为简历是求职者的脸面AI不能不经确认就擅自改写尤其涉及工作经历、时间线这些敏感信息时。LangGraph.js提供了interrupt函数。它在节点内部被调用时会抛出一个特殊信号让图的执行暂停并把自定义的payload返回给调用方。前端拿到这个payload后渲染重写后的片段预览确认/拒绝按钮用户点击后后端通过Command(resume...)恢复图运行。这是LangGraph.js的一个杀手级能力。import { interrupt } from langchain/langgraph; async function rewriteResumeNode(state: AgentState): PromisePartialAgentState { const updatedSections: Recordstring, string {}; for (const item of state.suggestions) { if (!item.original) continue; const keyword extractKeyword(item.suggestion); updatedSections[item.section] await rewriteSectionTool({ section: item.section, original: item.original, keyword, }); } // 通过interrupt暂停执行等待用户确认 const userDecision interrupt({ event: awaiting_approval, sections: updatedSections, }); if (userDecision.approved) { return { rewrittenSections: updatedSections, userApproval: true }; } return { userApproval: false }; }这里有个细节interrupt必须在节点执行过程中被调用并且调用后整个图的状态会被持久化到checkpointer里。下次用同一个thread_id恢复时LangGraph.js会自动从断点继续。这个机制是我把简历工具从Demo推进到可用状态的转折点。4. 实战中的坑并发、流式输出与Token成本4.1 流式输出从转圈等待到逐字返回最开始我的实现是等整个Agent跑完再返回JSON给前端用户看着生成中转圈体验非常糟糕。简历工具的核心链路里有好几次LLM调用最长的流程解析→提取→分析→改写→确认可能要等20秒以上。Later I switched to streaming.LangGraph.js的stream方法和streamEvents方法是两个路径。我用得最多的是streamEvents因为它可以按事件类型过滤出节点级别的进度。在Next.js的Route Handler里把它和Web的ReadableStream转发到前端非常直接export async function POST(req: Request) { const { resumeText, jdText, threadId } await req.json(); const graph buildGraph(); const config { thread_id: threadId }; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const events await graph.streamEvents( { rawText: resumeText, jobDescription: jdText }, { ...config, version: v2 } ); for await (const event of events) { if (event.event on_node_start) { controller.enqueue(encoder.encode(event: node_start\ndata: ${event.nodeName}\n\n)); } if (event.event on_chat_model_stream) { const chunk event.data?.chunk?.text?.(); if (chunk) { controller.enqueue(encoder.encode(data: ${chunk}\n\n)); } } } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端用EventSource或fetch的ReadableStream去消费这个事件流把节点状态和文字增量渲染成步骤卡片逐字输出的UI。这一步做完产品的体验直接提升了一个档次。4.2 并发与超时Serverless环境下的执行时长限制这恐怕是AI Agent怎么扛并发这个话题下最核心的东西。简历工具如果部署在Vercel上默认Serverless函数的执行时长上限是有限的。在Hobby计划下是10秒Pro计划可以到60秒但即便如此我的Agent链路在非流式情况下常常跑不到头就超时了。解决思路我验证过三条缺一不可第一条尽早开启流式响应。只要响应头先返回即使后端还在执行前端也不会超时无反馈。把Agent的执行转成流让函数持续吐字节这是应对Serverless超时最实用的手段。第二条拆分重活避免一站式超长执行。简历解析和文本提取这两个节点干的事太杂了。我把PDF解析这个步骤从Agent里挪出来放到了上传接口里先执行。这样Agent实际承担的只是文本进→文本出的轻量节点单次执行时长能压缩一半以上。第三条设LLM超时与重试。LangGraph.js对每个节点没有默认超时你需要自己在节点内部用Promise.race或AbortController给LLM调用加一个15秒超时。我踩过的坑是上游模型偶发慢响应一个节点卡死整个图挂在Serverless函数上直到平台强制杀掉。加超时后这种情况基本绝迹。另外如果预计用户量大Vercel的maxDuration直接拉满到最大档位并且给API路由加上export const maxDuration 60。应对并发上Serverless架构的好处是天然横向扩容但代价是LangGraph.js的checkpointer不能存内存里——必须用Redis这类共享存储否则不同实例间找不到对方的状态。我用langchain/redis和Upstash的Redis适配器做了checkpointer实测在并发请求下状态恢复是可靠的。4.3 Token成本控制简历内容太长怎么办简历工具是最典型的文本可能很脏但长度有限的场景。一份真实简历的纯文本通常在2000到5000字之间直接塞给LLM做信息提取不仅慢而且Token消耗惊人。我一开始没在意直到有天一个用户传了一份排版混乱的十页PDF那一单光模型输入就花了近两万Token成本直接爆表。后来我做了三件事预处理裁剪用正则去掉多余空行、页眉页脚把简历正文截断到前8000字符分段提取不是一次性让LLM输出完整结构化简历而是先按标题块教育背景、工作经历、技能切成小段逐段用LLM提取最后代码合并缓存解析结果同一份简历的原始文本哈希入Redis下次上传相同文件直接命中缓存不再重复调用LLM解析。这三招叠加下来单次简历解析的Token成本从原来的约6000 Token降到1500 Token左右用户体感反而更快。顺带说一句如果你用LangGraph.js的checkpointer保存中间状态注意别把它跟结果缓存混在一起——状态快照是给图恢复用的结果缓存是给跳过重复计算用的两者生命周期完全不同。4.4 checkpointer与人工中断的正确用法LangGraph.js的interrupt依赖checkpointer但如果你只在内存里创建一个MemorySaver实例那么Serverless环境重启后状态会全部丢失。这是生产环境最大的一个坑。我的做法是用Redis保存checkpoint。核心配置如下import { Redis } from ioredis; import { RedisSaver } from langchain/langgraph-checkpoint-redis; const redis new Redis(process.env.REDIS_URL!); const checkpointer new RedisSaver(redis); const graph buildGraph().compile({ checkpointer });每次请求携带thread_id就能从同一个断点继续执行。这里有个容易忽略的点interrupt恢复时LangGraph.js要求你必须传入与中断前相同的thread_id而且新传的状态必须与之前的状态类型兼容。否则它会当作新线程处理之前的进度全部白费。我在简历工具的UI里把thread_id绑定到用户浏览器的sessionStorage。用户刷新页面后如果Agent还在等待确认就能直接恢复对话。这个体验做对了用户会觉得Agent记得我。5. 部署上线与生产环境注意事项5.1 环境变量与密钥管理这看起来很简单实际执行中有很多细节。首先是所有模型密钥、Redis URL一律走环境变量本地用.env.local部署到Vercel用它的Environment Variables面板。我遇到过的典型问题有密钥包含特殊字符在.env.local没加引号导致解析异常在团队协作时把.env.local提交进了仓库差点泄漏。后来我在.gitignore里加强了排除搭建了两个模型供应商的备用方案所以环境变量里同时存在OPENAI_API_KEY和ANTHROPIC_API_KEY代码里需要做优先级切换。建议写一个getModel()函数统一收敛模型实例的创建这样后续切换供应商只改一处。5.2 在Vercel上部署LangGraph.js应用的注意事项Vercel的Serverless环境对Node.js的支持很完整但有几个坑需要规避体积控制LangGraph.js和LangChain相关依赖加起来体积不小如果打包到边缘函数会直接超限。全部走Node.js运行时的Serverless函数不要试图跑到Edge Runtime上PDF解析这类Node API在Edge里根本不存在。依赖分离把pdf-parse、canvas这类重的Node模块放到独立的API路由文件里避免它们被打进前端主包的依赖图。启用增量打包Vercel的outputFileTracingExcludes配置可以排除不必要的文件让部署产物更小。我最终把函部署包从120MB压到38MB左右冷启动速度明显改善。部署前还有一道关卡是构建检查。由于LangGraph.js的图定义在模块顶层创建如果包含interrupt相关的逻辑构建时可能被tree-shaking误伤。我的建议是图定义和状态类型单独一个文件只在compile()时才会真正实例化图这样SSG/SSR阶段完全不会碰Agent逻辑。5.3 监控与错误处理Agent类应用最怕静默失败——LLM返回了一段看起来合理但其实是胡说八道的内容用户察觉不到。我在生产环境做了三样监控日志每个节点进出时打印耗时和状态关键字段汇总到日志服务里。LangGraph.js本身有on_node_start和on_node_end事件直接通过graph.streamEvents统一捕获不需要自己埋点。成本统计每个节点的LLM调用累计的inputTokens和outputTokens需要记录按thread_id聚合我做了个简单的日报表能直观看到哪些环节在烧钱。错误文案无论节点抛错还是LLM解析失败前端一律显示暂时无法处理请稍后重试而具体错误堆栈只出现在服务端日志里。这样既避免泄露内部细节也减少了用户恐慌。写在最后的实际操作体会这个项目从第一个能跑的Demo到部署上线中间隔了大概三周最耗时的不是写代码而是弄清楚什么时候该让Agent自己跑什么时候该停下来问人。简历这种东西用户天然有强烈的掌控欲你让AI全自动改他不敢用你每个环节都让他确认他又嫌烦。LangGraph.js的interrupt机制给我提供了一个非常好的折中——AI负责批量产出改写草案用户只做一次全局确认。还有一点我想多说一句很多人用LangGraph.js时容易陷入把所有逻辑都塞成节点的过度设计。实际上像文本清洗、结果缓存、超时控制这类纯功能性代码放在图外面做会更清晰。图的节点应该尽量纯只负责调模型和加工模型返回的结果才能让整个流程可维护。如果你也想做一个类似的Agent工具我建议先从最小的图开始一个parse节点一个generate节点先跑通流式输出和checkpointer再逐步加条件分支和人工中断。不要一上来就追求复杂的多节点图不然后面排查问题会非常痛苦。简历工具是个很好的练手项目因为它的每一步都是真实需求不是为了Agent而Agent。
返回列表