☰
LangGraph.js + Next.js:AI简历优化Agent实战
2026/10/8 21:04:36 网站建设 项目流程

最近我把一个简历工具从“填表生成”的老架构,彻底重构成了基于 Next.js + LangGraph.js 的 AI Agent 应用。这个项目的核心玩法很简单:用户丢一份简历、贴一个岗位 JD,Agent 自动完成解析、匹配、改写建议、内容生成,最后产出一份针对该岗位的优化简历。听起来就是几个 prompt 的事,但真正落地时你会发现,简历这种高结构化、高确定性需求的场景,恰好是 LLM 最容易失控的地方——它不是一个“聊一轮就能完事”的对话任务,而是一条有状态依赖的多步骤流水线。这篇文章我会把整个 Agent 的状态图设计、节点实现、Next.js 集成、流式输出、部署运维里的关键决策和踩坑经历完整记录下来。如果你正在用 LangGraph.js 做实际业务 Agent,或者想在 Next.js 里跑一套完整的多步骤 AI 工作流,这份经验可以直接抄作业。

1. 项目定位:简历工具为什么需要 Agent 化

1.1 从表单工具到智能工作流的转变

先说清楚这个项目到底在解决什么问题。传统简历工具的核心逻辑是“表单 + 模板”——用户手工填教育经历、工作经历、技能列表,然后套一个 Word 或 PDF 模板导出去。这套流程最大的痛点不是“填表慢”,而是“用户不知道怎么写才能通过筛选”。很多人并不是没有经历,而是不会用招聘方看得懂的语言去表达:项目描述全是“负责 XX 系统开发”,没有任何量化结果;技能列表堆了十几个名词,却看不出和目标岗位有什么关系。

把这个问题拆开看,它本质上需要三件事:第一,理解用户现有简历里的信息结构;第二,理解目标岗位 JD 里的关键词、技能权重和隐性要求;第三,在不编造经历的前提下,把现有内容重新组织、改写、润色,让简历更贴合 JD。这三件事单靠一个“对话式 Chatbot”很难做好,因为用户往往连自己的需求都说不清楚——有人想转行,有人只要微调,有人需要完全重写,有人简历缺内容需要引导补充。如果只做一轮 prompt 生成,模型很容易给出天马行空但不实用的结果。

所以这个项目最终定义为:一个多步骤、有状态、可干预的 AI Agent 工作流。用户进来后,Agent 先判断意图和简历质量,再决定走“快速优化”还是“深度重写”路线,每个环节用户都能看到中间结果并手动修正。这正好是 Agent 比“单次调用 LLM”更适合的场景:任务链条长、中间结果可校验、需要分支决策。LangGraph.js 在这个定位下就不是锦上添花,而是整个系统的骨架。

1.2 技术栈选型的三个关键考量

技术栈最终定为 Next.js + LangGraph.js + Vercel AI SDK,这个组合不是拍脑袋选的,背后有几个很实际的理由。

第一个考量是前后端一体化。这个工具的用户操作路径很短:上传/粘贴简历 → 填写 JD → 进入生成流程 → 预览导出。用 Next.js App Router 把页面、API Route、流式接口全放在一个项目里,开发心智负担最小,也方便后续接数据库、接鉴权。如果用“前端 React + 后端 FastAPI”这种分离架构,光联调和部署就要多花一倍时间,对于这种工具型产品完全不划算。

第二个考量是LangGraph.js 对可控性的支持。简历生成最怕什么?怕模型自由发挥编造经历、怕生成结果结构化程度不够、怕流程跑一半卡死。LangGraph.js 的核心是“图状态机”:每个节点是明确的功能单元,节点之间用边连接,条件边决定流程走向,还可以通过interrupt()实现人工介入。这种显式的流程控制,比让模型自己决定下一步要做什么要可靠得多。简历工具不需要一个完全自治的 Agent,它需要的是一个“流程严谨、节点可替换”的智能流水线。

第三个考量是流式体验。简历生成通常要调用 2~4 次 LLM,每次生成内容都比较长,如果让用户干等十几秒没反馈,体验会很差。Vercel AI SDK 对流式输出做了很好的封装,配合 LangGraph.js 的streamMode: "messages",能把每个节点生成的增量内容实时推送到前端。用户可以看到 Agent“正在解析简历”“正在分析岗位差异”“正在生成优化建议”的完整过程,而不是一个转圈图标。

1.3 产品边界与适用人群

这个工具的目标用户很明确:有真实求职需求、有一定简历基础但表达不佳的求职者,以及帮候选人做简历优化的 HR 和职业顾问。产品边界上,我们刻意不做“从零创建简历”这种功能——因为从零写简历涉及大量信息收集,Agent 问一轮问题就能把人劝退。我们只做“已有简历的优化和适配”,也就是输入是现成简历 + JD,输出是优化后的简历。

这个边界决定了 Agent 的输入输出格式。输入统一为纯文本:简历允许用户粘贴文本或上传 PDF 后自动抽取,JD 也是一段文本。输出则分两个层面:中间层是结构化的分析结果(岗位关键词、匹配度评分、差距列表),最终层是排版好的一页纸简历内容。所有中间结果都进入状态管理,用户可以在任何一步修改或重新生成,而不是出了最终结果才后悔。这一点在后面的状态图设计中会详细展开。

2. LangGraph.js 状态图设计:核心架构思路

2.1 为什么不用简单的 LLM 循环

如果你对 LangGraph.js 不熟,先理解一个关键区别:它和“一个 while 循环里反复调 LLM”的 Agent 实现方式完全不同。普通 Agent 循环是让模型自己决定下一步调什么工具,这在小任务上没问题,但在简历这种多阶段流水线上,模型容易跳步、重复、漏掉关键校验。举个例子:如果让模型自己决定流程,它可能在还没解析完整简历时就去生成优化建议,结果建议全是基于猜的。

LangGraph.js 的核心概念是StateGraph:你把整个任务拆成节点(Node),节点之间有明确的边(Edge),条件边(Conditional Edge)根据当前状态决定走哪条路。整个图定义完之后,运行时引擎负责执行节点、维护状态、处理中断和恢复。这相当于把“流程控制权”从模型手里拿回来,交给确定性的代码逻辑。LLM 只负责节点内的智能工作,比如“从这段文本里抽取结构化信息”“给出改写的具体句子”,而不是决定“下一步该干嘛”。

这个取舍在这个项目上非常关键。简历工作是高容错要求场景:用户提交的信息就是事实来源,模型不能自己编造。用图状态机来定义流程,每一阶段都有明确的输入输出,我们可以对中间结果做精确校验,比如检查抽取出的工作经历数量是否合理、生成结果里有没有出现简历原文没有的公司名。这种校验逻辑如果放在“LLM 自治循环”里,会变得非常不可控。

2.2 状态定义与节点拆分

LangGraph.js 的所有状态都定义在 StateGraph 的 Annotation 里。这个状态对象是整个图的“共享内存”,每个节点可以读取它、修改它,通过 reducer 控制如何合并更新。我当时第一版的状态设计太粗,只放了messages一个字段,结果后面所有节点都得从 messages 里翻上下文,代码可读性极差。重构后的状态定义大概长这样:

import { Annotation, StateGraph, START, END } from "@langchain/langgraph"; const ResumeState = Annotation.Root({ // 对话消息,用于前端展示和上下文追溯 messages: Annotation<string[]>({ reducer: (a, b) => [...a, ...b], }), // 原始简历文本 resumeText: Annotation<string>(), // 目标岗位 JD 文本 jdText: Annotation<string>(), // 简历解析后的结构化数据 parsedResume: Annotation<Record<string, any>>(), // 岗位关键词与匹配分析结果 analysisResult: Annotation<Record<string, any>>(), // 生成的优化建议列表 suggestions: Annotation<string[]>({ reducer: (a, b) => [...a, ...b], }), // 最终生成的简历内容,可以是 Markdown 或 JSON 结构化文本 generatedResume: Annotation<string>(), // 错误信息,任何节点出错都会写入这里 error: Annotation<string>(), });

这里有几个容易踩的坑。reducer 不是随便写的:messages和suggestions用数组扩展的 reducer,表示“追加”;其他的标量字段默认是“覆盖”。如果你在一个节点里返回整个状态对象,LangGraph.js 会根据 reducer 自动合并,但如果你用默认的覆盖逻辑,某个节点忘返回messages字段,历史消息就被清空了。所以建议所有“需要累积”的字段都显式定义 reducer。

节点拆分我最终定为六个:parse_resume(解析简历)、analyze_jd(分析岗位需求)、generate_suggestions(生成优化建议)、generate_resume(生成简历内容)、review_output(质量检查)、format_export(格式化导出)。每个节点只干一件事,职责单一,测试也方便。节点之间的连接逻辑如下:

const graph = new StateGraph(ResumeState) .addNode("parse_resume", parseResumeNode) .addNode("analyze_jd", analyzeJdNode) .addNode("generate_suggestions", generateSuggestionsNode) .addNode("generate_resume", generateResumeNode) .addNode("review_output", reviewOutputNode) .addNode("format_export", formatExportNode) .addEdge(START, "parse_resume") .addEdge("parse_resume", "analyze_jd") .addConditionalEdges("analyze_jd", routeAfterAnalysis) .addEdge("generate_suggestions", "generate_resume") .addEdge("generate_resume", "review_output") .addConditionalEdges("review_output", routeAfterReview) .addEdge("format_export", END);

routeAfterAnalysis这个条件边比较有意思:如果分析结果显示用户简历和 JD 匹配度很高,就跳过深度改写,直接走轻量建议;如果匹配度低,就进入重量级重写流程。这个分支逻辑完全由代码决定,不依赖模型判断,保证了流程的可预测性。

2.3 条件路由与人工确认机制

再详细说说条件路由和人工确认。routeAfterAnalysis的实现在项目里是一个纯函数,读analysisResult里的matchScore字段,如果低于 0.4 走generate_suggestions深度重写分支,高于 0.4 走一个轻量节点。这里的关键是“分析结果本身要足够可靠”,不能是模型随口给个分数。我当时在analyze_jd节点里用withStructuredOutput让模型输出固定 JSON 结构,同时用两轮抽取来交叉验证关键词,才把匹配分数稳定下来。

人工确认机制是这个项目的灵魂。简历和人的职业生涯直接相关,用户不可能放心让 Agent 全程自动跑完,然后给一个“黑盒结果”。LangGraph.js 提供了interrupt()机制,可以在某个节点执行前暂停图,把控制权交还给外部应用,等用户确认后再继续。

我们的做法是在generate_resume之前加一个确认点:先让 Agent 列出“我准备怎么改你的简历”——比如“把工作经历从三段压缩成两段,突出数字指标;补充 XX 技能在项目中的应用;删除与岗位无关的课程实践”——然后展示给用户,用户可以选择“按这个方案继续”或“手动调整”。这个确认点用interruptBefore实现:

const compiledGraph = graph.compile({ interruptBefore: ["generate_resume"], }); // 执行时,Graph 停在 generate_resume 前,返回一个中断状态 const result = await compiledGraph.invoke(input); if (result.interrupt) { // 前端展示 result.interrupt 内容,用户确认后 // 用 Command(resume=true) 恢复执行 await compiledGraph.invoke(new Command({ resume: true })); }

这个设计的实际收益非常大:用户参与感提升,对最终结果的接受度显著提高,而且因为中途可以改正方向,后台因为“生成结果不满意”而重跑的调用次数大幅下降。从成本角度看,这个确认点反而帮我们省了 token 钱。

3. 核心节点实现与代码落地

3.1 简历解析节点

parse_resume节点是整个流程的地基。它的任务是把一段自由文本简历,转换成结构化的 JSON:基本信息、教育经历、工作经历、项目经历、技能标签、个人评价。这个节点模型能力要求高,因为简历格式千奇百怪:有按时间倒序排的,有按项目维度写的,还有混排的。

我用的是ChatPromptTemplate+withStructuredOutput的组合。Prompt 里明确要求模型:只抽取文中明确存在的信息,对于不确定的字段填null,绝对禁止根据已有信息“合理推测”缺失的经历。这个约束设计的背景是:模型在抽取时经常自作主张补全工作年限或公司职级,这在简历场景是不可接受的。抽取模版的输出格式大致如下:

const parseSchema = z.object({ basicInfo: z.object({ name: z.string().nullable(), phone: z.string().nullable(), email: z.string().nullable(), city: z.string().nullable(), yearsOfExperience: z.number().nullable(), }), education: z.array(z.object({ school: z.string(), major: z.string(), degree: z.string(), period: z.string(), })), workExperience: z.array(z.object({ company: z.string(), title: z.string(), period: z.string(), responsibilities: z.array(z.string()), achievements: z.array(z.string()), })), projects: z.array(z.object({ name: z.string(), role: z.string(), description: z.string(), techStack: z.array(z.string()), highlights: z.array(z.string()), })), skills: z.array(z.string()), summary: z.string().nullable(), });

这里有一个真实处理过的数据问题:简历里“工作经历”和“项目经历”的边界经常模糊。有些人的项目经历嵌套在工作经历里,有些反过来。我在 Prompt 里加了一条规则:如果一段工作经历里明显包含独立可验证的项目,就拆到projects字段;如果无法判断,全部留在workExperience.responsibilities里,让后续节点去处理。这样降低了解析失败率,也不会丢信息。

PDF 上传是另一个大坑。用户上传 PDF 后,我们用pdf-parse抽取文本,但 PDF 里的多栏布局、表格、图片文字会导致抽取结果乱序或缺失。最后我们做了个保守策略:如果抽取文本少于 200 字且 HTML 里没检测到表格结构,直接提示用户“请复制粘贴文本”而不是硬解析。实测下来,这个策略把解析失败率从 18% 降到了 3% 以下。宁可让用户粘贴文本,也不能把错误信息喂给后续节点。

3.2 岗位匹配与差距分析

analyze_jd节点在简历解析之后执行,核心任务是理解目标岗位 JD,找出简历与岗位之间的关键差距。这个节点不只做关键词匹配,还要理解技能的权重关系。

我第一版实现是简单的“提取 JD 关键词 → 扫描简历中是否出现”,效果很差。因为 JD 里“熟悉 React”和“精通 React”权重完全不同,而且有些核心要求是隐性表达的,比如“负责过从 0 到 1 的项目”这种描述,关键词抽取根本抽不出来。重构后,这个节点拆成了三个步骤:

第一步,让模型从 JD 中抽取“硬性门槛”和“加分项”,并给每个技能标注权重(1~5 分)。第二步,结合parsedResume里的技能、项目描述,逐项评估“是否达标”。第三步,输出一个结构化的分析报告,包含匹配率、核心差距、可调整项、不建议调整项。

withStructuredOutput在这种结构化分析任务上特别合适。构造的 prompt 里我特意加了一段“自我约束”说明,要求模型标注每一项判断的依据来源——比如“简历中所有项目均未提到性能优化,因此无法证明具备该项能力”。这样做的好处是,后面生成建议时,模型能引用这些依据去说服用户,而不是空泛地说“建议补充性能优化经验”。

差距分析的结果会直接灌入下一层生成建议的上下文。这里有个很容易犯的错误:把整个分析 JSON 全部塞进下一个节点 prompt 会导致 token 浪费且干扰生成质量。我后来只把“核心差距”“可调整项”“建议调整方向”这三个字段传给后续节点,其他字段只用于前端展示,对 token 成本优化很明显。

3.3 简历生成与导出

generate_resume是整个流程里最容易“翻车”的节点,因为简历生成既要保持真实性,又要针对 JD 做优化,还要在表达上足够专业。我采用的策略是“模板约束 + 模型局部改写”而不是“整篇重新生成”。

具体做法是:先根据analysisResult选定一个岗位类型模板(前端开发、后端开发、数据分析、产品经理等),模板定义了简历各分区的结构和每个分区的重点。然后让模型在模板框架内,分区块对原文进行改写——比如工作经历区块,模型需要把每条职责改成“动词 + 量化结果”的句式,但不能改变事实内容。为了防止编造,我在 prompt 里给出了一条硬性规则:数字、公司名、职位名、时间线必须完全来自原文,任何新增数据一律视为编造,需要跳过或留空。

const generateResumePrompt = ChatPromptTemplate.fromMessages([ ["system", `你是一个资深简历顾问。你的任务是在给定模板框架内,将用户的原始工作经历改写为针对 {targetJob} 岗位的优化版本。 严格遵守规则: 1. 所有事实信息(公司、职位、时间、数字指标)必须来自原始简历,严禁编造; 2. 每条经历改写后尽量包含"动作 + 方法 + 结果"结构; 3. 如果原文没有量化结果,不要虚构数字,可以改写为"负责 XX 系统的 X 个模块的设计与开发"这类描述性表达; 4. 删除与目标岗位关联度低的长篇描述,保留最相关的 3~4 条职责。`], ["human", `原始简历内容:{parsedResume} 岗位分析结果:{analysisResult} 简历模板结构:{templateStructure}`], ]);

生成结果出来之后,review_output节点做最后一轮质量检查。这个节点同样用结构化输出,让模型对生成结果做三件事:检查是否包含编造事实、检查关键岗位关键词是否覆盖、检查文本长度是否超出一页纸。任何一个检查项不通过,条件路由会把流程打回generate_resume并附带修正指令,最多重试两次。这个“生成 → 检查 → 打回”的循环,实际线上运行中大概有 15% 的生成会被打回重写,而且第二轮生成的质量明显提升,证明这个检查节点是值得的。

导出环节用的是react-pdf。Agent 生成的 Markdown 内容先转成 JSON 结构,再映射到 PDF 模板的分区。这一步技术上没什么难度,但有个小提醒:如果直接用 LLM 输出的 Markdown 做成 PDF,排版会非常不可控;中间加一层 JSON 结构转换,虽然多写几十行代码,但排版的稳定性能提升一个量级。

4. Next.js 集成与流式交互体验

4.1 API 路由与流式输出

整个 Agent 在 Next.js 里的运行入口是一个 POST API Route。前端把简历文本、JD、用户确认信息 POST 过来,API Route 里调用 LangGraph.js 的compiledGraph.stream(),并把流式事件转成 SSE(Server-Sent Events)推给前端。

这里最关键的 API 选择是streamMode。LangGraph.js 支持多种流式模式,updates模式只在每个节点结束时输出该节点的完整返回,messages模式则会把 LLM 的增量 token 也推出来。简历生成场景肯定选messages模式,否则用户会在“节点跑完前的空白期”感觉到卡顿。接口实现里我是这样处理的:

// app/api/agent/route.ts export async function POST(req: Request) { const { resumeText, jdText, confirmed } = await req.json(); const stream = await compiledGraph.streamEvents( { resumeText, jdText, confirmed }, { version: "v2", streamMode: "messages" } ); const encoder = new TextEncoder(); const readable = new ReadableStream({ async start(controller) { for await (const { event, data } of stream) { if (event === "on_chat_model_stream") { // 将模型增量 token 推给前端 controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "token", content: data.chunk?.text ?? "", })}\n\n`)); } else if (event === "on_chain_end") { // 节点完成时推送节点名,便于前端展示进度 controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "node_end", node: data.name ?? "", })}\n\n`)); } } controller.close(); }, }); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }, }); }

这个方案里容易踩的坑是:streamEvents的on_chat_model_stream事件只有在模型调用时才会触发,如果某个节点内部不直接调 LLM,而只是做逻辑判断,前端就会有一段时间没有数据推送。我的解法是在关键逻辑节点里显式地调用一次“空档填充”模型输出,比如analyze_jd节点开始时先输出一句“正在分析岗位需求,请稍候”,既让用户感知到进度,也给模型生成分析内容留出时间。

另一个坑是SSE 的编码问题。如果简历文本里有中文字符,直接用data: ${JSON.stringify(data)}\n\n推给前端,某些浏览器可能会因为编码不一致出现乱码。不要用字符串拼接,一定要用JSON.stringify序列化整个 payload,前端再JSON.parse解回来。这个坑排查了我快两个小时,最后发现是漏了JSON.stringify导致转义错误。

4.2 前端状态管理与交互设计

前端用 React 的useState+useEffect来处理 SSE 流即可,不需要额外引入状态管理库。我这里用一个useAgentStream自定义 Hook 来封装接收逻辑,核心代码不复杂,但有几个交互设计上的点想单独说。

第一点,进度展示要区分“节点级”和“token 级”。节点级进度是“正在解析简历”“正在分析岗位需求”这样的阶段提示,token 级进度是生成内容逐字蹦出来的效果。如果只推节点级,用户会在节点执行期间看到长时间空白;如果只推 token 级,用户不知道当前在哪个阶段。我们最终的做法是:在页面左侧展示节点流程图(已完成的节点打勾、进行中的节点高亮),右侧展示当前节点生成的实时内容流。这种双通道展示方式用户反馈非常好。

第二点,中途打断/重试按钮是刚需。Agent 流程跑 3~5 个节点需要时间,用户经常会想在某一步改输入。我们通过AbortController中断 fetch 请求,前端重置流状态,后端检测到客户端断开后,LangGraph 的 invoke 会抛错,我们用try/catch捕获并写入状态,避免服务器端未捕获异常导致进程崩溃。

第三点,确认环节的 UI 不能太轻。前面提过interruptBefore的人工确认,前端在收到中断事件时,要渲染一个完整的“修改方案确认卡片”,里面列出 Agent 准备做哪些改动、用户可以直接编辑这些改动项,再点“确认继续”。而不是用一个简单的window.confirm()弹窗。因为这本质上是人机协作的关键决策点,做重一点,用户反而更信任这个工具。

5. 常见问题与排障实录

5.1 LangGraph.js 运行时错误

LangGraph.js 的版本迭代很快,API 变化频繁,这是我遇到的最大坑。比如早期版本的Annotation.Root({ ... })写法,到后来被调整为需要显式定义每个字段的 reducer,如果你照着老教程写,运行时会直接报property xxx does not exist on state type。排查这类问题没有捷径,直接看官方 GitHub 仓库的迁移文档,别迷信搜索引擎里的旧代码。

另一个高频错误是RecursionLimitError。这个错误在流程中如果出现循环调用或条件路由逻辑写错时很容易触发。我第一次遇到是在review_output节点里,重写回generate_resume的边忘记加最大重试计数,导致生成质量一直不合格时图会无限循环。后来我在打回边加了全局的recursionLimit配置,以及一个retryCount状态字段,超过两次直接终止并返回最后一次结果:

const compiledGraph = graph.compile({ recursionLimit: 30, // 全局兜底 });

还有一类隐蔽问题是interrupt()和Command的配合。如果在interruptBefore之后用普通的invoke恢复(而不是invoke(new Command({ resume: true }))),LangGraph.js 会把当前状态当成“全新输入”重新开始,导致用户确认后流程从头跑一遍。这个问题在官方文档里其实有说明,但表述得很隐晦,我当时是把interrupt当成一个普通返回值在处理,完全没意识到要用Command恢复。

5.2 Token 消耗与上下文控制

简历工具类应用对 token 消耗特别敏感,因为用户会反复调整 JD、多次重跑流程。实测下来,一次完整流程(解析 + 分析 + 建议 + 生成 + 检查)大约消耗 12k~18k token,如果生成质量不行需要打回重写,成本会翻倍。我们在成本控制上做了三件事。

第一,状态里只保留必要的上下文。parsedResume是整个流程中体量最大的数据,约 2000~3000 token。生成建议和生成简历节点只需要其中的关键字段,所以在传给getLlm()之前,我会用函数把大字段裁剪成摘要版本。具体做法是把每个工作经历压缩成“公司 + 职位 + 不超过 80 字的要点”,把技能列表直接提取前 15 个。

第二,用便宜的模型跑低风险节点。像parse_resume和analyze_jd这种纯抽取分析任务,用gpt-4o-mini就能达到不错的效果;generate_resume这种和用户直接接触、质量要求高的节点,才用更强的模型。这个分层策略大约省了 30% 的 token 费用,对最终效果几乎没有影响。

第三,设置单轮流程的 token 上限。我们在 LangGraph 的配置里传入maxTokens给模型调用层,超出上限直接抛错并终止流程,前端展示“本次生成超出额度,请精简简历内容”,避免无限制调用导致账单失控。这个配置不能只听模型返回的 usage,因为流式模式下模型可能已经生成了大量内容才开始报错;所以我们用LangSmith的元数据记录每一轮的实际用量,按周聚合监控。

5.3 Next.js 部署环境下的性能陷阱

Next.js 在 Vercel 上部署很简单,但把 LangGraph.js 跑在 Serverless 函数里有一些隐藏的性能陷阱。最典型的坑是Serverless 函数超时。Vercel 的免费/Pro 计划里,函数默认maxDuration是 10 秒,我们一次完整 Agent 流程经常跑 25~40 秒(受模型响应速度和重试影响),如果不在route.ts里显式设置maxDuration,请求在 10 秒就被强制切断,前端收到504,但 LangGraph 的流程在服务端可能还在跑。

后来我在路由文件顶部加了:

export const maxDuration = 60;

并同步在vercel.json里配置了函数的超时参数。如果你的部署平台是自建的 Node 服务,则要注意bodyParser的限制——简历文本可能很大,默认的 1MB 限制必须调大,否则上传长简历时接口直接 413。

第二个坑是流式响应和内存限制。LangGraph 的streamEvents会生成大量事件对象,如果一次性把所有事件都缓冲到内存里再统一发送,在 Serverless 冷启动并且内存只有 1GB 的实例上很容易 OOM。正确做法是一边从异步生成器中遍历,一边通过ReadableStream写出,而不是先await收集成一个数组再返回。

第三个坑是冷启动延迟。LangGraph.js 和 LangChain 的依赖树很庞大,冷启动时 js bundle 加载加上节点初始化,经常要 1~2 秒。如果是生产环境,建议开启 Node.js 的instrumentation.ts做连接池预初始化,并且尽量把不常用的依赖用动态import()加载,减少主包的体积。我们实测从 2.3 秒的冷启动优化到了 1.1 秒,虽不完美,但对用户体验的改善非常明显。

6. 部署成本与后续扩展方向

6.1 部署配置与成本测算

整个项目的部署架构很简单:Next.js 应用跑在 Vercel 上,LangGraph 流程直接在 API Route 内执行,没有单独拆分 Agent 服务。这样做在项目初期非常合适,因为不需要维护两个服务的调度和运维。数据库用的 Postgres(Neon),存储用户简历快照和生成记录。日常部署成本主要是三块:Serverless 函数执行时长费、LLM token 费用、数据库存储费。

用当前的用量模型粗算一下:假设每天 200 个用户完成简历生成,每个用户平均跑 1.8 次完整流程,每次流程约 15k token,一天大概是 5.4M token。按gpt-4o-mini输入/输出均价折算,每天的 token 成本在 8~10 美元左右。加上 Vercel 的 Pro 套餐(20 美元/月)和 Neon 基础版,月成本大约 300~350 美元。如果后续用户量上来,LLM 费用会成为大头,一定要引入用量配额和缓存机制。

缓存是我觉得最被低估的省钱手段。实现上,我们把“解析后的简历结构化结果”按内容哈希缓存了 24 小时:如果用户只是换了个 JD,就不需要重新解析简历,直接从缓存读parsedResume。实测这个优化能省下约 25% 的 token 消耗。另外,analyze_jd的结果也做了基于“JD 哈希”的缓存,相同岗位的 JD 重复分析直接复用结果,进一步压低成本。

6.2 功能扩展与产品化思路

这个项目跑通之后,我已经在考虑几个扩展方向。第一个方向是多 Agent 协作:把“简历优化”和“面试准备”拆成两个独立的 Agent,共享同一个简历状态库。用户优化完简历后,可以一键生成针对目标岗位的模拟面试问题,面试 Agent 读取简历状态和 JD 分析结果,生成个性化的追问列表。LangGraph.js 对多 Agent 图有原生支持,技术上扩展成本不高,但对产品价值提升很明显。

第二个方向是团队版/企业版:很多 HR 和职业顾问是批量处理简历的,他们对“批量上传 → 批量分析 → 批量生成”的需求很强烈。这需要改造成支持 CSV 导入、队列任务、结果分页管理。后端要把 LangGraph 的单次调用结构调整为任务队列模式,用BullMQ或者Trigger.dev做异步调度,前端做任务中心页面。这个方向能明显提高客单价,也是我现在最看好的商业化路径。

第三个方向是本地模型替换。目前全流程依赖云端 LLM,在数据安全要求高的场景(比如企业内网招聘)里是个硬伤。LangGraph.js 的模型调用层本身就是抽象化的,理论上可以把getLlm()的model替换成Ollama或其他本地推理服务。真正做的时候会碰到性能瓶颈,本地模型在解析长简历和生成高质量内容上的能力目前还有差距,但相关模型的迭代速度很快,这个方向一年内应该有不错的落地可能性。

最后再分享一个个人体会:LangGraph.js 很适合这类“多步骤 + 强状态依赖 + 需要人工干预”的业务场景,但它的学习曲线确实比普通的 LLM 封装库要陡。一开始不要急着追求“图有多复杂”,先把一条最短可用路径跑通,再逐步往图里加节点、加分支、加中断。我自己是在第二版重构时才找到了状态和节点的正确粒度,第一版那种“一个大节点塞三个功能”的写法,调试成本高到让人崩溃。如果让我给一个建议,那就是:从简单开始,让图的结构跟着真实业务痛点走,不要为了“看起来很 Agent”而设计过度。

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

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

立即咨询