ARTICLE DETAIL

资讯详情

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

Next.js 与 LangGraph.js 实战:从零构建 AI Agent 简历分析工具

Next.js 与 LangGraph.js 实战:从零构建 AI Agent 简历分析工具 先交代一下背景。我最近一段时间一直在做 AI Agent 方向的落地尝试选的项目是一个简历工具输入一份简历文本和一份目标岗位 JDAI 自动完成岗位匹配分析、简历问题诊断、逐条优化建议、还能针对这个岗位做模拟面试提问。整套东西跑在 Next.js 应用里底层用 LangGraph.js 做 Agent 编排。现在项目已经上线跑了一段时间我把整个落地过程中的设计思路、技术选型、代码结构、并发处理、部署踩坑都记录下来给正在做类似 AI Agent 项目的人一个可以直接抄作业的参考。先说结论Next.js LangGraph.js 的组合非常适合做“有状态、多步骤、需要工具调用”的 AI Agent 应用。简历工具看起来简单但它正好覆盖了 Agent 落地最核心的几个难点——多轮状态管理、长任务执行、流式输出、并发隔离、token 成本控制。把这个项目吃透基本上你就掌握了 JS 生态里 AI Agent 的主流玩法。1. 内容整体设计与思路拆解1.1 为什么选“简历工具”这个场景练手很多人一上来就想做通用型 AI Agent什么都能干的那种结果做到后面发现状态管理乱成一锅粥、工具调用互相冲突、用户根本不知道怎么用。我个人强烈建议从小而明确的垂直场景开始。简历工具是我认为最适合 Agent 落地的场景之一原因有三个第一个原因简历和 JD 都是文本数据不需要复杂的多模态处理Agent 的输入输出边界非常清晰。第二个原因简历分析这个任务天然是多步骤的解析简历 → 提取关键信息 → 匹配 JD → 生成诊断报告 → 建议修改 → 模拟面试每一步的输出都是下一步的输入这让“图状态编排”有了用武之地。第三个原因用户使用场景是真实刚需投简历前都想让 AI 帮忙看看留存和复购逻辑都成立。我把这个 Agent 定位成“一个人工智能面试官兼简历顾问”核心能力分四块简历结构化解析、岗位匹配分析、逐条优化建议、模拟面试追问。整体体验是用户粘贴简历输入目标岗位 JD点击开始分析前端以流式方式输出分析过程最后生成完整的报告用户还可以针对报告里任何一条建议追问“为什么这么改”Agent 会结合简历原文给出解释。1.2 技术选型背后的关键思考先说为什么用 LangGraph.js 而不是 LangChain.js 的 AgentExecutor。LangChain 的 AgentExecutor 是一个循环执行模型Agent 循环调用模型模型决定调用什么工具执行完工具接着调用模型。这种方式在简单场景下没问题但只要业务流程里有固定顺序的阶段比如先分析再诊断再优化你就很难控制它的执行路径要么靠 Prompt 硬约束要么就失控。LangGraph.js 的核心是状态图StateGraph。它把整个 Agent 流程建模成一张有向图每个节点是一个处理函数每条边把节点的输出传给下一个节点。你可以通过条件边决定“走哪条分支”可以跨节点共享状态还可以用 Checkpointer 把每一步的状态持久化到外部存储让 Agent 记住多轮对话的历史。对于简历工具这种流程相对固定的场景LangGraph.js 的逻辑控制能力是 AgentExecutor 完全比不上的。很多文章在讲 LangGraph.js 和 LangChain.js 如何“替代”彼此。其实它们是不同层级的东西。LangChain.js 提供大模型调用封装、Prompt 模板、工具定义这些基础能力。LangGraph.js 更关注流程编排和状态管理。我实际项目里两个都在用LangChain.js 提供模型实例和消息抽象LangGraph.js 负责把流程组织起来。1.3 并发和技术架构的影响范围刚接触 AI Agent 的人往往只关注“Agent 能不能跑通”容易忽略一个更关键的问题Agent 怎么扛并发。这个关键词背后其实有两层含义。第一层是“单次任务长耗时并发”——一个 Agent 任务可能运行 30 秒甚至更久服务器同时要处理几百个这样的任务。第二层是“多用户隔离并发”——每个用户都拿着自己的简历和自己的对话历史A 用户的任务状态不能被 B 用户冲到。我的架构是这样解决的Next.js 用 App Router 方案纯前端承接交互请求全部走到 Route Handler。Route Handler 里不直接跑 LangGraph而是创建一个独立的 Agent 服务实例通过 API 调用。从用户体验层面我用流式输出SSE让结果边生成边推送用户不用干等转圈。从部署层面Agent 服务跑在 Node Runtime 而不是 Edge Runtime因为有状态编排需要完整的 Node API。从状态隔离层面每个用户每个会话分配一个独立的 threadIdLangGraph 的 Checkpointer 基于 threadId 做持久化天然隔离。2. 核心细节解析与实操要点2.1 LangGraph.js 的五个核心概念不搞懂后面全是坑我拆成五个点说这五个点是我用下来觉得必须真正理解的东西。State状态图里所有节点共享一个 State 对象各个节点往里面写数据后边的节点读数据。你要定义一个类型来描述这个 State 的完整结构。我在简历工具里定义的 State 大概是这样的原始简历文本、JD 文本、结构化简历数据、匹配分析报告、诊断问题列表、优化建议列表、对话历史。写完这个类型你会发现很多东西都清晰了。Node节点一个普通的异步函数接收 State 作为参数返回一个对象对象里的字段会被合并进全局 State。比如我的节点结构是async function analyzeResume(state: AgentState): PromisePartialAgentState { // 调用 LLM 分析简历返回 { resumeAnalysis: ... } }这里有个关键点节点函数是纯函数式的它不能直接修改传入的 State只能返回要更新的数据子集由 LangGraph 内部去 merge。不理解这一点后面做状态持久化时会出各种奇怪的问题。Edge边定义节点的执行顺序最简单的是一个节点跑完跑下一个节点。LangGraph 支持普通边和条件边。条件边用一个路由函数根据当前 State 的内容决定走向哪个节点。我在模拟面试模块就用了这个能力用户回答说“不想模拟了”路由函数就把流程切到总结节点而不是继续追问。Checkpointer检查点这是 LangGraph 最有价值的功能。它可以把每一步执行完的 State 快照保存到一个存储后端内存、Redis、SQLite 都可以。当用户对话中断你拿着同一个 threadId 进来Agent 会把之前的状态恢复出来接着往下跑。对简历优化这种多轮对话场景没有 Checkpointer 就等于失忆。RecursionLimit递归上限图里的循环边如果没有控制好很容易陷入死循环。RecursionLimit 防止无限执行。默认值是 25当你在图里设计“用户追问→AI 回答→再追问→再回答”这种循环时要预估最大可能循环次数太小会截断正常对话。2.2 设计简历 Agent 的状态图谱想清楚再写代码我把整个 Agent 流程画成了一张有向图这个功能类似于组件图、数据流转图的思维。Node 之间的依赖关系通过共享 State 中的数据实现。整个流程如下用户输入简历JD │ ▼ 节点1提取简历关键信息 │ ▼ 节点2对比 JD 做匹配分析 │ ▼ 节点3生成诊断报告 │ ▼ 节点4生成逐条优化建议 │ ▼ 节点5: 进入问答循环条件边 ├── 用户有新问题 → 回答后回到节点5 └── 用户结束 → 结束务必注意这个图里面第 5 节点是一个带循环的节点。我允许用户最多追问 6 轮第 7 轮就直接给总结。为什么不设置成无限轮两个原因一是每一轮都在烧 token开放循环会让成本失控二是简历场景的追问是有天然边界的用户不可能问 50 次还不结束六轮已经能覆盖绝大多数有效对话。LangGraph.js 构建图的 API 是链式调用风格。创建图之后编译compile 出来的就是可调用的 Agent 对象。设计图结构时我总结出几条经验不要把所有逻辑塞进一个超大的节点拆成小节点便于调试不要为了拆而拆节点之间没有明显的状态消费关系时拆了反而增加复杂度条件边的路由函数里不要写复杂业务逻辑只做“看状态挑路径”这件事。2.3 Next.js 中放置 Agent 的三种方式实战比选很多人问我Agent 应该放 Next.js 的 API Route 里还是 Server Action 里还是独立服务。我把三种方式都试了一遍结论如下方式一API Route 里直接跑 Agent。最简单代码都在一个项目里部署也方便。但缺点很明显——API Route 如果不设置长超时Serverless 平台会直接掐断请求。我第一版就是这种方式在本地跑得好好的部署到线上用户一分析就断排查了半天发现是请求执行超过了平台限制。方式二Server Action 里跑 Agent。Next.js 的 Server Action 适合做表单提交和操作类任务不适合跑这种长时间运行的大任务。流式输出在 Server Action 里的支持也比较别扭。方式三独立 Agent 服务 Next.js 作为壳层。这是最终采用的方式。Next.js 应用只负责页面渲染、用户交互、请求转发。Agent 核心逻辑跑在独立服务上用标准的 HTTP 协议通信。这样部署灵活Agent 服务可以弹性扩缩容Next.js 层做反向代理和权限拦截。我在项目里用了一个轻量方案同一个 Node 进程里仍然跑着 LangGraph但 API 的设计让它看起来像一个独立服务Route Handler 通过封装好的 SDK 去调。这样本地开发不用启动两个服务生产环境想拆开时只需要改环境变量指向远程服务。2.4 流式输出别让用户对着加载圈干等AI Agent 处理简历这种任务一次完整流程跑完可能消耗 20 到 40 秒。这个时长如果前端只是转圈用户早就跑了。所以我在架构设计上必须考虑流式输出。LangGraph.js 支持在编译好的 Agent 上调用 stream或 streamEvents方法逐段产生事件。我的做法是编译后的 Agent 调用 stream 时会先后触发节点开始、节点结束、LLM 生成 token 等事件。我把这些事件在 HTTP 响应里以 SSE 格式输出到前端前端用 fetch 的 ReadableStream 解析每收到一个 chunk 就更新界面。用户可以亲眼看到“正在提取简历信息… 正在对比 JD… 正在生成建议…”的过程这个体验和等一个 40 秒的转圈完全不一样。SSE 实现的核心代码如下const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const events agent.streamEvents(input, { version: v1 }); for await (const event of events) { controller.enqueue(encoder.encode(data: ${JSON.stringify(event)}\n\n)); } controller.close(); }, });前端解析时有几个注意点一是浏览器对 SSE 的 EventSource API 不支持自定义请求头但大家一般都走 fetch 流这个坑只有用纯 EventSource 的人才会遇到。二是网络层偶尔会把 chunk 拼接后一次性发过来前端解析时要按换行符来切分数据。三是前后端事件结构要约定一个简化版本不要直接把 LangGraph 的原始事件丢给前端那个结构太深了。3. 实操过程与核心环节实现3.1 环境初始化与依赖安装版本对齐别马虎我用的是 Next.js 14 App Router。初始化项目的命令npx create-next-applatest resume-agent --typescript --tailwind --appAgent 相关的依赖核心实际上是这几个包我标注了版本范围这些版本组合实测兼容npm install langchain/langgraph langchain/openai langchain zod jsonrepair这里特别提醒一定要用 langchain/langgraph 这个包不是 langchain/langgraph-node 或其他变体。LangGraph.js 生态更新比较快API 偶有调整建议初始化后立刻跑一个最简图的冒烟测试确认编译和调用都通过再往项目里铺业务逻辑。另外模型调用我推荐走 OpenAI 兼容接口的 provider在环境变量里配 BASE_URL 和 API_KEY。这么做的好处是模型层可以随时切换不用改动业务代码。3.2 定义状态类型与构建图结构先看状态类型。我把 Agent 内部共享的所有数据都定义在一个 interface 里interface AgentState { resumeText: string; jdText: string; parsed: ParsedResume | null; analysis: JobMatch | null; diagnosis: DiagnosisItem[]; suggestions: SuggestionItem[]; messages: BaseMessage[]; round: number; }图构建的核心代码长这样import { StateGraph, START, END } from langchain/langgraph; const workflow new StateGraphAgentState({ channels: { messages: { reducer: messagesStateReducer }, }, }) .addNode(parse, parseNode) .addNode(analyze, analyzeNode) .addNode(diagnose, diagnoseNode) .addNode(optimize, optimizeNode) .addNode(qa, qaNode) .addNode(finish, finishNode) .addEdge(START, parse) .addEdge(parse, analyze) .addEdge(analyze, diagnose) .addEdge(diagnose, optimize) .addEdge(optimize, qa) .addConditionalEdges(qa, routeFromQA, [qa, finish]) .addEdge(finish, END); export const agent workflow.compile({ checkpointer });messages 这个字段的 reducer 用了messagesStateReducer这是 LangGraph 提供的消息合并函数它会自动把新的消息追加到已有消息数组末尾而不是覆盖。3.3 核心节点的具体实现从调 LLM 到写进状态我拿“诊断节点”diagnose举例你就能理解节点函数是怎么写的async function diagnoseNode(state: AgentState): PromisePartialAgentState { const model getModel(); // 返回 ChatOpenAI 实例 const prompt buildDiagnosisPrompt(state.parsed, state.analysis); const response await model.invoke([ { role: system, content: prompt.system }, { role: user, content: prompt.user }, ]); const content response.content as string; const cleaned jsonrepair(content); // 容错处理 const diagnosis JSON.parse(cleaned).diagnosis; return { diagnosis }; }两个关键细节。第一个是 jsonrepair 这个库LLM 返回的 JSON 经常有尾逗号、单引号、缺引号这些问题直接 JSON.parse 会炸先 jsonrepair 再做 parse 能把失败率从 30% 降到 1% 左右。第二个是节点返回对象里的字段冗余数据不要返回你返回什么就写什么。如果你想在节点里读写消息史务必理解 reducer 的合并逻辑避免把之前的历史消息覆盖掉。3.4 完整的调用入口从 HTTP 请求到 Agent 执行Route Handler 的完整流程是这样的export async function POST(req: Request) { const { resumeText, jdText, threadId } await req.json(); // 线程隔离的关键一个用户一个 threadId const config { configurable: { thread_id: threadId }, recursionLimit: 40, }; const state agent.getState(config); const currentMessages state?.values?.messages ?? []; const stream await agent.stream( { resumeText, jdText, messages: currentMessages, parsed: state?.values?.parsed ?? null, analysis: state?.values?.analysis ?? null, diagnosis: state?.values?.diagnosis ?? null, suggestions: state?.values?.suggestions ?? null, }, config ); // 产出 SSE 流 return new Response(streamToSSE(stream), { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }核心逻辑是进来先查上一次保存的 Checkpointer 状态把历史的消息拿出来拼到这次输入里再调用 agent.stream 执行。threadId 是隔离单位前端每次发起分析时生成一个 uuid存到 localStorage后续追问都带同一个 threadIdAgent 就能记住上下文。3.5 前端交互和状态展示不那么重要但要顺前端这一层相对简单。用户输入区域是两个文本框一个放简历文本一个放 JD 文本点击“开始分析”后用 fetch 发出 POST 请求拿到 ReadableStream 后按事件类型渲染。关键的经验是不要用 axios 处理 SSE就用原生 fetch。原生 fetch 返回的 response.body 就是 ReadableStream直接 for await 遍历读取即可。事件类型我在 Agent 服务里做了二次封装前端只用判断三个类型node_start 显示当前在跑什么步骤text_delta 追加生成内容report_complete 显示最终报告。3.6 部署要点环境变量、持久化、超时与资源部署这块我踩的坑最多整理出几个硬约束。第一LangGraph 的 Checkpointer 如果只接内存部署到 Serverless 环境后所有的 threadId 状态都会丢失因为函数实例会被回收。我最后用的是 SQLite 持久化方案在文件系统里保存状态。但 Serverless 的文件系统也不是持久的所以生产环境要改成托管数据库。注意 LangGraph 官方提供了一组 Checkpointer 适配器SQLite、Redis、Postgres 都有对应实现。第二Next.js Route Handler 里如果直接跑 Agent 且没有流式输出Serverless 平台有执行时长限制。流式输出能让限制放宽不少因为它是一边算一边返回字节平台判定请求活跃度不同。但即便是流式也要关注整个连接的最长持续时间。第三Node Runtime 和 Edge Runtime 的区分。默认 Route Handler 在 Node Runtime 下运行这没问题。千万不要为了“性能”把 Route Handler 切到 Edge RuntimeEdge 不支持完整的 Node APILangGraph.js 的很多能力在 Edge 上不可用。第四并发扩大后模型 API 的限流会先到。给自己做一个简单的队列层或者换一个支持更大并发上限的 provider这是并发能力的关键瓶颈。4. 常见问题与排查技巧实录4.1 问题一状态丢失重启后整个会话消失现象用户分析完一轮刷新页面重新发起追问Agent 完全不记得自己刚才分析过什么。原因基本是 Checkpointer 没有正确配置持久化或者每次请求传的 threadId 不一致。排查路径翻后端日志看每个请求进来时 agent.getState 返回的是 null 还是有值。如果返回 null先检查同一个 threadId 是否传进了图调用再检查 Checkpointer 的后端存储是否真的写入。特别注意如果只在内存里存状态任何服务重启、部署更新、实例回收都会丢。这类问题通过日志基本能解决。我最后在管理后台加了“检查一个 threadId 的状态存在性”的调试接口排查效率大大提高。4.2 问题二并发请求导致状态相互覆盖现象同一个用户手滑点了两次“开始分析”或者同时开了两个浏览器标签页结果两份简历的内容混在一起又或者用户在流还没结束时又发了一条消息。这个是并发场景里最典型的坑。原因是同一个 threadId 在同一个时间点被多个请求使用后写覆盖先写。LangGraph 的 Checkpointer 并没有内置的“锁”机制。解决办法分两层。用户层前端在流式未结束时禁用按钮防止重复提交。服务层给每个用户一个全局唯一的 threadId每个分析任务再生成一个 taskId任务驱动执行尽量不要让同一个 threadId 并发访问。4.3 问题三Runner 请求超时被切断现象本地一切正常部署上线还是经常出现“连接已重置”类的错误。多半是运行平台对请求时长有硬限制而 Agent 流程太长。排查路径先在本地模拟超长文本估算完整流程的平均耗时用 Benchmark 数据说话。再看 SSE 的响应头是否正确——如果没有 Content-Type 设为 text/event-stream 并且持续 flush平台可能认为连接空闲而切断。方案有两个一是优化 Agent 流程把模型调用改成流式生成让服务端持续输出字节连接不会空闲二是把超长任务改成异步任务模式请求进来直接返回 taskId后台跑 Agent前端轮询任务状态或通过 WebSocket 订阅结果。后者工程量大一些但面对超长耗时场景更稳。4.4 问题四token 消耗不可控账单吓人“AI Agent token 是什么意思”是很多人刚接触时的困惑。说人话版本token 是大模型计费的最小单位约等于一个英文单词的一部分或一个汉字的一小段。你发给模型的每一条消息、模型生成的每一个字都按 token 计算费用。Agent 跟普通单次调用的区别在于它有多轮推理、工具间数据传递、上下文拼接同一个任务消耗的 token 数是普通问答的 5 到 10 倍。简历工具是典型的 token 大户原始简历可能三四千字JD 又是一千字再加上系统提示词、分析结果、用户多轮对话历史每次图执行携带的上下文很长。我最后的做法是限定上下文体积对话历史只保留最近 4 轮更早的做摘要。结构化统一压缩不需要把原始简历反复塞进每轮 Prompt解析完成后只传递结构化结果。设置硬性上限在 LangGraph 调用模型之前检查当前 context 的 token 预估达到阈值就先做摘要再继续。控制循环次数问答循环上限 6 轮多一点都只给总结。日志降噪不要在 LLM 输出里打印完整 JSON只打印字段名和长度。4.5 其他零碎问题速查表我把常见问题整理成了一个速查表遇到问题直接对照问题原因解决方案图编译报错节点找不到addNode 名字拼写不一致核对所有 addNode/addEdge 的字符串模型返回 JSON 解析失败LLM 输出不规范接入 jsonrepair 做预处理前端收不到任何流数据SSE 未设置正确的 Content-Type检查响应头和 flush 行为多轮对话后上下文漂移历史消息盲目拼接未裁剪提炼最近 N 轮 摘要旧对话Agent 陷入死循环循环边没有条件出口增加 recursionLimit 和条件边路由threadId 用错导致串号前端 uuid 生成逻辑有 bug前端服务端双重校验 threadId 合法性生产环境状态不断丢失内存 Checkpointer 不支持持久化迁移到 Redis 或数据库 Checkpointer4.6 避坑技巧先把图可视化预览再跑业务推荐一个比较实用的开发技巧在正式编写业务节点之前先用 LangGraph 把图打印出来看一眼。打印出来的结构能直观展示每个节点和边的连接关系。当你的图变大之后比如节点超过 6 个你会发现手写连接经常出现“忘了给某个节点加结束边”“条件边两个分支指向同一个终点”这类问题。看一眼图结构就能省下大量调试时间。我开发的过程中有一半的 bug 是在看图的时候发现的而不是调试日志的时候发现的。5. 项目价值与可扩展方向总结个人实操体会项目到这里已经完成了从选型到落地的闭环。回归到最初的主题Next.js LangGraph.js 让 AI Agent 开发真正想清楚了怎么落地。如果你也在考虑用这套技术栈做 AI 应用我的个人体会是这组合确实比普通 Prompt Chain 多了一层“可控性”和“生产可用性”。一个值得说的细节是真正难的不是把 LangGraph 图跑起来而是想清楚状态边界在哪里。我在开发中反复改了好几次图的节点划分——最开始把所有逻辑都塞进一个节点后面发现根本无法排查问题后来把节点拆细每个节点只做一件事定位问题变成了“看看哪个节点出的结果不对”排查效率直线提升。尤其是调试多轮对话时这个改动让整个开发体验完全不同。这个简历工具后续还可以继续扩展。比如接入简历文件解析PDF/Word打通 ATS 系统做直接投递或者把“模拟面试”改成多模态对话。但核心的 Agent 架构骨架已经稳定新增功能无非是往图里加节点的问题。最后分享一个排错经验如果 Agent 表现总是不可控先用最原始的输入测一遍——喂一个最简单的简历、最简单的 JD、关闭所有多余提示词看 Agent 能不能给出正确结果。如果最简场景都出问题说明是技术链路的问题而不是 Prompt 策略的问题。如果最简场景正常但复杂场景失控那基本可以确定是 Token 上下文处理或 Prompt 组合设计的问题针对性调整 Prompt 逻辑往往就能解决。这套排查思路在我做 AI Agent 的过程中反复用到非常值得一试。
返回列表