用LangGraph.js构建简历Agent:从状态图设计到Next.js生产级落地

发布时间:2026/10/7 18:43:46
用LangGraph.js构建简历Agent:从状态图设计到Next.js生产级落地 1. 从简历生成器到简历Agent这个项目到底在解决什么问题先说一个我在实际开发中反复观察到的现象市面上绝大多数简历工具本质上就是一个表单填写 模板渲染程序。用户填完几个字段系统套一个模板输出一份PDF结束。这种模式最大的问题在于——简历这件事根本不是一次性填表能完成的。一份好的简历是对话出来的。你需要不断追问用户你做过什么项目你在里面的角色是什么你带来了什么可量化的结果你投的是什么岗位、什么行业不同岗位对同一段经历的侧重点完全不同。一个做电商运营的人投运营岗和投产品岗同一段工作经历应该写出两种完全不同的表述。这正是我从一开始决定不用传统表单、而是做一个AI Agent来做简历工具的核心原因。用户不是被动的填表人而是通过自然语言对话让Agent逐步引导、收集、诊断、改写、润色最终产出一份结构化简历。这个Agent需要具备多轮对话能力、状态记忆能力、工具调用能力比如生成Markdown、导出JSON、调用评分模块还需要在用户中途改主意、跳着回答、一次说一大堆的时候保持流程不乱。技术选型上我用了Next.js作为应用框架LangGraph.js作为Agent编排引擎。你可能会问为什么不直接用LangChain.js或者直接裸调大模型API加上自己写状态机这个问题我放在下一节仔细说。先给你看一个宏观结论LangGraph.js解决的是Agent的流程控制问题Next.js解决的是Agent的部署形态问题。两者结合起来你得到的不是一个demo而是一个能真正上线、被真实用户使用、扛得住并发的完整产品。这篇文章会完整走一遍从零搭建这个简历工具Agent的过程包括状态图设计、流式输出、会话持久化、并发处理以及我在实际开发中踩过的一堆坑。适合已经会写基本的Next.js应用、想往AI Agent方向深入的前端/全栈开发者。2. 为什么是LangGraph.jsAgent流程控制的底层逻辑2.1 裸调API和LangChain的局限如果你只是写一个一问一答的聊天机器人裸调大模型API完全够用。一个循环把历史消息塞进去拿到回复返回给前端完事。但简历工具不是这样。我这里有一个典型场景用户说帮我写一份简历我做过三年电商运营主要做活动策划想投运营岗然后在下一轮又说哦对了我其实还负责过数据分析和商家对接。你作为开发者需要让Agent识别出几点用户想生成完整简历不是修改简历所以应该走完整生成流程而非局部优化流程用户提到了三段经历活动策划、数据分析、商家对接但信息量都不够需要逐个追问用户的目标岗位是运营岗行业是电商。这种理解意图 - 拆解任务 - 分步执行 - 多轮追问 - 组装结果的流程用裸循环写会非常痛苦。你需要自己维护状态、自己写分支逻辑、自己处理异常的上下文跳转。第一版可能还能跑加几个功能后代码就成了一团乱麻。LangChain.js解决了调用大模型的封装问题提供了Prompt模板、输出解析器、工具调用等便利设施但它在流程编排上不够直观。你仍然需要自己用if-else或者回调来处理多步流程而且每一步之间的状态传递仍然是手动的。更麻烦的是LangChain.js的AgentExecutor是一个黑盒式的循环——你很难在某个步骤之间插入人工确认、卡点检查、条件跳到别的分支。2.2 LangGraph.js的状态图思维LangGraph.js带来了一个完全不同的心智模型把Agent的整个执行过程定义为一个状态图StateGraph。这个状态图由节点Node和边Edge组成节点就是做某件事的步骤比如意图识别节点、信息补齐节点、简历生成节点、质量评分节点边定义了节点之间的流转方向你可以给边加上条件Conditional Edge让Agent根据上一步的结果自动决定下一步该走哪里——这本质上就是把一个复杂Agent的流程控制逻辑从散落的if-else中抽离出来变成一份可视化的、可维护的图定义。我用一个比喻来解释如果你把Agent比作一条流水线LangChain.js给你的是一把扳手而LangGraph.js给你的是整个流水线的设计图纸加上控制台。前者解决某个环节怎么做后者解决整条线怎么走、什么时候走到哪、异常了怎么处理。简历工具Agent的状态图是我这整个项目里最核心的部分。我先画一下初步设计你感受一下这个思路// 定义Agent状态的类型 interface ResumeAgentState { // 原始对话消息 messages: BaseMessage[]; // 解析出的用户意图generate | optimize | analyze | interview_prep intent?: string; // 收集到的简历信息 resumeData?: { basicInfo?: BasicInfo; workExperiences?: WorkExperience[]; education?: Education[]; projects?: Project[]; skills?: string[]; }; // 用户目标岗位/行业 targetRole?: string; targetIndustry?: string; // 当前流程阶段collecting | generating | reviewing | done stage?: string; // 生成的简历文本 generatedResume?: string; // 评分结果 score?: number; suggestions?: string[]; }这个State就是整个Agent的共享内存。任何节点都可以读取和更新它而LangGraph.js会负责在不同的节点之间传递这个State。你不需要手动把数据从一个函数传给另一个函数——图框架帮你干了这件事。2.3 为什么和Next.js是绝配你可能还会问LangGraph.js是纯Node/TypeScript库我完全可以单独跑一个服务然后用Next.js当前端。为什么要把它们放在同一个项目里原因有两个。第一简历工具的核心交互是流式对话而Next.js的Route Handlers天然支持ReadableStream响应。Agent的每一个节点执行结果、每一步思考过程都可以通过Server-Sent EventsSSE或者Streaming Response直接推送到浏览器。如果你把Agent部署成一个独立的微服务中间还要过一层协议转换反而增加了复杂度。第二Next.js的Serverless部署模型非常适合Agent类应用。简历工具的用户不是24小时都在用通常是求职季集中访问。用Serverless按需扩容冷启动由平台处理我只需要关注Agent逻辑本身。在下一节我会展示完整项目落地时的目录结构和部署形态。3. 项目落地的第一步环境搭建与LangGraph.js基础骨架3.1 初始化项目结构我用的Next.js 14App Router模式 TypeScript pnpm。不推荐用JavaScript写因为Agent的状态管理要定义很多类型TypeScript能帮你提前发现状态字段拼写错误、节点返回类型不对这类低级问题。# 初始化项目 pnpm create next-applatest resume-agent --typescript --app-router --eslint cd resume-agent # 安装核心依赖 pnpm add langgraph langchain/openai langchain # 如果你用的是国内模型或Azure OpenAI可以换成对应的LangChain集成包完整目录结构布局如下resume-agent/ ├── app/ │ ├── api/ │ │ └── agent/ │ │ ├── route.ts # 处理对话请求的Route Handler │ │ └── stream.ts # 流式响应的辅助函数 │ ├── page.tsx # 前端聊天界面 │ └── layout.tsx ├── src/ │ ├── agent/ │ │ ├── graph.ts # 简历Agent状态图定义 │ │ ├── nodes/ │ │ │ ├── intent.ts # 意图识别节点 │ │ │ ├── collect.ts # 信息收集节点 │ │ │ ├── generate.ts # 简历生成节点 │ │ │ ├── review.ts # 简历评分节点 │ │ │ └── optimize.ts # 简历优化节点 │ │ ├── state.ts # 状态类型定义 │ │ └── prompts.ts # 所有Prompt模板 │ ├── lib/ │ │ └── model.ts # 大模型实例的统一出口 │ └── types/ │ └── resume.ts # 简历数据类型定义这个结构的关键在于Agent逻辑和Next.js路由层彻底分离。src/agent下的一切都是纯Node.js代码不依赖Next.js的APIapp/api层只负责接收HTTP请求、调用Agent、把流式结果返回给前端。这样将来想把Agent单独拆出去部署或者接到Slack、飞书等平台只需要换一个入口适配器核心逻辑一行不用动。3.2 首个可运行的LangGraph流程图我们用一个最小示例来验证LangGraph.js的流程跑通。在src/agent/graph.ts中定义一张最简单的图——包含两个节点意图识别和简历生成。import { StateGraph, END } from langgraph; import { ResumeAgentState } from ./state; // 节点函数接收整个State返回State的部分更新 async function intentNode(state: ResumeAgentState): PromisePartialResumeAgentState { // 这里调用LLM对用户最近一条消息做意图分类 const intent await detectIntent(state.messages); return { ...state, intent, stage: intent generate ? collecting : processing }; } async function generateNode(state: ResumeAgentState): PromisePartialResumeAgentState { const resume await generateResumeFromState(state); return { ...state, generatedResume: resume, stage: done }; } // 条件边根据意图决定下一步走哪个节点 function shouldContinue(state: ResumeAgentState): string { if (state.intent generate) return generate; if (state.intent optimize) return optimize; return collect; } // 组装图 const builder new StateGraphResumeAgentState({ channels: { messages: { value: (left?: BaseMessage[], right?: BaseMessage[]) [...(left ?? []), ...(right ?? [])] }, intent: { value: (left?: string, right?: string) right ?? left }, resumeData: { value: (left?: any, right?: any) right ?? left }, generatedResume: { value: (left?: string, right?: string) right ?? left }, stage: { value: (left?: string, right?: string) right ?? left } } }); builder.addNode(intent, intentNode); builder.addNode(generate, generateNode); builder.setEntryPoint(intent); builder.addConditionalEdges(intent, shouldContinue, { generate: generate, optimize: optimize, collect: collect }); builder.addEdge(generate, END); export const resumeAgentGraph builder.compile();这段代码的核心逻辑就三件事状态定义channels定义了State中每个字段如何做更新合并。messages是追加语义intent是覆盖语义。这个设计很关键——LangGraph.js不是一个简单的State对象它对每个字段的更新策略都做了明确的声明避免多节点并发写入时的竞态问题。节点函数每个节点接收当前完整State返回部分更新。节点内部可以调用大模型、调用外部API、做任何异步操作。条件边addConditionalEdges让Agent在节点执行完后自动选择下一步。这是LangGraph.js最强大的一点——流程控制不再散落在代码的if-else里而是集中在图的定义中。在Next.js的Route Handler中调用这张图// app/api/agent/route.ts import { NextRequest } from next/server; import { resumeAgentGraph } from /src/agent/graph; export async function POST(req: NextRequest) { const { messages } await req.json(); const stream await resumeAgentGraph.stream({ messages, stage: start }, { streamMode: updates }); // 把图执行过程中的每一步更新通过SSE返回给前端 return new Response( new ReadableStream({ async start(controller) { // 注意这里用stream而不是普通return是为了把Agent的中间状态也推给前端 // 让用户能实时看到正在分析你的经历... } }) ); }4. 简历Agent的核心业务流从意图识别到简历成品现在进入正题。简历工具Agent和其它领域的Agent有个很大的不同它既要保证对话的灵活性又要保证最终产出的结构化数据是完整、可用的。如果用户聊了半天最后生成的简历缺了教育经历、技能描述空泛这产品就废了。所以我把整个Agent流程分成了五个阶段每个阶段对应图中的一个或几个节点。4.1 意图识别节点这一步决定了整个对话方向用户的第一条消息往往信息量巨大。比如你好我工作了四年做过前端开发也做过一点产品现在想跳槽去大厂做前端能帮我优化一下简历吗这句话里包含的信息有工作年限四年、经历前端开发、产品、诉求优化简历、目标大厂前端。意图识别节点需要从这条消息中榨取出所有关键信息并判断该走哪条流程。我用的是结构化抽取意图分类双管齐下的做法const intentPrompt 你是简历助手的意图识别器。用户消息如下 {userMessage} 请从以下三个维度分析并返回JSON 1. intent: 用户的核心诉求只能是以下值之一 - generate从零生成新简历 - optimize基于已有简历优化 - analyze仅做简历分析诊断 - other闲聊或其它 2. extractedInfo: 从消息中提取到的结构化信息对象 3. missingFields: 根据intent列出生成完整简历还缺少的关键字段 只返回JSON不要多余的说明。; const intentChain ChatPromptTemplate.fromTemplate(intentPrompt) .pipe(model) .pipe(new JsonOutputParser());这个节点的输出直接决定了后续流程。我在这里踩过一个坑一开始意图类型只有generate和optimize没有analyze。结果用户说帮我看看这份简历哪里有问题Agent会把看看误判为优化直接动手修改而不是先做诊断。后来我把analyze独立出来并且让意图识别节点把抽取到的结构化信息直接写入State后续节点就能直接使用了。4.2 信息收集节点动态追问与信息补齐意图识别完成后Agent大概率发现状态里还缺一些关键字段。比如用户要做完整简历生成但没提教育经历、没提技能清单、没提项目经历。这时候就进入信息收集节点。这个节点的Prompt设计很讲究。不能机械地列出一个问题清单问用户那样就是表单了。要让Agent根据已有的信息生成自然、有针对性的追问。比如用户说了三年电商运营经验追问应该是你提到做过三年电商运营那这段经历里你最想突出的是哪方面是活动策划的执行细节还是数据分析带来的销量提升另外我看你还没有填写教育经历——方便告诉我学校和专业吗这里的关键技巧是一次只追问最关键的1-2个信息而不是把五个问题一次性抛出去。真实的对话中用户一次回答两三个问题已经是极限五个问题只会让用户敷衍了事。收集节点的Prompt模板你是简历信息收集助手。当前已掌握的简历信息如下 {resumeData} 用户的最近一条消息是 {lastMessage} 请判断 1. 如果简历信息已基本完整基本信息、工作经历、教育经历、技能都齐全回复信息收集完毕 2. 否则根据缺失字段用自然的对话语气向用户追问最多追问2个问题且必须有明确的优先级 注意不要机械地说请问您的工作经历是这类问题要结合用户已有的回答用你刚才提到...的句式自然衔接。当收集节点判断信息已完整时通过条件边跳转到简历生成节点否则继续停留在收集节点。这个机制让收集和生成两个阶段形成闭环——用户随时可能补充新的信息图会重新回到收集节点做判断。4.3 简历生成节点结构化输出的核心简历生成是整个Agent里技术含量最高的节点还牵扯到评分。输入是State中的resumeData结构化数据输出是一份符合用户目标岗位定位的简历文本。实现要点有三个第一基于岗位定向调整内容权重。同样是做电商运营投天猫运营主管和投新消费品牌增长负责人简历上对同一段经历的表述几乎应该是两份不同的内容。所以这个节点的Prompt必须包含targetRole和targetIndustry并要求LLM对每段经历做重新裁剪和排序。第二强调量化结果。这是一份简历能否通过初筛的关键。所以Prompt里我定义了一个硬性规则每段工作经历至少包含一个数字量化结果如果没有先基于原文合理推算并标注让用户确认。这个规则配合后面的Review节点做双保险。第三一次性输出完整的Markdown结构。我要求模型直接输出完整的简历Markdown包括个人信息、个人总结、工作经历、项目经历、技能清单、教育背景等区块。前端拿到后直接渲染成可预览的界面。你是一位资深的简历写作专家。请基于以下结构化信息生成一份面向{targetRole}岗位的完整简历。 【结构化信息】 {resumeData} 【生成要求】 1. 使用Markdown格式使用二级标题区分区块 2. 每段工作/项目经历需包含背景、你的具体职责、量化成果 3. 量化成果如果没有原始数据基于合理推算补全并用【】标出需要用户确认的数字 4. 个人总结控制在3-5句话突出与目标岗位匹配的核心能力 5. 语言简洁有力避免负责参与这类弱动词改用主导搭建优化等强动词4.4 质量评分节点让Agent自己给自己的工作打分在简历生成之后我加了一个质量评分节点。这个节点会以HR和岗位JD的双重视角对生成的简历进行审查和打分然后输出结构化的修改建议。这个节点的价值在于让用户感知到Agent不是一次性交差了事而是有质量闭环的。即使之前生成的简历已经不错评分节点也会给出几条具体的优化建议用户可以选择没问题就这版或者按建议改一下。这种交互方式大大提升了用户对工具的信任感。const reviewPrompt 请以资深HR和一线业务主管的双重视角审查以下简历 {generatedResume} 目标岗位{targetRole} 从以下维度打分每项1-10分并给出文字建议 1. 匹配度经历与目标岗位的契合程度 2. 量化度是否包含足够的具体数字和成果 3. 语言质量动词选择、句式简洁度 4. 结构完整度如缺少关键区块请指出 输出JSON格式{ scores: {...}, suggestions: [...], missingBlocks: [...] }; // 评分节点会将结果写入State同时决定是否进入优化分支 function routeAfterReview(state: ResumeAgentState): string { const avgScore calculateAverage(state.score); if (avgScore 6.5) return optimize; // 低于阈值自动进入优化节点 return present; // 达标直接呈现给用户 }这里做了一个自动分流的设计如果评分低于6.5分Agent会自动进入优化节点进行改写不走先去问用户这个环节——因为低分简历直接给用户看体验很差但如果评分在6.5分以上Agent会把简历和评分建议一起呈现给用户由用户决定是否需要进一步优化。这种自动处理低质量结果、人工确认高质量结果的思路在Agent产品设计中很实用。5. 状态管理与长会话从单轮到多轮的实现细节5.1 一次请求和一轮对话的关系刚开始我犯过一个认知错误把LangGraph.js图的执行限制在一次API请求内。用户每发一条消息就调用一次图图执行完后整个State就丢了。这导致用户第一轮说了我要生成简历第二轮补充了一段项目经历Agent完全不记得又从头问起。正确的做法是把每一次用户消息当作图的一次输入但图的State是跨请求持续的。第一次请求结束时把最终State持久化保存第二次用户发消息时把保存的State作为初始状态传入让图接着往下走。LangGraph.js提供了Checkpointer机制专门解决这个问题。它可以在每次节点执行后自动把State保存到存储后端并且支持用thread_id来关联同一个会话的不同轮次。import { MemorySaver } from langgraph/checkpoint; // 使用内存型checkpointer做开发调试 const checkpointer new MemorySaver(); const graph builder.compile({ checkpointer }); // 每次调用时携带thread_id const stream await graph.stream( { messages: newUserMessage }, { configurable: { thread_id: user-session-123 }, streamMode: updates } );生产环境里我用的是Redis做checkpointer的后端存储。还用过Upstash Redis因为它是Serverless友好的不会像传统Redis客户端那样占用长连接。LangGraph.js官方也提供了Postgres版的checkpointer如果你团队本来就有Postgres这可能是最省事的选择。5.2 节点级别的State合并陷阱在定义channels的时候有一个非常容易踩的坑如果节点A返回的Partial State里包含整个resumeData对象而节点B同时也在更新resumeData的某个子字段这两个更新在合并时会发生冲突。LangGraph.js默认的合并策略是后者覆盖前者。对于复杂嵌套对象这个策略可能造成数据丢失。我实际遇到的问题是这样的节点A信息收集从用户消息中提取到新的工作经历返回{ resumeData: { workExperiences: [...new] } }节点B意图识别在核对了用户历史消息后发现了一条漏掉的技能信息返回{ resumeData: { skills: [...new] } }如果这两个节点没有先后依赖关系LangGraph.js是支持并行执行节点的。但并行会导致合并时一方覆盖另一方。我的解决办法是把嵌套字段摊平到State顶层。即不把整个resumeData作为一个channel而是拆成workExperiences、skills、education等独立的channel。这样每个channel有自己的合并策略互不干扰。const builder new StateGraphResumeAgentState({ channels: { messages: { value: mergeMessages }, workExperiences: { value: mergeArrays }, // 自定义数组合并 skills: { value: mergeArrays }, education: { value: mergeArrays }, targetRole: { value: overwrite }, intent: { value: overwrite } } });这个设计虽然让State的类型声明变多了但规避了一整类并发更新的bug。如果你的Agent流程里有很多并行节点建议从一开始就摊平状态结构不要图省事搞嵌套对象。5.3 流式输出的完整实现简历Agent的体验关键点在于每一步的执行结果都要让用户实时看到。LangGraph.js的stream()方法支持streamMode: updates会在每个节点执行完成后产出一个事件。这些事件经过Next.js的Route Handler通过SSE推送到浏览器。注意这里出现了一个技术细节——Next.js Route Handler默认会把Response缓冲起来等到整个响应结束后才发送给浏览器那样流式就白做了。你需要通过ReadableStream手动控制发送时机并且在调用model.stream()的时候也要确保LLM本身是流式返回的。我用的是一个工具函数封装这个过程export async function createAgentStream(messages: BaseMessage[]) { const encoder new TextEncoder(); return new ReadableStream({ async start(controller) { // 状态流把Agent的流程阶段推送过去 const stream await resumeAgentGraph.stream( { messages }, { streamMode: updates, configurable: { thread_id: ... } } ); for await (const event of stream) { // 事件格式{ 节点名: 该节点的返回结果 } const payload JSON.stringify(event); controller.enqueue(encoder.encode(data: ${payload}\n\n)); } controller.close(); } }); }前端用EventSource或者fetch配合ReadableStream来消费。我推荐后者因为SSE的EventSource不支持POST请求而聊天场景通常需要POST发送消息。前端核心代码const response await fetch(/api/agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }) }); 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); // 解析SSE格式的data行更新UI状态 const lines chunk.split(\n).filter(l l.startsWith(data: )); for (const line of lines) { const event JSON.parse(line.slice(6)); updateUI(event); } }6. 并发与性能AI Agent怎么扛住真实访问量6.1 冷启动与超时问题从热搜词ai agent怎么扛并发就能看出这是所有Agent类项目绕不开的痛点。我总结的三板斧是缩短冷启动路径、任务拆分降耗时、响应模式选流式。先看超时。传统HTTP接口给你个3-5秒超时是合理的但Agent调用大模型一次就可能要十几秒整个图跑下来可能到一两分钟。如果你的Route Handler是跑在Serverless平台上平台默认的超时通常只有10-30秒这不够用。解决办法有两个方向选支持长超时的平台比如Vercel的Fluid Compute、Cloudflare的延长执行时间把Agent的执行改成异步任务轮询/Webhook模式客户端发起请求后立即返回一个taskIdAgent在后台跑完后前端通过轮询或Webhook拿到结果。我推荐后端用流式响应、前端用取消式fetch的组合。流式响应可以让整条链路保持长连接客户端的体验是实时的同时要做好前端异常处理和重连机制Web Worker出现断流时自动重新建立连接。6.2 缓存策略让重复请求不再重复计算简历场景有一个其他Agent场景少有的好处——用户的输入有很强的复用性。同一个用户会反复调整同一份简历周围的字段教育经历、基本信息几乎不变只有某一段工作经历在改。所以我在信息收集节点做了缓存设计const cacheKey resume:${userId}:${projectId}:${targetRole}; const cached await redis.get(cacheKey); if (cached) { // 把缓存的结构化数据注入State跳过部分收集流程 return { resumeData: JSON.parse(cached), stage: skip_collect }; }这一段代码一旦生效用户的第二次、第三次简历生成就快了很多。因为结构化数据已经就绪Agent可以直接进入生成节点省掉了好几轮LLM调用。另一个缓存点是意图识别。如果用户反复发送相同模版的消息比如上传了同一份简历意图识别节点会重复调用模型。我给意图识别加了一层简单的语义缓存对用户消息做一个向量化和最近10条请求做相似度比对命中就直接复用意图结果不调LLM。这在高并发场景下省了不少token和时间。6.3 模型层的限流与重试并发上来之后大模型API的限流Rate Limit是逃不掉的。我在src/lib/model.ts里做了统一的调用封装内置了指数退避重试和并发限制。import { ChatOpenAI } from langchain/openai; import Bottleneck from bottleneck; const limiter new Bottleneck({ maxConcurrent: 5, // 同时最多5个LLM请求 minTime: 200 // 每个请求至少间隔200ms }); const baseModel new ChatOpenAI({ model: gpt-4o-mini, temperature: 0.7, streaming: true }); export const model limiter.wrap(baseModel.bind.bind(baseModel)) as typeof baseModel;这里有一个关键点流式调用必须绕过Bottleneck的异步节流。如果limiter包在外层整个流都会被缓冲SSE就失效了。我最后是分开处理的普通推理走Bottleneck限流流式推理单独走一个只做错误重试的wrapper。这也是实打实的教训分享出来免得你重复踩。6.4 并发压测的真实数据项目上线前我用k6做了一轮并发测试。模拟场景是200个虚拟用户同时发起对话请求每个用户连续交互5轮。回头看结果几个关键数字你可以做个参考纯计算/转发的空转场景不真正调LLMNext.js Serverless可以轻松撑住200并发真实调用LLM时单个Region的qps基本被模型供应商的限额卡住瓶颈不在应用服务器加缓存后意图识别和基本信息收集的请求有约40%命中缓存实际LLM调用量下降明显。这个结果印证了我的判断Agent应用扛并发的关键不是让应用服务器处理更多请求而是让应用服务器少调LLM。缓存、状态复用、任务合并才是Agent并发的核心优化方向。7. 踩坑实录LangGraph.js Next.js集成中的典型问题7.1 问题一流式响应在前端被截断我遇到的第一个诡异问题是简历生成到一半前端收到的数据突然断了没有任何报错。排查链路如下先看后端日志Agent图上所有节点都执行完了没有异常再看网络面板发现响应确实结束了但Content-Length比预期的小怀疑是代理层的问题——我在本地开发环境一切正常但部署到生产环境就出问题。根因是生产环境的托管平台在响应体超过一定大小或超过一段时间后会强制断开连接。简历生成节点的输出是完整Markdown简历加上评分建议文本量不小。再加上SSE还需要保持长连接正好踩中了平台的限制。解决办法是用Next.js的App Router Route Handler配合runtime: nodejs并且把发给前端的流式数据做了压缩——不是算法压缩而是把事件结构精简只发关键字段把完整数据放在最后一条结束事件里。export const runtime nodejs; export const dynamic force-dynamic;特别提醒如果用了runtime: edge很多Node.js原生的stream方法在边缘环境的表现和本地完全不一样会出现难以排查的诡异行为。我的建议是Agent类应用优先用Node.js运行时除非你的边缘运行时完全兼容你要用的依赖库。7.2 问题二图的状态在长时间对话后膨胀简历工具还有一类用户场景是今天了一半明天接着做。如果用户在会话里聊了一天messages这个channel会把所有历史消息都存下来。随着对话轮次增加每次调用图时都要把全部历史消息传给LLMtoken消耗跟着涨响应也越来越慢。我在图里加了一个消息压缩节点当messages超过一定长度时触发压缩节点用LLM把早期消息总结成摘要。这其实借鉴了LangChain.js里ConversationSummaryMemory的思路但LangGraph.js的图结构让这个压缩动作发生在固定的节点位置流程更可控。async function compressNode(state: ResumeAgentState): PromisePartialResumeAgentState { if (state.messages.length 20) { return { needCompress: false }; } const summary await compressMessages(state.messages.slice(0, -10)); return { messages: [new SystemMessage(对话摘要${summary}), ...state.messages.slice(-10)], needCompress: false }; }一个实用的注意事项压缩时机要在消息不太长的时候就做不要等到撑到极限再处理。我的阈值是20条超过就执行压缩。7.3 问题三Node函数的隐式依赖让调试变得困难LangGraph.js的一个特性是节点函数接收完整的State对象。这很方便但也隐藏了一个问题节点内部究竟依赖了哪些字段函数签名完全看不出来。我遇到过的情况是在修改了某个channel的更新逻辑后一个看起来无关的节点开始出现undefined错误排查了很久才发现它内部读取了那个channel的字段。后来我强制自己在每个节点函数的开头显式声明需要从State中取出的字段async function generateNode(state: ResumeAgentState): PromisePartialResumeAgentState { const { resumeData, targetRole, targetIndustry } state; // 其他字段虽然也在state里但这个方法不应该读取这是代码审查的约定 const result await generateResume({ resumeData, targetRole, targetIndustry }); return { generatedResume: result, stage: reviewing }; }这只是代码规范层面的事但对长期维护帮助很大。LangGraph.js不会强制你这么做如果你的团队要维护一个复杂的Agent项目我强烈建议建立这样的约定否则两周后回头看自己写的代码会一头雾水。7.4 问题四生产环境的敏感信息管理简历包含了用户的联系方式、工作经历等隐私信息Agent调用LLM的过程等于把这些信息发给第三方大模型。上线前我专门做了处理所有包含大模型调用的节点输出内容进入日志前做脱敏手机号、邮箱、公司名用占位符替换生产环境禁用DEBUG模式的LangGraph.js日志输出避免把完整State打到日志里前端引导用户知悉AI生成简历会用于模型处理同时给用户一个数据删除的接口。做AI应用越早考虑数据边界越好。简历工具的数据敏感级别很高安全合规是不能回避的底线。8. 跑通之后的进阶方向把简历Agent做成一家产品最后聊一下我做完这个项目后的真实体会。Agent类的产品demo和上线之间隔着一整条工程化的鸿沟。图、节点、大模型调用只是最基础的地基真正决定产品能否存活的是下面的工程细节可观测性是Agent项目的地基。每个节点执行了多久、调用了多少次LLM、消耗了多少token、用户的哪些操作导致了流程分支变化——这些数据必须有完整记录。我用LangSmith做了LangGraph.js的官方可观测性接入它能看到图的每一次节点追踪定位问题方便很多。自己造轮子做日志系统复杂度远超你的想象。Prompt的版本管理很重要。简历生成的Prompt改一个词可能直接影响整份简历的质量。我把所有Prompt模板放到src/agent/prompts.ts一个文件里配合Git做版本管理。每次调整后会记录测试用例的对比输出确保改动不会让已有场景退化。要做好数据资产的积累。运行一段时间后你手里会有大量用户输入 - Agent输出 - 用户反馈的数据。这些数据是持续优化Agent的最宝贵素材。后面我打算基于这些数据微调一个更适合简历场景的小模型把推理成本降下来同时让输出风格更稳定。这个简历工具Agent做完后我最大的感受是LangGraph.js真正改变了Agent的开发方式。以前做Agent最怕的是流程一复杂就失控现在整个流程可以从图上直接看出来每个节点都能单独测试出问题能找到精确的位置。不夸张地说这已经是Agent工程化的分水岭——从调模型写Prompt升级到设计状态图编排业务流。希望这篇文章能帮你少走一些弯路如果你的Agent项目也在落地阶段遇到问题欢迎一起交流踩坑经验。