
上个月我把一个内部简历工具从“表单 模板渲染”重写成了“AI Agent 多轮对话式生成”技术栈选的 Next.js LangGraph.js。改完之后我最大的感受是用户在使用这类工具时根本不按表单逻辑出牌——有人一上来就甩一大段零散经历有人半天憋不出一句完整描述还有人中途突然想起“哎我还有个开源项目忘了写”。这种碎片化、多轮、带条件分支的交互用传统表单根本兜不住但交给一个状态图驱动的 AI Agent 来做反而变得很自然。这篇文章不绕弯子直接讲我们是怎么用 Next.js 做服务端、用 LangGraph.js 编排 Agent 状态图把一个简历生成工具完整落地的。内容会覆盖场景分析、状态图设计、SSE 流式接入、并发压力下的无状态化改造以及真机跑起来之后踩到的几个坑。适合已经写过一些 LLM 调用、想往 Agent 架构上走的开发者也适合正在纠结“我这个需求到底要不要上 Agent”的人。1. 简历生成这件事为什么非得用 Agent先说结论不是所有 CRUD 场景都值得上 Agent但简历生成这个场景恰好踩中了 Agent 的舒适区。拆开看就三件事——信息收集、结构化输出、格式渲染每一件单独拎出来都不复杂可一旦用户真实参与进来复杂度就上来了。1.1 传统表单交互的痛信息总是碎片化的绝大多数简历工具的交互方式是一张长长的表单姓名、电话、教育背景、工作经历、项目经历、技能标签、自我评价……每项一个输入框。听起来没毛病但用户的真实行为是先写公司名和职位然后卡在“项目描述怎么写”上想半天或者先贴一段过去简历的纯文本再一点点拆。更常见的是用户根本不想一次性填完他今天想到什么补什么。表单本身无法处理这种“半成品状态”。你要么做分步表单把流程固定死要么做一个巨大的自由文本输入框让用户自己整理。前者把用户当机器人后者把整理负担全甩给用户。AI Agent 的多轮对话天然适合这种场景。用户说“我在字节干了三年前端”Agent 可以继续追问“主要做什么业务方向有没有带过团队”。用户突然贴一段 GitHub 项目描述Agent 能自动识别出这是项目经历问一句“这个项目你负责的是哪部分”然后收进对应字段。整个交互是收敛的而不是让用户面对一张 20 个空格的表单发愁。1.2 Agent 和普通 LLM 直连的差别在哪有人会说那不就是要一个聊天机器人吗我直接用 ChatOpenAI 的 API把历史消息往 system prompt 里一塞让 AI 回答问题不就完了能跑但你会发现纯 LLM 直连有几个问题很难处理。第一它没有“确定的出口”用户信息收集齐了模型可能还在闲聊不往下走。第二它无法稳定地执行“渲染成模板”这种动作——让 LLM 直接输出 HTML 不是不行但输出结果不可控模板样式稍微复杂一点它就会给你整出各种奇怪的 CSS。第三多轮对话的状态全靠消息数组硬扛如果中途要插入一个“信息完整度校验”这种确定性的逻辑就只能靠提示词碰运气。Agent 解决的是这个分层问题LLM 只负责“理解和生成文本”这种不精确的部分而“信息是否齐全”“要不要进入渲染环节”“调用哪个模板”这类确定性的规则交给代码节点和条件边来管。这整套编排能力正是 LangGraph.js 提供的东西。1.3 为什么不选 Python 版 LangGraph 而选 LangGraph.js如果团队里没有 Python 服务完全没必要为了一个简历工具再造一个 Python 后端。LangGraph.js 是 LangGraph 的 TypeScript/JavaScript 版本API 设计和 Python 版基本对齐核心概念就是 StateGraph、节点、边、条件边。更重要的是我们整个项目就是 Next.js前后端同一门语言Agent 的状态定义、工具函数、LLM 调用逻辑可以全部放在一个包里维护类型还能共享这比拆成 Python 服务和 Node 前端两个项目省心太多。顺带说一句LangGraph.js 对 TypeScript 的类型推导做得不错State 的字段类型、节点返回值的结构、条件边的返回值都会有比较明确的类型提示对于习惯了前端工程化的团队来说上手成本其实比 Python 版更低。2. 把简历生产画成状态图LangGraph.js 的节点与边LangGraph 的核心思想是把 Agent 的每一次运行定义成一个“图”节点是具体的处理步骤边是步骤之间的流转条件边负责根据当前状态决定下一步往哪走。简历工具这个场景图结构不复杂但设计得好不好直接决定了后面迭代顺不顺手。2.1 先定义 Agent 的记忆与状态ResumeAgentState在写任何节点之前先把状态结构定义清楚。这相当于给 Agent 定了一套“工作记忆”所有节点读写都基于这份状态。我们设计的状态长这样import { Annotation } from langchain/langgraph; import { BaseMessage } from langchain/core/messages; export interface UserProfile { name?: string; yearsOfExperience?: number; skills?: string[]; projects?: Array{ name: string; role: string; description: string; highlights?: string[] }; education?: Array{ school: string; major: string; degree: string; period: string }; expectation?: { position: string; salaryRange?: string; location?: string }; } export const ResumeAgentState Annotation.Root({ messages: AnnotationBaseMessage[]({ reducer: (current, incoming) current.concat(incoming), }), profile: AnnotationPartialUserProfile | undefined(), collectedFields: Annotationstring[]({ reducer: (current, incoming) Array.from(new Set([...(current ?? []), ...(incoming ?? [])])), }), resumeDraft: Annotationstring | undefined(), renderError: Annotationstring | undefined(), }); export type ResumeAgentStateType typeof ResumeAgentState.State;这里有个细节值得说messages 字段用的是数组 reducer这样每个节点返回的新消息会自动追加到历史里不会覆盖旧消息。collectedFields 记录当前已经收集到的简历字段名用什么字段就让节点返回什么不用管顺序。Profile 字段没有写 reducer默认行为是新值覆盖旧值。这在我们的场景里是合理的——如果中间某个节点更新了 profile我们希望拿到的是最新版本而不是历史合并结果。2.2 拆分节点收集、生成、渲染三条主线整个简历生成流程我们拆成了三个业务节点加一个 START/END 系统节点。从工程角度看节点拆得越细单个节点的逻辑就越容易测试后续在旁边加新能力比如“把简历翻译成英文”“针对 JD 优化关键词”时只需要插入新节点和条件边不需要动原有节点的内部实现。第一个节点是 collectInfo。它的职责是看当前 profile 缺哪些必要字段然后调用 LLM 生成一句或一段提问。注意这里的 LLM 调用只负责生成提问文本不负责判断信息是否齐全——“判断”这个动作从 LLM 手里拿出来交给代码做这是 Agent 编排里非常重要的一条原则。第二个节点是 generateResume。信息齐全后进入这个节点它会把对话历史里散落的信息聚合成一份结构化 profile然后调用一个工具生成最终简历内容。这个节点的输出不一定是一段纯文本更多时候是一个“工具调用请求”。第三个节点是 renderResume。它负责执行渲染把 structured profile 和选中的模板名传给一个渲染函数拿到 HTML 或 PDF 字符串存进 resumeDraft。渲染动作是确定性的代码不经过 LLM。2.3 条件边与工具注册把“信息够不够”变成程序逻辑条件边是这套图的灵魂。collectInfo 节点跑完之后不能无条件进入 generateResume它要先检查 profile。检查逻辑写成一个纯函数function isProfileComplete(profile: PartialUserProfile): boolean { if (!profile?.name || !profile.yearsOfExperience) return false; if (!profile.skills || profile.skills.length 3) return false; if (!profile.projects || profile.projects.length 0) return false; return true; }这个函数放在节点外面方便单独单测。然后注册条件边.collection(collectInfo) .addConditionalEdges(collectInfo, (state) { return isProfileComplete(state.profile) ? generateResume : collectInfo; }, [generateResume, collectInfo])如果信息不全条件边让图重新回到 collectInfo 节点继续下一轮提问如果齐全就走到 generateResume。整个过程 LLM 不需要自己做“到底完没完”的判断这个决策完全由确定性的代码掌控。这对用户体验是决定性的聊到第六轮模型还在问“请告诉我你的姓名”这种事在纯提示词方案里经常发生在状态图方案里不可能。工具注册用 LangChain 的 tool 函数参数校验交给 zodimport { tool } from langchain/core/tools; import { z } from zod; const renderResumeTool tool( async ({ template, profileJson }) { const profile JSON.parse(profileJson) as UserProfile; const html renderResumeHtml(template, profile); return html; }, { name: render_resume, description: 将结构化的简历信息渲染成最终 HTML 简历模板可选现代、经典、紧凑三种风格。, schema: z.object({ template: z.enum([modern, classic, compact]).describe(简历模板风格), profileJson: z.string().describe(简历信息的 JSON 字符串必须包含姓名、技能、项目等完整信息), }), } );工具描述和参数描述一定要写清楚因为 LLM 是读这些 description 来决定什么时候调用工具、传什么参数的。工具描述写得含糊模型就会在奇怪的时候触发工具调用。2.4 构建 StateGraph 的代码骨架最后把这些拼起来编译成图import { StateGraph } from langchain/langgraph; const workflow new StateGraph(ResumeAgentState) .addNode(collectInfo, collectInfoNode) .addNode(generateResume, generateResumeNode) .addNode(renderResume, renderResumeNode) .addEdge(__start__, collectInfo) .addConditionalEdges( collectInfo, (state) (isProfileComplete(state.profile) ? generateResume : collectInfo), [generateResume, collectInfo] ) .addEdge(generateResume, renderResume) .addEdge(renderResume, __end__); export const resumeGraph workflow.compile();编译出来的 resumeGraph 就是一个可被反复调用的对象。每次调用可以传入不同的消息状态图会自己决定走哪条路径。这意味着我们把“简历生产流程”从一个写死的 if-else 逻辑变成了一张可观测、可调试、可扩展的图。后续加一个“根据 JD 优化措辞”的节点只需要在 renderResume 后面加一条边非常轻量。3. 挂到 Next.js 上用API Route 与 SSE 流式输出的完整串联图本身跑在纯 Node 环境里没有任何 Next.js 依赖。真正要接的是服务端入口。我们用的 Next.js App RouterAgent 的执行体放在 API Route 里前端通过 fetch 拿流式响应。3.1 为什么服务端编排而不是页面里直接跑有人在浏览器端直接 new ChatOpenAI 然后在前端跑 Agent我强烈不建议这么干。原因很简单API Key 一旦打进前端包就是公开的。而且 Agent 的状态图在服务端跑才能统一控制并发、做日志、做权限校验。Next.js 的 API Route 天然适合做这个中间层——它就是一个普通的 Node 函数你可以在这里做认证、限流、调用 Agent、把结果流式吐给浏览器。3.2 API 路由设计对话接口和渲染接口分开我们实际开了两个路由POST /api/agent/chat接收用户新消息 当前状态快照返回 Agent 的流式增量。POST /api/resume/render接收结构化 profile 和模板名返回最终 HTML。这个接口是纯粹的确定性渲染供前端在“编辑模式”下单独调模板用不经过 LLM。为什么渲染要单独拆一个接口因为 Agent 图里工具调用的渲染结果和前端直接选模板的预览本质是同一件事。如果只把渲染能力锁死在 Agent 内部后续前端做个“换模板实时预览”就没法直接复用了。接口拆分遵循一条原则LLM 参与的路径和纯代码路径分开能不走 LLM 就绝不多花钱。3.3 SSE 流式下发的代码实现LangGraph.js 编译后的图对象支持 stream 方法可以按节点产出增量。我们用的是 streamMode: updates——每产生一个节点的输出就往 SSE 管道里推一个事件前端可以边收边渲染。import { NextRequest } from next/server; import { resumeGraph } from /lib/agent/graph; import { HumanMessage } from langchain/core/messages; export const runtime nodejs; export const maxDuration 60; export async function POST(req: NextRequest) { const { messages, state } await req.json(); const input { messages: [ ...(state?.messages ?? []).map((m: any) m), new HumanMessage(messages.at(-1).content), ], profile: state?.profile ?? undefined, collectedFields: state?.collectedFields ?? [], }; const stream await resumeGraph.stream(input, { streamMode: updates, recursionLimit: 30, }); const encoder new TextEncoder(); const readable new ReadableStream({ async start(controller) { try { for await (const update of stream) { controller.enqueue(encoder.encode(data: ${JSON.stringify(update)}\n\n)); } controller.enqueue(encoder.encode(data: [DONE]\n\n)); controller.close(); } catch (error) { controller.enqueue(encoder.encode(data: ${JSON.stringify({ error: (error as Error).message })}\n\n)); controller.close(); } }, }); return new Response(readable, { headers: { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }几个细节值得注意。runtime 必须设成 nodejs不能用 edge runtime——LangGraph.js 内部依赖一些 Node 内置模块edge 下会直接报错。maxDuration 在流式响应场景下同样重要因为一个多轮 Agent 的完整执行时间可能远超普通接口。3.4 前端怎么消费这个流前端侧我们用 fetch ReadableStream 手动解析 SSE没有引入额外的 SSE 库因为事件格式简单自己解析反而更可控。async function chatWithAgent(userMessage: string, state: any) { const response await fetch(/api/agent/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: [...state.messages, { role: user, content: userMessage }], state }), }); const reader response.body!.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop() ?? ; for (const line of lines) { if (line.startsWith(data: )) { const payload line.slice(6); if (payload [DONE]) continue; const event JSON.parse(payload); handleAgentEvent(event); // 根据节点名更新 UI } } } }前端拿到的事件结构是按节点区分的比如 collectInfo 节点的事件里是一段回复文本renderResume 节点的事件里是最后的 HTML。因为事件里带了节点名前端可以据此做不同的 UI 展示——聊天界面只展示 collectInfo 的文本渲染结果则在侧边栏预览。4. 并发压力下怎么扛无状态化设计与调用治理很多人关心“AI Agent 怎么扛并发”这其实是个复杂话题因为 Agent 的耗时和普通接口完全不同。一个普通接口 100ms 返回Agent 可能要 10 到 30 秒。这中间任何一个环节卡住都可能拖垮整个服务。4.1 实话实说Agent 的并发瓶颈不在框架在 LLM 调用先客观说一句LangGraph.js 本身是个很薄的状态机编排层它的 CPU 开销几乎可以忽略。真正的瓶颈在 LLM 供应商的响应时间以及你对每个请求持有的连接资源。Node.js 的事件循环天然适合这种 IO 密集型等待场景——你在等 LLM 返回时事件循环并没有被阻塞其他请求照样在跑。但有一个隐藏问题Agent 里可能调用了多个 LLM 串行执行一个节点一次调用多节点串起来就多次。如果每个请求内部串行调了 3 次 LLM每次耗时 4 秒那么这个请求的总耗时就是 12 秒。如果一个进程同时来 100 个请求虽然事件循环没卡死但 LLM 供应商那边会先扛不住返回 429 限流错误。4.2 方案 A用 Redis checkpointer 把会话状态摘出来第一种做法是用 LangGraph.js 的 Checkpointer 机制把会话状态持久化到 Redis。这样图可以暂停、恢复状态不放在内存里多个 Node 实例都能读到同一个会话的状态真正支持水平扩展。LangGraph.js 的 BaseCheckpointSaver 接口在做自定义实现时要小心不同小版本的抽象方法签名略有差异。核心思路是实现四个方法getTuple、put、putWrites、deleteTuple。一个最简的 Redis 实现长这样import { BaseCheckpointSaver } from langchain/langgraph; import { createClient } from redis; class RedisCheckpointSaver extends BaseCheckpointSaver { constructor(private client: ReturnTypetypeof createClient) { super(); } async getTuple(config: any) { const threadId config.configurable?.thread_id; if (!threadId) return undefined; const raw await this.client.get(resume:agent:${threadId}); return raw ? JSON.parse(raw) : undefined; } async put(config: any, checkpoint: any, metadata: any, newVersion: any) { const threadId config.configurable?.thread_id; const key resume:agent:${threadId}; await this.client.set(key, JSON.stringify({ checkpoint, metadata, newVersion }), { EX: 3600 }); } async putWrites(config: any, writes: any, taskId: string) { // 这个接口要按版本看实现有些版本可以留空 } async deleteTuple(config: any) { const threadId config.configurable?.thread_id; await this.client.del(resume:agent:${threadId}); } }然后把 saver 传给 graphexport const resumeGraph workflow.compile({ checkpointer: redisSaver });之后调用时config 里带上 thread_id图就能从对应会话的历史节点恢复。这个方案适合多实例部署、需要严格会话管理的线上环境。4.3 方案 B纯无状态调用客户端回传消息上下文如果只是个小工具、不需要严格的暂停恢复我建议先用更轻的无状态方案状态快照直接由前端带回服务端每次从完整状态重建。反正我们的 ResumeAgentState 本身是普通 JSON 结构完全可以序列化返回给前端。前端在每次请求时把上次拿到的 state 原封不动地放进 body 传回来。服务端拿到 state 后即使图每次从start开始执行条件边会立刻判断“信息已齐全”直接跳过 collectInfo 走到 generateResume不需要一个真的“恢复”机制。这个设计妙在我们不是靠框架的断点续跑而是靠状态图本身的可重入性。状态是完整的图每次从入口跑一遍也只会做“该做的事”。它代价是多做一次节点名检查换来的是服务端完全不用存内存 session彻底无状态每个实例都能服务任意请求。4.4 超时、重试与信号量防止 LLM 拖垮 Node 进程不管用哪种会话方案并发治理必须做。我们实践下来有三板斧。超时必须设置。给每个 LLM 调用设置 30 秒超时超了就返回错误提示不要无限等。Next.js 的 fetch 可以传 AbortSignalLangChain 的模型也都支持 timeout 参数。const model new ChatOpenAI({ model: process.env.LLM_MODEL ?? gpt-4o-mini, temperature: 0.3, timeout: 30_000, maxRetries: 2, });重试对 429 限流和临时网络错误很有用但要注意指数退避。不要把所有请求的重试都挤在同一时刻那会造成惊群效应。信号量是保护 LLM 供应商配额和本机连接池的关键。我们内部做了个简单的异步信号量控制同一时间同时进行的 Agent 执行数class Semaphore { private queue: (() void)[] []; private active 0; constructor(private limit: number) {} async acquire(): Promisevoid { if (this.active this.limit) { this.active; return; } return new Promise((resolve) this.queue.push(() { this.active; resolve(); })); } release(): void { this.active--; const next this.queue.shift(); if (next) next(); } } const agentSemaphore new Semaphore(20); export async function runWithConcurrencyLimitT(fn: () PromiseT): PromiseT { await agentSemaphore.acquire(); try { return await fn(); } finally { agentSemaphore.release(); } }每个 API Route 的入口套上 runWithConcurrencyLimit超出并发上限的请求会排到队列里等待而不是一股脑打到 LLM 供应商那里。4.5 用 Next.js 部署时要注意的配额与限制如果服务部署在 Vercel平台自带的限制要提前摸清Hobby 和 Pro 的 function duration 上限不一样Node runtime 的冷却时间和内存也有约束。如果 Agent 的平均执行时长超过 30 秒Vercel Serverless 可能不是最优选择更稳的是自托管一个 Node 服务或者用容器平台。我们实测下来的经验2 核 4G 的 Node 实例跑这个三节点图压到 50 个并发客户端服务端 CPU 占用不超过 30%瓶颈完全在 LLM 供应商的响应时长上同等条件如果不用信号量保护直接在代码里限制 LLM 调用供应商 10 分钟内必返 429。这个对比很能说明问题。5. 真机跑起来的坑版本、工具参数与流式丢消息最后这部分是实际操作中踩过的坑。代码能一次跑通是运气跑不通才是常态。我把几个印象最深的列出来希望能帮你少走弯路。5.1 LangGraph.js 的版本坑LangGraph.js 迭代速度很快API 变动比 Python 版更频繁。我们项目从 langchain/langgraph 0.2.x 一路升到 0.5.x期间踩到了两个明显的破坏性变化。第一个是 Annotation 的用法。0.2 时代用的是AnnotationType({ reducer })这种接近 zustand 的写法后来推荐改成Annotation.Root({...})一次定义整棵状态树。老的写法在 0.5.x 里还能用但类型提示会变得很怪。第二个是 tool 函数。早期版本是DynamicStructuredTool后来变成tool(fn, { schema, description })。如果你在网上看到一篇老教程复制下来的代码在本机直接报 TS 类型错误不用怀疑去项目 changelog 里查一下对应版本的 API 变化。建议package.json 里锁死版本号不要用 ^ 范围团队里统一升级时间。5.2 zod 工具参数校验坑工具调用失败的一大来源是参数校验。我们第一次跑 render_resume 时LLM 有时候会把 template 传成用户在聊天的原文比如“现代化一点的”而 schema 里定义的是 enumzod 直接抛错。LLM 收到抛错后会有一定概率自我纠错重新调用但纠错过程会多消耗一轮 token而且耗时翻倍。解决办法有两个。第一在工具描述里写清楚可选项的字面常量“template 参数只接受以下三种值modern、classic、compact分别对应现代、经典、紧凑风格。”这比单纯写“简历模板风格”有效得多。第二系统提示词里给一个示例映射“如果用户说想要大气一点的选择 modern。”另外schema 里的 describe 字段非常重要这相当于是给 LLM 的“字段说明文档”写得好不好直接决定工具调用准确率不要偷懒。5.3 流式输出丢消息的处理我们早期用 streamMode 默认值messages来推流结果前端收到的不是“每轮新增的消息”而是“全量消息数组的每个版本”。每推一次前端如果无脑 setState就会出现消息重复或覆盖的问题。后来统一改用 streamMode: updates它推的是每个节点的增量输出按节点结构返回比如{ collectInfo: { messages: [...] } }。前端按节点名处理就不会出现“新消息顶掉旧消息”的情况。还有个细节最后一个节点的输出里end事件会带出最终状态这里可以直接取到完整渲染结果前端可以靠它做“完成”状态判断。5.4 最后记得把内存缓存清掉如果早期图实现里在模块级别缓存了 session 状态比如MapthreadId, state跑一段时间后内存会悄悄上涨这是 Agent 服务最典型的隐形 OOM 原因。改用 Redis 或纯无状态方案之后这个问题才会根治。我们的最终选择是方案 B客户端携带状态快照。理由很务实——简历工具没有复杂的长周期会话用户通常几分钟内聊完无状态化让整个服务变得极其简单不需要额外部署 Redis 依赖也方便后续在边缘节点直接部署。如果哪天产品需要一个持续数小时的多轮 Agent 流程再加 Redis checkpointer 也不迟。收尾之前的一点心得整个项目落地下来我最深的体会是LangGraph.js 真正解决的不是“让 LLM 更聪明”而是“让流程可控”。简历工具这个场景里用户目标是明确的Agent 要做的是像老编辑一样该问的时候问该停的时候停最后稳定地产出一份格式正确的文档。这套“LLM 负责理解与生成、代码负责决策与控制”的模式比纯聊天式封装或者纯规则表单都要舒服得多。如果你也想在 Next.js 项目里做类似的东西我会建议先画一个非常小的图哪怕只有一个节点加一个条件边把 SSE 流式打通再逐步加节点。图结构的好处是天然可演进你不需要在项目第一天就把所有流程设计完。等你跑起来后大概率会和我一样开始琢磨能不能把图再拆细一点把更多确定性逻辑掌握在自己手里。