LangGraph.js实战:从零搭建生产级简历优化Agent的避坑指南
一个简历工具让我摸清了AI Agent落地的所有坑
从0到1用Next.js + LangGraph.js搭了一个简历优化Agent,不是那种聊聊天、给几句建议的Demo,而是真正把"解析简历→匹配岗位→找差距→重写简历"整条流水线跑起来的生产级工具。前前后后折腾了三个星期,中间踩了并发、状态、流式输出、成本失控好几个大坑。这篇文章把我整个落地过程记录下来,从架构选型到每一个节点的实现细节,再到并发问题的排查思路,全部摊开讲清楚,希望给那些准备用AI Agent做真实业务的朋友省点时间。
这个项目适合谁?两类人:一是想给简历工具、招聘SaaS这类场景加AI能力的后端/全栈工程师,二是看了LangGraph文档但不知道怎么把它接进真实应用的AI应用开发者。理解了这套东西,你会发现Agent不只是"调API聊天",而是可以像传统后端服务一样被设计、被测试、被运维。
1. 项目定位与整体设计思路
1.1 简历工具到底需要解决什么问题
市面上的"AI改简历"工具很多,但绝大多数只是把用户简历和职位描述拼在一起丢给大模型,让它"优化"一下。这种做法的体验非常糟糕:模型没有先理解你过去的经历,输出的内容经常是流水账式的堆砌;你说"我有5年Java后端经验",它给你写"具备5年Java后端开发经验,熟悉Spring Boot框架,参与过多个高并发项目研发"——这不叫优化,这叫满嘴空话。
所以我想要的是一个有"工作流"的Agent:它先解析简历,把非结构化的文本变成结构化的经历条目;再把岗位要求拆解成硬性技能、软技能、年限门槛;接着逐项比对,找出简历和JD之间的差距;最后针对每个差距给出具体的改写建议,并且产出完整的优化后简历。这几个步骤之间有明确的先后依赖关系,后一步必须拿到前一步的产物才能继续,这就是典型的图状编排场景,用LangGraph.js来处理再合适不过。
1.2 技术选型:为什么是Next.js + LangGraph.js
这个组合乍一看很杂,其实各有分工。Next.js负责应用框架和API层,它把前端页面、路由、Server Actions、Streaming Response都统一在了一个项目里,不用再单独起一个Node后端去对接前端;而LangGraph.js负责Agent的图编排。
有人会问,为什么不直接用LangChain.js?或者干脆裸写fetch调LLM API?我的判断是这样的:
- LangChain.js的核心价值是抽象了大量工具链,但在这个项目里,我需要的是"可控制的流程编排",而不是一堆自由调用的模块。LangGraph.js提供了StateGraph,能明确表达节点之间的边、条件分支、状态流转,Debug和可维护性都强很多。
- 裸写fetch更灵活,但简历解析、岗位匹配、多轮分析、最终生成这几个环节的中间状态要自己维护,还得自己处理并发和重试。LangGraph.js把状态管理内建进了图执行引擎,每个节点的输入输出由state统一承载,代码写起来干净得多。
- Next.js的Route Handlers天然支持Web Streams API,配合AI SDK的streamText可以做到Agent过程的边推理边输出,前端体验比"等一个烤得焦黑的HTTP响应"好一个数量级。
1.3 整体架构一图流
整个系统分成三层:
- 前端层:Next.js页面,用户粘贴简历原文 + 目标JD,点击"开始分析",然后以流式方式看到Agent每一步在干什么,最终展示优化后的简历。
- 编排层:一个编译好的LangGraph.js图实例,包含parse_resume、match_job、generate_resume三个核心节点,节点间通过共享state传递数据。
- 模型服务层:大模型API调用,中间穿插结构化输出的解析和重试逻辑。
需要特别说明的是,LangGraph.js本身可以跑在Next.js的Route Handler里,这对于小型项目很省事,不需要单独部署一个Graph Server。但如果你真要扛比较大的并发量,建议把图实例单独部署成一个Node服务,用Nginx做负载均衡。后面第4章会细讲并发相关的取舍。
2. LangGraph.js在项目里的核心用法
2.1 用StateGraph把工作流编排出来
LangGraph.js的核心概念是StateGraph。你可以把它理解成一张有向图,每个节点是一个函数,图执行器会按你定义的边依次调用这些函数,并且维护一个全局的state对象,节点之间通过state来读写数据。
这个简历工具的工作流我设计成了三段式:
// 状态定义,整个Agent执行过程中共享的数据载体 type ResumeAgentState = { resumeText: string; // 用户提交的原始简历文本 jobDescription: string; // 目标职位描述 parsedResume: ParsedResume | null; // 解析后的结构化简历 jobRequirements: JobRequirement[] | null; // 拆解后的岗位要求 gapAnalysis: GapAnalysis | null; // 差距分析结果 optimizedResume: string | null; // 最终优化的简历Markdown error?: string; }; const workflow = new StateGraph(ResumeAgentStateSchema) .addNode("parse_resume", parseResumeNode) .addNode("match_job", matchJobNode) .addNode("generate_resume", generateResumeNode) .addEdge(START, "parse_resume") .addEdge("parse_resume", "match_job") .addEdge("match_job", "generate_resume") .addEdge("generate_resume", END) .compile({ checkpointer });这几个节点之间的依赖关系非常直接:解析失败就不可能做匹配,匹配结果没出来就不知道往哪个方向优化。如果用传统代码写,你得自己定义中间变量的类型、控制调用顺序、处理异常,而StateGraph把这些都变成了图的拓扑关系,执行顺序由引擎保证。
2.2 节点函数的落地细节
每个节点就是一个异步函数,接收当前state,返回部分state的更新。这个模式跟redux的reducer有点像,好处是每个节点都可以单独测试,输入输出只要符合类型定义就行。
async function parseResumeNode(state: ResumeAgentState): Promise<Partial<ResumeAgentState>> { // 调用LLM把非结构化简历转成结构化JSON const parsed = await extractResume(state.resumeText); return { parsedResume: parsed }; }关于节点设计,我踩过几个坑,单独说一下:
- 节点函数里尽量不要做太重的IO操作以外的逻辑,比如数据清洗、格式校验,这些最好放在节点内部开头做,避免把脏数据传进模型。
- 节点返回的对象不要包含整个state的完整拷贝,只返回需要更新的字段,否则大对象复制会有内存压力。
- 一定要给每个节点加上超时控制,LLM调用偶尔会卡很久,不能让图中一个节点卡死整个流程。
2.3 Checkpointer:会话记忆与断点恢复
LangGraph.js的checkpointer机制是这个框架最值钱的功能之一。它可以记录图的每一次运行状态,支持thread_id级别的隔离。什么意思呢?就是说同一个编译好的图实例,可以同时服务多个用户,每个用户通过thread_id拥有完全独立的对话上下文和节点执行状态。
// 每个请求传入独立的会话ID,互不干扰 const result = await app.invoke( { resumeText, jobDescription }, { threadId: `resume_${userId}_${Date.now()}` } );这在以前用纯LangChain.js写的时候是做不到的——你需要自己在Redis或者数据库里管理每个会话的上下文变量。现在图引擎替你管了,你只需要传入threadId。
但我这里要泼一盆冷水:如果在单机部署场景下使用默认的MemorySaver,并发一大,内存会肉眼可见地往上涨。checkpoint越多,内存消耗越大。生产环境建议换成可持久化的checkpointer实现(比如基于Redis或Postgres),或者干脆像第4章说的那样,对于纯计算型任务不启用checkpointer。
3. 简历Agent核心功能模块的落地实作
3.1 简历解析与结构化:把PDF/Markdown变成真数据
简历解析是整个流程的地基。用户粘贴过来的内容千奇百怪,有的是一大堆文案,有的是从招聘网站导出的纯文本,还有的带着各种符号。你没法指望大模型每次都稳定输出漂亮的JSON,所以必须用结构化输出的方式约束它。
我的做法是:让模型输出一个固定Schema的JSON对象,然后用zod做运行时校验,解析失败就自动重试一次。
const ResumeSchema = z.object({ personalInfo: z.object({ name: z.string().optional(), email: z.string().optional(), phone: z.string().optional(), }), workExperience: z.array(z.object({ company: z.string(), title: z.string(), duration: z.string(), achievements: z.array(z.string()), })), skills: z.array(z.string()), education: z.array(z.object({ school: z.string(), degree: z.string(), major: z.string(), })), });解析完成后,我会把关键信息抽出来放到state里,作为后续匹配节点的输入。这里有个关键细节:不要把原始简历一直留在state里传来传去,token太长是成本杀手,解析成结构化数据就够了。
3.2 岗位匹配与差距分析:让优化建议有的放矢
这个节点的任务是把JD拆解成一系列要求,然后逐条与解析后的简历进行比对。我设计了一个"要求-满足度"的打分机制,让后续生成节点知道往哪个方向使劲。
async function matchJobNode(state: ResumeAgentState): Promise<Partial<ResumeAgentState>> { const { parsedResume, jobDescription } = state; const analysis = await analyzeGap(parsedResume!, jobDescription); // 返回的是类似这样的结构 // { // requirements: [ // { requirement: "熟悉React", satisfied: true }, // { requirement: "5年以上后端经验", satisfied: false, suggestion: "突出你在电商项目中承担的架构设计职责" }, // ], // overallScore: 72 // } return { jobRequirements: analysis.requirements, gapAnalysis: analysis }; }经验分享:这一步如果用纯Prompt让模型"直接改",模型往往会偷懒,只改几个词。而把差距分析单独拆成一个节点,让模型明确先做"评判"再在下一个节点做"改写"之后,输出质量明显变好。这就是把Agent编排成图带来的最大好处——你可以控制模型"先想后做"。
3.3 简历生成与流式输出:从Agent到UI
最后的生成节点会把原始简历、差距分析、岗位要求一起交给模型,让它生成一份Markdown格式的优化后简历。这里我用了流式输出,让用户看到内容一点点蹦出来,而不是对着一个转圈圈的按钮发呆。
在Next.js的Route Handler里,配合AI SDK的streamText来输出流。但要注意:不要让LangGraph的整个invoke过程都在流式响应里同步等待,尤其是前面解析+匹配节点耗时较长。我的处理方式是:先跑解析和匹配节点,拿到gapAnalysis之后,再进入生成节点,把生成节点的streaming输出直接通过Response stream转发给前端。
import { streamText } from 'ai'; import { openai } from '@ai-sdk/openai'; export async function POST(req: Request) { const { resumeText, jobDescription } = await req.json(); // 第一阶段:跑解析+匹配(非流式) const app = getCompiledGraph(); const stateAfterMatch = await app.invoke( { resumeText, jobDescription }, { threadId: `resume_${crypto.randomUUID()}` } ); // 第二阶段:流式生成 const result = streamText({ model: openai('gpt-4o-mini'), system: '你是一位资深简历优化专家...', prompt: buildGeneratePrompt(stateAfterMatch), }); return result.toTextStreamResponse(); }这样做的最大好处是把Agent的"决策过程"和"生成过程"分开了:前面需要等待,用户能接受;后面的生成过程用流式输出,体验上很像ChatGPT,用户不会觉得卡住了。
4. AI Agent并发问题:单用户Demo到扛住真实流量
网上讨论LangGraph.js的帖子不少,但认真聊并发问题的很少。这个项目上线后我第一波真实用户涌进来的时候,直接给我上了一课。这里说几个掏心窝子的结论。
4.1 一个编译好的图实例,能否同时服务N个请求
答案是能,但有个前提:你必须搞清楚state的隔离策略。LangGraph.js的StateGraph编译后的实例是可复用的,每次invoke都会创建独立的state输入。如果你不启用checkpointer,那并发请求天然互相隔离,每次invoke都是一个全新的执行过程。用起来就像调用一个普通的异步函数,没有资源锁。
不过一旦启用了checkpointer(比如MemorySaver),事情就变了。每个threadId的完整执行轨迹都会存进内存,几千个并发请求同时跑,内存原地爆炸。我第一版就是这么挂的,早上起来看服务器内存100%,进程直接被OOM Killer干掉了。
试错之后的结论:如果你的Agent是"一次性任务型"(简历优化就是典型,跑完就结束,不需要多轮上下文),别开checkpointer,或者把checkpointer换成Redis实现。LangGraph.js的checkpointer是为"对话型Agent"设计的,不是为"流水线型任务"设计的。
4.2 长任务挂起与HTTP超时的取舍
简历解析+岗位匹配+生成,一整套流程跑下来,用gpt-4o-mini大概要30~60秒,如果模型响应慢一点,可能要90秒。问题来了:普通的HTTP请求经不起这个折腾,云厂商的负载均衡器通常60秒就断连接了,浏览器更是等不起。
我试过的第一个方案是:让Route Handler同步等到结果再返回。结果就是用户在页面上转了将近一分钟,偶尔还出现504。这个方案不用想,直接枪毙。
后面改成两步走:
- 接口层只负责接收请求,把任务内容写入数据库,返回一个taskId。
- 后台Worker(可以是同一个Next.js项目里的定时任务,也可以是单独的Node进程)用LangGraph图跑这个任务,跑完后把结果写回数据库。
- 前端拿到taskId后轮询状态接口,状态变成completed再去拉结果。
这个模式本质上就是把Agent的调用从同步变成异步化,非常管用。如果你不想自己写Worker,可以看看LangGraph.js平台的Cloud方案或者自托管的LangGraph Server,它们提供的任务管理能力能省掉不少代码。
4.3 并发控制的最终形态:任务队列 + 限流
异步化解决了超时问题,但没解决并发本身。真实用户可不会客气,高峰期几百个请求同时进来,底层的大模型API会被打爆,账单也会以肉眼可见的速度增长。所以我引入了一个简单的内存队列做限流:
class TaskQueue { private queue: string[] = []; private running = 0; private maxConcurrency = 5; async submit(taskId: string) { this.queue.push(taskId); this.pump(); } private pump() { while (this.running < this.maxConcurrency && this.queue.length > 0) { const taskId = this.queue.shift()!; this.running++; this.runTask(taskId).finally(() => { this.running--; this.pump(); }); } } }同时,在前端做了request throttling,轮询间隔控制在5秒以上,避免每秒钟几千次请求打自己的后端。还有一个重要的点:在应用层对大模型API做并发限制,我直接用了支持rate limit的长连接客户端的配置,别小看这个,模型供应商的429错误在高峰期经常出现。
4.4 成本与缓存:钱花得值不值
Agent跑一次简历优化,用gpt-4o-mini大概消耗15000~25000个token(包括系统提示词、简历原文、JD、中间分析结果、最终输出),换算成钱大概是几厘美元。单个用户看不算多,但如果每天跑上万次,一个月下来数字就不那么美观了。
我的三个省钱技巧:
- 缓存高相似度请求。如果同一个用户提交的简历和JD跟上一次基本一致,直接返回上次的结果,不再调模型。我用了一个粗糙的simhash相似度算法,超过0.95就命中缓存。
- 解析和匹配节点用便宜的小模型(比如gpt-4o-mini),只有最终的生成节点用昂贵的大模型(比如gpt-4o),质量和成本折中效果很好。
- 开启prompt caching(模型服务商支持的话),同一份简历在同一会话内多次调用时,token成本能砍掉一半左右。
5. 实战踩坑记录与排查技巧实录
5.1 流式输出到一半就断了
SSE流式传输时,前端收到一半连接就关闭。排查后发现两个原因:一是生成节点里如果同时用了LangGraph的invoke和AI SDK的streamText,两者在同一个Route Handler里互相抢占响应流;二是前端在解析SSE数据时没有正确处理heartbeat事件。
解决办法很简单:把流式生成从图中拆出来,不要让图实例返回stream,生成节点的最终产物是一个完整的Markdown字符串,再单独用streamText流式返回。这相当于"轮子归轮子,车归车"。
5.2 LLM输出的JSON永远在变,解析别硬解析
模型生成的JSON偶尔会多出```json标签或者混入Markdown,直接用JSON.parse十次能有三次报错。后来我用了两个保险:一是让模型输出联邦格式,用工具调用(工具调用)强制走结构化通道;二是解析失败后做一次简单的清洗再重试,还不行就应用降级方案,返回原始文本。
坦白说,结构化输出的可靠性是依赖具体实现方式的。在OpenAI的SDK里,把response_format设成json_schema配合zod schema,稳定性比纯Prompt强非常多。这一点建议有条件的朋友优先采用。
5.3 Checkpoint内存膨胀的排查与替换
前面讲了MemorySaver惹的祸。这里补充一下排查过程:当时我发现服务内存曲线几乎是直的斜线上升,一开始以为是代码内存泄漏,用了heapdump抓快照,发现大量对象都是LangGraph的执行状态。然后去查LangGraph.js文档里有关checkpointer的部分,确认每个threadId的状态都会被内核保留。
最后我把checkpointer换成了ListRedisCheckpointSaver的实现,内存曲线瞬间就平了。如果你不想引入Redis复杂度,也可以直接把checkpointer参数去掉,对一次性任务来说完全没有功能损失。
5.4 Agent循环跑不完了
LangGraph.js的图结构理论上是有终点和起点的,但如果逻辑里用了递归边或者条件边,没写终止条件,节点就会不断循环执行。我的一个早期版本里,让生成节点在检测到输出质量不佳时会"自我反思"再生成一遍,结果它每轮都觉得不够好,愣是跑了十几轮都没停下来,账单哗哗地往下掉。
总结教训:给图加上最大步数限制。
const config = { threadId, recursionLimit: 15, // 最大执行步数 maxTime: 120000, // 总超时时间 };这个约束可以在LangGraph.js的invoke配置里设置,不到万不得已别依赖这个,最好是在图的逻辑一开始就设计清楚终点条件。
5.5 前端体验:不要让用户干等
最后一条不算技术问题,但直接决定用户是否留下来。我第一版页面是"点击分析——转圈——出结果"的模式,用户流失率很高。后来改成了"Mission Control"式的状态流展示:界面上实时显示Agent当前在做什么——"正在解析简历"→"正在分析岗位要求"→"正在生成优化简历",每一步都有一颗小绿灯亮起来。用户直观看到进度,焦虑感就没了。
这个信息是可以通过LangGraph.js的streamEvents拿到每个节点开始/结束的事件去实现的,比前端瞎猜状态要实在得多。
写在最后的一点私人经验
这个项目做完之后,我对AI Agent最大的体感变化是:它终于从"一个会说漂亮话的聊天机器人"变成了"一个可以上生产的后端服务"。LangGraph.js让我能把复杂的业务逻辑拆成节点和边,像画流程图一样做编排,同时保留了程序化调试的可能性——每个节点单独测,每个状态单独验证,出了问题不再两眼一抹黑。
如果你也想用AI Agent做点什么正事,我建议别一上来就追求ArrayList式的大而全,先从一个像"简历优化"这样边界清晰的流水线场景开始。把光阴的流程定死,把每个环节的产物定义清楚,让LangGraph.js替你管状态,把精力留给打磨单个节点的Prompt和数据结构。这套思路跑通之后,你会发现自己手里的不只是一个小工具,而是一套可以复制到任何业务场景的Agent开发方法论。