☰
Next.js+LangGraph.js简历AI Agent落地实践
2026/10/9 1:55:44 网站建设 项目流程

说实话,这类“简历工具AI Agent”的项目我观摩过不少,但真正从零到一完整落地、把前后端和Agent编排串起来、还能扛住日常并发,而不是只在本地跑个demo,确实需要点功夫。这个项目我前后折腾了一个多星期,从最初“一个接口调大模型”的简陋版本,一路改到今天基于Next.js+LangGraph.js的结构化Agent,中间踩了不少坑。这篇就把整个落地过程、核心代码、并发处理经验都摊开讲清楚,给想自己搭AI Agent的朋友一份可以直接抄作业的参考。

项目本身要解决的需求是:用户上传自己的简历和一个目标岗位JD,系统自动解析简历结构、提取工作经历和技能点,再和JD里的需求关键词做匹配打分,最终生成简历优化建议、面试可能追问的问题,甚至能直接产出针对该JD的简历改写版本。早期版本用单轮Prompt也能做,但最大问题是流程不可控、中间结果不可见、稍微复杂一点的逻辑(比如“匹配度低就自动触发改写”)就没法优雅实现。换成LangGraph.js编排之后,整个流程变成一张可观测、可断点、可回放的状态图,前端也能按节点逐步展示进度,体验和可维护性都上了一个台阶。

1. 项目拆解与技术选型:为什么是Next.js+LangGraph.js

1.1 一个Agent工具的核心需求拆解

动手写代码之前,我先想明白了一件事:这个工具到底“Agent”在哪里,还是只是套了个壳的聊天接口?普通聊天接口是一次请求、一次响应,模型自己发挥;而Agent至少要具备“多步骤任务执行”和“按需调用工具”这两个能力。放到简历场景里,真正有价值的工作流是这样的:

  1. 解析用户上传的简历,提取教育背景、工作经历、技能清单、项目经验等结构化信息;
  2. 解析目标JD,提取岗位关键词、硬性要求、加分项;
  3. 将简历与JD做匹配,输出匹配度分数以及差异项清单;
  4. 根据匹配结果走不同的分支:分数高就生成面试强化问题,分数低就触发简历改写工具;
  5. 最终汇总成一份完整报告,用流式输出推送到前端。

这五个步骤如果用一长串Prompt塞给模型,得到的输出几乎不可控——可能漏掉某个环节,可能凭空捏造经历,更没法在某个步骤失败时定位问题。所以我从一开始就确定要用图编排框架,把每一步固化成节点,让数据在节点之间明确流转。这也是我选LangGraph.js最核心的理由:它能让我用代码定义流程,而不是靠模型自觉。

1.2 技术选型:LangGraph.js提供了什么关键能力

当时我对比过几套方案。纯LangChain.js的话,它更多是提供模型调用封装和链式组合,对“带状态的多步骤Agent”支持不够直接;自己写状态机的话,要处理并发、重试、持久化、分布式协调,工程量太大;最后落在LangGraph.js上,原因很实在:

  • 图状态(Graph State):所有节点共享一个可序列化的状态对象,任何节点的输出都能写回状态,下一节点自动读取。这意味着简历解析结果可以被后续匹配节点直接消费,不需要我手工传参。
  • 显式控制流:节点之间用addEdge定义顺序,用addConditionalEdges定义分支,逻辑全都在代码里,调试时一眼能看明白。
  • Checkpointer持久化:每个运行状态都可以保存到外部存储,支持断点续跑、历史回放。部署到Serverless环境时配合远程Checkpointer,能绕过“函数实例被回收导致状态丢失”的大坑。
  • 回调与流式事件:节点运行中可以上报事件流,前端按步骤展示“正在解析简历…”“正在匹配JD…”,体验拉满。

而前端选择Next.js,理由更简单直接:一套代码搞定页面和API路由,App Router的Server Component天然适合做SSR,再加上API Route可以跑Node.js运行时,恰好能承载LangGraph.js的Agent执行逻辑,不需要额外搭一个后端服务。对于这种体量的工具型项目,这是性价比最高的组合。

1.3 整体调用流程与数据流转

整体架构上,走的是“浏览器 - Next.js API Route - LangGraph.js Agent - 大模型/工具函数”这条链路。浏览器把简历文本和JD文本POST到/api/agent,API Route里创建一次Agent运行,然后以SSE(Server-Sent Events)的方式把各个节点的执行进度和最终结果推回给前端。

数据在Agent内部以这样的顺序流动:初始状态包含resumeText和jdText两个字段 → 解析节点产出parsedResume结构化数据 → 匹配节点读取简历结构并产出matchResult→ 条件边判断是进入“生成问题分支”还是“调用改写工具分支” → 最终节点把全部结果汇总到finalReport字段。前端拿到的不是一个一次性生成的超长文本,而是一段段有语义边界的事件流,看起来就像一个真人在逐步处理任务。

2. 核心实现:从状态图到Agent流程的完整代码

2.1 环境准备与项目初始化

项目基于Next.js 14的App Router模式,Node.js版本要求18以上,TypeScript是标配。初始化命令平平无奇:

npx create-next-app@latest resume-ai-agent --typescript --tailwind --app cd resume-ai-agent npm install @langchain/langgraph @langchain/openai zod

这里有两处需要提前说清楚。第一,不是说必须用OpenAI,@langchain/openai只是个示例,换成DeepSeek、通义千问或者本地跑的Ollama都行,LangChain的模型接口是统一抽象的,后面接个大模型厂商的API就行,使用习惯上基本一致。第二,zod不是可选项,LangGraph.js里做结构化输出时,用zod定义JSON Schema几乎是标准操作,后面解析简历时你就知道它多好用了。

2.2 定义Agent状态与节点编排

整个Agent的核心是状态图。我用Annotation.Root定义了所有节点共享的状态结构:

import { Annotation, END, MemorySaver, START, StateGraph } from "@langchain/langgraph"; import { z } from "zod"; const AgentState = Annotation.Root({ resumeText: Annotation<string>, jdText: Annotation<string>, parsedResume: Annotation<Record<string, unknown> | null>, matchResult: Annotation<{ score: number; gaps: string[]; strengths: string[] } | null>, finalReport: Annotation<Record<string, unknown> | null>, rewriteResult: Annotation<string | null>, error: Annotation<string | null>, });

每个字段的更新遵循“后写覆盖”的规则。这里我特意把error字段也放进状态,后面做节点级容错时,每个节点可以把异常写进状态而不是直接抛出来,保证图能走完并返回部分结果。

节点定义和图的编排如下:

const graph = new StateGraph(AgentState) .addNode("parseResume", parseResumeNode) .addNode("matchJD", matchJDNode) .addNode("generateQuestions", generateQuestionsNode) .addNode("rewriteResume", rewriteResumeNode) .addNode("aggregateReport", aggregateReportNode) .addEdge(START, "parseResume") .addEdge("parseResume", "matchJD") .addConditionalEdges("matchJD", routeByScore, { high: "generateQuestions", low: "rewriteResume", }) .addEdge("generateQuestions", "aggregateReport") .addEdge("rewriteResume", "aggregateReport") .addEdge("aggregateReport", END) .compile({ checkpointer: memory });

注意routeByScore这个条件边,它就是Agent“自主决策”的体现:系统根据匹配度分数决定下一步走哪个分支,而不是预先写死流程。具体路由逻辑也很直白,低于60分就触发改写,否则生成面试问题。

2.3 节点实现:解析简历与匹配JD

所有节点本质上都是普通函数,接收当前状态,返回状态的部分更新。简历解析节点是最关键的一环,早期我试过直接让模型输出JSON文本,结果偶尔会漏字段或者格式出错;后来改用withStructuredOutput配合zod,模型直接返回校验通过的对象,省掉了所有后处理逻辑。

const resumeSchema = z.object({ name: z.string(), yearsOfExperience: z.number(), skills: z.array(z.string()), workExperiences: z.array(z.object({ company: z.string(), title: z.string(), duration: z.string(), highlights: z.array(z.string()), })), educations: z.array(z.object({ school: z.string(), degree: z.string(), major: z.string(), })), }); async function parseResumeNode(state: typeof AgentState.State) { const model = getChatModel().withStructuredOutput(resumeSchema, { name: "resume" }); const parsed = await model.invoke([ { role: "system", content: "你是资深HR,请从用户简历中提取结构化信息,不要修改任何事实。" }, { role: "user", content: state.resumeText }, ]); return { parsedResume: parsed }; }

匹配节点则把简历技能清单和JD里的关键词做一次系统性比对。我让模型从JD里提取“必须要求”和“加分要求”,再与简历技能计算重叠度,同时让模型给出差异分析。这部分输出同样用结构化约束:

const matchSchema = z.object({ score: z.number().describe("0到100的匹配度分数"), gaps: z.array(z.string()).describe("简历中缺失或不满足的要求"), strengths: z.array(z.string()).describe("简历中满足甚至超出的要求"), }); async function matchJDNode(state: typeof AgentState.State) { const model = getChatModel().withStructuredOutput(matchSchema, { name: "match" }); const result = await model.invoke([ { role: "system", content: "你是招聘专家,请严格依据简历与JD的匹配程度打分。" }, { role: "user", content: `简历结构化信息:${JSON.stringify(state.parsedResume)}\nJD内容:${state.jdText}` }, ]); return { matchResult: result }; }

2.4 用工具节点让Agent真正“干活”

光分析不够,Agent还得会动手改简历。这里我用tool装饰器把改写简历定义成一个可被模型调用的工具,让模型在低匹配度分支里自主决定如何调用。工具节点和普通计算节点最大的区别在于:它是“模型选择工具去执行,再把工具结果返回给模型”,而不是预先固定好的纯函数。LangGraph.js中把节点和工具组合,流程就变成了“Agent节点决定调哪个工具,工具执行完,结果进入消息列表,模型继续决策”的循环。

import { tool } from "@langchain/core/tools"; const rewriteResumeTool = tool(async ({ resume, jd, focusPoints }) => { // 实际改写逻辑:逐段针对JD关键词重写工作经历描述 const model = getChatModel(); const rewritten = await model.invoke([ { role: "system", content: "你是资深求职顾问,请针对目标JD重写简历中的工作经历部分,遵循STAR法则,保留事实,突出量化成果。" }, { role: "user", content: `简历:${resume}\nJD:${jd}\n重点修改:${focusPoints.join("、")}` }, ]); return rewritten.content as string; }, { name: "rewriteResume", description: "当简历与JD匹配度低时,针对JD要求重写简历经历描述", schema: z.object({ resume: z.string(), jd: z.string(), focusPoints: z.array(z.string()), }), });

实际项目中我把“重写经历”做成独立节点,节点内部再把这个工具绑定到绑定了工具的模型上,让模型先决定调用哪些参数、再执行工具、最后把结果汇总输出。这样既保留了模型的决策能力,又让工具复用性最大化。如果你后续想加“生成求职信”“整理作品集链接”这些工具,只需要在工具列表里追加,节点代码几乎不用动。

3. 前端交互与实时反馈的实现细节

3.1 用App Router搭建流式API接口

Agent跑在服务端,前端要看到“逐步执行”的效果,必须用SSE把事件推下去。Next.js App Router的API Route支持直接返回ReadableStream,实现起来比传统Node.js后端还简洁:

import { NextRequest } from "next/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { const { resumeText, jdText } = await req.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (event: string, data: unknown) => { controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); }; try { // 初始化Agent,输入状态 const config = { configurable: { thread_id: `resume-${Date.now()}` } }; const events = await graph.stream( { resumeText, jdText }, { ...config, streamMode: "updates" } ); for await (const event of events) { const nodeName = Object.keys(event)[0]; const nodeOutput = event[nodeName]; send("progress", { node: nodeName, output: nodeOutput }); } send("done", {}); } catch (err) { send("error", { message: (err as Error).message }); } finally { controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }, }); }

这段代码里值得注意的就两个点。一是streamMode: "updates",它会让LangGraph.js按节点粒度吐出更新,而不是等整个图跑完再一次性返回;二是thread_id配置,它是LangGraph.js的线程标识,相当于一次会话的ID,后续要支持“继续上一次对话”全靠它。

3.2 前端如何消费事件流

前端用fetch加ReadableStream读取,按event类型分发处理:

async function runAgent(resumeText: string, jdText: string) { const res = await fetch("/api/agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ resumeText, jdText }), }); 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 chunk of lines) { const eventMatch = chunk.match(/^event: (.+)$/m); const dataMatch = chunk.match(/^data: (.+)$/m); if (!eventMatch || !dataMatch) continue; const event = eventMatch[1]; const data = JSON.parse(dataMatch[1]); if (event === "progress") { updateStepUI(data.node, data.output); } else if (event === "done") { setLoading(false); } else if (event === "error") { toast.error(data.message); } } } }

这里有个我自己踩过的坑:TextDecoder必须设置{ stream: true },否则中文字符正好落在chunk边界时会出现乱码。SSE按\n\n分割事件,但如果服务端一次推了多个事件,Buffer拼接不及时也会丢事件,所以每次循环都要把未完成的残留在buffer里留到下一轮继续解析。

前端UI我做了三种可视反馈:左侧是Agent执行节点的步骤指示器,每完成一步就打勾并显示该节点产出的摘要;右侧是最终报告卡片,匹配分用环形进度条展示,差异项和优化建议用分区块列出;底部还有一个日志窗口,滚动显示模型调用的详细事件,方便用户了解当前正在做什么。整体效果就是用户能看着Agent一步步把简历“分析明白”,而不是干等一个转圈圈。

4. 并发与稳定性调优实录

4.1 服务器无状态化与内存管理

Agent项目刚上线时最大的隐患就是:LangGraph.js的MemorySaver把状态存在本地内存里。单实例开发调试没问题,一旦部署到Serverless或者多实例环境,用户第二次请求可能打到另一个实例,thread_id对应的状态根本查不到。所以第一步就是把Checkpointer换成远程存储。我用了Upstash Redis的兼容实现,这样所有实例共享同一份Agent状态,真实Serverless环境下Agent状态不再丢。

import { RedisCheckpointer } from "@langchain/checkpoint-redis"; import { Redis } from "@upstash/redis"; const redis = new Redis({ url: process.env.UPSTASH_REDIS_REST_URL!, token: process.env.UPSTASH_REDIS_REST_TOKEN! }); const checkpointer = new RedisCheckpointer({ client: redis });

换完Checkpointer之后还有另一个问题需要正视:MemorySaver和Redis Checkpointer在实现细节上是有差异的,本地测试通过不代表线上表现一致。我在切换之后专门做了一轮“断点续跑”测试:把thread_id固定下来,先让Agent跑一半,再手动调用graph.invoke续跑,确认它能从正确节点继续而不是从头再来。测试通过之后才放心。

4.2 模型请求的超时、重试与并发上限

Agent每个节点都要调大模型,一次完整任务相当于4到5次模型请求,比普通单次接口的耗时和失败概率成倍上升。这里我做了三件事,实测对稳定性提升非常明显:

第一,统一设置模型超时。@langchain/openai支持timeout参数,我把它设成90秒。因为Agent的节点里模型可能会调用工具,推理时间比普通对话长不少,太短会误杀正常请求,太长又会拖垮整体体验。

第二,给模型调用加自动重试。注意不是所有错误都该重试,只有网络抖动、429限流、5xx这类临时错误才值得重试。模型返回格式错误这种就别白费力气了。我重试次数设为2,退避策略用指数退避,初始等待1秒。

第三,限流。我把单用户并发请求数限制在2以内,超出直接返回“已有任务运行中”。这个限制的初衷很朴素:模型API配额是有限的,不限流会让Quota瞬间耗尽,连带影响所有用户。

4.3 大任务拆分与分块输出

还有一个对并发影响很大的优化:大模型输出的JSON不搞一次性生成。比如匹配分析报告,我把它拆成“匹配分计算”和“差异项说明”两个小节点,分别调用模型,每个节点只输出一个小JSON片段。这么做的好处非常明显:

  • 单次请求的Token数降低,首Token延迟更短,前端能更快看到第一个步骤完成;
  • 单个节点失败只影响局部,重新执行该节点成本低;
  • 每个片段独立校验,不会因为一个字段格式错误导致整份报告不可用。

前端配合这个优化,体验是“步骤2秒内出结果,步骤4随时能看到中间分析”。相比之前一把梭生成几千Token的长文,用户流失率体感上降了不少。如果你做的Agent跑的是更长的分析任务,这个思路可以继续延展:按章节拆分、按时间分批生成,甚至配合langgraph的interrupt机制做人工确认节点(让人在关键决策处点个确认再继续),都是合理的演进方向。

4.4 缓存与冷启动优化

并发上来之后,冷启动和重复计算的问题也暴露了。有些用户反复上传同一份简历微调JD,每次都重新走一遍完整Agent流程,既费钱又费时间。我在三个层级加了缓存:

  • 解析缓存:同一段简历文本的解析结果缓存24小时,用MD5做key。简历解析结果本质上是确定的,没必要反复让模型读。
  • 匹配缓存:同一对“简历Hash + JD内容Hash”的匹配报告缓存1小时。JD文本即使改几个字,Hash不同就会重新计算,不会误伤。
  • 前端静态资源缓存:Next.js默认的静态资源都走CDN缓存,这个不用操心,但要注意别把SSR页面也长期缓存了,否则用户看到的进度状态会串号。

冷启动方面,Serverless环境的函数实例有可能被冻住几秒,如果很不巧用户请求恰好赶上冷启动,直接体验就是队列里卡住不动。我的对策很笨但有效:加一个后端心跳接口,每5分钟用cron请求一次实例把函数“预热”住。另外把runtime明确设为nodejs而不是edge,因为LangGraph.js的Redis Checkpointer在Edge运行时里兼容性不如Node.js,硬上Edge省下的那点冷启动时间不够填兼容性的坑。

5. 常见问题与排查技巧实录

5.1 问题排查速查表

这次落地过程里最耗时间的不是写代码,而是排查各种“看起来没问题但就是不对”的诡异问题。整理一张速查表,按图索骥能省不少时间:

现象根因解决方案
前端收到乱码或事件解析失败TextDecoder未设置stream: true;或SSE事件被截断未缓冲解码时开启流式模式,事件数据留buffer待下一轮解析
Agent状态偶尔丢失使用了本地MemorySaver,多实例环境不共享换成Redis等远程Checkpointer
模型输出JSON偶尔解析失败未使用结构化输出约束,直接让模型吐JSON文本改用withStructuredOutput配合zod schema,让输出自动校验
一次任务耗时过长节点全部串行,且单个节点内把多件事塞给一次模型调用拆分子任务、每个节点只做一件事、开启流式输出
反复收到429限流错误没有做用户级并发限制加Redis计数器限流,单用户同时只允许一个Agent任务
部署后接口首次访问极慢Serverless冷启动加心跳预热,或用常驻进程方式部署
分支路由没按预期走条件边函数返回的分支名与图定义里的目标节点不一致检查routeByScore返回值是否严格等于"high"或"low"
工具调用结果没写回状态工具返回值未作为节点输出返回节点函数需要显式返回{ fieldName: toolResult }

5.2 几个值得说的踩坑细节

第一个坑是LangGraph.js的版本差异。早期我参考的示例代码用的是旧版API,StateGraph的泛型写法和Annotation定义方式跟新版完全不一样。升级到0.2之后,Annotation.Root是新写法,旧写法直接不兼容。这个坑之所以印象深刻,是因为报错信息很隐晦,只会提示类型不匹配,根本看不出来是API变更了。我的建议是:遇到诡异的类型报错先看LangGraph.js的CHANGELOG,别急着怀疑自己代码错了。

第二个坑是流式输出的缓冲机制。SSE看似简单,但Next.js的Response流如果你不主动flush,一些Serverless平台会等缓冲区攒满再推给客户端,导致前端拿到的是“一顿一顿”的数据块。我试过在每次controller.enqueue之后折腾各种flush办法,最后发现最稳妥的还是控制单个事件的数据量,保持每个事件Ping很小,并且不要在一个start函数里同步跑完整条Agent。加上streamMode: "updates"之后,LangGraph.js本身就会按节点粒度推送,配合小事件基本不会触发平台级缓冲问题。

第三个坑更隐蔽,是关于工具节点里模型思考链过长的。有一次低匹配度分支触发了rewriteResume工具,模型在工具调用前先输出了一大段英文思考过程,这些内容被塞进了消息上下文,导致回答变慢还浪费Token。后来我在工具绑定时限制了最大输出Token,并给系统提示加了“直接决定是否调用工具,不要输出额外解释”,问题立刻缓解。这类“隐形成本”在Agent里特别容易出现,因为你看得到主输出,却很容易忽略工具调用前模型的“碎碎念”。

5.3 部署与上线经验

最后说部署。我最终选择把Next.js应用构建成Docker镜像部署到一台4C8G的云服务器上,前面挂Nginx做反向代理和HTTPS终止。为什么不继续用Serverless?因为Agent场景的长时间运行任务在Serverless下容易撞执行时长上限,而且每次请求都要重建图、连Redis,开销比常驻进程大得多。常驻Node.js进程用PM2管理,设置max_memory_restart为4G,实测单机扛住几十个并发用户没问题,再往上就加实例、用Nginx做轮询负载均衡。

部署时还有几个小点可以留意:环境变量统一放.env管理,模型API密钥走密钥管理服务而不是直接写仓库;日志接入结构化JSON格式,方便按thread_id搜索一次完整Agent任务的执行链路;启动脚本里加NODE_OPTIONS="--max-old-space-size=4096"防止内存溢出。整个项目从代码到部署,核心就一句话:Agent不是算法题,是工程题,把状态、并发、可观测性这三件事做好,项目就已经成功了大半。

如果你也想自己搭一个类似的东西,我的建议是先别急着上复杂框架,用一个最小闭环跑通“模型调用→结构化输出→前端展示”,再逐步引入图编排、工具调用、状态持久化。等真的遇到流程不可控、步骤需要复用的问题时,你会比任何人更清楚LangGraph.js解决的是哪一类痛点。我实际用下来最大的感受是:图编排带来的不只是代码结构上的清晰,更是你终于能站在更高的视角去设计Agent的行为边界,而不是每天被一堆if-else和回调地狱追着跑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询