
去年我把“简历优化”这件事从头到尾做成了一个真正的 AI Agent 落地项目用户上传一份简历前端用 Next.js核心编排层跑在 LangGraph.js 上后端 Agent 自动完成解析、结构化、逐条优化、评分、再回流修改的完整闭环。整个过程不是调用一次模型就结束而是有状态、有循环、能随时把中间结果推给前端展示的“智能体式”交互。前前后后大概花了三周其中一半时间都在处理稳定性、超时和 token 浪费的问题。这篇稿子就把从选型到最后部署的真实过程拆开讲尤其适合那些准备在 Next.js 里引入 Agent 编排、但不确定要不要上 LangGraph.js 的开发者。1. 为什么选了 Next.js LangGraph.js1.1 简历增强这个场景卡点在哪先说业务本身。简历工具看起来简单实际却很“磨人”。一份用户上传的简历可能来自 Word 模板、PDF 扫描件、招聘网站导出的 HTML格式五花八门。用户想要的不只是“帮我改改措辞”而是希望系统先读懂他做过什么再针对应聘岗位去调整亮点最后还要能解释“为什么这么改”。这意味着交互链路很长解析、理解、改写、评价、再改写每个环节都在产生新的上下文。如果用最原始的方式写无非是在 Next.js 的 API Route 里调用大模型接口把整段简历塞进 Prompt等模型一次返回结果再拼一个前端页面。这种做法跑 Demo 没问题但真正做产品时会撞上几个很硬的墙第一一旦用户在界面上说“第二段改得太平淡再主动一点”你不得不把整段历史重新拼给模型随着上下文变长token 成本快速失控第二改写过程里用户想看到实时生成效果单纯的“转圈等待”体验非常差第三多轮编辑的中间状态散落在前端变量里刷新页面就丢用户只能反复复制粘贴。这些痛点最终都指向同一个结论需要一个有状态、可循环、能持久化中间结果的工作流引擎也就是 Agent 编排层。1.2 LangGraph.js 解决的核心问题在 Node.js 生态里LangGraph.js 是我当时能找到的最贴合这个场景的编排工具没有之一。它把 Agent 的每一次运行建模成一张有向图节点是具体的动作比如“解析简历”“优化项目经历”“检查是否达标”边是动作之间的流转关系而整张图的执行状态由内部状态机制统一管理。你可以把它理解成一套生产流水线每个工位只干一件事但整条线通过传送带把半成品送给下一个工位并且随时能停下来检查某个工位产出是不是合格。相比直接用代码写回调、写 Promise 链LangGraph.js 最大的优势在于“可控”。模型调用天然有不确定性但如果把流程拆成解析、改写、评审三个独立节点每个节点都能独立重试跑挂任意一个环节都能精确定位到是哪一步出了问题。再加上它支持条件边我可以让 Agent 写完一版简历之后自动进入“评审节点”如果打分不过线就让图回到“改写节点”再循环一轮直到达到质量阈值或超过最大轮数。这种循环能力是普通 API 编排很难优雅实现的。另外LangGraph.js 自带 checkpoint 能力可以把每一轮的状态存下来。这个特性对简历工具来说几乎是救命级的用户改到一半页面刷新了重新进入页面时能把之前的改写上下文恢复出来而不是让用户从头再来。社区里有人总说“LangGraph 就是给写代码的人画状态机”我觉得这个评价很中肯画状态机本来就是做业务的最好方式想清楚状态整个 Agent 的边边角角就都清楚了。1.3 整个系统的链路设计我在项目里的真实链路是这样前端 Next.js 负责简历上传、结果展示和流式文本渲染后端在同一个 Next.js 应用里用 Route Handler 暴露一个/api/agent接口收到用户简历后实例化一个 LangGraph.js 编译好的图把简历文本作为初始状态跑起来。图内部有三个核心节点解析节点负责去噪和提取结构化字段优化节点调用大模型逐段改写评审节点给改写结果打分并决定是否进入下一轮循环。图的每一次stream迭代都会把当前节点的增量输出通过 Server-Sent Events 推送给前端。前端拿到流式数据后用ReadableStream配合 React 的useState增量渲染文本视觉上就是“模型实时打字”的效果。同时我会把每一轮的评审结果和 token 用量记录进数据库这样用户可以在历史记录里看到上一次简历评了多少分、优化消耗了多少 token。整个链路没有引入单独的微服务也没有把 Agent 逻辑拆出 Next.js 应用好处是部署简单、一个 Vercel 项目全搞定坏处是单入口容易在长任务上踩超时这部分后面会详细讲。2. 从零把 Next.js LangGraph.js 项目跑起来2.1 初始化 Next.js 工程与依赖安装初始化环节我用的是 Next.js 14 的 App Router 模式理由很直接App Router 下的 Route Handler 做流式响应比较顺而且 Server Components 可以让上传简历这类操作直接走服务端逻辑避免把用户文件暴露到客户端。具体命令是老一套npx create-next-applatest resume-agent --typescript --app --tailwindTailwind 我会留着前端交互细节多有样式库能省不少时间。接下来安装核心依赖这一块是最容易踩版本坑的地方我贴一下当时的完整安装命令npm install langchain/langgraph langchain/openai zod这里多说一句LangGraph.js 本身不绑定具体大模型厂商它通过 LangChain 的ChatModel接口对接各家模型。我用的是langchain/openai里的ChatOpenAI选它主要是因为当时的结构化输出功能最成熟传一个 JSON Schema 进去模型就能乖乖按格式返回对象这对解析简历和输出评审分数来说太关键了。2.2 版本与服务商的几个注意坑单独说下版本问题。2024 年到 2025 年之间LangGraph.js 的 API 有过几次调整比如状态定义从纯接口变成了Annotation体系节点注册从addNode(节点名, fn)演进来后又强调 reducer 合并策略。如果你查资料时看到 2023 年的旧代码直接用new StateGraph({ channels: ... })大概率会报类型错误。我的建议是安装时不要“能装上就行”一定要看看package.json里的实际版本号然后以官方文档当前版本为准把示例代码先跑通再往上叠业务逻辑。模型服务商也要提前做决定。我一开始图省事只配了默认的 OpenAI 模型后来发现简历解析这种任务用大杯模型太浪费小杯模型又容易出现 JSON 输出断掉的情况。最后我把不同节点拆分成了不同模型解析节点用小杯模型省钱优化节点用中杯模型保证改写质量评审节点再换一个两个模型都能用的具体型号。成本控制方面这一刀切下去单份简历的处理费用从几毛钱降到了几分钱效果很直观。2.3 目录结构设计整个项目的目录我是这样规划的resume-agent/ app/ page.tsx # 主页面上传简历 展示结果 api/ agent/route.ts # Agent 入口接收简历并流式返回 agent/ graph.ts # 状态图定义、节点注册、编译 nodes/parse.ts # 解析节点 nodes/optimize.ts # 优化节点 nodes/review.ts # 评审节点 state.ts # ResumeState 状态定义 utils/ stream.ts # SSE 流式处理工具 store.ts # 状态存储辅助内存/db把 Agent 相关代码独立放在agent/目录而不是散落进页面组件里是我这次最不后悔的决定。这样后续如果想把 Agent 逻辑抽成独立服务或者在公司另一个项目里复用这套简历工作流只需要把agent/这个文件夹整个搬走就行。页面层和 Agent 层的耦合被压到了最小接口就是“输入 resumeText输出流式结果”互不干扰。3. Agent 状态机与核心节点实现3.1 先定义状态再想流程图我在写图形之前犯过的最大错误就是跳过了状态定义先画节点结果节点之间传参全靠脑补改一次图就要动五处代码。这次我学乖了第一步就是定义状态类型import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ resumeText: Annotationstring, parsedData: AnnotationRecordstring, any, optimizedText: Annotationstring, reviewResult: Annotationstring[], rounds: Annotationnumber({ reducer: (left, right) left right, }), }); export type ResumeStateType typeof ResumeState;这里最关键的是想清楚哪些数据是共享的、哪些是节点各自的中间产物。resumeText是用户原始输入必须从头到尾存在parsedData是解析节点结构化之后的 JSON后续两个节点都要读optimizedText是优化节点的输出评审节点读它来打分前端流式显示时也读它rounds用来记录循环轮数避免无限循环。所有节点函数的签名都是输入整个状态、输出部分状态的增量比如async function parseNode(state) { // 从 resumeText 中提取信息 return { parsedData: { basics, workExperiences, projects } }; }reducer则是 LangGraph 里处理“同一个字段被多个节点写入”时的合并规则。默认情况下后写覆盖先写但像rounds这种计数变量就需要自定义累加逻辑。这个细节看文档容易忽略实际跑起来之后你会发现没有 reducer 的字段经常会在多节点流式运行时报错或者是值莫名其妙变回初始值。3.2 解析节点把乱七八糟的简历变成结构化 JSON解析节点的核心工作不是做 NLP而是做“去噪 转结构化”。很多用户的简历长得像文档小说有标题、表格、页眉页脚直接塞给优化节点不仅浪费 token模型还会把不需要的内容也改一遍。我当时的做法是先把上传文件按扩展名处理成纯文本再在解析节点里让模型提取关键字段import { ChatOpenAI } from langchain/openai; import { z } from zod; const ResumeSchema z.object({ basics: z.object({ name: z.string(), role: z.string().optional(), contact: z.string().optional(), }), workExperiences: z.array(z.object({ company: z.string(), title: z.string(), period: z.string(), highlights: z.array(z.string()), })), projects: z.array(z.object({ name: z.string(), techStack: z.string().optional(), description: z.string().optional(), })), }); const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }); const parser model.withStructuredOutput(ResumeSchema); export async function parseNode(state: any) { const { resumeText } state; const parsedData await parser.invoke(resumeText); return { parsedData }; }这个节点最值得强调的地方是temperature 0。解析任务追求的是稳定还原事实不是发挥创意一旦 temperature 太高模型会擅自润色工作内容导致后面优化节点的“改写”建立在被污染的原始信息上产出一堆虚构经历。结构化输出也必须用 Zod 严格约束否则模型偶尔会吐出一个半截 JSON导致整个流程中断。我后来专门写了一个重试 wrapper解析失败时自动重新调用一次第二次还失败就直接告诉用户“抱歉这份简历格式不太常规请粘贴纯文本”不再浪费 token。3.3 优化节点用函数绑定让模型“真实动手”优化节点是整个 Agent 最像 Agent 的部分。我不满足于让模型只是输出一段话而是让模型学会调用一组工具来“操作”简历数据。比如rewriteHighlight这个工具接收项目 id 和目标描述模型判断当前项目描述不够亮点化时会主动调用工具把推荐改写后的结果写回数据。这在代码上就是你初始化模型时传 tools让模型具备 tool calling 能力import { tool } from langchain/core/tools; import { z } from zod; const rewriteHighlight tool(async ({ highlightId, newText }) { // 这里可以真正写数据库或更新内存中的 parsedData return 已更新 id${highlightId} 的亮点描述; }, { name: rewriteHighlight, description: 重写简历中某一条项目亮点的描述使其更有行动导向和结果导向, schema: z.object({ highlightId: z.string(), newText: z.string(), }), });然后把工具传给优化节点用的模型const optimizeModel new ChatOpenAI({ model: gpt-4o, temperature: 0.4, }).bindTools([rewriteHighlight]);启用工具调用之后模型不再只是输出一段“建议”而是直接产生一系列可执行动作先调用rewriteHighlight改动某段描述再调用addMetric在项目经历里补充一个量化数字最后生成一段总结说明改了什么、为什么改。每一个动作都可以被前端捕获、回显、甚至撤销。这才是我理解中合格的 AI Agent不是“替你写一段话”而是“替你把事情做了”。当然工具调用也带来了额外复杂性。模型可能在一个循环里反复调用同一个工具导致改写结果反复横跳。我的解法是限制单次优化节点的最大动作数并在系统 Prompt 里明确规定“你已经对一个亮点完成改写后不要重复修改同一 id继续推进到下一个未处理的亮点”。同时我会在循环节点的出口加一个评审如果模型这一轮没有产生任何工具调用就直接结束流程避免空转。3.4 评审节点与循环控制评审节点负责决定这次优化到底算不算过关。我把评审标准拆成三个维度准确性、量化程度、动词强度。每个维度 0 到 10 分总分低于 18 分就进入下一轮循环否则结束。这个节点仍然用结构化输出定义一个简单 schemaconst ReviewSchema z.object({ scores: z.object({ accuracy: z.number(), quantification: z.number(), verbStrength: z.number(), }), verdict: z.enum([pass, improve]), reason: z.string(), });状态图里关键的一步是条件边const graph new StateGraph(ResumeState) .addNode(parse, parseNode) .addNode(optimize, optimizeNode) .addNode(review, reviewNode) .addEdge(START, parse) .addEdge(parse, optimize) .addEdge(optimize, review) .addConditionalEdges(review, async (state) { if (state.rounds 3 || state.reviewResult.verdict pass) { return end; } return improve; }, { improve: optimize, end: END, }) .compile();这段逻辑看起来简单但真正执行时你会发现“循环”会把 token 成本翻好几倍。所以我给rounds设置了一个硬上限默认最多循环 3 次第 3 次无论评审过不过都强制结束。另外每个轮次之间我会让优化节点读一次上一轮评审意见相当于把“评委的话”喂回给“作者”提醒它这一轮哪个维度没做好。这个环节对效果提升非常明显比单纯多跑几轮强得多。3.5 流式输出与前端实时渲染Agent 流程跑起来了之后体验上最大的坎就是“让用户看到过程”。我用的是 Route Handler SSE// app/api/agent/route.ts export const runtime nodejs; export async function POST(req: Request) { const { resumeText } await req.json(); const stream new ReadableStream({ async start(controller) { const encoder new TextEncoder(); const send (data: object) { controller.enqueue(encoder.encode(data: ${JSON.stringify(data)}\n\n)); }; const result await graph.stream( { resumeText, rounds: 0 }, { recursionLimit: 5 } ); for await (const step of result) { const currentNode Object.keys(step)[0]; const state step[currentNode]; send({ type: node, node: currentNode }); if (state.optimizedText) { send({ type: text, content: state.optimizedText }); } if (state.reviewResult) { send({ type: review, scores: state.reviewResult }); } } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }前端用fetch读这个接口重点不是解析整个 JSON而是按行读取data:前缀的事件每来一段就追加渲染。这种做法的好处是响应不需要全部生成完才开始展示用户的等待焦虑感会明显降低。实测对比过不流式和流式两种方案用户的留存率不在一个量级。还需要强调的是这种流式接口不能放在 Edge Runtime 上跑。Edge 环境主要是为轻量响应设计的不适合承载 LLM 调用和几十秒的长任务。我一开始图 Vercel 默认优化没显式声明 runtime结果日志里报了一堆关于ReadableStream和环境变量的问题。把export const runtime nodejs加上之后这些诡异报错全部消失。4. 常见问题与排查技巧实录4.1 平台超时与长任务的拆法用 Vercel 部署最大的隐患就是函数超时。免费和 Hobby 计划的 Serverless 函数默认只有 10 秒上限虽说流式响应可以在函数返回后持续推送但服务端把 LangGraph 全部节点跑完加上模型调用很容易超过 60 秒限制。我那段时间被这个问题折腾到怀疑人生前端明明已经看到了一部分优化结果但整条响应在生成中途被平台掐断用户拿到一份残缺的简历改写。我的解法是“拆包”。不让一次请求跑完整个 Agent 图而是把图和 HTTP 交互拆成两段。第一段请求负责解析简历很快返回结构化结果第二段请求才开始跑优化和评审循环前端在展示流式内容的同时用户如果看到“解析完成”就可以松一口气。即便如此Long-running 还是会超时进一步措施是把图执行搬到异步任务队列里HTTP 请求只创建任务并拉取状态不过这个方案需要引入数据库轮询或者 WebSocket复杂度高了不少适合用户量上来之后再做。如果你只是做 MVP我建议至少做到“流式返回 前端断线重连”这层兜底断线之后用户能重新发起请求并且带上上次已经生成的前半段结果去续跑。4.2 并发场景下如何扛压和控 token“AI Agent 怎么扛并发”是很多从单机脚本转向 Web 服务的开发者最关心的问题。我的体感是在初期用户量不大时不要一上来就追求分布式先把并发带来的两个问题解决掉一个是模型限额一个是状态冲突。模型限额很好理解。热门模型的每分钟请求数RPM和每分钟 token 数TPM有限一旦同时进来几十个简历优化请求直接会报 429。我在 Agent 外层套了一个极简的令牌桶内存里维护一个数组记录每次调用的时间戳超过设定的 RPM 就强制排队。虽然看起来简陋但配合单实例部署效果立竿见影。如果以后要多实例部署再把这个限流逻辑换成 Redis 计数也不难。状态冲突则是 LangGraph 特有的坑。多个请求同时操作同一个 PDF 解析结果时如果没有做好隔离用户的 A 编辑可能会覆盖用户 B 的数据。Agent 是有状态的两个用户共用一个状态池就会串号。我后来强制要求每个请求都携带一个sessionId所有状态都挂在 session 作用域下对象存储的 key 也是sessionId:{id}彻底隔离。这里我踩过一个特别蠢的坑测试时用了一个固定的 userId 去跑并发结果两个测试用户的“优化内容”互相覆盖排查了两天才发现是测试数据的问题代码本身没毛病。token 成本方面我的经验是“先算再跑”。一份常见简历的纯文本大概 1500 到 3000 个 token解析一次约 1000 token优化循环每轮 2000 token评审一次 500 token三轮循环下来一份简历的实际消耗轻松破万 token。这个账必须先算到产品定价里否则做免费试用时一天烧掉几百块不是开玩笑。我后来加入了输入长度的预检查超过 4000 token 就引导用户精简同时在每个节点调用前打印 token 数方便随时把握成本。4.3 Agent“胡写”时的兜底设计LangGraph 循环跑起来之后最吓人的不是报错而是它“不自知地乱写”。有一版测试模型在优化“项目经历”时把用户在前公司负责的产品线名字整个替换成了一个看起来更高级的同名产品这种错误很难通过评审分数发现因为准确性维度模型可能根本没识别到事实漂移。我做了三层兜底。第一做事实隔离优化节点能从parsedData里读取原始公司名、产品名、时间区间但 Prompt 里明确命令“只许润色描述语言禁止修改专有名词、数字和时间如果一定要修改必须先调用工具申请”。第二做版本对比每一轮改写之后前端把原文和新文并列展示用户一键选择“用原文还是用新文”。第三在评审节点里新增一个factDrift布尔字段让模型重点比对原简历内容与改写内容之间的专有名词一致性一旦发现不一致直接判为不通过进入下一轮。这三层下来事实漂移的情况从偶尔发生变成了几乎不再出现。说白了Agent 落地最忌讳的就是让模型直接做终稿决策必须不断加“决策护栏”把人放在最终确认环节Agent 才能放心用。4.4 Next.js 构建与部署的常见坑部署阶段再补三个 Next.js 特有的小坑。第一个是环境变量。所有模型 API Key 都放在.env.local里生产环境必须在 Vercel 的项目设置中逐个配置不要直接提交到代码仓库。加一个.env.example文件记录变量名团队协作时就不会扯皮。第二个是构建时的类型检查。LangGraph 的节点如果是async function返回对象必须和状态注解完全对齐否则next build的类型检查会直接失败。如果有临时的不确定字段最简单的方式是先定义一个Partial类型但别用any糊过去不然后续维护会让你想骂人。第三个是缓存问题。Next.js App Router 默认会对 GET 响应做静态优化和缓存但我们的 Agent 接口是 POST一般不涉及不过页面本身如果用了fetch去读历史记录记得把cache: no-store加上否则你改了数据库内容页面上还是旧数据。5. 后续还可以继续进化的方向5.1 从单 Agent 到多 Agent 协作单图能解决的问题是有限的。简历工具发展到后面值得考虑把“解析 Agent”“优化 Agent”“评审 Agent”拆成多个独立部署的服务甚至让不同 Agent 使用不同模型。多 Agent 架构的优势在于职责单一出错定位明确也方便单独扩容。比如解析 Agent 用户量大可以单独给它加副本优化 Agent 用的模型贵可以自己在内部做排队不让慢请求拖垮解析环节。LangGraph.js 本身支持子图把已经跑通的单图嵌到更大的图里等于把简历优化作为子任务外层再注册一个调度节点来编排多个子图这种扩展路径很自然。5.2 引入持久化存储和回调结果目前整个流程是“跑完即走”缺少持久化会让很多场景受限。用户希望看到优化前后的 diff 历史希望把某一次优化结果收藏成模板甚至希望直接生成一份 PDF 下载。要把这些支撑起来至少需要一张resume_sessions表存会话状态一张resume_results表存每次节点的输出和 token 消耗。接入数据库之后LangGraph 的 checkpoint 能力才真正发挥价值因为你可以恢复一个历史会话让用户说“从上次改到一半的地方继续”。没有存储Agent 的记忆力就是零用户每次进来都要从零开始体验会差一个档次。5.3 从简历工具延伸到招聘侧简历 Agent 的技术栈完全可以平移。面向求职者的“简历优化”和面向 HR 的“简历筛选”其实共享同一套解析和结构化能力解析节点把简历变成结构化 JSON 之后招聘侧可以接一个匹配节点把职位要求输入进去模型自动算匹配分数、列出重点疑问。再往后甚至可以做成“简历智能问答”用户对着自己的简历问“我这三年到底有没有在进步”Agent 基于状态数据回答问题。这个方向是同一套图结构在不同业务角色上的复用比再从零搭一套框架省力太多。最后再分享两个小技巧。一是做这类 Agent 项目一定要从第一天就在每个节点打印 token 消耗和耗时哪怕只是console.log你能不能持续优化模型选型、判断成本问题全靠这套基础数据。二是别把所有逻辑都堆进状态图里图适合表达可控的流程不适合表达琐碎的业务规则比如文件格式校验、用户权限这些放在图外面更清晰。跑了一阵子之后你会发现Agent 落地真正难的不是“调用模型”而是把模型放进一个有边界、可回滚、能观测的系统里让每一次生成都变成产品功能的一部分。