LangGraph.js实战:从0到1打造简历优化AI Agent
很多人都把 AI Agent 做成了“演示版”能聊天、能调用一两个工具但真正丢到生产环境里一接真实用户的输入就开始露馅。简历工具这个方向看起来简单实际是一个非常典型的 Agent 落地场景因为它要经历解析、结构化、分析、生成、评分这些多步骤状态流转每一步都可能出错每一步的结果都影响后续决策。我用 Next.js 做前端和 API 层用 LangGraph.js 编排整个 Agent 流程做完了一个能上传 PDF 简历、自动解析、对照职位描述生成优化建议、输出重写后简历的完整工具。这篇博客就是从 0 到 1 的全过程包括架构设计、关键代码、部署时踩过的并发和流式输出的坑给那些想用 LangGraph.js 做点真实东西的人一个参考。1. 项目定位与整体设计1.1 先搞清楚简历工具到底要解决什么问题简历工具不算新东西市面上模板生成器一大堆但绝大多数都停留在“填空”层面你填一段工作经历它换个排版。真正招聘场景里的问题不是排版而是“我怎么针对这个岗位改简历”。同一个候选人投不同公司的不同岗位需要突出的技能侧重点完全不一样手改一遍要花一两个小时而且回头看自己写的简历总是漏东西。所以我把这个工具定位成“简历改造 Agent”用户传一份原始简历 一个职位描述系统自动完成五件事提取简历关键信息、解析目标岗位需求、逐条做匹配分析、输出重写建议和优化后的简历文本、给匹配度打分。整个流程不能是黑盒用户必须能看到每一步在做什么、结果是什么这是 Agent 和普通表单处理的本质区别——Agent 的价值在于过程可解释、结果可干预。技术选型时我几乎没有犹豫就定了 Next.js LangGraph.js。Next.js 的优势在于前后端一套代码API Route 可以直接托管 Agent 的服务端逻辑部署成本低LangGraph.js 则是目前 JS/TS 生态里做多步骤 Agent 编排最顺手的框架它把流程建模成状态图每个节点做一件确定的事节点之间通过状态传递数据天然适合简历处理这种流水线。1.2 为什么不用 LangChain.js 一把梭很多人会问LangChain 都出 JS 版了为什么还要单独用 LangGraph我的判断是LangChain 擅长的是“工具调用链”和“记忆管理”适合对话型 Agent而简历处理是明确的多阶段流程我需要的是对流程状态的控制而不是一套抽象的链式调用。LangGraph 的核心抽象是 StateGraph我把“解析简历”和“生成优化建议”拆成两个节点它们的输入输出类型、失败重试逻辑、是否允许跳转都显式地定义在图里。这里也对比一下用“一个超级 Prompt”的实现方式。很多人做简历优化就是拿一大段 prompt 让 LLM 输出看起来能跑但有几个致命问题一是输出稳定性差简历解析要严格的 JSON 结构单一 prompt 很难保证每次都符合 schema二是中间结果没法复用比如解析好的结构化数据本来可以缓存在数据库里同一个用户第二次上传就不用再调模型但单一 prompt 就无法拆分三是出了错没法定位到底是解析错了还是生成错了无从查起。拆成节点之后每个阶段都能打日志、做校验测试的时候能精确看到哪一步出的问题。简历 Agent 的数据流相对固定非常适合用状态图表达先解析、再抽取、再匹配、再生成、再评分每个节点还能根据中间结果决定跳转。比如解析出来的简历如果格式损坏就直接短路返回错误提示不再浪费 token 去调用后面的生成节点。1.3 整体数据流与状态设计整个 Agent 流转看起来是这样的用户上传 PDF 或粘贴文本简历前端把文件发送给 Next.js API RouteAPI 层启动一个 LangGraph 图实例按状态图顺序执行各节点最终把结果以流式事件推回前端。关键路径我梳理如下阶段输入输出说明parseResume原始文件/文本提取后的纯文本处理 PDF 解析失败则短路extractStructured纯文本结构化简历对象包含基本信息、工作经历、技能、项目matchJD结构化简历 JD逐条匹配分析结果硬技能、软技能、经验匹配度generateOptimized匹配分析 结构化简历优化建议 重写简历结合 JD 关键词做定向改写scoreResume全部上文匹配评分加权计算输出分项分数在 LangGraph.js 里这些阶段的输入输出统一收敛到一个 state 里。我定义的 JobState 大概长这样const ResumeState Annotation.Root({ originalText: Annotationstring, structuredData: AnnotationStructuredResume, matchAnalysis: AnnotationMatchResult, optimizedResume: AnnotationGeneratedResume, score: AnnotationScoreResult, errors: Annotationstring[], });这里要解释一个关键点Annotation 不只是一个类型声明它还定义了状态更新的 reducer 行为。比如 errors 这个字段我希望多个节点都能往里追加内容而不是覆盖那就需要配置一个自定义 reducer 或者直接使用 Annotation 提供的 update 逻辑。这是 LangGraph.js 和普通 TS 类型最大的区别——状态更新是有语义的如果不理解这一点后面会踩很多坑。2. 核心实现拆解每个节点到底做了什么2.1 Next.js 里的路由与上传处理Next.js 侧我用了两个关键路由/api/upload接收文件并触发解析/api/agent/stream建立 SSE 长连接推流式事件。上传这块 Next.js 默认对 body 大小有限制App Router 的 Route Handler 可以在配置里指定体积上限简历文件一般不会太大但也要覆盖扫描版 PDF 的情况我设置为 10MB实测已经足够。export const runtime nodejs; export const dynamic force-dynamic; export async function POST(req: Request) { const formData await req.formData(); const file formData.get(file) as File; const jdText formData.get(jd) as string; const buffer Buffer.from(await file.arrayBuffer()); // 触发解析流程返回一个 runId const runId await startResumeAgent({ file: buffer, jdText }); return Response.json({ runId }); }注意这里runtime nodejs非常关键。很多人默认用 edge runtime 跑 Next.js API但 LangGraph.js 目前对 Node 环境的支持远好于 edge特别是涉及文件系统、Buffer 解析 PDF 这类操作时edge 上经常踩坑。我一开始图省事没设 runtime结果在生产环境遇到 PDF 解析库找不到 Node API 的报错排查了半小时才发现是默认 edge 环境在作祟。2.2 LangGraph.js 状态图的定义与节点注册LangGraph.js 的核心用法是先定义状态再定义节点函数最后把它们编排成图。节点函数接收一个 state 参数返回一个部分状态对象LangGraph 会根据 Annotation 的 reducer 把这个返回值合并进全局状态。我用一个简化示例展示匹配分析节点const matchNode async (state: typeof ResumeState.State) { const { structuredData, jdText } state; const result await callLLM({ messages: [ { role: system, content: MATCH_SYSTEM_PROMPT }, { role: user, content: 简历${JSON.stringify(structuredData)}\nJD${jdText} }, ], responseFormat: { type: json_object }, }); const parsed JSON.parse(result.content); return { matchAnalysis: parsed }; };这里最关键的一点是节点函数必须是纯函数或者至少是无副作用的它不能修改外部变量只能通过返回值更新状态。我开始时习惯在节点函数里直接往 state 上挂属性LangGraph.js 会直接报错因为它内部的状态是不可变更新。这种设计对调试非常友好每个节点的输入输出都可以通过回调事件捕获到。图编排部分的核心代码const graph new StateGraph(ResumeState) .addNode(parseResume, parseResumeNode) .addNode(extractStructured, extractNode) .addNode(matchJD, matchNode) .addNode(generateOptimized, generateNode) .addNode(scoreResume, scoreNode) .addEdge(parseResume, extractStructured) .addEdge(extractStructured, matchJD) .addEdge(matchJD, generateOptimized) .addEdge(generateOptimized, scoreResume) .addConditionalEdges(parseResume, (state) { return state.errors.length 0 ? __end__ : extractStructured; }) .compile();addConditionalEdges这个方法是最体现 Agent 价值的地方。它让流程不是一条直线走到黑而是可以根据中间结果做判断。比如解析失败就别继续了或者评分过低就追加一个“重写优化”的循环节点。我之前做线性流程时这些分支逻辑散落在各个函数里调试起来想骂人用条件边之后整条路径一目了然。2.3 简历解析PDF 与结构化抽取的实战细节简历解析是整个 Agent 的入口也是坑最多的地方。文本型 PDF 可以直接交给pdf-parse这个库处理几行代码就能拿到文字但现实是很多简历是扫描件或设计型 PDF直接抽文本全是乱码或空字符串。我的处理策略是分层降级先尝试pdf-parse抽取文本如果提取出的文本长度低于一定阈值就判定为扫描件进入 OCR 流程调用本地部署的 PaddleOCR 或云端的 OCR 接口。实际接入后我发现中文简历的 OCR 准确率直接决定了后续结构化抽取的质量OCR 错一个名字后面的匹配分析就全偏了所以这一步宁可用更贵的云端 OCR 也不用免费但精度差的库。结构化抽取是另一个重头戏。我让 LLM 把自由文本转化成严格的 JSON schema这里有两个必须做的事一是 system prompt 里给出一段 JSON Schema 示例而不是只描述字段二是在代码里做一次真实的 schema 校验而不是信任 LLM 的输出。const schema z.object({ name: z.string(), contact: z.object({ email: z.string().nullable(), phone: z.string().nullable() }), skills: z.array(z.string()), experiences: z.array(z.object({ company: z.string(), title: z.string(), period: z.string(), achievements: z.array(z.string()), })), projects: z.array(z.object({ name: z.string(), description: z.string(), techStack: z.array(z.string()) })), });校验失败时我的模板是让节点返回一个带repairAttempt标记的中间状态LangGraph 里加一条循环边让extractStructured节点最多重试两次两次仍失败就短路终止避免死循环。有读者可能会问为什么不直接在 prompt 里要求“输出合法 JSON”因为 LLM 的 JSON 输出在复杂 schema 下确实会出错尤其是在 token 限制边缘时更容易截断代码层校验和重试是最稳妥的兜底。2.4 匹配分析与优化生成提示词设计是核心资产简历匹配分析这个节点本质上是在做分类 对比任务而不是生成任务。我把分析目标拆成四个维度硬技能命中、软技能契合、经验年限匹配、成就量化程度。每个维度输出三个级别的判断强匹配、弱匹配、不匹配并附带原因说明。对应的 system prompt 我写得很细核心思路是要求 LLM“先逐条列出 JD 中的关键要求再和简历逐条对照”而不是让它直接给一个总分。因为如果把“逐条对照”和“给总分”放在同一个 prompt 里LLM 往往会为了逻辑自洽而忽略细节打分结果不稳定。拆成两步后中间的分析列表可以被评分节点复用也方便用户看到详细的理由增加结果可信度。生成优化简历时我特别注意一个细节不能让 LLM 凭空编造经历。很多通用简历工具生成的优化版本会“润色”出完全虚构的项目和数据这在真实求职场景里是致命的。所以我在提示词里规定了严格的边界只能重写措辞和调整顺序不能新增事实所有量化数据必须来源于原简历。这个约束听起来简单但需要在 prompt 中反复强调并且在进行结构化校验时做一轮过滤。2.5 评分模型既要有规则也要有弹性评分节点我采用的是“规则 LLM”混合策略。规则部分先算出一个基础分比如硬技能命中率和年限匹配这部分完全可复现、可解释LLM 部分负责评估那些规则难以量化的维度比如成就是否量化清晰、项目描述是否有冲击力。最终分值 基础分 0.6 LLM 主观分 0.4。基础分的计算逻辑const scores { hardSkillHit: (matchedSkills.length / jdSkills.length) * 30, experienceMatch: Math.min((userYears / requiredYears), 1) * 20, projectRelevance: calculatedByRule * 10, keywordCoverage: (keywordHits / jdKeywords.length) * 40, };这个设计的动机是防止 LLM 给出全凭感觉的分值。我实测过纯 LLM 打分的场景同一个简历换个 prompt 表述分数能差 15 分左右这对用户来说是很难接受的。规则打底 LLM 做微调之后分数的稳定性明显提升用户追问评分依据时也能给出清晰的逻辑链路。3. 实操把工作流跑通并部署上线3.1 环境准备与依赖清单开发环境我用的技术栈很常规Node 20 pnpm Next.js 14 App Router。关键依赖如下next14 react18 langchain/langgraph langchain/openai pdf-parse zod这里我直接用了langchain/openai作为 LLM 封装层但 LangGraph.js 的节点函数其实不依赖它理论上任何能返回文本的函数都能作为节点。我选用它是因为 LangGraph.js 和 LangChain 的生态绑定比较自然流式输出、结构化输出这些能力都有现成封装省去自己处理 SSE 事件解析的时间。环境变量方面OPENAI_API_KEY、OCR_API_KEY都放进.env.local但这里我强烈建议给每个节点单独配置超时和重试参数因为 LangGraph 本身不会帮你做 LLM 调用的超时控制模型卡住会导致整个 Graph 卡住在 API 层看就是请求一直 Pending。3.2 流式输出让用户看到 Agent 在“干活”Agent 流程通常要跑十几秒甚至几十秒如果前端用普通 POST 请求傻等体验非常差。我用 SSE 做流式输出LangGraph 提供了 streaming 事件能拿到每条边执行和每个节点结束的通知。服务端把 Graph 的streamEvents包装成 SSEconst stream await graph.streamEvents(input, { version: v2, configurable: { thread_id: runId }, }); for await (const event of stream) { if (event.event on_chain_start event.name matchJD) { sendSSE({ type: stage, stage: matching, message: 正在匹配职位要求... }); } if (event.event on_chain_end event.name scoreResume) { sendSSE({ type: result, data: event.data.output }); } }前端用fetch配合ReadableStream解析 SSE把节点状态渲染成进度条。这个改动带来的体验提升是巨大的用户看到“正在解析简历”“正在匹配 JD”“正在生成优化建议”这些实时状态就不会频繁刷新页面或者觉得系统挂了。很多人问我前端解析 SSE 有没有成熟库我的建议是别用第三方库原生事件流处理足够简单还省一个依赖整个解析逻辑大概 30 行代码const reader response.body.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); for (const line of chunk.split(\n)) { if (line.startsWith(data:)) { const event JSON.parse(line.slice(5)); updateStage(event.stage); } } }3.3 AI Agent 怎么扛并发从单人 demo 到多用户可用这是我在热搜里看到最多人问的问题也是这个项目里花心思最多的一块。刚做完 demo 版时我把 Graph 直接放在 Next.js API Route 里跑一个人用没问题但当我把链接发给几个朋友同时测试时很快就出现了大量超时和 502。原因是 Node.js 是单线程事件循环虽然 LLM 调用是异步 IO 不阻塞主线程但并发一高每个请求都要占用一个 Graph 实例的内存和状态资源。我做的第一步是在 API 层加了一个简单的内存队列限制同一时间最多处理 5 个 Agent 请求其余排队。这个方案在小规模下效果不错队列机制大概长这样class AgentQueue { private active 0; private queue: Array() void []; async runT(task: () PromiseT): PromiseT { if (this.active 5) { await new Promisevoid((resolve) this.queue.push(resolve)); } this.active; try { return await task(); } finally { this.active--; this.queue.shift()?.(); } } }内存队列只是权宜之计因为应用重启队列就没了而且它只能限制进程内的并发。如果真正部署到多实例或者想要更强的抗压能力必须引入外部任务队列。我把 Agent 执行从 API Route 里剥出来放到了独立的 Worker 服务里用 BullMQ Redis 做任务分发。API 层只负责接收请求、写入任务队列、返回 runIdWorker 监听队列拿到任务后跑 LangGraph把结果写回 Redis前端通过 runId 轮询或 SSE 拿结果。压测数据供参考单机 Worker 进程 OpenAI 接口同时 30 个简历任务排队处理平均完成时间大约 40 秒没有出现超时崩溃。如果直接在 Next.js 进程里跑3 个并发就开始偶发超时了。这个改造的核心思路是“Agent 不要在 Web 服务进程里跑”Web 层要快进快出脏活累活交给后台 Worker。对个人项目来说Redis BullMQ 的搭建成本不高但换来的是稳定性质的提升。4. 常见问题与排查技巧实录4.1 LangGraph 状态更新把旧数据覆盖了这是我遇到的第一个 LangGraph 特定问题。我在extractStructured节点返回结构化数据时发现多个工作经历只保留了最后一条之前的都被吞掉了。排查后发现原因在定义状态时experiences字段没有配 reducer默认行为是整体覆盖。当我返回的structuredData包含完整数组时按理说不会丢数据但在重试场景下节点返回的是部分数据而状态里原来已有的完整数据被新的部分数据覆盖了。解决办法是给特殊字段配置 reducer。数组字段配置add会把新旧值合并也可以使用自定义 reducerconst ResumeState Annotation.Root({ structuredData: AnnotationStructuredResume({ reducer: (current, incoming) incoming ?? current, }), errors: Annotationstring[]({ reducer: (current, incoming) [...current, ...incoming], }), });这个问题的教训是用 LangGraph 之前一定要先理解 Annotation 的 reducer 语义它不是简单的 TypeScript 接口而是定义“状态如何被更新”的核心机制。4.2 PDF 解析出来是空白的这个问题在测试时差点让我怀疑人生。用户上传的 PDF 明明打开看得到内容但pdf-parse解析出来却是空字符串。最后发现这些 PDF 大多是扫描件文字是图片PDF 层根本没有可提取的文本数据。解决方案是加 OCR 流程但这里有一个组合坑OCR 后得到的文本不够干净包含大量换行和误识别字符直接丢给结构化抽取节点会导致 JSON 校验失败率上升。我加了一个预处理节点专门清理 OCR 文本去重换行、合并断裂的英文字母、过滤纯符号行。经过轮测试这个预处理让结构化抽取的 schema 通过率从七成提升到九成五以上。别小看这层清洗Agent 流程里每一层输出的质量都会向后传导脏数据进脏数据出。4.3 流式输出在部署环境卡住SSE 在本地开发跑得很好一部署到服务器就卡住不动。我排查了很久发现是部署环境对响应做了缓冲SSE 的chunk没有实时 flush 到客户端。Next.js 自托管模式下需要设置flushHeaders或使用ReadableStream直接返回而且 API Route 里不能设置dynamic force-static否则会被缓存成静态响应。最终我在 Route Handler 里显式创建了一个ReadableStream把事件写入流的 controller 中这样能绕开框架层的缓冲const stream new ReadableStream({ start(controller) { const encoder new TextEncoder(); const send (event: unknown) { controller.enqueue(encoder.encode(data: ${JSON.stringify(event)}\n\n)); }; // 执行 Agent, 在事件回调里调用 send }, cancel() { // 清理 Agent 运行, 防止内存泄漏 }, }); return new Response(stream, { headers: { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, });4.4 LLM 返回的 JSON 总是解析失败结构化抽取节点要求 LLM 输出 JSON但实测中它偶尔会在 JSON 前加一些解释文字或者被 token 截断导致不完整。我用response_format强制 JSON 模式后大部分问题解决了但截断问题仍存在。最佳实践是使用允许更大输出 token 的模型同时在解析时做容错处理。我写了一个容错解析器先把结果里第一个{到最后一个}之间的内容截取出来再解析这能覆盖大多数截断场景。另一种情况是 schema 太复杂模型输出的字段名和预期不一致。我改用 zod 库做运行时校验并把校验错误信息反馈给模型进行修复。LangGraph 的条件边适合实现这种“重试循环”比在外面写 while 循环要清晰得多。5. 一些实用经验与扩展思考5.1 Token 成本控制是简历 Agent 的隐形命门简历解析 匹配分析 生成优化 评分一次完整流程要调 4 次 LLMtoken 消耗轻松破万。如果每个用户反复试几次成本是非常可观的。我用了三层策略控成本第一层缓存同一个用户上传同一份简历结构化结果直接复用不做二次解析第二层模型分级解析和评分用便宜的小模型生成优化用强模型匹配分析用中档模型能省不少钱第三层是控制重试次数LangGraph 的循环边不加限制就会出现“校验失败—重试”的死循环token 哗哗地烧。实测一个普通求职者的简历单次完整流程成本在 0.05 美元左右。如果加上缓存大多数页面打开不会触发完整流程成本能控到很低的水平。做这类工具成本模型一定要想清楚不然用户量稍微上来一点账单会让你怀疑人生。5.2 给每个 Agent 运行加一个可观测的 runId简历 Agent 有几个节点每个节点都调 LLM任何一个环节出问题都要能快速定位。我从第一版开始就在状态里放了一个runId每一步的输入输出都带上这个 ID 打日志或写入数据库。出了问题不用靠猜直接查这个 runId 对应的完整轨迹。LangGraph 本身有checkpointer机制可以记录每一步的状态快照恢复执行时能从某个节点继续跑。我在开发环境开了 checkpointer测试时能回放一遍流程生产环境为了性能会关掉但保留了关键节点的快照日志作用类似。这种可观测性设计是 Agent 应用和后端 CRUD 最大的区别没有它你几乎没法做任何线上问题的排查。5.3 后续还能怎么扩展当前版本只是单份简历和单个 JD 的匹配最简单的扩展是批量场景手里有 100 份简历要筛出最匹配某个岗位的前 10 个候选人。这个需求本质上和单份匹配一致只要把单份匹配做成一个子图再在上层用一个编排节点循环调用就行。LangGraph 的节点函数可以嵌套另一个编译好的 state graph这种组合能力非常适合做放大版流程。另一个值得做的方向是“简历历史版本管理”。用户在求职周期里会反复优化简历每次生成的版本都保存下来通过对话的方式迭代。这需要在图里加一个长期记忆节点让 Agent 能根据用户上一轮的偏好调整生成策略LangGraph 的持久化 checkpointer 正好能做支撑。最后分享一个我做这个项目最大的体会不要一上来就上 LangGraph。第一版我确实是先写了一个线性函数串起四个 LLM 调用跑通之后才把流程迁移到 LangGraph 上。因为只有先理解线性流程的瓶颈在哪里——状态传递混乱、分支逻辑难加、没有可观测性——你才能体会到状态图编排的真正价值。直接对着文档上框架很容易被抽象概念绕晕觉得这工具没什么用。等真正迁移完看到条件边和 reducer 解决了我之前手写状态管理的所有痛点才明白值得。那些在热搜里问“AI Agent 练手小项目”的人简历工具真的是一个好选择需求明确、流程固定、用户痛点真实、LLM 输出质量容易评估。搭完这个流程后你会对状态图、流式输出、并发隔离、成本控制有一套完整的认知这些经验迁移到任何 Agent 项目上都通用。做 Agent 最好的方式不是看教程而是亲手做一个会不断出错的真实项目然后一个一个坑填过去。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →