
大概从去年开始我身边做产品、做运营的朋友就陆续开始用 AI 改简历但他们的路线基本都是把简历文本粘贴到 ChatGPT 里复制一轮建议再手动改。这种用法最大的问题是模型每次面对的是散装信息上下文里塞满了格式噪声既不能稳定抽出结构化字段也做不到解析—打分—对比 JD—定向改写这条完整链路的状态衔接。试过几次之后我意识到与其让用户在对话框里自己编排流程不如直接用代码把这套流程固化成一个真正意义上的 AI Agent。这就是我这次用 Next.js LangGraph.js 把简历工具完整落地的起因。这篇文章不会只讲 Demo 怎么跑通我会把从项目初始化、图编排、流式输出到部署排坑的完整过程都拆开讲适合两类读者一类是已经在用 Next.js 做全栈应用、想给项目接入 Agent 能力的人另一类是听说了 LangGraph.js 但不知道它在真实业务里怎么用、和裸调 LLM API 到底差在哪的人。看完之后你应该能直接照着这套思路搭出自己版本的简历分析 Agent甚至把它平移到方剂推荐、合同审查、竞品分析这类同样需要多步骤推理的业务场景。1. 我为什么把简历分析拆成图来跑LangGraph.js 到底解决了什么先说结论简历工具看起来是上传文件 → 给出建议两步但真要做出能用的效果内部流程至少是四到五步而且每步之间是有依赖的。解析把 PDF/DOCX 转成纯文本去格式噪声抽取从文本里结构化出姓名、技能、工作经历、教育背景评估拿着目标 JD对画像做匹配打分找出 gap建议基于 gap 生成定向修改建议甚至直接改写某个模块收尾可能还要根据用户选的模板重新生成一版简历如果这些步骤全塞进一个 Prompt、一次大模型调用里完成效果会非常不稳定——简历长一点就超出上下文窗JSON 字段一多就漏字段更没法做分数低于 60 分走改写分支这种条件逻辑。你要么反复让模型重试要么自己在业务代码里写一堆 if/else 状态机。我自己第一次试的时候就是这么干的状态满天飞改一处逻辑动全身。这时 LangGraph.js 的价值就体现出来了。它本质上是个面向 LLM 应用的图编排框架核心抽象就三个State共享状态、Node处理节点、Edge流转边。你定义好状态结构之后每个 Node 都是纯函数读 state、做处理、返回增量 state。Node 之间通过边串联边可以是普通边也可以是条件边——条件边就是路由函数根据当前 state 决定下一个走哪个节点。拿简历场景来对照这套抽象几乎是量身定做的状态天然存在上一步抽出来的parsedProfile就是下一步评估节点的输入条件路由天然存在评估完分数低就走建议节点分数够高可以切润色节点人工介入天然存在LangGraph 的interrupt机制可以在节点之间暂停等用户确认后再继续我用一张表对比过裸调 API、LangChain、LangGraph 三种路线方便你理解为什么最后选了图方案对比维度裸调 OpenAI SDKLangChainLangGraph.js多步骤编排自己写 if/else有 Chain 但线性为主图结构天然支持分支循环状态管理手拼 messages需要自己维护显式 State 定义增量更新断点续跑不支持不支持Checkpointer 持久化任务可恢复流式输出只流 Token部分支持按节点/按消息流式输出调试排查对着日志猜链条一长就懵能看到节点级的状态流转说白了LangGraph 不是让你少写代码而是让你把流程逻辑从业务代码里彻底拆出来。流程长什么样图就是什么样改流程就是改图不用动交互层。这一点在简历这种流程图很清晰的场景里体验尤其明显。2. 整体架构与选型Next.js 只负责 UI真正的 Agent 逻辑长这样落地之前先把架子搭明白。我的工程结构是这样的实际跑下来职责划分得很干净resume-agent/ ├── app/ │ ├── page.tsx # 前端主页面上传、展示流式结果 │ └── api/ │ ├── analyze/route.ts # 简历分析接口SSE 流式返回 │ └── rewrite/route.ts # 定向改写接口 ├── lib/ │ ├── resume-graph.ts # LangGraph 图定义与编译入口 │ ├── nodes/ │ │ ├── parse.ts # 解析节点PDF/DOCX - 纯文本 │ │ ├── extract.ts # 抽取节点纯文本 - 结构化画像 │ │ ├── evaluate.ts # 评估节点画像 vs JD - 匹配分和 gap │ │ └── suggest.ts # 建议节点gap - 具体修改建议 │ ├── tools/ │ │ └── resume-tools.ts # 工具函数解析文件、缓存、调模型 │ └── schemas.ts # zod schema结构化输出的契约 ├── middleware.ts # 简单鉴权防止接口裸奔 └── package.json2.1 前端后端的边界Next.js 在这个项目里充当的是壳 API 层。前端页面负责文件上传和流式结果展示真正的 Agent 逻辑全放在lib/和app/api/下面。这里有个经验不要为了炫技把图跑到 Server Action 里。Server Action 适合表单提交这类短交互简历分析是一个几十秒的长任务你需要的是可控的流式连接Route Handler 用ReadableStream手动控制输出节奏要顺手得多。2.2 模型与工具选型模型层面我用了两层策略抽取环节用便宜快速的模型gpt-4o-mini 级别评估和建议环节用更强的模型gpt-4o 或 claude-sonnet。理由很直白——抽取是机械活只要结构化输出稳定就够评估是判断活需要模型真正读懂 JD 措辞里的倾向和简历经历之间的匹配度。两块拆开之后成本能省将近一半。文件解析工具上PDF 我用了pdf-parseDOCX 用mammoth这两个库纯 Node 实现部署友好。如果你接的是扫描版 PDF后面排坑章节我会专门讲 OCR 怎么补。2.3 为什么所有节点都要有清晰的输入输出约定LangGraph 的 State 是强类型的这对我来说是最大的隐性收益。写复杂 Agent 时最常见的翻车方式是某个节点返回了模型吐的脏数据下游直接崩。用 TypeScript zod 定义好 State 结构后每个节点返回前我都会做一个运行时校验格式不对当场重试绝不把坏数据传进图里。这个习惯救了我很多次。3. 核心图实现从 PDF 文本到结构化画像再到改进建议这章是全文的骨架我按图的实际构建顺序来讲。先定义状态再逐节点实现最后组装编译。3.1 State 定义LangGraph 的 State 用Annotation.Root来定义。每个字段可以理解为图运行时的共享黑板节点往上面写数据下一个节点读数据。注意一点普通字段是覆盖写如果多个节点往同一个字段写且你想保留历史需要显式声明 reducer。import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ // 原始输入 resumeFileName: Annotationstring, resumeRawText: Annotationstring, jdText: Annotationstring, // 中间产物 parsedProfile: AnnotationResumeProfile | null, evaluation: AnnotationResumeEvaluation | null, suggestions: Annotationstring[], // 给 streaming 用的当前节点名 currentNode: Annotationstring, });我只保留了真正跨节点共享的字段。像用户是否选择了模板这种 UI 态不该进 Agent 状态页面自己存就行。3.2 解析节点把二进制文件变成可分析的文本解析节点本身不调用 LLM纯粹是工具活。核心代码如下注意这里我做了文件大小限制和页数限制防止超大 PDF 把后续环节拖垮import { PDFLoader } from langchain/community/document_loaders/fs/pdf; import { DocxLoader } from langchain/community/document_loaders/fs/docx; export async function parseNode(state: typeof ResumeState.State) { const { resumeFileName } state; const loader resumeFileName.endsWith(.pdf) ? new PDFLoader(resumeFileName, { splitPages: true }) : new DocxLoader(resumeFileName); const docs await loader.load(); const rawText docs .map((doc) doc.pageContent) .join(\n) .replace(/\n{3,}/g, \n\n) .slice(0, 30000); // 防止超长上下文 return { resumeRawText: rawText }; }splitPages: true很重要后面做长简历的分块分析时要靠它拿到第几页的维度信息。这里把文本截断到 3 万字符是基于 128k 上下文模型留出的安全余量后面还要塞 JD 和结构化输出的指令。3.3 抽取节点让模型输出结构化画像抽取是整条链路里最依赖结构化输出能力的一步。核心思路不是让模型自由发挥而是用 zod 定义严格的输出契约再通过withStructuredOutput绑定到模型上import { z } from zod; import { ChatOpenAI } from langchain/openai; export const ResumeProfileSchema z.object({ name: z.string().describe(候选人姓名), contact: z.object({ email: z.string().email().nullable().describe(邮箱找不到就填 null), phone: z.string().nullable().describe(电话号码找不到就填 null), location: z.string().nullable().describe(所在城市), }), skills: z.array(z.string()).describe(技能清单尽量保留原始说法不臆造), workExperience: z .array( z.object({ company: z.string(), title: z.string().describe(职位头衔), duration: z.string().describe(起止时间保持原文格式), highlights: z.array(z.string()).describe(每条工作成就逐条保留关键数字), }) ) .describe(按时间倒序排列的工作经历), education: z.array(z.string()), highlightedMetrics: z .array(z.string()) .describe(简历中出现的量化指标如提升了 30% 效率), });抽取节点的实现很直接import { ChatOpenAI } from langchain/openai; import { ResumeProfileSchema } from /lib/schemas; const extractorModel new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }); export async function extractNode(state: typeof ResumeState.State) { const prompt 你是资深简历解析器。请从下面简历文本中抽取结构化信息。 规则 1. 只抽取原文中出现的信息不得臆造 2. 技能、成就保持原文本说法 3. 文本可能是 OCR 噪音较多的版本容忍错别字但抽取时尽量还原 4. 如果某个字段确实缺失填 null 或空数组 简历文本 ${state.resumeRawText} ; const modelWithSchema extractorModel.withStructuredOutput( ResumeProfileSchema, { method: jsonSchema } ); const parsed await modelWithSchema.invoke(prompt); return { parsedProfile: parsed }; }temperature: 0在这里不是可选项。抽取任务任何统计随机性都是噪音必须固定为 0。method: jsonSchema用的是 OpenAI 的 strict 结构化输出模式只要 schema 描述足够精确返回 JSON 基本不会漏字段。另一条经验是schema 字段的 describe 要写业务语义而不是格式要求比如邮箱找不到就填 null这比string or null有效得多模型对业务上下文更敏感。3.4 评估节点打分必须给可解释依据评估环节是判断整个 Agent 质量的分水岭。我的做法是让模型按评分规则输出分数、匹配点、差距点并且每个判断都必须给出依据文本这样前端展示时用户能看懂为什么是 73 分而不是看一个神秘数字。export const EvaluationSchema z.object({ totalScore: z.number().min(0).max(100), skillMatchScore: z.number().min(0).max(100), experienceScore: z.number().min(0).max(100), achievementScore: z.number().min(0).max(100), strengths: z.array(z.string()).describe(简历中与 JD 高度吻合的点带依据), gaps: z.array(z.string()).describe(简历中与 JD 明显不匹配或缺失的点带依据), firstImpression: z.string().describe(如果 HR 只看 10 秒第一印象是什么), });评估节点拿到的输入是parsedProfile和jdText。这里有个容易被忽略的点不要把结构化画像转回 JSON 塞进 prompt 就完事要把它渲染成自然的简历文本。模型读自然语言的简历文本比读 JSON 更容易理解上下文匹配判断也更准。我写了一个renderProfile()函数把画像渲染成人话格式效果实测比直接贴 JSON 有明显提升。3.5 建议节点 条件路由有了 gap 之后建议节点会针对每个 gap 生成具体修改建议。这里的核心难点是建议不要太空——提升量化指标这种建议等于没说。我的做法是给足上下文把原简历相关段落、JD 要求、gap 三个信息拼成一个带引用定位的 prompt明确要求模型输出原句引用 问题诊断 修改示例三段式建议。条件路由则是 LangGraph 提供的一等公民能力function routeAfterEvaluate(state: typeof ResumeState.State) { if (!state.evaluation) return __end__; if (state.evaluation.totalScore 60) { return suggest; } return polish; // 分数够高就走润色分支 }组装图的代码如下import { StateGraph, START, END } from langchain/langgraph; export const resumeGraph new StateGraph(ResumeState) .addNode(parse, parseNode) .addNode(extract, extractNode) .addNode(evaluate, evaluateNode) .addNode(suggest, suggestNode) .addNode(polish, polishNode) .addEdge(START, parse) .addEdge(parse, extract) .addEdge(extract, evaluate) .addConditionalEdges(evaluate, routeAfterEvaluate, [suggest, polish]) .addEdge(suggest, END) .addEdge(polish, END) .compile();读这段代码就知道流程长什么样完全不需要注释。4. 流式输出与人工介入让 Agent 用起来不像黑盒图能跑只是第一步用户真正感受到的是一个会思考的过程。如果上传简历后白屏等 40 秒再一次性吐结果体验特别像老式批处理作业。我选择用 SSEServer-Sent Events LangGraph 的streamMode: updates把节点级进展实时推给前端。4.1 Route Handler 里的流式实现import { resumeGraph } from /lib/resume-graph; export const runtime nodejs; export async function POST(req: Request) { const formData await req.formData(); const resumeFile formData.get(resume) as File; const jdText formData.get(jd) as string; // 把上传文件写入临时路径给 loader 使用 const tempPath /tmp/${crypto.randomUUID()}-${resumeFile.name}; const buffer Buffer.from(await resumeFile.arrayBuffer()); await fs.writeFile(tempPath, buffer); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const send (data: unknown) controller.enqueue(encoder.encode(data: ${JSON.stringify(data)}\n\n)); const compressed await resumeGraph.stream( { resumeFileName: tempPath, jdText }, { streamMode: updates, recursionLimit: 20 } ); for await (const chunk of compressed) { // 每个 chunk 是 { nodeName: partialState } const [nodeName] Object.keys(chunk)[0]; send({ type: node_start, node: nodeName }); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端用EventSource或fetch读流都行。我在页面上维护了一个节点状态列表每收到一个node_start事件就把对应的步骤标记为进行中/已完成配合转动的指示器用户能直观看到 Agent 正在解析简历 → 抽取结构化信息 → 评估匹配度 → 生成建议。这个细节看似不起眼但对产品质感的提升非常明显。4.2 用流式输出压住用户耐心实验数据是我自己产品里统计的完整分析一次简历gpt-4o-mini 负责抽取 gpt-4o 负责评估的情况下平均耗时 20-35 秒。如果这 35 秒是白屏用户流失率很高改成节点级实时展示后几乎没有人中途退出。人不怕等待怕的是不知道在等什么。4.3 Checkpointer任务中断与恢复LangGraph 的 Checkpointer 机制是简历 Agent 里被我低估的功能后来在用户离开页面再回来的场景里派上了大用场。默认图编译时不带记忆run 完状态就没了。接上 Checkpointer 后图在任何节点暂停或中断状态都会持久化下次用同一个thread_id就能接着跑。import { MemorySaver } from langchain/langgraph; const checkpointer new MemorySaver(); const resumeGraph new StateGraph(ResumeState) // ... 节点和边 .compile({ checkpointer }); // 运行时指定 thread_id const config { configurable: { thread_id: user-xxx-resume-1 } };生产环境建议换 Postgres 版 Checkpointerlangchain/langgraph-checkpoint-postgres这样任务状态存在数据库里服务重启也不丢。用户上传简历、中途关了网页第二天回来还能继续同一份分析这是纯前端方案做不到的差异化能力。4.4 人工确认节点interrupt 的实际用法简历分析里有一个天然的人工介入场景生成改写建议后用户可能想选某个模板再生成定制版简历。这时可以用interrupt暂停在模板选择节点前等用户确认后继续往下走。import { interrupt } from langchain/langgraph; async function askTemplateNode(state) { const action interrupt(请选择目标简历模板风格); // 用户返回后从这里继续执行 return { selectedTemplate: action.templateId }; }这比让用户重新提交一次任务的体验好得多因为整条链路中间产物全部保留只执行后续分支。LangGraph 把这块叫 human-in-the-loop我认为它才是 Agent 产品可信赖的关键——不是全自动黑盒而是在关键决策点把方向盘交回用户。5. 落地踩坑实录结构化输出、PDF 乱码和 Token 失控所有 Demo 到生产之间的路都是坑铺出来的。这部分我直接把踩过的、最值得分享的坑拉出来讲每个都给结论和绕坑姿势。5.1 结构化输出不是百分百可靠必须有兜底即使开了jsonSchemastrict 模式模型在应对超复杂嵌套 schema 时仍可能抽风。真实案例我把工作经历里的每一条 highlight 是否包含量化指标做成布尔字段后gpt-4o-mini 在 40 次调用里出现过 3 次字段缺失原因都是嵌入过深触发截断。我的兜底策略有三层zod parse 失败时自动重试一次换更高温度0 - 0.2故意打散模型路径重试仍失败就转用直接补全修复把原始模型输出 错误信息发给模型让它修成合法 JSON修不了就降级抽取字段置 null 或空数组宁可数据缺失也不造假评估节点遇到缺失字段会在评分依据里明确标注缺少该信息这个兜底逻辑放在节点函数里不污染图结构。5.2 PDF 解析的乱码和扫描件问题pdf-parse对文字版 PDF 很可靠但有两个场景它解决不了一是某些设计软件导出的 PDF 字体映射诡异抽出来是乱码二是扫描版简历本质是图片PDF 里根本没有文本层。乱码问题我最后的处理方式是通过页面布局相似度做预处理识别出乱码率过高的页面直接丢弃或提示用户重新上传清晰版本。扫描版则接了一层 OCRtesseract.js纯前端或后端跑都行。但 OCR 出来的文本错别字多所以我特意在抽取节点的 prompt 里写了容忍 OCR 噪音并尽量还原实测对准确率有显著帮助。贴一条成本经验别在简历文本上花太多精力追求 100% 分毫不差。核心字段公司、职位、时间、量化成果准确率上去之后就够用了因为评估节点依赖的是语义层面的匹配不是逐字比对。5.3 Token 消耗失控简历 Agent 是典型的多节点重复消耗 Token场景一个完整分析流程下来输入侧消耗的 Token 远大于输出侧。最长的一份简历15 页咨询顾问光抽取环节就吃了 3.2 万 Token加上评估环节 2.1 万单次成本直追强模型的输出成本。我做了三个成本控制手段抽取用便宜模型 评估用强模型的分层策略同上文所说简历超过 30000 字符时走 map-reduce 分支先按页分块抽取画像再合并去重。这一步把单次调用从超长截断变成可控的多段调用整体 Token 反而更省按内容哈希做结果缓存同一个人反复上传同一份简历前序节点直接命中缓存只有改过的部分重新分析整条跑下来最贵的单次分析成本能压到 0.06 美元以内平均大约 0.04 美元。这个量级放在免费试用的产品里也扛得住。5.4 Serverless 的超时和内存双重夹击简历分析很容易踩中 Serverless 的隐形限制。本地跑 30 秒的完整流程部署到 Vercel Hobby 套餐的 10 秒函数超时会直接掐断。即使升级套餐放行了时长大 PDF 解析还吃内存和临时磁盘——pdf-parse加载 10MB 的 PDF 时临时峰值能到 120MB接近边缘环境的限制红线。我的最终解题路线是生产环境放弃纯 Serverless跑在带常驻内存的小型 Node 服务上Next.js 只做 SSR 和 API 网关。如果你受限于运维能力想继续留在 Serverless那就把解析和分析解耦文件解析交给独立任务队列Agent 图在 Worker 里跑前端轮询任务状态。这是架构上的取舍后面部署章节我会展开对比。6. 部署形态与进阶方向别把宝全押在 Serverless 上部署这件事我前后折腾了三个版本最终定型方案是有参考价值的。6.1 三种部署形态对比部署方案优点缺点适用场景Vercel 全托管零运维、一推上线函数超时、大文件解析受限、流式长连接不稳定演示 Demo、小并发原型Vercel 外部 Worker兼顾托管便利和长任务多一套 Worker 的运维成本中等规模、不想自己买服务器自管 Node 服务 Nginx完全可控支持长连接和复杂资源要处理进程守护、日志、扩容生产级、有并发和成本要求我最终选了第三种原因很直接简历解析和分析的流程里无论是 OCR 还是多步骤图编排都对允许跑 60 秒 能开临时文件 内存不紧张有硬性要求。自管 Node 服务不需要特别强的机器2C4G 足够扛初期流量瓶颈基本在模型 API 的速率限制上。6.2 部署后的三个真问题首先是 Checkpointer 持久化。上生产后我立刻把MemorySaver换成了 Postgres 版否则 PM2 一重启用户的任务状态全丢。import { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer PostgresSaver.fromConnString(process.env.POSTGRES_URL, { tableName: checkpoints, }); await checkpointer.setup();其次是模型限流。用户量上来后gpt-4o-mini抽接口经常被 429 限流。我的处理是给模型调用包了一层带退避的重试同时把请求随机错峰。不要指望任何 SDK 自带限流处理必须自己加。最后是安全。接口裸奔几天后我就在日志里看到了别人拿你的接口做免费简历分析的痕迹。后来加了简单的 token 鉴权 来源域名白名单单用户限频每分钟 5 次请求这才消停。这个坑如果你做的是 C 端产品一定会踩到。6.3 进阶方向从单 Agent 到多 Agent简历工具做完之后明显的扩展方向已经浮现了面试陪练 Agent把简历画像作为输入让一个 Agent 扮演面试官针对简历细节深挖提问另一个 Agent 评估回答质量JD 反向生成 Agent输入简历自动生成本段位能找到的最优 JD 目标清单多份简历对比 Agent对同一候选人投不同公司的多份定制简历做横向对比检查信息一致性这三个方向都不是再加一个 Prompt能解决的每个 Agent 都需要独立的图、独立的状态、独立的工具集而 LangGraph.js 的多 Agent 叠加能力正好能承接。我现在已经在做面试陪练那条线架构上就是把面试官 Agent和评估 Agent两个子图挂到主图里通过边连接。这比重新撸一套框架省太多事了。最后说点个人体会。做这类 Agent 项目最大的认知转变是不要追求让模型一次性理解所有事情而是把一个复杂任务拆成多段模型擅长的小任务每段都给它足够清晰的上下文和输出约束再用图把它们串起来。LangGraph.js 在这里的作用不是帮你少写代码而是强迫你用更清晰的方式思考这个任务本身的结构。等你想清楚了图也就写得差不多了剩下的只是填充节点逻辑。这套方法论换到任何一个垂直场景都成立希望你读完也能动手画出自己的第一张状态图。