
直接说结论我用Next.js和LangGraph.js做了一个简历优化工具内置一个真正意义上会规划-调用工具-反思-重写的AI Agent智能体。它不只是接一次大模型API把简历文本丢进去润色而是把这个过程拆成了解析、ATS评分、改进建议、重写成稿四个阶段由Agent在状态图里自主流转。这篇文章会把整个项目的架构思路、状态设计、核心代码和上线后踩过的坑完整记录下来如果你正打算做类似的AI Agent应用可以直接照着我这套流程落地。先交代一下背景。最近AI Agent这个词火得不行各种框架层出不穷。但你真要去搭一个能实际干活儿的Agent会发现市面上讲概念的帖子一大堆讲怎么落地的少得可怜。我选LangGraph.js就是因为它把Agent的本质——有状态的图编排——做得足够清晰而且和TypeScript生态天然契合。配合Next.js做全栈应用前后端一把梭。下面我把这个项目的完整拆解过程分享出来。1. 为什么用LangGraph.js搭简历Agent架构选型的真实考量1.1 Agent和一次普通AI调用差在哪里先把这个最基础但也最容易被绕晕的问题讲清楚。很多人以为接了大模型API就算做了Agent实际上那只是调了个接口。真正的Agent核心是能自主决策调用哪些工具、以什么顺序执行、在什么条件下中止或循环。打个比方普通AI调用就像一个只会背书的实习生你问一句他答一句换一种问法他就卡壳而Agent更像一个会查资料、做笔记、列提纲、反复修改的项目助理他会自己拆解任务遇到不会的地方去翻参考资料做完了还会检查一遍。具体到这个简历项目里普通调用做出来的工具大概长这样把一段简历文本发给GPT让它输出优化后的版本完事。问题很明显不同格式的简历Markdown、PDF提取文本、纯文本处理的策略根本不一样一个prompt套所有场景效果很差。简历优化这事儿是分步骤的先要结构化解析再评估哪里弱最后才能针对性重写。一步到位会让模型漏掉关键信息。纯文本的prompt调用没有状态这个概念无法在中间步骤里插入规则校验、人工确认或外部工具。Agent方案就完全不同了。我把整个任务定义成一张有向图解析简历 - 提取结构化信息 - ATS关键词评分 - 生成优化建议 - 重写简历其中评分不达标的时候还会走一个强化修改的循环分支。每个节点是一个独立函数节点之间通过共享状态通信。LangGraph.js负责整个图的运行、状态传递和条件跳转。1.2 方案选型Next.js LangGraph.js vs 其他组合工具选型这块我纠结过一段时间试过好几条路线最后锁定了Next.js LangGraph.js。简单说一下对比过程。我先用纯Python方案搭了个原型FastAPI做后端LangGraphPython版做编排LangChain做模型封装前端随便写了个React页面。开发体验很好LangGraph Python版的生态相对成熟各种教程也多。但问题出在部署和协作上——项目组里都是前端背景让他们为一个小工具单独维护一个Python服务还得管虚拟环境、依赖版本心智负担太重了。然后又试了纯Next.js方案所有逻辑都写在API Route里硬编码调用大模型。代码倒是能跑但问题更大一旦业务流程要加分支比如评分低走强化修改评分高直接结束就得在路由里写一堆if-else加上并发状态管理代码很快变成一坨。最后确定用Next.js LangGraph.js理由有三点TypeScript全栈统一前端、后端、Agent编排逻辑全用一种语言类型定义还能共享简历解析结果、评分结构这些TypeScript接口前后端通用少了很多沟通成本。LangGraph.js和Next.js的App Router配合非常自然Agent的无状态/有状态调用可以封装在后端的Route Handler里前端通过Server Action或普通fetch触发流式输出用ReadableStream直接对接链路短、调试方便。状态管理能力是硬需求简历处理这种多步骤、有条件分支的场景用图来编排比用手写流程控制要清晰得多。LangGraph.js提供内置的Checkpoint机制MemorySaver还支持人工介入节点对未来加用户确认修改这类交互非常友好。方案开发效率部署复杂度Agent状态管理团队门槛FastAPI LangGraph(Python)高高双服务强需要Python背景纯Next.js 手写流程低低弱全手写低Next.js LangGraph.js高低单服务强只需TS背景选型这件事说到底是在开发效率和业务复杂度之间找平衡。如果你的Agent业务是简单的一问一答用什么框架都一样但凡业务里出现多步骤流转、条件分支、需要记忆上下文图编排的优势立刻体现出来。2. 简历Agent的状态设计与核心流程拆解2.1 状态机思维让AI按步骤干活的关键LangGraph.js的核心概念其实不多理解了StateGraph、Annotation、节点Node、边Edge这四样东西基本就入门了。它的运行机制可以理解成一张流程图。你把整个任务拆成若干个节点每个节点是一个普通异步函数节点之间用边连接边可以是有条件的——满足某个条件就走A分支否则走B分支。所有节点共享一个全局状态对象每个节点从状态里读取自己需要的数据执行完把计算结果更新回状态里下一个节点接着用。这里最反直觉、也最重要的设计是状态更新不是覆盖而是合并。LangGraph.js的Annotation可以指定reducer默认行为是节点返回值直接覆盖同名状态字段。但如果你定义了一个Annotationstring[]并配置了{ reducer: (x, y) x.concat(y) }那每次节点返回值都会追加到数组里而不是替换。这个特性在做多轮迭代修改的Agent里特别有用可以天然保存每一步的历史记录。简历Agent的状态我设计了五个字段const ResumeState Annotation.Root({ // 原始输入 resumeText: Annotationstring, // PDF/文本解析后的干净文本 cleanText: Annotationstring, // 结构化抽取结果 parsedResume: Annotation{ basicInfo: Recordstring, string; workHistory: ArrayRecordstring, string; skills: string[]; projects: ArrayRecordstring, string; }, // ATS评分结果 atsScore: Annotation{ total: number; breakdown: Recordstring, number; matchedKeywords: string[]; missingKeywords: string[]; }, // 优化建议列表 suggestions: Annotationstring[], // 最终重写后的简历 finalResume: Annotationstring, });这套设计的目标是让每个节点只做一件事状态定义了节点之间的接口协议。比如ATS评分节点不需要关心原始简历长什么样它只读parsedResume里的skills和workHistory字段算完把atsScore写回状态后面生成建议的节点自然能拿到。2.2 四段式工作流从原始简历到成稿这个Agent的图结构我设计成这个样子const graph new StateGraph(ResumeState) .addNode(resume_parser, parseResumeNode) .addNode(ats_scorer, atsScorerNode) .addNode(suggestion_generator, suggestionGeneratorNode) .addNode(resume_rewriter, resumeRewriterNode) .addNode(quality_check, qualityCheckNode) .addEdge(START, resume_parser) .addEdge(resume_parser, ats_scorer) .addEdge(ats_scorer, suggestion_generator) .addEdge(suggestion_generator, resume_rewriter) .addEdge(resume_rewriter, quality_check) .addConditionalEdges(quality_check, (state) { const score state.atsScore?.total ?? 0; if (score 85) { return suggestion_generator; // 质量不达标重新生成建议并再次重写 } return END; }) .compile();简单解释一下这个流程resume_parser简历解析节点接收用户输入的原始文本先用正则和自然语言处理做粗清洗去掉多余换行、格式化乱码再调用大模型做结构化信息抽取把工作经历、技能列表、教育背景等拆成字段。这一步是为后续评分打基础模型直接读长文本容易丢信息拆成字段后每部分都能精准分析。ats_scorerATS评分节点读取解析后的结构化数据用一套预设的关键词库不同岗位方向有不同词库比如前端岗会匹配React、Vue、TypeScript、性能优化等做匹配打分同时结合大模型对简历文字表达质量做评估输出总分和分项评分。这里没让模型直接输出1-100分而是规则打分为主、模型语义评分为辅。因为模型直接打分不稳定同样一份简历换温度参数能差出20分而规则部分可复现、可解释。suggestion_generator建议生成节点拿着ATS评分结果和解析出的简历结构化数据让大模型生成针对性的优化建议。这个节点的prompt我会把当前简历内容和评分明细一起放进去比如技能项缺少XX关键词工作经历描述过于流水账缺少量化成果模型给出的建议会非常具体。resume_rewriter简历重写节点根据建议逐段重写简历这一步对模型输出格式有强约束——必须保持JSON结构每个section单独成块方便前端做分栏预览。quality_check质量检查节点重写完后做个快速复检看是否解决了建议里提到的问题。如果评分仍然偏低就回到suggestion_generator再来一轮。这个循环设计是Agent区别于普通流水线的标志性特征——能自我反思、迭代优化。我当时测试的时候发现有相当比例的低分简历走一轮循环之后评分能提高10-15分。当然这个循环不是无限的我在条件边里用了评分小于85这个阈值并且在实际部署时加了一个最大轮数限制超过3轮强制结束防止模型无限循环把token烧光。2.3 工具注册与模型选型LangGraph.js的Agent通常会和工具调用function calling配合使用。这个项目里我用到了两个外部工具都注册在对应节点内部// Suggestion节点里用到的检索工具 const jdSearchTool new Tool({ name: jd_search, description: 根据岗位关键词搜索相关的职位描述用于分析当前岗位需要什么技能, func: async (query: string) { // 实际项目里这里可以接招聘网站的搜索API return searchJdFromCache(query); }, });再说模型选型。我推荐至少选一个支持工具调用的模型GPT-4o、Claude Sonnet、或国产的Qwen、GLM都行并且要能输出结构化JSON。在LangGraph.js里ChatOpenAI、ChatAnthropic这些封装类都原生支持绑工具模型输出会按你声明的JSON Schema生成节点里拿到结果直接用就行省了解析脏JSON的麻烦。实际开发中还遇到过一个问题大模型在重写简历的时候容易自作主张编造经历。我在重写节点的系统提示词里做了强约束——只允许对已有内容做表达层面的润色和强调不允许新增任何工作经历、项目、教育背景条目并且在后置逻辑里用字符串比对校验新增了哪些li标签或章节。这算是简历场景特有的一个安全边界其他领域的Agent如果有类似不能无中生有的诉求可以参考这个做法。3. 完整落地实操从后端图到前端交互3.1 项目目录结构与初始化我用的Next.js 14 App Router项目结构大概长这样my-resume-agent/ ├── app/ │ ├── api/ │ │ ├── agent/ │ │ │ ├── route.ts # Agent编排的HTTP入口 │ │ │ └── stream.ts # 流式SSE接口 │ │ └── analyze/ │ │ └── route.ts # 独立分析接口非流式 │ ├── dashboard/ │ │ └── page.tsx # 主交互页面 │ └── page.tsx # 引导页 ├── lib/ │ ├── agent/ │ │ ├── graph.ts # LangGraph.js图定义 │ │ ├── nodes.ts # 节点实现 │ │ ├── state.ts # 状态类型定义 │ │ ├── prompts.ts # 各节点提示词 │ │ ├── tools.ts # 工具注册 │ │ └── llm.ts # 模型实例化 │ └── utils/ │ └── ats.ts # ATS规则评分逻辑 ├── types/ │ └── resume.ts # 前后端共享的简历类型 └── package.json初始化命令没什么特别的create-next-app一把梭然后安装langchain/langgraph和langchain/openai或者对应模型平台的SDK。注意LangGraph.js的版本更新很快我建议锁定一个版本再开发别追最新版。3.2 核心实现LangGraph.js节点与图编排代码先说LLM实例化。我封装在lib/agent/llm.ts里方便统一管理模型参数import { ChatOpenAI } from langchain/openai; export const llm new ChatOpenAI({ model: gpt-4o, temperature: 0.4, // 简历优化场景温度调低减少幻觉 apiKey: process.env.OPENAI_API_KEY, }); export const structuredLLM llm.withStructuredOutput(ResumeSchema, { name: resume_output, });再上节点实现。每个节点就是一个(state) PromisePartialtypeof ResumeState.State类型的函数。第一个节点做解析export async function parseResumeNode(state: typeof ResumeState.State) { const { resumeText } state; const cleanText preprocessResumeText(resumeText); // 正则清洗 const parsed await structuredLLM.invoke([ { role: system, content: 你是资深HR助理从简历文本中抽取结构化信息字段缺失时使用空字符串占位。, }, { role: user, content: cleanText }, ]); return { cleanText, parsedResume: parsed }; }评分节点比较关键我把规则和模型结合了一下export async function atsScorerNode(state: typeof ResumeState.State) { const { parsedResume } state; // 1. 规则部分关键词匹配 const jobKeywords getKeywordsForRole(parsedResume.basicInfo.targetRole ?? frontend); const allText JSON.stringify(parsedResume).toLowerCase(); const matched jobKeywords.filter((k) allText.includes(k.toLowerCase())); const missing jobKeywords.filter((k) !allText.includes(k.toLowerCase())); // 2. 模型部分语义质量评分 const semanticScore await llm.invoke([ { role: system, content: 你是ATS系统专家请从『量化成果』『行为动词』『排版清晰度』三个维度给简历打分每项0-10分返回JSON。, }, { role: user, content: JSON.stringify(parsedResume) }, ]); // 3. 综合计算总分 const total calculateTotalScore(matched, missing, semanticScore); return { atsScore: { total, breakdown: { keywords: matched.length / jobKeywords.length * 100, semantic: semanticScore }, matchedKeywords: matched, missingKeywords: missing, }, }; }图编排部分用一个函数暴露给上层调用这样API Route层不需要关心图内部结构export const resumeAgentGraph graph.compile({ checkpointer: new MemorySaver() });MemorySaver是LangGraph.js内置的记忆机制它让每次调用之间可以保留历史会话状态。对于简历优化这种需要多轮对话前后文一致的场景很好用但要注意MemorySaver把状态存在内存里服务重启就没了生产环境可以换成PostgresSaver之类的持久化方案。3.3 接入Next.jsAPI Route 流式输出后端Route Handler直接调用编译好的图对象代码非常薄// app/api/agent/route.ts import { NextRequest, NextResponse } from next/server; import { resumeAgentGraph } from /lib/agent/graph; export async function POST(req: NextRequest) { const { resumeText, sessionId } await req.json(); try { const config { configurable: { thread_id: sessionId ?? default } }; const result await resumeAgentGraph.invoke({ resumeText }, config); return NextResponse.json({ success: true, data: result }); } catch (error) { console.error(Agent execution failed:, error); return NextResponse.json({ success: false, error: Agent processing failed }, { status: 500 }); } }这里用了thread_id来区分不同用户的会话状态。实际项目中用户可能会反复修改简历内容提交同一个thread_id下Agent能记住之前解析的结果和修改历史下次优化时可以做增量处理而不是从头再来。一开始我没做流式直接等整张图跑完返回结果。但实际体验非常差——整个流程要调3-4次大模型加上中间的状态处理平均耗时15到30秒用户对着一个转圈儿的loading按钮干等。后来改成SSE流式把每个节点的执行进度实时推给前端体验提升立竿见影。流式实现的核心是graph.stream()方法// app/api/agent/stream.ts import { resumeAgentGraph } from /lib/agent/graph; export async function POST(req: Request) { const { resumeText, sessionId } await req.json(); const config { configurable: { thread_id: sessionId ?? default } }; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { for await (const event of await resumeAgentGraph.stream( { resumeText }, { ...config, streamMode: updates } )) { const payload data: ${JSON.stringify(event)}\n\n; controller.enqueue(encoder.encode(payload)); } controller.enqueue(encoder.encode(data: [DONE]\n\n)); } catch (error) { controller.error(error); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }streamMode: updates这个参数很有意思它只输出每个节点更新后的状态字段而不是整张图的所有内部信息前端拿到的数据体积小很多也不用自己挑重点。前端用EventSource或fetch的流式读取都能接。3.4 前端交互设计让用户看到Agent在“干活”前端这块我用了三个核心组件。简历输入区支持粘贴文本和上传文件文件解析在客户端做PDF用pdf-parse的浏览器版本提取文本docx用mammoth。然后是进度面板它接收SSE推送的事件根据event.nodeName显示当前正在执行的环节配合一个步骤指示器——解析、评分、建议、重写、检查五个阶段按顺序展示已经完成的打勾正在执行的显示旋转动画。进度面板的代码逻辑const [progress, setProgress] useStatestring[]([]); const [finalResult, setFinalResult] useState(null); async function handleSubmit(resumeText: string) { setProgress([]); const response await fetch(/api/agent/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ resumeText }), }); const reader response.body?.getReader(); const decoder new TextDecoder(); while (reader) { const { value, done } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); const lines chunk.split(\n).filter((line) line.startsWith(data:)); for (const line of lines) { const payload line.slice(6); if (payload [DONE]) continue; const event JSON.parse(payload); // 每个节点执行完成时推进进度 setProgress((prev) [...prev, event.nodeName]); if (event.nodeName quality_check) { setFinalResult(event.state.finalResume); } } } }右侧结果区做成分栏预览左边显示优化前简历的Markdown渲染右边显示优化后的简历并标注哪里被改动了我用diff库做了文本对比新增的内容加绿色高亮删除的加红色。下方再放一个ATS评分对比卡片优化前的63分和优化后的87分放在一起用户一眼就能看到变化。再加上重新优化按钮点一下会在会话上下文基础上再跑一遍Agent流程相当于让它在已有修改基础上再做一轮迭代。4. 上线之后踩过的坑与排查实录4.1 真实部署中遇到的5个高频问题问题一模型返回JSON经常解析失败。这个问题出现得最频繁。虽然用了withStructuredOutput但长输出场景比如重写整份简历偶尔还是会出现截断或马尔可夫链式的重复内容。我的处理方案是加了JSON Schema校验和自动修复先用jsonrepair库做一次宽松解析失败就把出错片段重新喂给模型让它补全。问题二图执行超时。默认情况下LangGraph.js一个节点的执行时间完全取决于模型API的响应时间遇到模型端抖动一次调用可能拖到60秒。我做了两层防护模型实例上设置maxRetries和timeout同时在整个图的入口包了一层Promise.race超过45秒直接降级为部分成功响应把已经生成的内容返回给用户。问题三MemorySaver导致的状态冲突。用户连点两次提交同一个thread_id下的并发请求会互相覆盖状态。排查发现LangGraph.js的invoke不是并发安全的我在Route Handler里加了一个简单的互斥锁——同一个thread_id同时只允许一个请求在执行另一个排队。问题四流式响应在前端偶发断开。后来发现是Vercel的Serverless函数默认最大执行时长限制 hobby套餐10秒pro 60秒超过时限会掐断连接。两个解决方案一是把maxDuration配置加到Route Handler里二是把整个Agent任务提交到队列异步处理前端轮询结果。小流量场景直接调大maxDuration最省事。问题五token消耗比预期大得多。一开始没控制循环轮数又开着MemorySaver一次简历优化最多能烧掉数万token。后来做了三件事给重写节点限制输出长度把循环最大轮数设为3用廉价的快模型处理清洗和初筛节点只有重写环节才用顶级模型。问题原因解决方案JSON解析失败模型输出脏JSONjsonrepair 自动重试节点超时模型API抖动设置timeout 整体降级状态冲突invoke并发不安全thread_id互斥锁流式断开Serverless执行时长限制maxDuration 队列化token超支循环失控限制轮数 分级模型4.2 跨语言生态的启发Rust Agent框架带来的一课开发过程中我顺手研究了一下Rust生态的AI Agent框架虽然最终没有用到生产环境但有些设计思路很值得借鉴。Rust语言的Agent框架在类型安全、编译期校验和内存占用方面确实有先天优势一些框架直接把Agent的图定义、工具调用和状态流转做成编译期检查的DSL代码写错根本编译不过去。这个思路启发我在LangGraph.js项目里加强了运行时校验。具体做法是在状态字段的getter里加入运行时断言所有节点返回值都要过一遍Zod schema校验。效果很明显有一次一个节点返回了缺失的字段配套的结构化输出校验立刻抛错避免了状态污染传递到后面的节点。如果你在选型阶段可以考虑这样判断团队全栈以TS为主、业务迭代快、部署环境是Serverless选LangGraph.js没毛病如果你是系统级工程师追求极致性能和类型安全或者Agent要嵌入到高性能网络服务里Rust生态的Agent框架值得研究。但前提是你的团队能接受Rust的陡峭学习曲线。4.3 关于Agent开发路径的个人经验总结这个项目做完我最大的感悟是AI Agent开发本质上还是工程问题AI只是其中一个模块。很多人一上来就研究各种花哨的Agent框架、提示工程技巧却忽略了最基础的状态设计、异常处理、安全和成本控制。没有良好的状态管理和工程化再聪明的Agent也会在生产环境里翻车。对想入坑Agent开发的朋友我建议的学习路线是这样的第一步先用LangChain或直接调API做一个简单的工具调用demo理解function calling的底层机制第二步学习LangGraph.js的状态图概念手动把输入-处理-输出改造成多节点流程图第三步给Agent加工具、加条件分支、加自我反思循环第四步认真处理并发、超时、成本、安全这些问题。走到这一步你已经能独立做Agent应用了。最后分享两个项目上线后特别有用的实践。一是日志里一定要记录每个节点的执行时间和tokens消耗这是后续调优的基础数据二是Agent的prompt一定要做版本管理和代码一起走Git提交我前一阵改了一个节点的prompt导致评分逻辑混乱就是因为没有版本回退能力排查花了一个下午。这俩算是用时间和教训换来的经验希望对正在做Agent的你有些帮助。