ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Next.js与LangGraph.js构建简历AI Agent的全栈实践

基于Next.js与LangGraph.js构建简历AI Agent的全栈实践 如果你最近在做一个带 AI Agent 的全栈应用大概率跟我一开始想的一样套一个聊天界面接一个大模型再把几个函数调用塞进去就以为完事了。但真要把一个工具类的 AI Agent 完整落地尤其是像“简历工具”这种既要读文件、又要分析、还要生成优化建议的场景事情远比想象中复杂。我最近用 Next.js LangGraph.js 把这样一个简历 AI Agent 从零搭了出来前后踩了不少坑也摸索出了一套可以复用的架构打法。这篇文章就把整个项目的设计思路、核心模块、实操步骤和问题排查完整复盘一遍。不管你是想找一个 AI Agent 学习路线上的实战案例还是正在做 AI Agent 开发相关项目这篇内容应该都能帮你少走一些弯路。我会把技术选型的理由、LangGraph.js 的状态图编排方式、流式输出的落地细节以及我在部署和调试时遇到的实际问题都讲清楚。1. 项目定位与整体设计思路1.1 为什么选 Next.js 而不是前后端分离做 AI Agent 应用最常见的方案是 React/Vue 前端 FastAPI/Express 后端但在简历工具这个场景里我最终选了 Next.js 全栈一把梭。原因很直接这类工具的核心交互是“上传文件 多轮对话”本身就不需要特别重的后端业务逻辑用 Next.js 的 App Router 和 Route Handlers 就能承载全部接口前端和 Agent 逻辑还能共享 TypeScript 类型定义少维护一套数据契约。另一个决定性因素是流式输出。AI Agent 的体验瓶颈往往在“等待感”用户在点击分析后如果界面一直白屏转圈体验就很糟糕。Next.js 对 ReadableStream 和 Server-Sent Events 的支持非常原生在 Route Handler 里就能直接往流里写数据前端用 fetch 一边读一边渲染不需要额外搭 WebSocket 服务。而且部署到 Vercel 的 serverless 环境也比自建 Node 服务简单得多我对运维投入的预期很低这一点很重要。当然如果你预判这个 Agent 会演化成多租户、高并发的独立服务或者需要独立的定时任务和消息队列那前后端分离会更合理。但作为工具类 MVPNext.js 的工程成本是最低的这也是 AI Agent 开发中很主流的选择。1.2 LangGraph.js 在 Agent 里扮演什么角色对于 Agent 编排很多人第一反应是手写一个 while 循环不断把 messages 丢给模型然后调用返回的工具直到模型说“结束”。这种写法在 demo 里没问题一旦任务分支变多就会失控。简历工具虽然看起来只有一个入口但实际上至少有三条任务线分析简历问题、优化简历内容、根据岗位 JD 做匹配度评估。如果把这些逻辑揉在一个函数里日志、状态、异常处理都会非常痛苦。LangGraph.js 解决的核心问题是把 Agent 的运行过程建模成一张有向图。节点是具体的操作比如“解析简历”“路由意图”“执行工具调用”边则定义了状态的流转方向。这样做有三个直接收益第一整个流程可视化调试的时候能清楚看到 Agent 当前停在哪一步第二状态管理有规范所有节点共享一个 State 对象中间数据不会散落得到处都是第三支持 Checkpoint 持久化可以在多轮对话之间保留上下文这是手写循环很难做好的部分。我用的 LangGraph.js 是 JavaScript/TypeScript 版本和 Python 版 LangGraph 的核心概念一致但 API 和类型系统更贴近前端生态。对于想在浏览器/Node 全栈环境里开发 agent 的团队来说它比 Python 版更容易嵌入现有代码库。1.3 简历 Agent 的工作流设计在设计工作流之前我先把用户的实际场景列了一遍有人是拿到一份简历不知道哪里需要改有人是直接提出“把项目经历改得更量化”还有人夹带一份 JD 说“帮我看看匹配度”。这些场景背后的 Agent 行为差异很大所以第一步不是直接写提示词而是规划节点。整个流程我分成了四个阶段解析层接收上传的简历文件转成结构化文本或 JSON。路由层根据用户指令和简历内容判断当前属于分析、优化、匹配中的哪种意图。执行层调用对应的工具函数或模型推理生成分析结果或改写内容。输出层把结果整理成用户能读的 Markdown 或结构化 JSON并回流到前端。这四层对应 LangGraph.js 里的四个节点节点之间通过条件边连接。比如路由节点判断出“匹配”意图后就走匹配节点判断出“优化”意图后就走优化节点。设计这种图结构时我特意让“解析”和“路由”串行执行层各节点并行分支最后汇总到输出节点避免所有逻辑都堆在同一个提示词里。2. 核心模块拆解与实现要点2.1 简历解析文件上传与结构化处理简历工具的第一个难点是文件上传和解析。浏览器端拿到的是 File 对象通过 FormData 传给 Next.js Route Handler这一点大家都会真正的坑在文件内容解析上。纯文本简历和 Markdown 简历处理最简单直接读字符串即可PDF 简历则需要额外的解析库。我试过 pdf-parse 和 pdfjs-dist最后选择 pdf-parse 搭配内存文件 Buffer 来用因为它在服务端解析速度更快API 也更简单。import { NextRequest } from next/server; import pdf from pdf-parse; export async function POST(req: NextRequest) { const formData await req.formData(); const file formData.get(resume) as File; const buffer Buffer.from(await file.arrayBuffer()); // pdf-parse 接受 Buffer 输入 const result await pdf(buffer); const text result.text; }解析之后我建议立即做一轮简单的文本清洗去掉多余空行、压缩连续空格、保留常见的段落结构。如果简历原本是两栏排版PDF 解析后的文本顺序会错乱这个问题放到后面“踩坑实录”里细说。清洗后的纯文本我会存到 Agent 的 State 里同时抽取出一个结构化摘要比如“姓名、工作年限、技能关键词、最近三段经历”供后续路由和查询使用。抽取动作可以交给模型做但如果想省钱也可以先用正则匹配粗筛。我的做法是第一轮请求让模型以 JSON 格式输出结构化摘要后面所有节点都基于这个 JSON 做判断而不是反复把整份简历塞给模型。2.2 意图路由让模型自己决定下一步走哪个分支这个 Agent 和普通聊天机器人最大的区别是它需要根据用户指令决定执行路径。比如用户说“帮我根据这个 JD 看看匹配度”Agent 应该走匹配分支如果说“这段项目经历太空洞”应该走优化分支。我没有用传统的关键词匹配而是把意图判断交给了模型本身的工具调用能力。在 LangGraph.js 里我会定义一个routeIntent工具它的 schema 只有一个intent枚举字段analyze | optimize | match。然后在路由节点里让模型根据用户消息和简历摘要调用这个工具并给出意图值。模型返回的工具调用结果会成为 State 的一部分决定条件边走向哪个节点。这种方法比硬编码规则健壮得多因为用户表达意图的方式五花八门“看看问题在哪”是分析、“帮我改一下”是优化、“和这个 JD 搭不搭”是匹配。模型能理解语义规则匹配做不到。2.3 执行层工具函数的设计真正干活的是执行层的节点。我把每个执行动作都封装成独立的工具函数而不是把逻辑写在节点内部。这样既方便复用也能让 LangGraph 的图更干净。比如优化简历的时候我把优化文本的提示词模板单独放在一个文件里工具函数接收section和instruction返回改写后的文本。工具函数的设计有几个要点。第一输入输出尽量结构化不要返回一大段人话让后续节点还要二次解析第二每个工具都要有独立的错误处理比如模型超时、输出格式不对Node 里要能捕获并返回可读错误第三所有工具的调用记录要追加到 messages 里这样 Agent 在后续对话中能“记住”自己做过什么。这里我最开始犯过一个错误把“分析”“优化”“匹配”三个逻辑都写在一个超长工具函数里试图用参数区分。实际跑起来发现提示词复杂度爆炸输出质量也不稳定。后来拆成三个小工具每个工具的职责单一模型反而更愿意正确调用。2.4 流式输出把 Agent 的进度变成可读的实时反馈我一开始用的是graph.invoke()等全部跑完再返回效果很糟。用户看到空白页面等十几秒根本不知道系统在干什么。后来换成了 LangGraph 的graph.stream()配合 Next.js 的 ReadableStream 做 SSE把 Agent 的节点流转实时推给前端。前端可以渲染出类似这样的节奏先显示“正在解析简历”再显示“正在判断意图”然后显示“正在生成优化建议”最后贴出完整结果。这个体验提升非常明显而且实现成本不算高。需要注意的是SSE 推送的事件要有统一的 JSON 结构和类型字段前端才能区分“节点状态事件”和“最终结果事件”。3. 实操落地全流程3.1 项目初始化与依赖安装项目基础用 create-next-app 初始化我选的是 App Router 加 TypeScriptTailwind 顺手带上后面写界面方便。npx create-next-applatest resume-agent --ts --tailwind --app cd resume-agent然后安装 LangGraph.js 和模型依赖。我用的是 OpenAI 接口兼容的模型所以安装 LangChain 相关的 OpenAI 包。npm install langchain/langgraph langchain/openai zod这里强调一下 zod 的作用LangGraph 和 LangChain 的工具 schema 支持用 zod 描述模型侧会自动转换成 JSON Schema这样工具调用的参数校验就有了运行时保障。安装这三个包就够了路由、文件处理都用 Next.js 自带能力。3.2 用 LangGraph.js 构建简历 Agent 状态图这一节是核心。我用 Annotation 定义 Agent 的 State然后依次添加节点和边。先看 State 定义import { Annotation, type BaseMessage } from langchain/langgraph; export const ResumeAgentState Annotation.Root({ // 消息列表记录整个对话过程 messages: AnnotationBaseMessage[]({ reducer: (x, y) x.concat(y), default: () [], }), // 简历原始文本 resumeText: Annotationstring({ reducer: (x, y) y ?? x, default: () , }), // 简历结构化摘要 resumeSummary: Annotationany({ reducer: (x, y) y ?? x, default: () ({}), }), // 当前意图 intent: Annotationanalyze | optimize | match({ reducer: (x, y) y ?? x, default: () analyze, }), // 最终输出 result: Annotationstring({ reducer: (x, y) y ?? x, default: () , }), });State 的 reducer 是 LangGraph 的核心机制。默认情况下每个节点的返回值会直接覆盖对应字段但 messages 这种需要累加的字段就要用 reducer 做 concat。我用y ?? x表示“新值存在就覆盖否则保留旧值”很常用。接下来建图import { StateGraph, START, END } from langchain/langgraph; const builder new StateGraph(ResumeAgentState) .addNode(parse, parseResumeNode) .addNode(route, routeIntentNode) .addNode(analyze, analyzeNode) .addNode(optimize, optimizeNode) .addNode(match, matchNode) .addNode(format, formatResultNode) .addEdge(START, parse) .addEdge(parse, route) .addConditionalEdges(route, (state) state.intent, { analyze: analyze, optimize: optimize, match: match, }) .addEdge(analyze, format) .addEdge(optimize, format) .addEdge(match, format) .addEdge(format, END); export const resumeAgent builder.compile();这样一个图就建好了。每个节点都是一个接收state、返回部分 State 的异步函数。路由节点单独看async function routeIntentNode(state) { const { messages, resumeSummary } state; const response await modelWithTools.invoke([ ...messages, systemPrompt, ]); const toolCall response.tool_calls?.[0]; return { intent: toolCall?.args?.intent ?? analyze }; }注意这里我把模型调用和工具 schema 放在了单独的模块里节点函数只关心业务编排这样后续替换模型或者改提示词都不需要动图结构。3.3 API 路由与流式对接图构建好之后需要在 Next.js 的 Route Handler 里把它跑起来。我的做法是先用graph.stream()以updates模式推送每个节点的输出再在最后推送最终结果。// app/api/agent/route.ts import { resumeAgent } from /lib/agent/graph; export async function POST(req: NextRequest) { const formData await req.formData(); const resumeText formData.get(resumeText) as string; const userInput formData.get(userInput) as string; const threadId formData.get(threadId) as string; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const send (data: unknown) { controller.enqueue(encoder.encode(data: ${JSON.stringify(data)}\n\n)); }; const config { configurable: { thread_id: threadId } }; for await (const event of resumeAgent.stream( { resumeText, messages: [{ role: user, content: userInput }], }, config )) { // event 形如 { parse: {...}, route: {...} } const [nodeName, nodeOutput] Object.entries(event)[0]; send({ type: node, nodeName, output: nodeOutput }); } // 最终结果直接在 format 节点返回时取一下 send({ type: done }); controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }这段代码里有个我反复强调的细节用thread_id做多轮对话的上下文标识。LangGraph 的 checkpointer 会保存每一个 thread 的状态同一简历文件在同一 thread 下连续追问Agent 能记住之前分析过哪些内容。我用的是内存版 checkpointer部署时如果要跨请求保留对话状态就得换成 Redis 或数据库持久化这个话题在踩坑部分再展开。3.4 前端交互与事件渲染前端我用一个简洁的双栏布局左侧是简历上传和聊天输入右侧是 Agent 的节点状态和最终结果。前端逻辑的核心是解析 SSE 流。async function runAgent(formData: FormData) { const res await fetch(/api/agent, { method: POST, body: formData }); const reader res.body!.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop() ?? ; for (const line of lines) { if (!line.startsWith(data:)) continue; const json JSON.parse(line.slice(5)); if (json.type node) { // 更新状态面板 } else if (json.type result) { // 渲染最终结果 } } } }真正落地的时候我建议把“状态面板”和“最终结果”分开渲染。状态面板只是过程反馈实质内容仍然是最终那一段 Markdown 结果。前端渲染 Markdown 我直接用了 react-markdown简历优化结果里的标题、列表、加粗都能正确展示。3.5 部署与生产化配置部署到 Vercel 时我踩了一个典型的坑默认 serverless 函数有执行时长限制。我的 Agent 涉及多次模型调用解析 PDF、路由、生成结果加起来可能超过 10 秒。Vercel 的 hobby 计划默认 10 秒超时但通过配置export const maxDuration 60;可以延长到 60 秒。export const maxDuration 60;另一个生产化要点是密钥管理。OpenAI API Key 这类环境变量要放到.env.local然后在 Vercel 的项目设置里配置同名环境变量绝对不能写进前端代码。实际上因为我用的是 Route HandlerAPI Key 始终留在服务端前端永远拿不到。内存版 checkpointer 在本地开发没问题但部署后每个 serverless 实例都是独立的内存状态在多实例之间不共享。如果产品只做 demo问题不大如果上线一定要换 Redis 存储。我后来接了一个 Redis 的 checkpointer逻辑其实很简单LangGraph 提供了RedisCheckpointer的封装稍作配置即可。这一步属于“架构上正确的做法”在 AI Agent 主流架构里状态持久化基本是标配。4. 踩坑实录与问题排查4.1 常见问题速查表问题现象根因解决方案PDF 解析出来的文字顺序错乱双栏排版导致文本流混乱先做文本清洗必要时按行号重组扫描件直接提示用户上传文本版Agent 反复调用同一个工具模型没有收到足够明确的终止条件在提示词里强调“如果已经给出结果就不需要再调用工具”并设置最大迭代次数流式输出断断续续甚至中断前端没有处理 SSE 的缓冲包拆包按\n\n分片处理跨分片的 JSON 行部署后 Vercel 函数超时默认 maxDuration 过低设置export const maxDuration 60;多轮对话上下文丢失没有配置 checkpointer配置 thread_id 并使用持久化 checkpointer模型输出格式不符合预期提示词约束不足用 zod 定义工具参数并在结果节点里用 schema 校验这张表是我实际开发过程中最常遇到的几类问题下面挑几个展开说。4.2 PDF 解析与文本错乱的细节简历工具最恼人的问题就是 PDF 解析。我第一次测试一份双栏英文简历时解析出来的文本把左侧技能的单词和右侧工作经历混在一起完全没法读。后来我加了一层文本后处理按行拆分过滤掉行内长度过短的内容再用换行拼接。但这只能缓解问题真正的解决方法是提示用户优先上传 Word 版本或纯文本版本因为 PDF 本身的文本流对双栏布局并不友好。另外扫描版 PDF 其实没有文本层pdf-parse 解析出来是空字符串。我在产品里做了一个兜底检测到解析文本过短时直接返回“无法识别请上传可复制的文本内容”。这个处理虽然简单但避免了模型在空文本上硬编答案的可怕现象。4.3 工具调用与模型行为的调优LangGraph 的 Agent 一旦工具调用失败整个流程会卡在路由节点。我观察到的规律是温度太高时模型偶尔会凭空捏造工具参数文件所以我把路由节点的 temperature 调到了 0执行节点的温度设成 0.3优化节点甚至可以到 0.5。不同节点用不同温度这一点在 LangGraph 里很容易做到——每个节点调用模型时单独传参数即可。还有一次我发现模型在 analyze 之后继续调用 optimize 工具因为用户消息里同时有“帮我看看问题再改一下”这种复合指令。解决方案是增强路由节点的判断当检测到复合意图时返回一个multi意图并让图在 condition edges 上增加一条“先 analyze 再 optimize”的串联路径。这个改法很优雅也展示了图编排的真正优势——你可以在不重写业务逻辑的情况下增加一条路径。4.4 状态持久化与 serverless 限制内存 checkpointer 在本地跑着很爽但一上 Vercel 就出问题同一个 thread 的第二次请求可能落在另一个 serverless 实例上内存状态全丢了。我换成了 Redis checkpointer 之后状态恢复才稳定。如果你的项目还在起步阶段最简单的方案是把关键上下文塞进前端传给后端比如把简历摘要和之前的分析结果放在 FormData 里。虽然不如真正的 checkpointer 完整但也能支撑不少场景。还有一个容易被忽略的点graph.stream()的事件输出顺序和节点完成顺序相关而不是定义顺序。前端状态面板如果依赖预期顺序可能会出现“先显示优化完成再显示路由完成”的错觉。解决方法是前端只管展示节点名和输出的摘要不强行排序或者用时间戳自行排序。4.5 一些经验建议最后分享几条实操心得先做单轮再做多轮。把一次完整的“上传简历并分析”跑通之后再考虑上下文关联否则调试复杂度会瞬间翻倍。每个节点都要能单独测试。我写了一个简单的脚本直接调用每个工具函数并打印输入输出这比每次都启动完整 Agent 快得多。提示词和代码分开。把系统提示词、用户提示词模板放在独立文件里后续改文案不碰代码逻辑。对简历工具这种需要持续调校文本的 Agent这个习惯能省很多时间。做好输出校验。模型生成的结果偶尔会不符合约定格式比如少了一段分析。我在 format 节点里做了一次 JSON/Markdown 结构校验不符合就要求模型重试一次最多重试两次。这个机制极大降低了线上翻车概率。5. 后续扩展与个人体会按照我最终的体会LangGraph.js 这个方案最大的价值不是“看起来高级”而是让 Agent 的每一步都可观测、可控制、可恢复。简历工具只是第一个应用场景当你把图的节点替换掉它完全可以变成合同审查助手、需求分析助手、甚至一键生成小红书的文案工具。核心的“解析—路由—执行—输出”四层架构是通用的。我个人在实际使用中还有一个体验特别深这类工具产品用户的耐心是有限的等待超过 15 秒就会烦躁。所以流式输出不是锦上添花而是刚需。如果你现在准备做一个 AI Agent 产品我的建议是第一天就把 streaming 接上把每个阶段的进度条都做出来哪怕还没加状态面板也要先有“正在处理”的反馈。那个“复合意图先分析再优化”的路径设计后续我打算继续扩展成更细的多步工作流比如让 Agent 先分析出简历里的五个问题再逐项生成修改建议最后汇总成一份完整的优化报告。LangGraph 的条件边和循环节点正好能承载这种更复杂的流程控制。如果你也在做类似的 AI Agent 开发希望这篇复盘能给你一些参考少踩我踩过的坑。
返回列表