拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Next.js+LangGraph.js实战:AI Agent简历工具从架构到并发落地

Next.js+LangGraph.js实战:AI Agent简历工具从架构到并发落地

“AI Agent能不能真正落地”,这话我听了太多次。很多项目Demo阶段风生水起,一上生产就露馅。但这次用Next.js加LangGraph.js搭的简历工具AI Agent,从架构设计到上线扛并发,全程踩坑也全程填坑,总算把“能用”变成了“好用”。这篇文章就把完整落地过程拆开揉碎,包括选型逻辑、核心代码、并发方案、成本控制,以及我踩过的那些坑。

先交代背景:这个项目是做一个在线简历优化工具。用户上传简历,AI Agent自动解析内容、诊断问题、给出修改建议,甚至直接生成优化后的简历。整个过程不是一次Prompt能搞定的,需要分步骤、带状态、还要调用多个工具(解析PDF、提取结构化信息、调模型分析、生成文档)。这种场景天然适合“Agent化”而不是“单轮问答化”。技术栈选型上,前端交互和API路由给了Next.js,Agent编排给了LangGraph.js。

当时对比过几套方案,最后敲定这套组合,核心原因有三:

  • Next.js同时承担前端界面和API Route,部署简单,一个服务搞定,不用额外拆前端工程。
  • LangGraph.js在JS生态里把“有状态、可编排、可断点续跑”的Agent流程做得最顺手,Python版LangGraph很成熟,但团队是JS栈,LangGraph.js是天然选择。
  • 简历解析、诊断、重写这些节点天然适合有向图编排,而不是一条链走到黑——比如某些简历缺少技能模块,Agent要临时插入一个“技能提取”节点再继续往下走。

1. 项目整体设计与技术选型

1.1 核心需求解析

简历工具看起来简单,实际拆开有这些硬需求:支持PDF和Word上传;准确解析出教育背景、工作经历、技能标签、项目经验等结构化字段;对简历质量进行多维诊断(完整度、量化程度、关键词匹配、格式规范);能给出逐项修改建议;最终生成一份排版整洁的优化后简历。整个过程要做到可追踪、可中断、可恢复。

这就不是“传一个文件,返回一段话”的接口能覆盖的。单轮Prompt做不到精确提取和分步诊断,普通函数调用链又没法处理分支和条件跳转。AI Agent的引入让这个流程变成了可编排的状态机:上传文件后进入解析节点,解析结果写入状态,诊断节点读取状态产出报告,如果简历缺少某个模块,Agent会动态插入一个修复节点。整个流程的状态是显式的,每一步都能审计,出了问题还能从断点恢复。

1.2 为什么用Next.js做载体

选Next.js不仅仅是前端框架顺手,它在这个项目里的价值有三层:

第一,统一开发模型。前端页面、API路由、SSR逻辑都在同一个工程里,简历上传页、解析进度页面、结果展示页共享TypeScript类型定义,不需要前后端联调接口文档。

第二,API Route天然适合做Agent的HTTP入口。Next.js的Route Handlers支持流式响应(后面细讲SSE实现),而Agent执行过程中的每个节点状态、每条日志,都可以通过Server-Sent Events推给前端。对比WebSocket,SSE实现简单、自动重连、天然适配单向事件流,而Agent执行过程恰好是服务器向客户端单向推送进度。

第三,部署友好。打成一个Docker镜像直接跑,天然支持无服务器部署,配合Redis做状态存储,横向扩容几乎不需要改代码。

当然也有代价:长时间运行的Agent任务会占满Serverless函数的执行时长配额。所以架构上我把“短请求”和“长任务”做了分离——API Route只负责接收请求、创建任务、查询状态,真正跑Agent的是独立的工作进程或队列Worker。这个设计后面细说。

1.3 为什么选LangGraph.js做Agent编排

看了不少Agent框架,最后还是选了LangGraph.js。原因是它在“有状态图执行”上做得最贴近生产需求。

你可以把LangGraph.js理解成一个专门为Agent设计的状态机引擎:定义状态类型,定义多个节点(每个节点可以是LLM调用、工具调用、普通函数),定义节点之间的边(包括条件边),然后编译成一张图。运行时,每个节点接收输入状态,执行后返回新的状态,图引擎根据当前状态和条件边自动决定下一步走向。

与LangChain的链式调用(Chain)相比,LangGraph有几个关键优势:

  • 显式状态共享:所有节点共享一个State对象,读取和写入都用类型约束,调试验证都简单。
  • 条件分支:根据LLM输出或工具结果,不同情况下走完全不同的路径,这是真实业务场景的刚需。
  • 可断点续跑:支持中断和恢复,可以暂停在某个节点,人工介入或等待外部事件后再继续。
  • 内建检查点机制:保存每一步的状态快照,配合Redis可以直接做持久化。

简历工具里最典型的分支场景就是:解析节点如果发现“工作经历”字段缺失或为空,会直接走一条“缺失字段补充”路径,而不是硬着头皮继续诊断。这种动态决定性,传统Chain很难做到。

1.4 整体数据流设计

整个系统跑起来后的数据流是这样的:

  1. 用户在Next.js前端上传简历文件。
  2. API Route接收文件,存入对象存储,生成任务ID,把任务推入队列。
  3. 队列Worker取出任务,调用LangGraph.js编译好的Agent图开始执行。
  4. Agent图中:解析节点调用文本抽取工具 → 结构化节点调用LLM做字段提取 → 诊断节点调用LLM分析打分 → 生成节点调用LLM重写简历 → 文档生成节点调用排版工具产出最终文件。
  5. 每个节点执行完毕,状态快照写入Redis,同时通过Redis Pub/Sub把进度事件推送给API层,API层通过SSE推送给浏览器。
  6. 前端页面实时展示“正在解析…正在诊断…正在生成…”,全部完成后展示报告和下载链接。

这套设计的精髓在于:流程图是预定义的(LangGraph编译期确定),但执行路径是动态的(运行期根据状态分支)。既保证了可控性,又保留了Agent的灵活性。

2. 简历工具Agent的核心实现细节

2.1 定义Agent状态与图结构

状态是LangGraph的基石。简历工具的状态我定义为:

interface ResumeState { filePath: string rawText: string | null structuredData: { basics: BasicInfo | null education: EducationItem[] experience: ExperienceItem[] skills: string[] projects: ProjectItem[] } | null diagnosis: DiagnosisResult | null rewriteResult: string | null outputPath: string | null currentNode: string error?: string }

每个字段对应Agent流程中的某个阶段的产物。定义好状态后,用LangGraph.js构建图:

import { StateGraph, END } from "@langchain/langgraph" const graph = new StateGraph<ResumeState>({ channels: { filePath: { value: (a, b) => b ?? a }, rawText: { value: (a, b) => b ?? a }, structuredData: { value: (a, b) => b ?? a }, // ... 其他通道定义 }, }) .addNode("extractText", extractTextNode) .addNode("structureParsing", structureParsingNode) .addNode("diagnose", diagnoseNode) .addNode("rewrite", rewriteNode) .addNode("generateDoc", generateDocNode) .addEdge("__START__", "extractText") .addEdge("extractText", "structureParsing") .addEdge("structureParsing", "diagnose") .addConditionalEdges("diagnose", (state) => { if (state.diagnosis?.missingSections?.length > 0) { return "rewrite" // 有缺失模块时优先补全再重写 } return "rewrite" }) .addEdge("rewrite", "generateDoc") .addEdge("generateDoc", END)

这里有个坑:channels里的value函数如果不写合并逻辑,默认用新值覆盖旧值,但structuredData这种嵌套对象容易丢字段。建议用显式的合并函数,或直接使用LangGraph.js内置的Annotation化的状态类。

2.2 工具节点的设计与JSON Schema化

LangGraph.js里的节点可以是普通函数,也可以调用工具。简历工具里最核心的节点是“结构化解析节点”:把纯文本简历解析成结构化JSON。这个节点不能只靠Prompt裸调,必须用JSON Schema约束输出格式。

const structureParsingNode = async (state: ResumeState) => { const llm = getLLM() const extractionChain = llm.withStructuredOutput({ type: "object", properties: { basics: { type: "object" }, education: { type: "array" }, experience: { type: "array" }, skills: { type: "array" }, projects: { type: "array" } }, required: ["basics", "education", "experience", "skills"] }) const result = await extractionChain.invoke({ input: state.rawText }) return { structuredData: result } }

使用withStructuredOutput的最大好处是避免LLM返回乱七八糟的Markdown或多余解释。实测下来,结构化输出的解析成功率从裸Prompt的80%出头提高到99%以上,这个差异在批量处理场景下是致命的。

工具节点内部还可以嵌套调用其他函数。比如技能标签提取,如果发现某一项缺失,就调用一个专用的“技能补充”小工具,直接从岗位JD里提取技能。这样就把“工具调用”嵌进了Agent节点内部,形成Tool-in-Node的模式,灵活性比纯链式高很多。

2.3 流式输出与SSE实现

Agent执行耗时动辄十几秒甚至几十秒,如果让用户盯着空白页面干等,体验再好在生产环境也是废的。我的方案是:Agent状态变化时实时推送给前端。

LangGraph.js原生支持stream执行模式。通过graph.stream(state, { streamMode: "updates" })可以拿到每一步的节点执行结果。我需要把这些事件实时转发给浏览器。

Next.js API Route实现SSE的关键代码:

export async function POST(req: Request) { const { taskId } = await req.json() const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { const subscriber = redis.duplicate() await subscriber.subscribe(`task:${taskId}:events`) subscriber.on("message", (channel, message) => { controller.enqueue(encoder.encode(`data: ${message}\n\n`)) }) req.signal.addEventListener("abort", () => { subscriber.unsubscribe() subscriber.quit() controller.close() }) } }) return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive" } }) }

前端用EventSource或fetch的流式读取方式来接收事件。这里有一个重要经验:SSE连接必须处理客户端断连,否则Redis订阅会一直挂着,造成连接泄漏。上面的代码里用req.signal的abort事件做了清理,这个一定不能省。

另一个容易被忽略的点:代理服务器和浏览器的SSE缓冲。Next.js默认可能会有缓冲,需要在next.config.js里关闭API路由的压缩,或者确认部署平台不缓存SSE响应。否则前端拿到的是一坨一次性到达的缓冲数据,流式的体验就完全没了。

2.4 状态持久化与断点续跑

Agent如果跑了一半崩溃,整个流程重来,对用户和成本都是灾难。LangGraph.js的检查点机制(Checkpointer)就是为这个设计的:每执行完一个节点,把完整状态存到持久化存储里。

import { MemorySaver } from "@langchain/langgraph-checkpoint" // 生产环境用Redis实现,而不是MemorySaver const checkpointer = new RedisSaver({ redisClient }) const app = graph.compile({ checkpointer })

使用Redis做检查点时要注意:Redis里存的是整个State对象,包括rawText这种可能很大的字段。一定要给每个任务设置TTL,比如2小时。简历文本一般不大,但如果将来接入其他文件类型,状态体积会膨胀得很厉害。

断点续跑的执行方式是传入同一个threadId:

const result = await app.invoke( { filePath: "/tmp/xxx.pdf" }, { configurable: { thread_id: taskId } } )

如果某次执行中途失败,只需要用同一个threadId再次调用invoke,LangGraph.js会自动从最后一个成功的检查点继续执行,而不是从头开始。

3. AI Agent怎么扛住并发:一套完整的生产级方案

这是全网都在问的问题。Agent执行任务耗时远高于普通API调用,如果不做架构优化,一个涉及多轮LLM调用的Agent任务能把服务器线程池瞬间打满。

3.1 问题拆解:Agent的并发瓶颈在哪

Agent任务的耗时构成和普通请求完全不同。普通接口几百毫秒返回,Agent任务动辄10到30秒,期间包含多次LLM调用、工具执行、节点编排。一个Agent任务占用的资源是一个普通请求的几十倍。这意味着同样的QPS下,Agent服务需要的资源量完全不是一个量级。

还有上下游依赖问题:LLM服务有速率限制(RPM/TPM),Redis有连接数上限,对象存储有并发限制。Agent编排层即使扛住了,下游任何一环被限流,整个任务也会失败或重试。所以“扛并发”远不止加机器那么简单,必须做全链路治理。

3.2 全链路异步化:API层和Worker层分离

我的核心设计原则就一句话:HTTP请求不直接执行Agent,只创建任务。

用户在Next.js前端上传的请求进来,API Route做三件事:保存文件、生成任务ID、把任务消息推入队列。然后立即返回“任务已受理”。真正的Agent执行放到Worker进程里。

// API Route:只入队,不执行 export async function POST(req: Request) { const formData = await req.formData() const file = formData.get("file") as File const taskId = crypto.randomUUID() await storage.put(`${taskId}.pdf`, file) await queue.add("resume-task", { taskId, filePath: `${taskId}.pdf` }) return Response.json({ taskId, status: "pending" }) }

Worker侧用一套独立的进程拉取队列消息:

// Worker:消费队列,执行Agent queue.process("resume-task", async (job) => { const { taskId, filePath } = job.data const app = compileAgentGraph() await app.invoke( { filePath }, { configurable: { thread_id: taskId } } ) })

这个模式最大的好处是:API层的Pod和Worker层的Pod可以独立扩缩容。上传量突增就多开API Pod;Agent任务积压就多开Worker Pod。互不拖累。

队列系统我用的是Redis Stream或者BullMQ。注意要设置任务的超时时间、重试次数、死信队列。Agent任务重试要尤其小心:LLM调用重试可能导致重复计费。我设置了“幂等键”,LLM请求带上taskId-nodeName作为幂等标识,对于超时的请求宁可报错也不要盲目重试。

3.3 并发控制与实例隔离:核心中的核心

有了队列之后,并发控制变得极其可控。关键的参数是Worker进程的并发数。

// Worker并发数控制 const worker = new Worker("resume-task", processor, { concurrency: process.env.WORKER_CONCURRENCY || 2, limiter: { max: 10, // 每分钟最多处理任务数 duration: 60000 } })

这里我踩过一个很深的坑:一开始为了追求吞吐,把Worker并发数调到10,结果LLM服务直接返回429限流错误,大批任务失败重试。后来把并发数降到2到4,每个任务内部的LLM调用再做一次本地信号量控制,整体吞吐反而更稳更高。

每个Worker进程内部,Agent执行是独立的。Node.js单线程事件循环模型下,并发数本质上是任务争抢事件循环的调度。Agent任务里有大量IO等待(LLM、Redis、存储),所以并发数大于CPU核心数是有意义的,但不宜过大。实测下来,4GB内存的容器跑2到4并发是最稳的状态。

除了进程内并发控制,还有一层是实例级隔离。API层和Worker层分开部署之外,我为Worker配置了独立的Redis实例和独立的LLM API Key池。这样API层即使被刷爆,也不会影响正在执行中的Agent任务。这就是分层隔离的价值。

3.4 Token成本控制与性能优化

Agent任务和单次LLM调用的成本结构完全不同。多次调用意味着输入token被反复发送,语境累积,成本呈指数上涨。简历工具的控制方案:

第一,最小化Context。原始简历全文提取后是一次读取,后续节点不要再带全文。每个诊断节点只读structuredData里的相关字段,比如诊断“技能匹配度”的节点只接收skills字段和JD文本,不要接收整个structuredData。这样既能降低成本,也能减少无关上下文对LLM判断的干扰。

第二,结果缓存。同一份简历如果只是调整了诊断标准,不需要重新跑解析节点。我做了节点级别的缓存:以“输入内容的哈希+节点名称+模型版本”作为缓存键,命中直接返回历史结果。实测简历解析节点缓存命中率在30%以上,对于老用户重试场景非常划算。

第三,用小模型做粗筛,大模型做精修。技能标签提取用轻量模型就能做好,但是简历重写的质量必须用更强的主力模型。LangGraph.js的节点是可以自由选择LLM实例的,所以在图定义里就给每个节点指定了不同的模型。这个灵活度是链式调用很难替代的。

还有一个隐藏成本:结构化输出解析失败会触发重试。每次重试都等于重新跑一遍LLM。所以前面强调withStructuredOutput的必要性——它是省钱的工具,不只是省心的工具。

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

4.1 高频问题速查表

问题表现直接原因排查方法解决方案
SSE连接频繁断开代理缓冲或心跳缺失查看浏览器网络面板,看是不是一次性返回关闭Next.js路由压缩,服务端每15秒发送注释心跳包
Agent任务在“解析”阶段卡死LLM超时未处理检查LLM调用是否设置了超时所有LLM调用加timeout和maxRetries
结构化输出偶尔缺失字段输出Schema约束力不足打印原始LLM输出给withStructuredOutput增加formatInstructions,或在节点内做二次解析兜底
Worker堆积大量任务并发数设置过高导致限流查看LLM服务RPM剩余量调低Worker并发,加任务队列积压告警
Redis内存暴涨检查点TTL未设置查看Redis内存碎片占比设置TTL并限制状态字段大小
前端进度条停留在90%文档生成节点超时查看日志确认执行到哪个节点对文档生成节点单独设置超时和重试次数

4.2 几个让我印象深刻的现场排查

第一次压测时,QPS一到5就整片超时。排查后发现既不是Worker不够,也不是LLM限流,而是API层和Worker共用了一个Redis实例。Agent频繁读写检查点把Redis的CPU冲到100%,API层的队列写入和状态查询全部被拖慢。解决方案是一拆二:API用Redis A实例,Worker用Redis B实例,问题当场消失。

又一次,SSE推送总是延迟十几秒。排查发现是部署平台默认开启了响应缓冲,SSE的数据攒到一定量才刷给浏览器。流式效果彻底失效。解决方案是在路由的响应头里显式加X-Accel-Buffering: no,同时关闭API路由的compress: true配置。

还有一次是Worker内存泄漏。排查发现是LangGraph.js的图对象每次执行任务时都重新编译,并且有大量的闭包引用没有释放。解决方案是在进程启动时编译一次图,之后所有任务复用同一个编译结果。内存曲线瞬间平稳。

4.3 避坑技巧和设计建议

  • Agent图不要设计得太深。节点之间层级过深,排查问题时看日志能把人绕晕。图结构控制在6到8个节点以内,超过就要考虑拆分多个子图。
  • 给每个节点设置独立的超时时间。解析节点和生成节点的耗时基准完全不同,统一超时会导致某项任务频繁失败。
  • 善用检查点做A/B测试。同一个状态快照,可以尝试不同的诊断Prompt或不同的模型,对比输出质量。这个能力让我在优化Prompt时效率翻倍。
  • 所有LLM调用都要监控token消耗。我之前只关注成功率,没有按节点维度统计token成本,结果月底账单翻了三倍才知道出了大问题。现在每个节点执行完都记录token用量到日志,成本一目了然。
  • 任务要有“取消”机制。用户中途取消操作,如果没有及时终止Agent任务,Worker还在白白烧钱跑。我在Redis里维护任务状态,Worker每执行完一个节点前检查一次该任务是否被标记为“已取消”,如果是,立即终止并释放资源。

4.4 关于“Agent落地的最后一步”的思考

有过这次实战后我最大的体会是:Agent框架只是骨架,真正决定成败的是状态设计、成本控制和并发治理这三件事。很多人把Agent项目做成“高级聊天机器人”,就是因为忽视了状态的可管理性和系统的可运维性。LangGraph.js让人能把握前两点,但并发治理的功夫全在框架之外——消息队列的选择、并发数的调优、Redis实例的拆分、LLM调用链路的限流,每一项都是实打实的工程经验。

如果从零开始再做一个Agent项目,我会先画三张图:状态流转图(有哪些状态、谁能改状态)、成本时间图(每个节点的平均耗时和token费用)、故障恢复图(谁来重试、怎么重试、怎么止损)。这三张图画清楚了,选什么框架、怎么扛并发都是水到渠成的事。

这个简历工具的Agent架构目前已经平稳运行了几个版本。后续我打算把简历诊断的规则引擎再往LangGraph的节点里下沉一层,让静态规则(比如“技能缺失”“时间线断档”)和动态LLM判断做强结合。毕竟AI Agent落地这件事,永远是架构先行、细节为王。

返回列表