
前阵子我把一个维护了半年的简历分析脚本彻底推翻用 LangGraph.js 重构成了一个真正的 AI Agent 工作流前端同步换成了 Next.js做成了一个可以交互、可以看见中间过程、可以在关键节点停下来让用户确认的简历工具。这个项目前后花了三天过程中踩了不少坑。今天就以这个实际项目为线索聊聊在 JS/TS 生态里落地一个 Agent 应用时最容易被忽视的那些设计和工程问题。先说背景。我最初做的是一个简历评分器用户贴一段简历文本我再贴一段职位描述脚本调一次大模型返回一个匹配分数和几条优化建议。这个版本上线后用户反馈还行但维护起来非常痛苦任何一点需求变化都要去改那条早已膨胀到上千字的提示词而且大模型偶尔会漏字段、输出格式不稳定完全无法控制中间步骤。这也是我决定引入 LangGraph.js 的初衷把一次性问答变成可编排、可中断、可人工介入的工作流。这篇东西不适合零基础读者。如果你想看 Next.js 怎么写页面、简历工具怎么调 API那可能走错地方了。我默认你已经写过大模型应用能用 TypeScript听说过 LangChain 但没用过 LangGraph。我会从架构选型讲到节点实现再讲到和 Next.js 集成时踩过的各种问题最后给几个可以继续优化的方向。1. 为什么要拆成图我原来的简历评分脚本哪里不够用1.1 单次 Prompt 的失控表现旧版脚本的逻辑非常简单读文件、拼提示词、调模型、解析 JSON、渲染列表。第一版很顺因为需求只有两个给简历打一个匹配分列出三个改进建议。问题出现在第三个需求出现之后用户要求把简历中的技能按 JD 里的级别要求逐项比对。我开始在提示词里追加对于每一项技能判断是缺失、初级、中级还是高级接着又有人要给出一个可以放进简历里的完善版项目描述于是提示词越来越大。提示词变大并不可怕可怕的是输出开始不稳定。模型经常在生成了完整 JSON 后又多写了一段 Markdown 文字导致 JSON.parse 直接失败有时又漏掉技能级别这个字段我只能用正则去猜。每次失败我都要加一段修复逻辑这些修复逻辑本身又在提示词里占空间。到后期我完全不知道一次调用里模型到底经历了什么也不可能让用户在某个中间节点说这一步算了换个方向。本质上我做的还是一个黑盒问答而不是一个 Agent。1.2 LangGraph.js 的定位状态图比多轮对话更接近业务我后来去研究 LangGraph.js发现它解决的恰好是这个问题。它将一个复杂任务拆成节点node节点之间通过状态state传递数据用边edge定义流转关系还支持条件分支、循环、人工中断和恢复。用一句大白话讲它让你能把业务流程图直接映射成代码而不是把所有逻辑都压在模型一次输出里。对比 LangChain.jsLangGraph.js 更关注状态和时间线LangChain 则侧重组件和调用链。如果你只是做一个普通的 RAGLangChain.js 的 chain 足够了。但我的简历工具天然是一个多阶段流程解析原始文本提取结构化字段对照 JD 分析生成建议每一步之间还有可能出现信息不够需要重新解析的分支。这种结构用图来表达远比用一个 prompt 硬撑清晰。1.3 为什么用 Next.js 做宿主而不是 Express既然有了 LangGraph.js 作为 Agent 引擎外层还需要一个 Web 服务。我第一反应是 Express但很快放弃。简历工具的前端需要展示 Agent 运行的中间状态比如当前执行到哪个节点、匹配分是如何算出来的、建议是基于哪一段理由生成的。这些需要后端不断推送事件Next.js 的 Route Handler 天然支持流式 Response配合 React 的渲染模型非常顺。另外Next.js 可以把 API 和前端放在同一个项目里部署简历工具这种中小型产品根本不需要拆微服务。我最后用的是一个 Next.js App Router 项目所有 Agent 逻辑放在src/agent/文件夹里页面和路由组件放在app/下一次部署就能跑。这一点对独立开发和快速验证特别重要。2. 节点设计与状态编排简历 Agent 的器官图2.1 先画业务流程再写代码我做的第一件事不是写代码而是把简历工具的完整流程画出来。最终定下的主流程是接收简历原文和职位描述节点 A简历解析提取姓名、工作年限、技能列表、项目经历、教育背景节点 BJD 分析提取职位关键词、硬性要求、级别要求节点 C匹配比对计算技能覆盖度、年限达标度、项目经验相关度节点 D生成优化建议包括具体改写例句分支判断如果解析出的简历字段过少回到节点 A 要求补充信息人工确认节点在生成最终报告前允许用户确认或修改建议流程图看起来并不复杂但它决定了整个代码结构。LangGraph.js 的核心抽象就三个状态、节点、边。我先用 TypeScript 定义状态然后逐个写节点函数最后把节点和边连接成图。这样每一步都能单独测试不再是一次性的大模型调用。2.2 状态的类型设计决定了扩展难度状态是节点之间传递的数据结构。在 LangGraph.js 里状态对象的值可以定义 reducer每个节点返回的数据会通过 reducer 合并到全局状态里。我一开始没有认真设计状态所有字段都用any结果在写第三个节点时频繁出错。重写后我定义了如下结构简化版type AgentState { resumeText: string; jdText: string; parsedResume?: ParsedResume; jdAnalysis?: JDAnalysis; matchReport?: MatchReport; suggestions?: Suggestion[]; statusMessage: string; errors: string[]; }; type ParsedResume { name?: string; yearsOfExperience?: number; skills: string[]; projects: { title: string; description: string }[]; education?: string; }; type JDAnalysis { requiredSkills: string[]; preferredSkills: string[]; minYears?: number; level?: string; }; type MatchReport { overallScore: number; matchedSkills: string[]; missingSkills: string[]; experienceScore: number; }; type Suggestion { section: string; original: string; suggested: string; reason: string; };这里最关键一点不要把所有状态都做成必填。用可选字段表达这个节点还没跑到的状态配合默认值避免节点之间互相踩。在 LangGraph.js 里我这样声明状态 Schemaimport { StateGraph } from langchain/langgraph; const stateConfig { resumeText: { value: (a: string, b?: string) b ?? a, default: () }, jdText: { value: (a: string, b?: string) b ?? a, default: () }, parsedResume: { value: (a?: ParsedResume, b?: ParsedResume) b ?? a, default: () undefined }, errors: { value: (a: string[], b?: string[]) [...a, ...(b ?? [])], default: () [] as string[] }, };不要把 reducer 写成a b这种字符串拼接除非你想让节点每次把历史叠加成超长字符串。一般我会遵循新值覆盖旧值或数组追加两种模式前者用于单项数据后者用于日志和错误收集。2.3 节点实现解析、分析、匹配、建议每个节点函数签名都一样接收整个状态对象返回一个状态补丁partial state。这是 LangGraph.js 最有价值的设计之一节点之间完全解耦每个节点只关心自己需要的字段。简历解析节点我用了一个混合策略先用正则和启发式规则尝试抽取邮箱、电话、公司名等稳定字段再把整段文本交给大模型提取技能和项目经历。这样既控制了成本也避免模型在简单字段上犯错。async function parseResumeNode(state: AgentState): PromisePartialAgentState { const { resumeText } state; const basicInfo extractBasicInfoWithRegex(resumeText); const modelExtraction await resumeModel.invoke([ { role: system, content: RESUME_PARSE_PROMPT }, { role: human, content: resumeText }, ]); const parsed normalizeExtraction(modelExtraction, basicInfo); return { parsedResume: parsed, statusMessage: 简历解析完成 }; }这里我用了normalizeExtraction做一层兜底模型返回的字段可能缺失我就用正则结果补模型返回的技能里如果混入了精通、熟悉这种修饰词则在 normalize 阶段清理。可以把normalizeExtraction理解成模型输出后的质检员它保证后续节点拿到的数据结构是可靠的。JD 分析节点和匹配节点思路类似匹配节点是容易出错的地方。我为了让匹配过程不是简单关键词比对把模型和代码做了分工技能匹配用代码处理综合评分用模型。具体做法是把parsedResume.skills和jdAnalysis.requiredSkills分别做归一化然后交给一个纯函数计算覆盖率模型只负责对项目经历是否匹配 JD 职责做语义判断输出一个相关度等级和理由。这个设计让我后续调优时可以单独优化技能词表而不用每次让模型重新把所有事情做一遍。async function matchNode(state: AgentState): PromisePartialAgentState { const missing diffSkills( state.jdAnalysis!.requiredSkills, state.parsedResume!.skills ); const matched intersectSkills( state.jdAnalysis!.requiredSkills, state.parsedResume!.skills ); const score calcScore(missing, matched, state.parsedResume!.yearsOfExperience); const semanticReport await judgeProjectRelevance(state); return { matchReport: { overallScore: score, missingSkills: missing, matchedSkills: matched, experienceScore: semanticReport.score }, statusMessage: 匹配分析完成, }; }2.4 条件边和人工中断让 Agent 像人一样决定下一步图结构里真正体现 Agent 智能的是条件边conditional edge。我在简历工具里用了两个条件判断第一个如果解析节点抽取到的技能数量少于 3 个或者文本长度低于某个阈值我会让 Agent 走一条补充信息分支直接返回一个问题给用户而不是继续往下跑。状态图允许你在某个节点后接一个判断器判断器的输入是当前状态输出是下一个节点的名字。function shouldRequestMoreInfo(state: AgentState) { return state.parsedResume state.parsedResume.skills.length 3 ? requestClarification : analyzeJd; }第二个在生成最终建议之前我会插入一个人工确认节点。这里我用的是 LangGraph.js 的interrupt机制。这个机制允许在图执行的某个节点停下来把控制权交还给外部程序等待用户输入后再从停下的地方继续执行。用自然语言解释就是一个 Agent 在执行到一半时会卡住等你首肯再往下走。import { interrupt } from langchain/langgraph; async function reviewSuggestionsNode(state: AgentState) { const confirmed await interrupt({ type: review, suggestions: state.suggestions, }); return { suggestions: confirmed.suggestions ?? state.suggestions }; }这个设计的实际价值是用户可以对每一条建议说这不行换个说法而不是只能默默接受模型输出。对简历这种高度个人化的内容人工确认是刚需。2.5 工具调用Agent 不只是说话LangGraph.js 还支持在节点内调用外部工具。我的简历工具里有一个节点叫enhanceResume它会把用户选定的建议应用到原始简历文本中生成一份新简历。这个操作其实不适合直接由模型修改文本因为格式容易坏。我的做法是用代码实现一个工具传入原始文本和要替换的片段返回替换后的完整文本。节点首先调模型判断应该替换哪一段再调用工具执行替换最后再调模型检查一遍格式是否完好。这就是 Agent 和纯 LLM 应用的本质区别。模型在这里扮演决策者工具扮演执行者。LangGraph.js 提供了注册工具并让模型决定是否调用的能力如果你熟悉 OpenAI 的 function calling会发现思想是相同的但 LangGraph.js 把工具调用放进了图的节点中你可以精确控制它在流程中的哪个位置出现而不是模型随性调。3. 基于 Next.js 的落地方式从 Node 脚本到可交互 Web 应用3.1 把 Agent 包成一个 API 路由整个 Agent 逻辑写好之后我需要把它暴露给前端。在 Next.js App Router 里我建了一个app/api/agent/route.ts这个路由接收 POST 请求请求体包含resumeText和jdText然后在服务端构建并执行图。import { NextRequest } from next/server; import { buildResumeAgent } from /agent/graph; export const runtime nodejs; export const maxDuration 120; export async function POST(req: NextRequest) { const { resumeText, jdText } await req.json(); const graph buildResumeAgent(); const events await graph.stream( { resumeText, jdText }, { recursionLimit: 15 } ); // ... 转换成 SSE }这里有几件容易被忽略的事。export const runtime nodejs很重要因为 LangGraph.js 依赖一些 Node 原生能力不能跑在 Edge Runtime 上。maxDuration用来设置函数超时时间简历解析加多次模型调用可能超过默认的 10 秒限制。我在本地开发时经常因为忘了设置这个参数而看到网关超时。3.2 用 SSE 推送中间状态用户体验是整个项目让我最满意的地方。前端不是傻等一个最终结果而是实时显示 Agent 当前在做什么先看到正在解析简历然后看到正在分析 JD接着看到正在进行匹配最后才弹出报告。这需要后端持续向前端推送事件。我用的是原生 SSEServer-Sent Events没有引入 Socket.IO。实现方式是把graph.stream()产生的异步事件逐个编码成 SSE 格式写进 Response 的 ReadableStream 中。LangGraph.js 的 stream 默认返回的事件结构大概是{ [nodeName]: state }我对它做了一层封装只暴露type和data给前端async function* transformGraphEvents(graph, initialState) { const stream await graph.stream(initialState, { recursionLimit: 15 }); for await (const event of stream) { const nodeName Object.keys(event)[0]; const state event[nodeName]; yield ${nodeName} ${JSON.stringify({ status: state.statusMessage, data: state })}; } }然后把这个生成器优雅地接入 ReadableStreamconst encoder new TextEncoder(); const readable new ReadableStream({ async start(controller) { for await (const chunk of transformGraphEvents(graph, initialState)) { controller.enqueue(encoder.encode(data: ${chunk}\n\n)); } controller.close(); }, }); return new Response(readable, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, });前端用原生的fetch配合ReadableStream读取即可不需要额外依赖。3.3 前端状态机不只是展示数据既然后端已经是一套状态图了前端最好也有一层对应的状态管理。我用 React 的useReducer维护一个小的状态机状态包括 idle、running、interrupted、completed、error。当收到后端发来的interrupt事件时前端弹出一个确认卡片用户可以编辑建议后再提交后端会通过一个单独的/api/resume端点接收用户确认然后调用图的恢复接口继续执行。这一步是很多项目容易做坏的地方。如果前端只是把新状态全量渲染中断期间的用户输入很容易丢失。我最后选择的是所有中断数据由 LangGraph.js 的interrupt返回值接收后端在恢复执行时把这部分数据作为节点输入传回去前端不需要维护复杂缓存。export async function resumeAgent(req: NextRequest) { const { threadId, userConfirmation } await req.json(); // 通过 threadId 找到之前保存的执行状态 const result await graph.resume(threadId, userConfirmation); return Response.json(result); }3.4 前后端数据格式约定这个项目里我吃过一次亏就是让前端直接读取 Agent 内部状态导致后端一改字段前端就崩。后续我统一了 API 的输出契约后端只返回以下三种结构progress包含当前节点名和状态描述interrupted包含待确认的建议列表finished包含匹配报告、建议列表和完整优化后的简历这样 LangGraph.js 内部的 state 无论如何演变只要最终的 API 契约不变前端就不受影响。这也是我在这个项目里最重要的架构经验之一Agent 的内部数据模型和对外 API 必须分离否则图结构调整一次整个前端就要跟着返工。4. 跑在生产环境的硬核问题超时、乱序、上下文爆掉4.1 流式输出乱序第一次做 SSE 时我以为很简单结果很快就发现事件到达前端后顺序乱了。原因在于 LangGraph.js 的stream是多阶段异步的虽然整体是一个 AsyncGenerator但节点如果并行执行为了缩短耗时我让 parseResume 和 analyzeJd 并行事件到达的顺序就不是固定的。解决办法有两个。一是不要在节点里人为并行执行关键步骤保持流程线性二是给每个事件加上序号前端接收后先缓存再按序号渲染。我最后选择了第二种因为简历解析和 JD 分析并行可以省 3 到 5 秒体验提升明显。我给事件封装的transformGraphEvents里加入一个计数器前端用useReducer维护一个按序号排序的事件数组。let seq 0; for await (const event of stream) { yield JSON.stringify({ seq: seq, ...event }); }这个简单改动解决了 90% 的乱序问题。4.2 模型返回非结构化数据另一个高频问题是大模型偶尔返回的 content 里夹杂多余文本尤其是项目描述这种长文本。哪怕我在提示词里写只返回 JSON依然会有模型在 JSON 后面补一句这份简历整体很棒之类的话。我最后采用三层防御第一层提示词里给出严格的 JSON 示例并要求必须使用代码块包裹 JSON。第二层用正则从响应里提取第一个{到最后一个}之间的内容再做 JSON.parse。第三层如果解析失败把这段文本交给一个专门的修复节点让模型只看错误文本和上次输出重新生成一个干净的 JSON。加的第三层其实也用上了 LangGraph 的条件边解析失败就走 repair 子图成功才继续往下走。这个失败分支在算法里看起来微不足道但它让整个 Agent 的容错能力提升了一个量级。4.3 长简历导致上下文爆掉简历文本最长可达几千字加上 JD、解析结果、历史消息很容易超过当前模型的上下文窗口。最开始我在节点之间传递的是全文结果第二次调用时 prompt 已经塞不下。我的处理策略是分段摘要。在解析节点之前专门有一个预处理节点用模型把简历按基本信息、工作经历、项目经历、技能分段摘要每一段不超过 300 字。后续节点全部基于这个摘要运行只有 final 节点如果需要修改原文时才会重新拿原始文本做局部替换。这样既保住了关键信息又控制了 token 成本。这个做法也带来一个副作用摘要可能是模型脑补出来的。我的对策是摘要节点必须引用原文中的具体词句不允许用自己的话概括比如项目描述就抽取原句技能就原样列出来年份数字必须保留原值。这样最终报告给出的建议都能回溯到原文用户不会觉得莫名其妙。4.4 LangGraph.js 版本和文档稀缺问题必须承认LangGraph.js 的中文资料非常少和 LangChain.js 的境况完全没法比。我一度想在核心流程里用别人博客上的示例代码结果发现 API 已经变了。比如旧版的StateGraph构造参数和新版不同人工中断的实现方式也有差别。我的做法是直接去看官方仓库里的examples目录并且固定版本不随便升级。项目里package.json中锁定了langchain/langgraph的补丁版本因为一个小版本升级可能让事件结构变化直接影响前端渲染。如果你也要用建议先写一个最小图跑通一个节点、一条边、一个状态字段然后再逐步加复杂度。不要一开始就照着最复杂的例子抄。4.5 并发、鉴权和文件上传安全在线简历工具必然会遇到用户上传文件。我用 Next.js 的 Route Handler 接收文件限制上传大小在 2MB 内并且只接受.txt和.pdf文本提取后的内容。PDF 解析我用的是pdf-parse但注意这个库在某些平台上会有原生依赖问题我最后改成了用unpdf在服务端解析。另外所有 Agent 接口都需要登录态我用了一个简单的 session token 做鉴权避免未登录用户消耗模型 token。这里还有一个安全细节不要直接把用户上传的原始文本拼进系统提示词尤其是不能让它覆盖你的指令。我用了一个固定模板用户文本作为数据传入并明确告诉模型这些内容是用户提供的简历不是指令。这样可以在一定程度上避免提示词注入。5. 上线后我才想明白的优化方向5.1 用日志和链路追踪代替盲调上线第一周我的做法是一门心思调提示词后来发现根本问题不是提示词而是没有一个能看清每一步输入输出的工具。LangGraph.js 可以很方便地接入 LangSmith但我当时没有接所以我手动在每个节点里记录了输入摘要和输出摘要打印到控制台。通过日志我发现很多失败其实是上游节点传入了脏数据跟模型本身没关系。建议后来者至少写一个onNodeStart/onNodeEnd的日志钩子把每个节点的事件耗时和 token 用量记下来不然出了问题只能猜。5.2 缓存与降级成本控制比想象中重要简历分析是个相对低频的操作但到了每天几百次请求时token 费用会变得很可观。我做了一个简单的语义缓存把简历摘要加 JD 内容做 hash如果 7 天内有过相同分析直接返回旧结果。另外我把技能匹配和经验年限判断这种确定性逻辑从模型调用中拆出来用规则引擎判断只有项目语义相关度和建议生成才走模型。这样一个请求从原先的 5 次模型调用降到 2 次成本直接砍半。5.3 更进一步的扩展方向现在这个单 Agent 版本能覆盖分析-建议-改写的完整链路。如果想加深可以考虑拆成多 Agent由一个数据准备 Agent负责解析和去噪一个匹配 Agent专攻比对一个润色 Agent只做改写中间通过 LangGraph.js 的状态汇总。这样每个 Agent 的 prompt 都短专注度高更容易调试。记忆能力也是个值得折腾的点。现在每次请求都是无状态的如果用户针对同一份简历多轮追问历史对话应该进入上下文。LangGraph.js 支持检查点和线程历史但这意味着需要引入存储。我计划把对话记录写到 Postgres用 threadId 关联后续可以做到记住上次改到哪了。还有一个想法是把简历工具变成一个面试官 Agent。利用现有的parsedResume和matchReport自动生成针对弱项的追问、模拟面试题用户答完后还能给反馈。这不需要改动底层图结构在 finished 节点上扩展一个新子图就行。最后说一点个人实际体会。这个项目让我印象最深的不是 LangGraph.js 功能多强大而是它逼着我把流程想清楚。以前写大模型脚本我给一个 prompt 就完事现在必须把解析、分析、判断、确认、执行每个步骤落成节点自然就会去想每一步哪些该用模型、哪些该用代码、哪些该让人参与。简历工具的形态也因此从一个输入输出黑盒变成了一个可以让用户参与、可以控制节奏的产品。如果让我重新开始我会先用最简单的方式把一个节点的输入输出和失败分支跑通再慢慢加图千万不要一开始就搭一个所有功能的大图。这套思路在任何 Agent 项目里都适用。