先说个背景。我做简历工具这个方向大概有两年了最早版本就是一个 Next.js 套壳的表单页加一个 GPT 接口用户粘贴一段经历系统返回三条“建议”。后来发现这条路走不通——真实的简历修改根本不是一次问答能解决的用户会上传 PDF、会要求“把这段再写专业一点”、会追问“为什么你觉得这个岗位匹配度只有 60%”、会改完一版又要求换一种叙述风格。需求一变多单接口回调式的代码就成了屎山。所以当 LangGraph.js 发布之后我几乎没犹豫就决定把整套系统推倒重来用状态图的方式组织整个简历加工流程。这篇文章就是这次重构的完整记录。包含我做方案选型时比较过的几条路线、LangGraph.js 里状态图和节点怎么在简历场景里具体建模、Next.js 端怎么把 Agent 的思考过程用流式输出呈现给用户以及最后落到生产环境时在并发、缓存、可观测性上踩过的坑。如果你是正在做类似 AI 应用、或者想用 LangGraph.js 但不确定它到底适合什么场景的人这篇应该能给你省不少时间。1. 简历场景对 Agent 的需求拆解它不是聊天机器人先把问题定义清楚。一个简历工具要能真正“干活”不是把用户输入的字符串送给 LLM 就完事而是要完成一个多阶段的加工流程。我拆解之后发现至少有三个任务是不可跳过的。1.1 从“一问一答”到“多阶段加工”简历工具的三个必备能力第一个是简历解析。用户上传的简历格式极其混乱PDF、Word、纯文本、图片截图转出来的文字、还有从招聘网站导出的 HTML。解析阶段要把这些五花八门的输入标准化成结构化数据——姓名、联系方式、教育经历、工作经历、项目经历、技能标签、时间线。这个环节最烦人因为 PDF 解析出来的文本经常是乱序的教育经历和工作经历混在一个段落里。第二个是岗位匹配分析。这要求把目标 JDJob Description也一并采集过来然后和解析好的简历做对比输出一个结构化的分析报告哪些技能是岗位硬要求但简历里没有的哪些经历是强相关但没有量化表达的哪些措辞偏弱、一看就是应届生口语。这一环节如果只用单个 Prompt 硬怼输出结果会非常不稳定因为你要同时处理“提取”“对比”“评分”“给建议”四件事模型很容易顾此失彼。第三个是简历改写与多轮修订。分析报告出来后用户不会照单全收。有人觉得建议太平庸有人觉得改写后失去了自己原有的叙述风格有人只想要针对某一条经历的修改。所以系统必须支持“基于当前版本继续加工”多轮迭代。这三个任务拼在一起就已经是一个典型的多步骤、有分支、状态可变的工作流了。它不是一次性问答更不是简单的“A 调用 B”的线性管道。而 LangGraph.js 恰好是干这件事的。1.2 为什么单接口方案在这里必然失控你可能觉得这三步每个都是调一次 LLM API 的事用普通后端也能串起来。理论上确实可以但实际写两个月之后你一定会遇到几个绕不开的问题。第一个问题是状态传递混乱。解析完的简历对象要在多个环节之间共享用户改了几轮之后的“当前版本简历”需要能在任意节点被读写如果你把中间状态存在一个有状态的全局变量里并发一上来就开始互相覆盖如果存在数据库里每次节点切换都要手动序列化反序列化。我在老版本里就是因为状态管理写了太多样板代码最后补丁摞补丁。第二个问题是流程控制难以可视化。当一个流程里的分支条件越来越多——比如“如果匹配度低于 50 则直接进入深度诊断分支”“如果用户要求在改写时保留原段落结构则走另一个 Prompt 模板”——你用手写的 if/else 去控制流程逻辑散落在各个 API 处理函数里团队成员根本没法快速理解整条链路。第三个问题是难以干预中间过程。实战中经常会需要人工介入比如解析环节完成后用户确认“这版解析漏掉了我的第二段实习”这时候你不能让整个流程回滚重跑你得能重新进入解析节点并且只更新后续依赖这个状态的节点。这三个痛点对着 LangGraph.js 的 StateGraph 模型看几乎每种都对应一个核心机制状态通道channels、节点nodes、边和条件边edges/conditional edges、检查点checkpoint。所以我说它不是“适合”做简历 Agent而是它就是为了这种有状态、可中断、可恢复的流程设计的。2. 选型对比LangGraph.js 比起 LangChain.js 和自研状态机赢在哪技术选型是最容易翻车的一步。当时我的备选方案一共有三个继续用 LangChain.js手写一个简单的状态机以及切到 LangGraph.js。各有利弊但最终在简历场景里只有最后一个能同时满足整个团队对开发效率和可维护性的要求。2.1 时间线对比从 2025 年视角看几个方案的成熟度先说说当时的背景。LangChain.js 的哲学是“链式调用”你用一个 Chain 把 Prompt、LLM、输出解析器串在一起非常轻量。但它本质上是一个线性管道工具对分支、循环、共享状态的表达力很弱。当你需要“如果 A 则走 B否则走 C”时LangChain.js 会逼你去写各种 RunnableLambda 嵌套可读性比屎山还屎山。自研状态机则是另一个极端你可以获得完全的自由度但“自由”意味着你要自己设计状态表、事件系统、节点注册机制还要处理并发执行时状态隔离的问题。这大概需要一到两周的时间才能做到生产级。更重要的是后续每新增一个流程节点你都在给自己的状态机加框架代码而不是加业务逻辑开发效率会持续递减。LangGraph.js 在 2025 年已经迭代得非常成熟。它和 LangChain.js 不是替代关系而是它的超集——LangGraph 的节点内部可以继续调用 LangChain 的组件但图的编排能力是前者不具备的。另外 LangGraph.js 原生支持流式输出、状态检查点、人机回环human-in-the-loop这三样东西在简历这类交互式场景里几乎是刚需。所以我最后把重心完全放在 LangGraph.js 上LangChain.js 只作为节点内部工具。2.2 状态图StateGraph对简历工作流的具体建模方式LangGraph.js 的核心概念很好理解你把整个业务流程画成一张图图里有节点node和边edge节点是“做一件事的函数”边是“做完这件事下一步去哪”的声明式描述。加上条件边后图的走向可以根据当前状态动态决定。对应到简历场景我是这样拆节点的parseResume接收原始文本调用解析模型输出结构化简历 JSON。extractJDRequirements从目标 JD 中提取核心能力要求、年限要求、技能列表。analyzeGap对比简历和 JD 需求输出差异分析报告。generateRewritePlan根据差异报告生成具体改写方案按经历段落批量拆分子任务。rewriteSection执行改写返回新版本片段。validateStyle核对新版本的叙述风格、用词专业度、量化表达是否达标。decideNextAction条件边决定继续改写还是汇总结束。这些节点之间通过 StateGraph 的状态通道共享数据。LangGraph.js 的状态不是简单的“一个变量筏子”而是由多个 channel 组成每个 channel 有自己的 reducer用来控制写入时的合并策略——这一点特别重要因为多个节点可以往同一个 channel 里写数据比如简历重写后的多个 section 片段要用 append 的方式合在一起而不是后写覆盖先写。这就是为什么我说自研状态机很容易在并发写入这里翻车你只用一个对象当全局状态每次节点执行前得深拷贝、执行后得手动 merge一旦漏了一个字段就出脏数据。LangGraph.js 里配置好 reducer 后写状态这件事就全是框架责任了。2.3 我为什么没选 Vercel AI SDK 自带的工作流能力顺带提一个很多人问过的选项既然用了 Next.js为什么不用 Vercel AI SDK 里的streamText和tool直接搭我的结论是AI SDK 很擅长单次流式生成和函数调用但它不是一个状态编排工具。简历流程需要多轮 LLM 调用每一轮之间要共享和修改状态AI SDK 没有提供“图状态”这样的一等公民抽象。把复杂流程塞进 AI SDK 的 tool 调用循环本质上是在模仿一个状态机但是是用递归函数和外部闭包实现的调试起来非常痛苦。所以最终架构是Next.js 做前后端一体化宿主LangGraph.js 负责整个 Agent 工作流的编排两者之间通过 API Route 或者 Server Action 通信。LangGraph 跑在服务端前端拿到的是“可执行的 Agent 会话”。3. 手写落地简历 Agent 的图结构、状态与节点实现这节进入正题。我会把关键代码拆开讲尽量写到你可以在自己项目里直接“抄作业”的程度。3.1 状态结构设计不要把所有东西塞进一个大 JSON设计 StateGraph 的第一步是定义状态。很多人上来就写一个巨大的interface ResumeState { rawText, parsedJson, analysis, ... }这不是错的但 LangGraph.js 的 channel 机制允许你做得更精细。我把状态拆成了三个层级的 channeltype ResumeWorkflowState { // 输入层不变数据 rawResumeText: string; jobDescription: string; // 加工层逐步构建的结构化数据 parsedResume: ResumeData | null; jdRequirements: Requirement[] | null; gapAnalysis: GapReport | null; // 产物层可累积追加的辅助数据 rewritePlan: RewritePlan | null; rewrittenSections: RewriteSection[]; finalOutput: ResumeData | null; // 控制层流程专项数据 requiresDeepDiagnosis: boolean; iterationCount: number; };定义 channel 时要显式声明哪些字段是“覆盖模式”、哪些是“追加模式”。rewrittenSections就应该是追加模式因为不同 section 的改写结果是分布产生的需要全部保留const workflow new StateGraphResumeWorkflowState({ channels: { rawResumeText: { reducer: (_, next) next }, parsedResume: { reducer: (_, next) next }, jdRequirements: { reducer: (_, next) next }, gapAnalysis: { reducer: (_, next) next }, rewrittenSections: { reducer: (current, next) [...(current ?? []), ...(Array.isArray(next) ? next : [next])], }, iterationCount: { reducer: (current 0, next) current next, }, }, });一个小提示默认的 reducer 就是“后写覆盖”所以不需要每个字段都写。但只要你有一个需要 append 的字段就别偷懒去改 reducer因为后面会有多个 Runner 并发写同一个状态实例的场景Reducer 一旦写错就是那种“偶发只能复现一次”的线上 Bug。3.2 核心节点实现解析与改写节点的写法与输出约束节点函数本质上是一个(state) Partialstate的函数。以解析节点为例我用的方式是“LLM 输出 JSON Zod 强校验”而不是直接把模型输出往状态里塞import { RunnableLambda } from langchain/core/runnables; import { ChatOpenAI } from langchain/openai; import { z } from zod; const resumeSchema z.object({ personalInfo: z.object({ name: z.string().optional(), email: z.string().email().optional(), phone: z.string().optional(), location: z.string().optional(), }), skills: z.array(z.string()).default([]), education: z.array(z.object({ school: z.string(), degree: z.string(), startDate: z.string(), endDate: z.string().optional(), })).default([]), workExperience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string(), bullets: z.array(z.string()), })).default([]), projects: z.array(z.object({ name: z.string(), description: z.string(), highlights: z.array(z.string()), })).default([]), }); async function parseResumeNode(state: ResumeWorkflowState) { const model new ChatOpenAI({ model: gpt-4o, temperature: 0, responseFormat: { type: json_object }, // 这里明确要求 json 输出 }); const parser model.withStructuredOutput(resumeSchema); const parsed await parser.invoke( 你是资深 HR 简历解析专家。请从以下简历文本中提取结构化信息。\n 文本内容\n${state.rawResumeText}\n 要求\n- 技能标签从文本中出现的名词中提取尽量精简。\n - 如果某段经历既像工作又像实习按年份判断。\n - 保持原始语义不要补充不存在的经历。, ); return { parsedResume: parsed }; }这里值得注意的一个细节是temperature: 0。解析任务严禁“发挥”它是个抽取任务不是生成任务。如果你在这里给模型高温度参数它可能会把你简历里的“后端工程师”写成“全栈工程师”这就是脏数据的源头。改写节点的写法相似但输出约束要弱一些而且更强调“保留原始信息 增强表达”。我给改写节点写的系统提示词里有一条重要规则“不得删除任何事实性信息只允许调整措辞、补充量化表达、提升逻辑层次”。因为用户在真实使用时会非常敏感于“这段经历里的数字丢了”、“这个技术栈名词被替换了”这属于信任危机一次出现就很难挽回。3.3 条件边与多轮修订循环让 Agent 能“自己决定下一步”图结构里最关键的是条件边的写法。以decideNextAction为例function decideNextAction(state: ResumeWorkflowState): string[] { if (!state.gapAnalysis) { return [generateRewritePlan]; } if (state.requiresDeepDiagnosis state.iterationCount 2) { return [analyzeGap]; // 回到深度诊断 } const missingSections state.rewritePlan.sections .filter((s) !state.rewrittenSections.some((r) r.sectionKey s.sectionKey)) .map((s) s.sectionKey); if (missingSections.length 0) { return [rewriteSection]; } return [finalize]; // 所有 section 都改完收尾 }把这个函数挂到边上workflow .addNode(parseResume, parseResumeNode) .addNode(extractJDRequirements, extractJDRequirementsNode) .addNode(analyzeGap, analyzeGapNode) .addNode(generateRewritePlan, generateRewritePlanNode) .addNode(rewriteSection, rewriteSectionNode) .addNode(finalize, finalizeNode) .addEdge(parseResume, extractJDRequirements) .addEdge(extractJDRequirements, analyzeGap) .addEdge(analyzeGap, generateRewritePlan) .addConditionalEdges(generateRewritePlan, decideNextAction, { rewriteSection: rewriteSection, finalize: finalize, }) .addConditionalEdges(rewriteSection, decideNextAction, { rewriteSection: rewriteSection, finalize: finalize, });这种写法的最大好处是加新分支极其容易。比如后来我们想支持“用户手动指定优先修改某一条经历”只需要新增一个applyUserOverride节点在生成改写计划时读取用户偏好字段插到条件边里。整个图的结构没有变只是多了一条可走的路径。如果当初是用 if/else 手写的流程控制这个需求就要在五六个地方各改一遍。3.4 流式输出是刚需怎么把图节点“思考过程”推给前端简历工具的编辑场景里用户都是盯着页面等你输出。如果点击“分析”之后页面白屏转菊花转 20 秒用户基本就流失了。所以必须流式输出。LangGraph.js 对流式输出有原生支持。graph.stream()会在每个事件之间产生一个event你可以监听on_chain_start、on_chain_end这些事件也可以直接在节点函数内部手动streamEvent往外包一层。前端我用的是 SSEServer-Sent Events数据格式统一成event: stage_start data: {stage: analyzeGap, message: 正在分析简历与 JD 差异} event: delta data: {content: 发现你简历中后端相关技能覆盖率为 72%...}在 Next.js 的 Route Handler 里这样写// app/api/agent/analyze/route.ts export async function POST(req: Request) { const { resumeText, jobDescription, runId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const send (event: string, payload: unknown) { controller.enqueue( encoder.encode(event: ${event}\ndata: ${JSON.stringify(payload)}\n\n), ); }; send(stage_start, { stage: parse, message: 开始解析简历 }); const graph buildResumeGraph(); const result await graph.stream( { rawResumeText: resumeText, jobDescription }, { streamMode: values, configurable: { runId }, // 传入 runId 用于追踪与恢复 }, ); for await (const event of result) { // event 的结构是 { nodeName?, output }具体取决于 graph.stream 模式 send(agent_event, event); } send(done, { ok: true }); controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }这里有个大坑SSE 流不能挂在 Vercel Serverless 函数的默认同步响应上也不能超过平台的执行超时时间。如果你是纯 Node 自托管或者用 Next.js 的自定义服务器那没问题。但如果部署在 Vercel函数最长执行时间在那摆着长任务很容易被掐断。后来我做了混合方案短任务走 SSE 实时流式长任务如整份简历深度改写走“提交任务 Webhook/轮询”模式。交互上略有割裂但至少不会死在超时上。4. 连接前后端Next.js 里让 Agent 状态与会话无缝衔接图编排只是服务端内部的事前端真正关心的是我发起一个请求能不能实时看到进度、能不能在中间打断、刷新页面之后状态还在不在。接下来是我在 Next.js 集成层做的事情。4.1 短任务走 SSE长任务走任务队列典型接口设计我实际暴露的接口只有四个覆盖整个交互闭环接口方法用途数据流模式/api/agent/analyzePOST接收简历文本和 JD启动解析分析流程SSE 流式/api/agent/rewritePOST基于分析结果启动改写SSE 流式/api/agent/eventsGET长任务模式下轮询获取进度轮询/api/agent/threads/[id]GET/PATCH读取/暂停某个 Agent 会话的状态普通 JSON这里面值得展开的是rewrite接口如何处理“长任务”。整份简历改写一般要跑 40 秒以上而且中途可能被突发流量拖慢。我的方案是把它拆成一个BullMQ任务LangGraph 的stream结果实时写入 Redis前端每 3 秒拉一次任务进度。这不是最优解可能有人会用 WebSocket 双通道但对简历编辑这种低频高延迟场景轮询已经完全够用。4.2 状态持久化用 Checkpointer 实现“刷新之后还能继续改”LangGraph.js 的另一个杀手级能力就是状态检查点。每次图节点执行完框架会把当前状态序列化保存到检查点存储里。内置支持里有内存型的MemorySaver也有适配 Postgres 的PostgresSaver。简历工具这种需要真实会话延续的场景必须直接上 PostgresSaver。我当时的实现大致是import { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer PostgresSaver.fromConnString(process.env.DATABASE_URL!); await checkpointer.setup(); // 建表初始化 const graph workflow.compile({ checkpointer }); // 每次请求都带上 agentThreadId const config { configurable: { thread_id: agentThreadId }, }; // 下一次调用 graph.stream 时传同一个 config // LangGraph 会自动恢复上一次执行完的状态。这个机制一旦接入前端体验就能顺势升级用户干了 20 分钟的活刷新页面之后对话上下文还在图执行到一半如果分页接口报错了重新invoke时可以接着跑而不是从零开始。简历工具里最常见的一个反人类流程——“分析完匹配度页面刷新了匹配报告没了”——就是靠检查点解决的。BUT这里我要提个醒不要把所有对话历史都塞进状态里。我一开始把每次改写的 prompt 和中间结果都放在 channel 里结果状态对象膨胀到几百 KBPostgres 表都快装不下了。正确做法是只把必要的结构化字段作为 channel把完整对话历史放在另一个消息表里图的状态只存“当前版本简历”和“当前执行进度”。4.3 Server Action 和 API Route 怎么选很多 Next.js 教程里都在讨论到底用 Server Action 还是 API Route。我的简化决策标准是如果操作是“用户的主动编辑动作”比如提交简历文本、修改某个 section、确认重写计划用Server Action。因为它能直接操作服务端资源不需要额外暴露 HTTP 接口语义。如果操作是“客户端需要流式接收的”比如 SSE 分析、长轮询进度用API Route。因为流式输出需要直接控制 Response 对象Server Action 在这点上是先天的劣势。所以我的实际代码里两种都用rewriteSection的提交动作是 Server Actionanalyze的流式连接是 API Route。5. 并发与成本AI Agent 在真实流量下的保命手段热搜词里很多人在搜“ai agent 怎么扛并发”。简历工具虽然没有社交产品那么夸张的突发流量但一旦你在某个招聘季做了推广流量也是有峰值的。而我代码里最担心的反而不是 CPU 或内存而是LLM API 的慢调用和高成本。这两个问题要在代码层面提前设计。5.1 单 Agent 进程的并发瓶颈慢 LLM 与队列一个 LangGraph 实例跑一次完整简历分析可能要调用 4-5 次 LLM单次 LLM 响应 3-5 秒。如果不做控制100 个并发用户直接就把 LLM 提供方的配额打爆而且出错的请求会大量堆积。我的解法是给 LLM 调用层加了一个带信号量的受控执行器class LLMConcurrencyLimiter { private active 0; private queue: (() void)[] []; private maxConcurrency 8; // 根据 API 配额/预算调整 async runT(task: () PromiseT): PromiseT { if (this.active this.maxConcurrency) { await new Promisevoid((resolve) this.queue.push(resolve)); } this.active; try { return await task(); } finally { this.active--; this.queue.shift()?.(); } } }配合 LangChain 的Runnable层也可以用withRetry做退避重试但信号量这层是必要的——否则某个瞬间来的 30 个并发请求会同时打向 API后端的重试逻辑根本来不及兜底。5.2 分流策略短请求直跑重请求进队列我最终把请求分成了两档第一档解析匹配分析。这类请求需要的 LLM 调用次数少2-3 次单个响应时间短直接在当前 Node 进程内跑。第二档完整深度改写。需要循环执行多个 rewrite 子任务LLM 调用次数可能达到 8-10 次消耗的 token 和运行时间都很大。这一类走 BullMQ 队列Worker 进程并发度控制得比第一档更保守。这样分的原因很简单深度改写是弹性的用户等 30 秒和等 50 秒对满意度影响不大但它发生的频率很高每个用户都会用而匹配分析是用户第一印象的关键路径必须让它尽可能快。两类流量互相挤占谁都跑不快所以必须物理隔离。5.3 缓存策略同一份简历不要反复烧钱我最初的实现里没有缓存结果两个星期烧掉了大几百美元其中很大一部分是用户在测试阶段反复点击“分析”按钮造成的——每次点击后端都会重新解析简历、重新请求 LLM。后来加了一个超简单的缓存对简历文本和 JD 文本做哈希拼接作为 key。同一 key 且用户不同时命中缓存直接返回上一次的分析结果结果存在数据库里。如果用户明确点击“重新分析”则强制穿透缓存。这个缓存上线后成本下降了约 40%。唯一的业务代价是如果用户修改了简历文本哈希变了自然就不会命中如果是完全相同的文本返回缓存结果没有任何实用损失。我强烈建议所有做 LLM 应用的人把这个缓存机制尽早加上——LLM API 的价格再降也经不起无意义的重复调用。5.4 Token 成本审计每个节点到底花了多少钱另一个控制成本的做法是给每个节点记录 token 消耗。LangGraph 节点的config里可以拿到langchain的调用元数据但我的做法更粗暴在每个节点函数里统一记录model.getNumTokens()并把节点名、输入文本长度、输出 token 数、耗时写入日志平台。这样每天看报表时你能知道是解析节点最贵还是改写节点最贵某个用户的超长简历是不是在执行时产生了异常大的 token 消耗。这类监控在早期可以很简单直接打印到结构化日志里就行但一定要有。没有成本观测的 AI 应用上线后就是一只吞金兽。6. 从 Demo 到生产部署、观测与那些必须提前知道的事最后一部分讲落地。很多人开发时跑得飞快一上生产就各种翻车大多是因为“开发环境和生产环境的差距被低估了”。6.1 部署拓扑与 env 特例我的部署栈是Next.js 应用跑在自托管 Node 服务上也有 BTB 服务商Postgres 存储业务数据和 LangGraph 检查点Redis 承担队列任务和缓存。三个独立的资源缺一不可。如果你用的是 Vercel有两个特殊点要提前规划Serverless 环境下 LangGraph 的 Checkpointer 要放 Postgres不能用 MemorySaver因为每个请求可能落在不同实例上。SSE 长连接不适合挂在 Serverless 上。Vercel Pro 的 streaming 有软超时限制大文件或长时长的图执行很可能中断。我的做法是用 Next.js 自托管跑核心 Agent 接口Vercel 只承载静态页面和常规 CRUD。6.2 每个 Agent 运行都必须可观测Debug 一个多节点 Agent最怕的是“它跑了但不知道跑到哪一步出错”。我做了三件事每个请求生成一个全局唯一的runId作为日志的 correlation ID从请求进入开始贯穿到 LangGraph 的每个节点。在 LangGraph 的on_chain_start/on_chain_end事件里把nodeName、输入字段摘要、输出字段摘要、耗时、token 数全部打日志。建一个agent_runs表记录每次完整运行的所有步骤前端界面里也能看到“本次运行链路”。没有这套观测体系之前用户反馈“分析结果很慢”都没法查是哪个节点拖慢了整体耗时。加上之后我一眼就能看到解析节点平均耗时 12 秒、改写爆了 token 数优化目标变得非常清晰。6.3 避坑清单我从这个项目里沉淀出的七个教训最后分享几个我当时没有尽早知道、后来付出过学费的点第一LangGraph 的版本升级很不讲武德。从 0.x 到 1.xStateGraph的构造函数写法、checkpointer的导入路径都有过不兼容调整。建议从第一天就把依赖版本锁死升级前先看 changelog。第二Structured Output 的 Zod schema 必须覆盖所有可能出现但你想忽略的字段否则校验失败会直接让节点抛错。我用z.object(...).passthrough()兜底过一段时间后来发现宽松校验会掩盖模型输出的结构性问题反而更难 debug。最终方案是schema 严格但解析失败的节点要有降级逻辑比如返回null然后从其他路径重试。第三条件边的返回必须是string[]不是string。虽然单返回也能用但如果你未来要并行发散到多个节点数组类型才是标准契约建议一开始就统一写数组。第四Postgres 存的检查点表不要和业务表放在同一事务里。LangGraph 写检查点有自己的事务逻辑和业务事务混用会有死锁风险。我一个线上版本就是在这里出了问题后来把检查点表和业务数据彻底分开不同 schema 或直接不同库才消停。第五LLM 的temperature不是越低越好但在简历场景里解析和匹配建议用 0-0.2改写可以在 0.3-0.5风格生成可以到 0.7。全都用一个温度输出会显得非常呆板用户会觉得“AI 味太重”。第六对话历史要截断不要无限累积。多轮修改之后历史里可能有很多用户反悔的段落。如果全塞进上下文不仅是成本问题模型会被早期信息误导。我后来对“当前版本简历”有个强制约定每一轮修改完之前的修改历史就折叠成一条摘要而不是保留完整原文。第七超时和重试必须分开配置。LLM 调用超时设为 30 秒重试 2 次但整个图流程的执行超时是 120 秒。之前把两者混用一个超时时间结果 LLM 重试还没跑完图流程先超时了用户看到“操作失败”但后台还在烧 token。我个人实际操作下来最深的一个体会是LangGraph.js 的价值不在代码本身而在于它逼着你把 Agent 的任务过程当成一个可描述、可中断、可恢复的系统来设计而不是随手写一坨调用。简历工具这类应用用户要的不是“AI 替我写一个东西”而是“AI 陪我把一个东西改到满意”——这恰恰就是 StateGraph 最擅长表达的那类模式。等你把这样的流程跑顺了回头再看别的 Agent 应用会发现很多场景都能套用这套图和节点的方法论。