
前段时间一直在折腾一件事用 Next.js 做前端LangGraph.js 做编排从零搭了一个简历优化 AI Agent。今天把整个落地过程完整梳理一遍算是给“AI Agent 练手小项目”这个话题补一份能直接抄作业的实战案例。这个项目不是那种聊天机器人 demo而是把“读简历、分析岗位、生成优化建议、输出新版简历”这一整条工作流真正跑起来。用户上传 PDF/DOCX 或粘贴文本简历输入目标岗位 JD系统会自动完成解析、差距分析、项目经历重写、技能关键词匹配最后输出一份优化后的简历和一份分析报告。整个流程由 LangGraph.js 的图状态机编排Next.js 负责页面、API 和流式输出部署之后就是一个可以直接使用的产品形态。适合谁来参考有 Next.js 基础、想深入 LangGraph.js、对 Agent 落地感兴趣但不想只看概念文章的人这篇能省掉不少弯路。我当时搜了一圈资料中文社区里 LangGraph.js 的实战内容偏少很多还是 Python 版所以自己摸索完了写出来希望对后面做同类项目的朋友有点帮助。1. 项目概述与核心思路1.1 为什么选“简历工具”作为 Agent 落地的第一刀选简历优化这个场景我是有私心的。它天然适合 Agent 而不是简单的 Prompt 套壳输入包含两份文本简历和 JD中间需要对双方做语义对齐输出既有结构化分析又有长文本改写还带明显的“任务边界”——改完简历这事就算结束不涉及无限对话。对比纯 Prompt 链式调用Agent 化的价值在于可编排、可中断、可恢复。简历解析如果失败了我可以让 Agent 走一条分支去追问用户“请补充工作年限”如果 LLM 输出格式不稳定我可以在节点里做 json 解析和重试而不是让整个任务从头再来。这些行为在 LangGraph.js 里都是显式的图结构不是一堆 if else 堆出来的面条代码。更直白地说这个场景特别适合拿来验证“AI Agent 从 0 到 1 搭建”的全部环节模型调用、状态管理、条件分支、流式输出、并发控制、成本优化。做完这一个项目其他工具型 Agent 基本都能套同一个骨架。1.2 用 LangGraph.js 而不自己写编排的理由我自己确实先写了一个轻量编排版本一个 for 循环按顺序调用函数中间塞各种条件判断。结果到了第四个节点任务就乱了——失败重试怎么办中途用户关掉页面怎么办怎么把中间结果持久化下来这些问题全部要我自己造轮子。而 LangGraph.js 本身就把图编排、状态管理、checkpoint、条件分支这些给规范好了我只需要描述“有哪些节点”“节点之间怎么走”“状态长什么样”剩下的由框架托管。这是 LangGraph.js 和 LangChain 的核心区别之一。LangChain 的 chain 更偏向线性快捷组合LangGraph.js 则是真正把 Agent 当有状态的图来建模支持循环、分支、人工介入human-in-the-loop对于生产级的工具型 Agent 来说这个能力差异很关键。我当时也纠结过要不要直接用 Python 版 LangGraph。后来考虑到整个项目都是 TypeScript 写的前端 Next.js 和后端逻辑可以共享类型定义简历处理的工具函数也能直接在 API 路由里复用最后定了 LangGraph.js。事实证明这个选择对单人项目来说非常友好少维护一套语言栈就是少一半的运维成本。2. 整体方案与架构设计2.1 技术选型全景前端、编排、模型的分工整个项目我用了三块Next.js 14 App Router 做前后端一体LangGraph.js 做 Agent 编排模型层接 OpenAI-compatible API也可以替换成国内模型只需改 baseURL 和 model 名称。Next.js 在我这里承担的责任比较重但也很清晰。App Router 下的页面和 API Route前端组件直接 fetch 流式接口上传文件的 API 处理PDF/DOCX 提取文本部署由 Vercel 托管环境变量集中在服务端避免密钥外泄。LangGraph.js 就专注一件事把简历处理的业务流程变成一张可执行的图。每一个业务步骤是一个 node节点之间通过 state 传递数据。这种分层明确到让我后面几乎没为“业务逻辑放哪里”纠结过前端只管渲染和交互图只管流程编排模型层只管生成。为什么不用前后端分离因为我只有一个人拆成 Next.js 前端 Python FastAPI 后端 Redis 存储维护成本直接翻倍。前后端一体在这个项目体量下是最合理的选择而且 Next.js 的 API Route 天然支持流式 Response跟 SSE 配合得很顺。团队项目另说个人项目的第一原则永远是“少一个服务少一夜失眠”。2.2 项目目录与数据流总览目录结构上我是这样组织的src/ app/ api/ analyze/route.ts # 分析主流程SSE 流式 upload/route.ts # 简历文件上传与解析 page.tsx # 主页面 agents/ graph.ts # LangGraph 图定义 nodes/ parseResume.ts analyzeGap.ts generateOptimized.ts summarize.ts state.ts # Agent 状态定义 lib/ llm.ts # 模型客户端封装 prompts.ts # 提示词模板 pdf.ts # PDF 文本提取数据流是这样的用户上传文件或粘贴文本 → 前端发到/api/analyze→ 服务端把简历文本和岗位 JD 灌进 Agent 图的初始状态 → 图开始按节点执行通过 SSE 把每个节点的进度推回前端 → 页面按节点展示当前状态并实时显示生成内容。这个数据流里最关键的一个设计决定是把“文件解析”和“Agent 编排”分成两层。文件解析在进入图之前完成产生纯文本图里只处理文本和结构化数据。这样 LangGraph.js 的 state 里不需要承载二进制内容调试时打印状态不会刷屏序列化也方便后面接持久化存储毫无障碍。2.3 状态设计的“为什么”——Agent 的地基LangGraph.js 里状态state是贯穿全程的地基所有节点只能读取和声明自己要更新的字段。这个约束看起来简单但我一开始就因为贪方便把一个字段塞了“包含一切结果”的对象导致后面每个节点都要做深层类型断言排错排到怀疑人生。状态字段我设计得精简且职责单一const AgentState Annotation.Root({ resumeText: Annotationstring, jobDescription: Annotationstring, rawAnalysis: AnnotationRecordstring, unknown, optimizedResume: Annotationstring, summary: Annotationstring, statusList: Annotationstring[]({ reducer: (a, b) a.concat(b), }), });statusList 这个字段专门用来记录每个节点是否执行过前端靠它渲染“解析中 → 分析中 → 优化中”的进度条。用 reducer 追加而不是覆盖是因为我想保留 Agent 的完整执行轨迹。LangGraph.js 允许给字段定义自定义 reducer默认是覆盖换成 concat 后每次节点返回都会追加一条记录这对可观测性帮助很大。状态设计有一条铁律节点返回的新字段必须在 state 定义里声明过否则 LangGraph.js 会直接抛类型校验错误。这个机制防呆但也要求你提前想清楚数据在整张图里怎么流动。我的建议是先画一遍手写数据流图标注哪些节点读哪些字段、写哪些字段再开始写代码。状态字段一旦定下来后面改起来牵扯所有节点成本不低。3. 核心工作流实现详解3.1 节点设计从简历解析到优化建议我的图一共有 4 个主节点和 1 个条件分支parseResume接收原始文本解析出结构化简历信息工作经历、教育背景、技能、项目描述。analyzeGap把简历信息与岗位 JD 做差距分析输出匹配点、缺口、关键词缺失三部分。generateOptimized基于差距分析逐段重写简历内容尤其是项目经历要求按 STARR 原则输出量化成果。summarize把分析结果和优化建议整理成一份报告摘要。条件分支如果在analyzeGap节点发现简历缺少关键信息比如没有工作年限走一条边让 Agent 生成一个追问问题而不是强行生成结果。图定义的代码骨架长这样import { StateGraph, END } from langchain/langgraph; const graph new StateGraph(AgentState) .addNode(parseResume, parseResume) .addNode(analyzeGap, analyzeGap) .addNode(generateOptimized, generateOptimized) .addNode(summarize, summarize) .addEdge(__START__, parseResume) .addEdge(parseResume, analyzeGap) .addConditionalEdges(analyzeGap, routeAfterAnalysis) .addEdge(generateOptimized, summarize) .addEdge(summarize, END);routeAfterAnalysis是一个纯函数返回下一个节点名LangGraph.js 会根据返回值决定走哪条边。这也是我觉得 LangGraph 比 LangChain 顺手的重要原因分支逻辑是显式的、可测试的不会藏在一坨逻辑里面。你可以单独导出这个纯函数写单元测试喂不同的分析结果断言走哪条边这对长期维护来说太重要了。3.2 parseResumePDF、DOCX 与纯文本三种输入的解析策略简历来源三种用户直接粘贴文本、上传 PDF、上传 DOCX。这里我吃过不少亏单独说。纯文本最省事直接用 LLM 做信息抽取效果很好。PDF 用pdf-parse提取文本但有个坑部分扫描版 PDF 没有文本层提取出来是空串这时候要提示用户改传文本或用带文本层的 PDF。DOCX 用mammoth.js转成 HTML 再剥离标签效果比直接解压 XML 稳得多我实测过两种方案mammoth 对复杂的表格和列表处理更符合人类阅读顺序。解析节点内部我做了个分层先用本地工具提取文本再交给 LLM 做结构化抽取。为什么不在服务端直接调 LLM 看图因为通用视觉模型看图提取简历信息成本是纯文本抽取的好几倍而且字体渲染不同很影响准确率。文本层能解决的就别让视觉模型加班这是所有工具型 Agent 都应该遵守的成本意识。这一步的提示词也很关键我把它单独放进lib/prompts.ts大意是“你是简历解析引擎。将以下原始简历文本解析为 JSON 对象字段包含 basicInfo、workExperience、education、skills、projects、certifications。如果原始文本缺失某字段输出空数组或空字符串不要编造。”然后我用JSON.parse配合正则兜底避免模型偶尔多输出 json 标记导致解析失败。这个兜底逻辑我写了三层先直接 parse失败再用正则提取花括号块再失败就重试一次。简历解析是整个链路的入口如果这里不稳定后面所有节点都是空中楼阁。3.3 analyzeGap 与 generateOptimized差距分析和内容重写的技巧analyzeGap是整个 Agent 的价值核心。它不直接让 LLM “优化简历”而是先要求模型“对照 JD 逐项打分”把结论结构化之后再交给后续节点重写。为什么拆成两步因为如果让模型一步做完分析和改写结果往往是一锅粥——分析部分写得空洞改写也缺乏针对性。差距分析提示词里我固定了输出格式{ matchingPoints: [有 8 年后端经验满足 5 年要求], gaps: [缺少微服务治理相关经验描述], missingKeywords: [Kubernetes, Redis Cluster], suggestionList: [在项目经历中加入缓存设计细节] }然后generateOptimized拿着这份结构去改简历它只需要专注“怎么写”不用重复判断“写什么”。这一步是让 LLM 输出质量提升最大的改动——把事实判断从写作任务里剥离出去让每一步的职责都变得单一。如果混在一起模型会在两个任务之间来回切换注意力产出内容质量明显下降。改项目经历时我要求模型遵循 STARR情境、任务、行动、结果、复盘而且每一个行动必须对应一个可量化的结果。没有量化数字的“负责xx系统”一律重写。这一步对模板化简历的改善非常明显同样的经历描述优化后“体感”完全不一样。比如“负责订单系统开发”会变成“独立设计订单超时关闭方案将异常订单率从 1.2% 降至 0.3%直接减少资损约 7 万元/月”这种对比是用户一眼就能感知到的价值。4. Next.js 集成与流式输出实战4.1 API Route 接入 LangGraph 图Next.js 这边我用 App Router 的 Route Handler 暴露/api/analyze。入口接收{ resumeText, jobDescription }构建图片初始状态然后调用graph.invoke(initialState)。最简单版本这样就能跑通但产品形态上用户要等十几秒才能看到结果体验太糟。所以我把invoke换成stream让 Agent 按节点逐步输出。LangGraph.js 的graph.stream()会返回一个异步迭代器每个 chunk 都包含当前节点信息这样前端可以实时展示“现在跑到哪个节点了”。这个改动对用户体验的提升比任何 UI 美化都有效。十几秒的等待变成逐条刷新的进度用户能感知到系统在工作而不是怀疑页面卡死了。4.2 SSE 流式返回与前端实时进度服务端用标准 SSE 协议往 Response 里写数据export const runtime nodejs; export async function POST(req: Request) { const { resumeText, jobDescription } await req.json(); const stream new ReadableStream({ async start(controller) { const encoder new TextEncoder(); const result await graph.stream({ resumeText, jobDescription, rawAnalysis: {}, optimizedResume: , summary: , statusList: [], }); for await (const chunk of result) { const payload { node: Object.keys(chunk)[0], data: chunk[Object.keys(chunk)[0]], }; controller.enqueue(encoder.encode(data: ${JSON.stringify(payload)}\n\n)); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端我用一个fetch加ReadableStream解析不用 EventSource因为需要 POST 传 JSON 入参。解析 SSE 时注意按\n\n分块再解析data:开头的那一行每个块更新页面上对应节点的状态标识。这里有个小陷阱event-stream 的分隔符是空行不要用split(\n)直接切否则会切出很多空事件。进度条我做了简单的节点到文案映射收到parseResume的 chunk 就把步骤 1 标记为完成收到analyzeGap就标记步骤 2依次类推。如果generateOptimized节点内部把生成过程也通过streamMode: messages暴露出来前端甚至可以做打字机效果不过这个项目里我控制住了复杂度只做到节点级别因为简历文本逐字输出对用户没什么意义用户要的是最终成品。4.3 客户端中断处理——一个容易被忽略的细节SSE 流式输出有一个很坑的细节用户在生成过程中关闭页面或刷新服务端如果还在继续调 LLM费用照扣结果却没人收。我一开始完全没处理直到一次测试发现日志里有一堆“孤立生成”才意识到这是真金白银在烧。处理方式是在 Route Handler 里监听请求的abort信号用AbortController终止图执行。LangGraph.js 支持传signal给内部的invoke或streamLLM 调用也会透传中断。加上之后用户中断会立即停止上游 API 调用省下的是实打实的钱和额度。如果你接的模型 API 按 token 计费这个处理不是锦上添花是必须项。这里还有一个细节一旦中断不能把半成品当作任务结果返回。我会在节点内部捕获AbortError并通过内存 Map 记录一个任务状态前端下次轮询时能看到“任务已取消”。注意内存 Map 只在单实例有效多实例部署要用 Redis 之类的共享存储否则用户打到另一个实例就查不到状态了。5. 并发、稳定与成本控制实战5.1 “AI Agent 怎么扛并发”——serverless 场景的并发策略搜索热词里反复出现“AI Agent 怎么扛并发”我的答案可能和不少人预期不同先别想着扛并发先让你的 Agent 在单实例下稳定。部署在 Vercel serverless 环境时每次请求都可能是冷启动实例这意味着并发的本质问题不是“同时处理多少请求”而是“如何在无状态实例之间管理好外部 API 的限流”。我的做法是三层应用层并发控制给 LLM 调用封装一个并发限制器同一时间最多 3 个请求在途超出排队。超时与重试LLM 请求设置 30 秒超时对 429 和 5xx 做指数退避重试最多重试 2 次并给重试次数做日志标记。任务队列兜底如果并发真的上来了就放弃同步等待把任务丢进队列前端轮询获取结果。实现并发限制器用了一个很轻的库p-limit代码大概是这样import pLimit from p-limit; const llmLimit pLimit(3); export async function callLLM(messages: any[]) { return llmLimit(() chatModel.invoke(messages, { timeout: 30000 })); }那是在单实例内部限制如果部署到多实例要再加一层分布式锁或消息队列否则限流是各限各的。这一点我在文档里特意标注了项目的并发能力边界取决于外部 LLM 的配额和模型响应速度不是单靠代码就能无限扩展的。个人项目里做到单实例限流 超时重试已经能应付绝大多数日常流量了。5.2 Token 成本控制提示词压缩与模型分层简历工具这种场景Token 大头在generateOptimized节点一次可能输入 3000 token输出又常常 2000 token。我从三个方向控制成本模型分层。parseResume和analyzeGap用便宜快速的模型generateOptimized才用高质量模型。实测同样任务分化后成本能省 30% 以上质量几乎无损因为解析和分析是结构性任务不需要顶级推理。输入压缩。上传的简历如果包含冗余格式堆砌的空格、重复页脚先在进入 Agent 前做文本清洗。这一步能在源头省掉百分之十到二十的 token还可以顺手把邮箱、电话这些敏感字段做脱敏处理防止它们进入模型上下文。输出控制。要求每个节点只输出必要内容比如analyzeGap明确 JSON 结构禁止多余解释。LLM 输出越短越好这个“短”来自提示词约束不来自事后裁剪因为事后裁掉的内容已经被计费了。另外我把相同岗位 JD 的分析结果做了缓存同一岗位描述和同一份原始简历的哈希值一致时直接读取缓存结果只在缓存命不中时才真正调用 LLM。个人开发者用它来压成本效果很明显尤其是测试阶段反复调同一份简历的时候这个缓存能帮你省下一大笔 token 费。5.3 可观测性给 Agent 装上“监控仪表盘”Agent 从 demo 走到生产最大的体验变化就是“看不见里面在干嘛”变成了一件非常焦虑的事。我加了一个最小化的日志方案每个节点执行都往statusList里追加一条{node, status, duration, tokenCount}图跑完通过 summarize 节点落一份到数据库。这样用户回看历史任务时能知道上次是卡在解析还是卡在分析比黑盒调用强得多。如果还要往深做LangGraph.js 官方有 LangSmith 可以接链路追踪不过那个要注册账号外加 API Key我暂时没接先用日志兜着。生产级别的 Agent 项目观测不是可选项是必需品——没有它你永远不知道问题出在 LLM 还是在你的图逻辑里。我见过太多 Agent 项目上线后“间歇性失灵”最后定位半天其实就是某个节点在特定输入下返回了空结果而日志里什么都没留下。6. 常见问题与排查技巧实录6.1 LangGraph.js 状态字段类型校验的坑第一次跑图遇到最经典的报错Node analyzeGap tried to update the state field analysisResult, but is not defined in the graph state schema。原因是我定义 state 时只写了rawAnalysis但节点返回用了analysisResult。LangGraph.js 的类型校验是严格的任何节点不能私自新增字段。解决方式是在 state 定义里补齐所有节点会返回的字段另一个更干净的方案是统一字段命名规范比如所有节点返回都用AnalysisOutput类型防止手滑。这个报错倒也不算坑它反而逼着你把数据流定义清楚。我觉得比那些自由传对象的编排框架靠谱得多至少上线之前就帮你堵住一半错误。凡是遇到这类报错第一件事一定是回到state.ts把节点返回的字段和注解定义对齐而不是去改节点代码。6.2 LLM 返回非法 JSON 导致节点崩溃analyzeGap节点要求 LLM 输出 JSON但 LLM 偶尔会犯病多包一层 json 代码块或者夹杂一句“好的以下是分析”在前面。直接JSON.parse必挂。我这边的兜底逻辑是三层先尝试直接 parse。失败则用正则提取第一对花括号之间的内容再 parse。还失败就把这次结果当作“分析失败”走一条失败分支让图重新生成一次最多重试两次。这个兜底函数在本地验证的时候很稳但有一次线上还是挂了——原因是模型输出里有个引号没转义正则把内容截断了。后来我加了一道预处理去掉所有回车换行后再按花括号边界提取问题就解决了。处理大括号嵌套时还有一个小技巧优先找最后一个前括号和第一个后括号而不是第一对因为模型可能在 JSON 前面写了一堆废话。6.3 Next.js 服务端流输出的超时陷阱Vercel 免费版默认函数执行时长限制是 10 秒我的图在最忙的节点上经常超。这事一度让我很崩溃因为本地跑得好好的一部署到线上就 504。解决方向有两个如果是 Vercel Hobby 套餐在route.ts里导出export const maxDuration 60;这是官方支持的配置如果业务套餐也不行就把 Agent 执行切到独立进程Next.js 只做 API 网关收到任务立刻返回 taskId前端轮询去拿结果。这其实也可以作为并发扩展的正解——把重计算从请求链路里挪出去。“AI Agent 怎么扛并发”的终极答案其实就是这个不要让用户请求和 Agent 计算强绑定。同步等待快但扛不住并发异步任务慢一点但系统稳定性和扩展性都上了一个台阶。我的建议是一开始就做成异步任务架构哪怕第一版不追求实时体验也比后面改骨架容易得多。6.4 PDF 解析在 serverless 环境的兼容问题pdf-parse在本地 Node 环境工作正常部署到 Vercel 后偶发报错后来查出来和字体引擎依赖有关系。我换成了pdfjs-dist来做文本提取稳定性好不少。但代价是包体积大了冷启动会多一两百毫秒对我这个场景可接受。如果扫描版 PDF 太多建议在解析节点里加一个“文本长度为 0”的判断友好提示用户不要走扫描件。这类边界情况在产品上线前一定要想到否则用户传一个手机拍照的简历进来服务端返回一堆空内容体验直接劝退。同理DOCX 文件也要做大小限制和格式校验我设了 5MB 上限超过直接拒绝防止有人传 100MB 的带图简历把内存打爆。7. 我的实操体会与后续扩展最后聊一下运行这个项目以来我的真实感触。第一个体会是Agent 项目成败的关键不在“智能”而在“边界”。用户上传的简历千奇百怪有人只传三行有人传 20 页带封面Agent 如果没有清晰的节点边界一个小问题就会把整条链路拖崩。我经常说LangGraph.js 最大的价值不是让模型更聪明而是让你能把“如果这个步骤失败”变成显式的一条边而不是藏在 catch 里的一个 console.log。第二个体会是先用“最土”的方式把你的流程跑通再上框架。我最初用手写 Promise 链实现的时候对整条链路的数据模型理解得很透后来迁移 LangGraph.js 只花了一个晚上。文中所有代码骨架都可以直接复制到你的项目里跑一遍再把 PDF 解析、模型参数替换成你自己的你就拥有一个可以持续扩展的 Agent 底座。后续如果要继续扩展我目前想到的几个方向都基于现有图结构加一个“面试追问”节点让 Agent 针对简历盲区生成提问加一个“行业关键词”搜索节点用向量检索把人才市场趋势喂给analyzeGap还有把状态持久化接到 Postgres让用户能回到历史任务继续编辑。LangGraph.js 的 checkpoint 机制天然支持中断恢复接上持久化之后用户做到一半关掉页面下次进来还能从断点继续这是比“聊天机器人”强大得多的产品能力。最后再分享一个小技巧每次改完图结构和状态定义我都会先跑一次只有两条样例数据的“干跑”把stream输出打印出来确认所有节点走的边和预期一致再上线。这个小动作帮我省了无数次线上排查。还有一个心得是别急着把所有功能塞进一张图简历工具目前也就 4 个节点新需求来了先问自己“能不能不加节点、只改提示词”能的话就改提示词不能才动图结构这个原则帮我保持了图的可维护性。就这样吧希望这篇记录对准备做 AI Agent 实战项目的你有点用。