ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用LangGraph.js构建多节点AI Agent:从简历工具到生产级工作流

用LangGraph.js构建多节点AI Agent:从简历工具到生产级工作流 上个月我把一个原本只靠“把简历丢给聊天窗口让人工智能自由发挥”的简陋脚本重构成了一个跑在 Next.js 之上的 LangGraph.js 多节点 AI Agent。整个项目做完我最直观的感受是简历工具这种带文件输入、多步骤改写、按岗位定制输出的场景几乎是所有 Agent 教程里最适合被低估的练习对象。很多人聊 AI Agent 就想到客服机器人但简历工具才是真正能把状态流转、条件路由、工具调用和外部数据校验全都串起来的完整落地样板。这篇文章会围绕整个项目的核心设计来写——为什么选 LangGraph.js 而不是继续堆 Prompt如何把简历改写从一个“一次性聊天”拆成“有状态的工作流”以及 Next.js 在其中的真实定位。我也会把部署到生产环境之后才踩到的坑一并放进来这些内容在官方 Demo 里基本不会有人主动告诉你。1. 为什么是 LangGraph.js简历工具不是简单链而是状态机1.1 简历 AI 从“一问一答”变成“多阶段流水线”的关键转变刚开始我用纯 LangChain.js 写简历改写逻辑就是prompt - LLM - output用户把简历原文和岗位 JD 贴进来模型吐一段新文本。单看演示没问题真用起来全是问题用户一会儿想翻译成英文一会儿只想针对 JD 优化经历描述一会儿只需要调整排版措辞这三种任务的提示词和输出校验完全不同。如果全都塞进一个大 Prompt整个系统就是一个不可控的黑盒改一处就影响另一处。后来我把这个流程重新拆解成了“先理解简历结构、再判断任务类型、然后执行改写、最后强制校验格式”四个阶段。这四个阶段之间不是简单的先后顺序而是存在条件分支的。比如用户传来的岗位 JD如果英文岗位配中文简历系统要自动先做翻译仓再进优化仓如果本身就是英文简历翻译仓就应该被跳过。这种“不同输入决定不同路径”的逻辑用 if/else 在普通代码里当然也能写但如果每个阶段之间还要传共享状态、还要支持中途人工确认、还要允许失败回退普通函数调用很快就变成一座屎山。LangGraph.js 解决的核心问题就是把整个流程当成一张图来管理。图里的每个节点是一个函数比如analyzerNode、translatorNode节点之间的连线是状态转移而所有节点共享同一个 State 对象——这个 State 就是整个 Agent 的“工作台”。简历原文、岗位描述、解析出来的结构化信息、改写后的文本、校验错误列表全都放在这个工作台上任何节点都能读能写。1.2 StateGraph 核心概念State、Node、Edge 与三种图LangGraph.js 在 0.2 版本之后把StateGraph和FlowGraph分得比较清楚。FlowGraph适合那些不需要跨节点共享复杂状态、跑完就算的流水线StateGraph则管理一个全局的、带有 reducer 的状态对象适合简历工具这种节点之间存在大量共享上下文的场景。理解 LangGraph.js 的三个核心概念是必须的。第一个是State本质是Annotation.Root里定义的类型集合。每个字段可以自己定义reducer也就是当多个节点尝试写同一个字段时怎么合并。比如我定义了errorMessages: Annotationstring[]和reducer: (prev, next) [...(prev ?? []), ...(next ?? [])]这样各个节点往里面追加错误信息时就不会互相覆盖。第二个是Node就是一个普通的异步函数接收当前 State返回部分 State 的更新LangGraph 会按 reducer 规则把它合并回全局状态。每个节点只关注自己要做的事——解析节点只管把 PDF 文本变成结构化对象改写节点只管拿解析结果生成新文本互不干扰。这让日志排查变得非常舒服出错时只要看是哪个节点报的错基本就知道是哪个环节出了问题。第三个是Edge分为普通边和条件边。普通边表示“这个节点结束后无条件进入下一个节点”条件边则通过一个路由函数返回下一个节点的名字addConditionalEdges就是为此设计的。简历工具里最核心的分支逻辑就挂在条件边上网关节点根据用户任务类型返回translator、optimizer还是formatterLangGraph 会按返回值走下一条路。1.3 对比 LangChain.js 与普通状态机选型理由与代价很多人问我既然 LangChain.js 也有 Chain为什么还要再套一层 LangGraph.js我举一个很直接的例子用户上传了一份简历里面写着“精通 Vue”但岗位 JD 要求的是 React。在纯 Chain 架构下你要么在同一个 Prompt 里塞下所有指令让模型自己判断要么在 Chain 外部写大段预处理代码。而 LangGraph.js 允许我把“技能差距识别”做成一个独立节点检测到清单差异后由条件边决定走“技能补充建议”子图还是直接跳过。对比手写状态机普通的switch/case状态机当然也能描述流程但每个状态之间的共享数据你得自己定义、自己维护中断续跑、超时恢复这些能力要全部自己造轮子。LangGraph.js 已经把checkpointer做了——给图传一个thread_id它就能把每一步的状态存进持久层下次用同一个thread_id就能从断点继续跑。这不是省一点代码量的事而是把 Agent 的“可恢复性”直接变成了内置能力。选型当然也有代价。最明显的就是概念负担Annotation、reducer、conditional edge、checkpointer这些概念对刚接触的人来说有一定门槛。而且 LangGraph.js 的调试体验并不算好一旦图结构变复杂你很难像读普通函数代码那样一眼看穿执行顺序。我自己的应对方式是在上手阶段先把每个 Node 写成独立纯函数用单测单独测再组装进图这样定位问题时能快速二分。2. 简历工具的产品边界与 Agent 任务拆解2.1 这个 Agent 到底要做什么三个典型用户场景在写代码之前我花了半天时间把“简历工具”这四个字拆成了三个能落地的用户场景这三个场景也直接决定了我后续要设计几个节点。第一个场景是“中英翻译”用户有一份中文简历目标是投外企需要一份保留原版内容的英文版。这种任务对“忠实度”要求极高模型最常犯的错误是自作主张调整经历顺序或者把一些中文里模糊的级别描述夸大成英文里的高级头衔。第二个场景是“按 JD 优化”用户已经有一份简历希望针对从招聘网站复制的 JD 调整重点。这个任务要求 Agent 做差异分析——提取 JD 中的技能关键词对照简历正文找出遗漏和弱化点然后在不篡改事实的前提下调整描述重心。第三个场景是“格式与措辞整理”用户的简历内容没大问题但句式重复、排版层级混乱、也用了一堆弱动词。这个场景不太需要外部信息更考验模型对文本风格的把控。值得一提的是这三个场景经常叠加出现。一个用户可能既想翻译成英文又要针对某外企 JD 优化他可以一次性把两个诉求都提交。Agent 的网关节点要把这个“复合意图”拆成路由序列我的实现里是让网关节点在 State 里写一个tasks: string[]数组后面的条件边按数组顺序依次调度。2.2 四个子 Agent 的职责划分与交接协议根据上面的场景我把整个 Agent 分成了四个子 Agentgateway网关、analyzer解析器、editor改写器内部又有翻译模式/优化模式、formatter格式化器。网关节点第一件事不是调用 LLM而是先做轻量校验。检查有没有resumeText、有没有jobDescription、mode是不是合法值。这一步我坚持用规则代码而不是 AI 来完成原因很简单LLM 不适合做这种确定性判断既慢又可能漏判。只有真正进入任务分流时才开始调用模型让模型从用户输入里抽出意图关键词并映射到具体的任务序列。解析器节点负责把用户粘贴的或上传的简历文本结构化成 JSON。我最初直接用 Prompt 让模型输出 JSON但后来发现两个问题一是模型偶尔会把 JSON 字段名写错二是长 resume 文本经常被截断。后来我引入了带输出校验的withStructuredOutput方法定义好 Zod Schema让模型输出后立即做解析校验失败就返回错误码而不是把脏数据往下游传。编辑器和格式化器是文本重灾区。编辑器拿解析器产出的结构化经历数组逐条做改写。这里我特别强调“逐条改写”而不是“整体重写”。整体重写会让模型自由发挥常出现比原简历更多的编造成分逐条改写则每一次改动都在原句基础上微调保留了用户的真实经历边界。格式化器最后做统一排版比如统一动词时态、统一标题层级、保持每段经历的行数平衡。这四个子 Agent 的交接协议全部通过 State 完成解析器输出ParsedResume编辑器读取它并输出RevisedBlocks[]格式化器读取后者并输出最终的纯文本。没有节点之间私下调用所有数据变更都反映在 State 上。2.3 不编造经历的防线设计静态校验与置信度门槛这可能是全项目最有价值的一段内容。简历场景最怕的就是模型在改写时“润色”过头给用户凭空加上“主导了某千万级项目”或“获得了某项认证”。如果你把这份 AI 润色的简历直接发给 HR有可能被认为是简历造假这是产品层面绝对不能接受的风险。我的对策分成三层。第一层是 Prompt 约束在编辑器的 System Prompt 里明确写“你只能改写已有内容禁止新增任何经历、数字、头衔、公司名、日期。所有事实信息必须完全来自原始简历”。这一层能挡住大部分幻觉但不能全信。第二层是静态字段比对改写前后分别提取所有数字、日期、职位头衔、公司名做集合比对把新增的实体全部单独标记成“疑似新增”。如果这些实体出现在“经历补充”这类本不应该新增内容的节点输出里直接拒绝输出返回错误给用户。第三层是置信度门槛。在翻译模式下如果模型对某段措辞的调整幅度太大我用编辑距离做量化我会在响应里附带一条提示“这段改动幅度较大建议人工核对原文表述”。别小看这句话它把 AI 工具从“黑盒代笔”变成了“AI 辅助校对”用户的风险感知完全不同。3. 基于 Next.js App Router 的完整工程落地3.1 在 Next.js 里跑 LangGraph.jsruntime 的坑与 Streaming 协议项目前端选择了 Next.js。为什么选它而不是直接写一个 Node 后端加 React 前端因为 App Router 的 API Route 允许我把 LangGraph.js 的 run 时长逻辑直接放在app/api/agent/route.ts里和前端共用同一个仓库、同一套类型定义。Agent 的输入是AgentState输出也是AgentStateNext.js 的类型推导能把这些类型一直传到前端的useState省掉前后端接口的重复定义。第一个坑是 runtime 设置。Next.js App Router 默认可能走 Edge Runtime而 LangGraph.js 依赖完整的 Node.js API如MemorySaver的某些内部实现、pdf-parse这类第三方库Edge 环境直接报错。解决办法是在 route 文件里加一行export const runtime nodejs强制接口走 Node runtime。这个设置很简单但如果不知道能卡你大半天。第二个重点是 Streaming。Agent 的单个节点执行有时需要几十秒如果走普通的 request/response用户只能盯着空白页面干等。LangGraph.js 支持streamMode: updates会持续产出类似{ step: analyzer, data: {...} }的增量事件。我在 API Route 里把 Graph 的 stream 转换为 SSEServer-Sent Events格式前端用fetch加ReadableStream按行读取。SSE 核心代码大致是这样export const runtime nodejs; export const maxDuration 60; export async function POST(req: Request) { const { resumeText, jobDescription, mode, threadId } await req.json(); const graph getResumeGraph(); const config { configurable: { thread_id: threadId } }; const stream await graph.stream( { resumeText, jobDescription, mode }, { ...config, streamMode: updates } ); const encoder new TextEncoder(); const readable new ReadableStream({ async start(controller) { for await (const step of stream) { controller.enqueue(encoder.encode(data: ${JSON.stringify(step)}\n\n)); } controller.close(); }, }); return new Response(readable, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }3.2 API 路由设计一次对话怎么走完整个 Graph我只有一个 Agent 接口POST /api/agent。所有的任务类型差异都通过mode字段传给网关节点去路由接口本身不感知具体任务。这样设计的好处是前端不会越写越复杂——加一个新的子任务类型不用改前端代码只需在网关路由逻辑里加一个分支。接口参数固定为resumeText简历正文、jobDescriptionJD 文本可空、modetranslate/optimize/format/auto、threadId会话 ID。注意这里jobDescription是 optional 的因为纯翻译任务可能根本不需要 JD但如果你传了 JD 且 mode 是autoGateway 会把“翻译优化”串起来。接口返回的是一个 SSE 流而不是一个 JSON这是为了配合前端的节点状态可视化。前端收到 Stream 后解析每一条事件并按照data.step更新界面状态显示“正在解析简历”、“正在分析 JD 差距”、“正在翻译前 5 段经历”。每个节点执行完毕界面上的进度条就往前走一格。体验上很像在看一个有思考过程的 Agent而不是一个黑盒。从实际运营角度看单接口设计在日志收集上更省事。线上我只记录threadId、任务类型、各节点耗时、最终是否成功所有指标都在同一个接口维度下聚合。3.3 前端交互用流式更新还原“思考过程”前端的核心组件是一个聊天式工作台。用户上传简历文本或 PDF粘贴 JD选择任务模式点击执行。之后整个右侧列表动态展示 Agent 的每一步解析、技能差异分析、逐段改写、格式化。每一步都会显示当前节点用到的时间如果某些节点因为输入质量被判失败错误信息也会以“Alert”形式出现在对应步骤上。有一点我想重点提醒不要试图把 LLM 输出的所有中间 token 都实时推到前端。这在技术上可行但在简历场景下没意义。有一段时间我试过streamMode: messages把模型一次输出的每个 token 都往浏览器推结果用户看到一大段乱跳的文本反而困惑。后来我改成只推节点的完成事件和关键统计信息每个节点结束后显示结果摘要比如“已完成 12 段经历中 8 段的改写”。这种抽象层级比 token 级流式更合适。3.4 状态持久化与断点恢复checkpointer 的作用如果用户改到一半关闭了页面回来要继续上一次的会话怎么办这就是checkpointer的用武之地。StateGraph在编译时可以传入一个checkpointer它会在每次节点执行后把整个 State 快照持久化。我本地用MemorySaver方便调试线上则换成了一个轻量 Redis 实现。checkpointer 的使用方式是传同一个thread_id。当以相同thread_id再次invoke时LangGraph 会先从 checkpoint 恢复之前的 State再继续执行未完成的节点。这对简历工具很重要用户可能在翻译过程中突然决定先上传新版简历这时如果状态不能恢复之前的修改就全丢了。import { MemorySaver } from langchain/langgraph; const checkpointer new MemorySaver(); const graph workflow.compile({ checkpointer }); // 第一次执行 await graph.invoke(inputs, { configurable: { thread_id: user-123 } }); // 第二次执行从上次的 checkpoint 继续 await graph.invoke(newInputs, { configurable: { thread_id: user-123 } });需要留意的是MemorySaver的局限它是进程内存重启即失。生产环境最好用 Redis 或 Postgres 作为 checkpoint 存储否则用户刷新一次页面断点续跑能力就没了。4. 简历工具的状态 Schema 与真实工具调用4.1 AgentState 怎么设计才不会越写越乱State 是整个项目的地基设计不好后面全是坑。我的AgentState定义分成了三类输入区、计算区、输出区。输入区只放resumeText、jobDescription、mode计算区放parsedResume、skillGap、revisedBlocks输出区放finalResume、errorMessages。import { Annotation } from langchain/langgraph; export const AgentState Annotation.Root({ resumeText: Annotationstring({ reducer: (prev, next) next ?? prev, }), jobDescription: Annotationstring({}), mode: Annotationtranslate | optimize | format | auto({}), parsedResume: AnnotationParsedResume({}), skillGap: AnnotationSkillGap[]({}), revisedBlocks: AnnotationRevisedBlock[]({}), finalResume: Annotationstring({}), errorMessages: Annotationstring[]({ reducer: (prev, next) [...(prev ?? []), ...(next ?? [])], }), });一个非常有效的经验是计算区字段不要用 reducer 随意合并尽量让每个节点只负责写自己的字段避免并发写冲突。我在早期版本里让解析器和编辑器同时往notes字段里追加内容结果出现状态覆盖排查起来极其痛苦。后来明确每个节点的输出字段互不相交问题就消除了。4.2 让 Agent 调用真实工具而不是幻想Tool 节点与 Airtable 示例LangGraph.js 里实现工具调用有两种层级底层是节点内部直接调用你自己写的函数上层是给模型绑定 Tool 列表让模型在推理时决定要不要调用工具。在简历工具里我更倾向于前者因为简历改写的工具链是确定性的——解析器、格式化器、校验器都是硬逻辑不适合交给模型按“概率”决定是否触发。不过有一个地方我用了真工具调用保存简历到外部系统。用户改完简历后可以点击“保存到 Airtable”这个动作需要 Agent 读取配置中的 Airtable API 密钥、按预定 Schema 写入记录、然后返回记录 ID。我把这个能力写成一个自定义工具挂在saveNode上模型只有在用户的指令里明确出现“保存”时才触发。import { tool } from langchain/core/tools; import { z } from zod; const saveToAirtable tool( async ({ baseId, tableName, record }) { const res await fetch(https://api.airtable.com/v0/${baseId}/${tableName}, { method: POST, headers: { Authorization: Bearer ${process.env.AIRTABLE_TOKEN}, Content-Type: application/json, }, body: JSON.stringify({ fields: record }), }); if (!res.ok) throw new Error(Airtable save failed: ${res.status}); return (await res.json()).id; }, { name: save_to_airtable, description: 将最终简历保存到指定 Airtable 表格, schema: z.object({ baseId: z.string(), tableName: z.string(), record: z.record(z.any()), }), } );这里有个小坑工具函数的 schema 用 Zod 定义后LangGraph.js 会把它转成 JSON Schema 传给模型但如果你用的模型不支持 function calling工具调用会退化。我一开始用了一个轻量模型测试发现它完全不触发工具调用后来换成支持原生 function calling 的模型才正常。所以如果你发现工具一直不被调用先查模型是不是支持而不是怀疑 Graph 写错了。4.3 解析简历 PDF非结构化输入的结构化极限用户上传 PDF 是最常见的使用方式。PDF 解析我直接用了 Node 环境的pdf-parse在解析节点里先提取纯文本再交给 LangGraph 的 LLM 层做结构化。这里踩过的坑是pdf-parse在某些部署平台上会依赖原生模块构建体积会暴涨。所以我的建议是在本地或服务端做一个独立的解析微服务或者直接在前端用 PDF.js 先把内容转成文本再传后端不要让 Next.js 的 API Route 承载 PDF 解析。另一个体验问题是简历 PDF 往往有多栏布局pdf-parse直接抽取文本时会把左右两栏混在一起。我的处理是解析节点发现抽取的文本包含大量非连续片段时会优先按行清洗过滤掉明显的页眉页脚和页码之后再送 LLM 结构化。这一步可以用规则代码完成越早做后面 LLM 的结构化准确率越高。5. 从本地跑通到生产部署那些不写在 Demo 里的坑5.1 Vercel 函数超时与 Node runtime 的取舍如果你和我一样用 Vercel 部署 Next.js第一个要面对的问题是函数超时。Vercel 的 Hobby 计划默认函数最长执行时间为 10 秒左右而一个完整的简历改写 Graph包含两到三次 LLM 调用经常要跑 30 到 60 秒。直接把 Graph 挂在 Serverless 函数里大概率会超时。我做了两个调整。一是把maxDuration明确设置到当前套餐允许的上限。Vercel 从环境变量VERCEL_FUNCTION_MAX_DURATION读这个值但前提是你的服务商套餐支持长时间运行。二是更关键的对策——不要把整个长任务压在 HTTP 请求周期里。我的生产方案是接口接收到任务后立刻生成threadId返回给前端一个“任务已接收”的响应然后把 Graph 的执行交到外部任务队列我用了一个轻量队列服务。前端轮询或通过 WebSocket 获取节点状态不再依赖 SSE 的长连接。请求进入 - 入队 - 返回任务ID 任务队列 - 执行 Graph - 更新状态至 Redis 前端轮询 - 拉取状态 - 渲染节点进度这样设计之后前端体验反而更稳。用户关闭页面任务还在后台继续跑下次打开页面能看到完整执行历史。如果你只是做本地演示SSE 方案完全够用但一旦要上线给真实用户用建议早点切换到任务队列模型。5.2 可观测性没有 tracing 的 Agent 等于盲飞Agent 项目比普通 Web 服务更难调试因为一个错误可能发生在多个 LLM 调用中的任意一环而且大多是概率性的不是稳定复现。我第一次部署后用户反馈“有时候翻译出来乱码”“有时候优化完还是原样”我盯着 console.log 排查了半天最后才意识到应该先接可观测性工具把每个节点执行的输入输出、LLM 调用的 token 数、延迟和错误信息都落到一个集中平台。我用 LangSmith 做 LangGraph 的 tracing每走完一个节点都能看到完整的输入状态、输出状态、LLM 调用次数和耗时。排查诡异问题变得非常顺——直接按threadId搜索 trace看哪个节点在什么时间点拿到了什么输入、产出了什么输出。我强烈建议任何 Agent 项目在第一天就接上这类工具而不是等项目跑起来再补。Agent 的不可预测性不在于代码逻辑而在于模型输出的分布没有观测数据你就永远只能靠猜。5.3 安全与合规简历数据是最敏感的“小数据”最后一点可能最容易被忽略简历包含姓名、联系方式、教育经历、工作经历是典型的个人敏感信息。你把它交给 LLM 处理数据就会经过第三方模型服务这在某些行业和地区是有合规风险的。我的处理方式是在项目里增加一个明确的提示告诉用户简历内容将被发送给大模型服务用于改写处理并提供一个“不联网模式”该模式下只使用本地规则做格式整理不调用外部模型。同时在后端日志中不记录简历全文只记录节点状态与耗时。如果你把简历工具卖给别人用建议在隐私政策里写清楚数据流向、存储期限和删除机制。注意任何涉及真实用户简历的项目开箱前先确认数据合规边界。不要只看技术可行性要看你能否承担数据泄露的后果。整个项目从构思到现在跑通最大的收获并不仅在于学会了 LangGraph.js 的 API而是想明白了一件事所谓 AI Agent并不是把一堆工具丢给模型让它自由发挥而是用确定性的流程框架把模型的能力约束在可控的轨道里。简历工具之所以适合作为练手项目就是因为它足够小却能逼你处理分支、状态、工具、校验、部署这些真正的工程问题。如果你也想从聊天机器人升级到有真正工作流的 Agent我建议先别急着上复杂架构拿简历改写这个场景做一个完整闭环你会比读十篇架构文章学到更多。最后分享一个小技巧跑通图之后先用 Mock 数据把所有节点走一遍打印每一步的 State 变化这一步能帮你提前避开大量隐藏的逻辑错误。
返回列表