
做 AI Agent 最难受的地方是你在教程里看到的东西和真实跑起来的东西完全是两码事。demo 里一个 agent 在笔记本上优雅地调用几个工具上线之后面对几十个人同时提问就开始卡顿、状态错乱、账单飞涨。我这次用 Next.js LangGraph.js 做了一个简历工具 AI Agent完整走了一遍从选型、工作流设计、开发到部署的流程中间踩了不少坑这篇文章就把整个落地过程复盘一遍重点是那些文档里不会写的东西。这个项目要解决的核心问题很简单用户上传一份简历 PDF填写目标岗位Agent 自动完成简历解析、岗位匹配度评估、给出具体优化建议还能针对某一条建议和用户多轮对话。整套方案里Next.js 承担前后端和 API 层LangGraph.js 负责编排整个 Agent 的决策流程底层模型走 OpenAI 兼容接口。如果你正在学习 AI Agent 开发或者手上有个 Agent demo 不知道该怎么推向生产这篇文章应该能帮你省掉不少弯路。1. 选型分析为什么是 Next.js 和 LangGraph.js1.1 Next.js 扛起前后端一个框架解决 Agent 应用的两大问题做 Agent 应用和做普通 Web 应用不一样它天生需要三样东西一个页面让用户交互一个后端接口来跑 Agent 循环还有一个能长时间维持的流式连接把模型的思考过程逐步推给用户。要是在传统的前后端分离架构里这三样东西意味着至少两个服务中间还要处理跨域、鉴权、WebSocket 连接管理这一堆琐碎问题。Next.js 恰好能把这三样东西收拢到一个项目里。页面部分用 React 组件实现Agent 的执行逻辑放在 API Routes 里流式输出通过 ReadableStream 直接推向浏览器。更省心的是部署到 Vercel 或者任意 Node.js 环境时整个项目就是一个单体应用不需要额外配 Nginx、不需要处理跨域策略测试环境和线上环境的行为高度一致。这不是说 Next.js 是唯一的选择但对于一个想快速验证 Agent 产品逻辑的团队来说它是前期综合成本最低的方案。我实际开发中还用到 Next.js 一个容易被忽略的特性API Route 天然支持流式响应。这意味着 Agent 的思考过程、工具调用日志、最终答案都可以边生成边推送用户在浏览器里看到的是逐字输出的结果体感上比转圈等待好太多。这一点对简历工具尤其实用因为评估一个大段简历内容往往要等好几秒有流式输出用户至少知道系统还在工作而不是怀疑页面卡死了。1.2 LangGraph.js放弃手写状态机选一个正经的 Agent 运行时用 LangChain.js 写 Agent 的人大概都经历过这种时刻用 AgentExecutor 跑一个循环代码看着很简洁但一旦要加入“先解析简历、再判断是评估还是优化、评估完还要允许用户追问”这种多阶段逻辑就会发现这个框架的抽象太死板了——工具调用行为写死在框架里你想在某个步骤之间插入一个人工确认环节要么靠 hack要么只能自己重写循环。LangGraph.js 解决的就是这个问题。它的核心思想是把你脑子里的流程图直接翻译成代码定义好状态声明节点然后用边把这些节点连接起来节点之间可以用条件判断决定走向。这就是我选它而不是 LangChain.js 的 AgentExecutor 的根本原因——AgentExecutor 是“一个黑盒循环”LangGraph 是“一张你可以完全控制的图”。调试的时候你可以把每一步的输入输出都打出来看模型哪一步走错了一目了然。LangGraph.js 另一个重要特性是 Checkpointer即检查点持久化。Agent 的每次运行状态都被保存下来也就是说一个用户和 Agent 对话到一半下次回来还能接着聊前提是你把 checkpointer 配到了数据库里。这个能力对简历工具来说尤其重要因为用户上传简历后可能会来来回回追问多个问题如果每次对话都要重新解析一遍 PDF那成本和响应时间都不可接受。1.3 Agent 架构选型ReAct、Plan-and-Execute还是别的写 Agent 第一件事就是决定架构。我调研了一圈主流的就这几种架构核心思路适合场景这次为什么没用ReAct推理和行动交替进行每步先思考再调工具多数工具调用场景直接用没问题但缺少“先整体规划”的一步复杂任务容易走偏Plan-and-Execute先让模型产出完整计划再按序执行多步骤长任务简历评估需要根据解析结果实时判断计划容易失效条件图编排用状态机显式控制节点走向流程固定但分支明确最终选择与 ReAct 混合使用我这个简历工具实际采用的是“条件图编排 ReAct 混合”。具体来说把简历解析视为一个前置节点不可跳过解析完成之后进入一个 Agent 循环节点这个节点内部采用 ReAct 模式模型可以决定调用评估工具、调用优化工具或者直接回答用户追问。这样既有图的确定性又保留了模型在对话阶段的灵活度。这个架构选择背后有一个很实际的考量文件解析这类操作你绝不希望模型“自由发挥”。万一模型某次抽风决定不调用解析工具而是直接猜一个结果出来整个后续评估就全歪了。所以解析必须是一个强制节点绕不过去。而评估、优化这些后续动作恰恰需要模型根据用户的具体问题灵活选择因此放进 ReAct 循环里让模型自己决定。2. 简历工具的功能设计与 Agent 工作流拆解2.1 需求梳理不做大而全的 AI只解三个最痛的场景简历工具最容易犯的错误是做成“万能简历助手”一问什么都能答但什么都答不细。我一开始也踩了这个坑后来把需求收敛成三个核心场景。第一个是解析。用户上传 PDF 简历系统要提取出个人信息、教育背景、工作经历、技术栈、项目经验这几类结构化字段。这个场景的难点不在 LLM而在文件预处理——PDF 转出来的文本经常带乱码、错行表格和两栏排版的数据会串这些脏数据一旦进入模型解析结果质量就大打折扣。第二个是匹配评估。用户填一个目标岗位描述Agent 基于解析结果逐项打分硬技能匹配度、项目经验相关度、关键词覆盖度、表达清晰度最后给出总分和短板分析。这个场景要求 Agent 的评分标准前后一致不能这次说“精通 Java”是加分项下次又变成扣分项。第三个是一对一优化。基于评估结果Agent 直接给出可落地的修改建议并且能针对单条建议展开多轮对话。比如说Agent 建议把“参与开发电商系统”改成“主导订单模块重构QPS 从 200 提升到 2000”用户完全可以追问“这句改动的依据是什么”“如果我的项目没有量化指标怎么办”Agent 需要结合简历原文和历史对话来回答。2.2 工作流设计把工具调用和对话编排进一张图整个 Agent 的执行流程用 LangGraph 的状态图来表达是这样的开始 → 节点A文件解析与预处理→ 条件判断解析是否成功失败则返回错误信息成功则进入节点B节点BAgent 循环→ 判断用户当前意图是需要评估、需要优化还是追问评估则调用评估工具优化则调用优化工具追问则直接回答 → 回到节点B 继续判断直到用户满意或到达最大轮数这个设计的好处是文件解析是固定管线不受模型随机性影响稳定性有保障而 Agent 循环内部又提供足够的灵活性让模型可以基于上下文动态决定调用哪个工具。更重要的是所有中间状态都保存在 State 里用户任何一次的追问都能引用之前解析出来的结构化简历数据不用重新跑整个流程。有一点需要特别强调评估和优化这两个工具在 LangGraph 里也是节点但它们的触发路径是“模型在 agent 节点里产生了 tool_calls”。LangGraph 的模型节点会自动检测工具调用并路由到对应节点执行完再把结果传回。这个机制的好处是节点之间无需手动处理数据搬运但也要求你在设计节点函数时严格遵守“输入是 State、输出是部分 State 更新”的函数签名。2.3 状态设计什么数据必须存什么数据不该塞进上下文LangGraph 的 State 是整张图的“共享内存”设计好这个 State 基本就决定了后续开发的顺畅程度。我反复调整过几版最后定了这几个通道状态字段类型说明fileNamestring上传文件名用于跟踪会话rawTextstringPDF 提取出的原始文本保留但默认不进模型上下文parsedResumeobject结构化后的简历对象教育、经历、技能等jobDescriptionstring用户输入的目标岗位描述evaluationobject最近一次评估结果messagesBaseMessage[]Agent 对话历史走 LangGraph 默认的消息列表这里最关键的一点是 rawText 和 parsedResume 分开存。rawText 可能高达三五千 token整段塞进每次模型调用会迅速撑爆上下文也让成本不可控。正确做法是解析完成后后续所有节点只读取 parsedResume 里的结构化字段rawText 只用于展示原文或供用户点击“查看原文”时使用。我还额外保存了 evaluation 到 State 里而不是每次都重新调用评估工具。这样用户连续追问“评分依据是什么”“短板怎么补”时Agent 可以直接引用最近的评估结果不用重复计算。这就是 LangGraph 状态图相对传统“无状态接口调用”的最大优势——跨轮次的数据复用是原生支持的不需要自己在外面套一层缓存。3. 实操落地从零搭建完整项目3.1 项目初始化与目录结构我用 create-next-app 初始化选择了 App Router 和 TypeScript。依赖方面核心是两个包langchain/langgraph 和 langchain/openai再加一个 pdf-parse 负责 PDF 文本提取。完整目录结构如下resume-agent/ ├── app/ │ ├── api/ │ │ ├── agent/ │ │ │ └── route.ts # Agent 主接口处理流式对话 │ │ └── upload/ │ │ └── route.ts # 文件上传接口 │ └── page.tsx # 前端主页面 ├── lib/ │ ├── graph.ts # LangGraph 图定义 │ ├── nodes.ts # 各节点实现 │ ├── tools.ts # 工具定义 │ ├── state.ts # 状态类型定义 │ └── resume.ts # PDF 解析与结构化这里我特意把图定义、节点、工具拆成三个文件。很多人写 LangGraph 喜欢把全部逻辑堆在一个文件里几周后再看就完全理不清了。老老实实按职责拆分后续加工具、调节点都会轻松很多。3.2 状态与图的定义先说 state.ts。LangGraph.js 的 State 里除 messages 以外其余字段我都做成“新值覆盖旧值”。// lib/state.ts import { BaseMessage } from langchain/core/messages; export interface ParsedResume { personalInfo: Recordstring, string; education: ArrayRecordstring, string; workExperience: ArrayRecordstring, string; skills: string[]; projects: ArrayRecordstring, string; } export interface EvaluationResult { overallScore: number; dimensions: Array{ name: string; score: number; comment: string }; shortBoards: string[]; suggestions: string[]; } export interface ResumeState { fileName?: string; rawText?: string; parsedResume?: ParsedResume; jobDescription?: string; evaluation?: EvaluationResult; messages?: BaseMessage[]; }然后在 graph.ts 中构建状态图。我用的 LangGraph.js 的 StateGraph API节点只有四个parse、agent、evaluate、optimize。严格来说evaluate 和 optimize 是被 agent 节点通过工具调用触发的但 LangGraph 会自动把工具调用路由到对应节点所以图上它们是独立的节点。// lib/graph.ts import { StateGraph, START, END } from langchain/langgraph; import { ResumeState } from ./state; import { parseNode, agentNode, evaluateNode, optimizeNode } from ./nodes; const workflow new StateGraphResumeState({ channels: { fileName: { reducer: (_prev, next) next ?? _prev }, rawText: { reducer: (_prev, next) next ?? _prev }, parsedResume: { reducer: (_prev, next) next ?? _prev }, jobDescription: { reducer: (_prev, next) next ?? _prev }, evaluation: { reducer: (_prev, next) next ?? _prev }, messages: { reducer: (left, right) right ?? left }, }, }) .addNode(parse, parseNode) .addNode(agent, agentNode) .addNode(evaluate, evaluateNode) .addNode(optimize, optimizeNode) .addEdge(START, parse) .addEdge(parse, agent) .addEdge(evaluate, agent) .addEdge(optimize, agent) .addConditionalEdges(agent, routeAfterAgent); export const graph workflow.compile();可能有人会问为什么 parse 之后不做条件判断因为我在 parse 节点内部对解析结果做了校验解析失败直接抛错误返回给前端不进 agent 节点。这样图的结构更简单错误处理也更集中。routeAfterAgent 这个条件边负责判断如果模型这次产生了要调用工具的消息就路由到对应工具节点否则就停留在 agent 节点继续循环直到模型输出最终答案或达到步数上限。3.3 三个核心节点的实现细节解析节点的职责很明确但陷阱都在细节里。我用 pdf-parse 把 PDF 转成文本然后调用模型做一次结构化输出。这里有一个我反复踩的坑直接把整段 PDF 文本丢给模型让它返回 JSON长简历的结果经常出现 JSON 截断、关键字段漏掉。后来我在提示词里加了“分节提取”的要求让模型先按板块切分再逐项输出稳定性明显好很多。对于扫描版 PDFpdf-parse 提取出来的是一堆乱码我直接在解析节点里做了文本可读性检测低于阈值就提示用户上传文字版简历。// lib/nodes.ts节选 const parseNode async (state: ResumeState) { const text await extractTextFromPdf(state.filePath); const readable checkTextReadability(text); if (!readable) { throw new Error(PDF 内容疑似为扫描版无法提取文字请上传文字版简历); } const structured await llm.invoke([ [system, RESUME_PARSE_SYSTEM_PROMPT], [human, 请从以下简历文本中提取结构化信息\n\n${text}], ]); return { rawText: text, parsedResume: JSON.parse(structured.content), }; };agent 节点是最核心的一环。这里我用 bindTools 把工具绑定给模型然后进入内部循环。实现思路是模型每次输出之后检查是否有 tool_calls有就执行并回填结果没有就把这条消息追加到 messages 作为最终回答。为了防止死循环我在循环外加了 maxIterations 上限默认 10 轮。evaluate 和 optimize 两个节点本质上都是调用工具函数evaluate 读取 parsedResume 和 jobDescription按评分维度输出结构化 JSONoptimize 读取评估结果和用户当前的问题返回具体的改写建议。这两个节点的实现并不复杂真正的难点在它们的输入组织——每次调用前把哪些字段拼进 prompt直接决定输出质量。我采用的原则是“只把当前步骤需要的数据贴进上下文”比如评估时只贴 parsedResume、jobDescription绝不带 rawText既省 token 又减少噪音干扰。3.4 Next.js API 路由与流式输出对接API 层我做了两个接口。upload 接口负责接收 PDF 文件调用 graph 的 invoke 执行第一轮解析并返回结构化结果agent 接口负责后续所有对话用 stream 方式返回结果。流式输出是用户体验的关键。LangGraph.js 提供 stream 方法可以按 step 产出事件。我在 API 路由里把事件转成 SSE 格式推给前端前端拿到事件后根据事件类型分别处理如果是消息完成事件就逐字渲染如果是节点开始事件就显示“正在调用简历解析工具...”之类的提示。// app/api/agent/route.ts节选 import { graph } from /lib/graph; export async function POST(req: Request) { const { threadId, input } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const config { configurable: { thread_id: threadId } }; const events await graph.stream(input, config); for await (const event of events) { controller.enqueue(encoder.encode(data: ${JSON.stringify(event)}\n\n)); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }这里有个细节必须提前说threadId 就是 LangGraph Checkpointer 里标识一次会话的 key前端每次对话都要带上同一个 threadId否则 Agent 的上下文会断掉。我把 threadId 存在浏览器 localStorage 里每次会话开始时生成一个 UUID之后所有请求都携带它。如果不这么做用户问第二句话时LangGraph 会认为这是全新会话之前的简历解析结果全部丢失。4. 生产落地的几个关键问题并发、成本与稳定性最近搜“AI Agent 怎么扛并发”的人越来越多了说明大家都意识到Agent 的 demo 好写Agent 的上线难。我在这部分说几个切实的感受。4.1 并发瓶颈不在 Node.js在模型 API 和上下文组装先给结论Node.js 本身处理并发能力足够Next.js 部署到 Serverless 平台后按请求维度自动扩缩容单请求内 CPU 计算几乎可以忽略。真正的瓶颈在三个方面——大模型 API 的并发限制和响应时长、上下文检索和组装耗时、外部工具PDF 解析等的稳定性。模型 API 这边OpenAI 兼容接口一般按 TPM每分钟 Token 数限流。一个简历评估请求如果 prompt 有 3000 token接口返回 2000 token那么这个请求就是 5000 token。要估算并发上限直接用账号的 TPM 限额除以单请求平均 token 消耗就能得到一个粗略数字。我实测下来单个评估请求的端到端耗时大概在 3 到 6 秒这已经是 prompt 组织得比较精简的情况了。所以同时来 20 个请求你真正要担心的是账号配额和账单而不是 Node.js 进程。想扛住并发第一件事不是加机器而是做缓存。简历解析结果可以按文件哈希做缓存同一个 PDF 重复上传直接命中评估结果可以按“解析结果哈希 岗位关键词”做缓存大多数用户搜索相同岗位时候选结果高度相似。我加了一层 Redis 缓存之后接口的 QPS 能力提升了好几个量级。4.2 上下文裁剪是成本控制的必修课我在 2.3 里说过把原始简历全文塞进上下文的隐患。实际算一笔账一份详细简历原文约 2500 token一次多轮对话如果每次都把原文带上假设对话 8 轮、每轮历史消息额外增加 800 token最后一轮发出去的 prompt 可能高达 2500 800 × 8 系统提示词 工具定义轻松超过 10000 token。这个消耗对免费额度来说几分钟就能烧完。我的解决方案是三层裁剪原始文本只解析时用一次后续对话一律用 parsedResume 中的结构化摘要对话历史用 trim_messages 做窗口截断只保留最近 6 轮消息更早的对话按需做摘要工具描述用精简版去掉冗长的说明文字只保留参数 Schema 和必要注释。这三层做完之后一个正常对话流程的单轮 token 开销从 10000 降到 3000 左右成本降低了六成以上。如果你做的 Agent 也是文档处理类强烈建议把“裁剪”做进系统设计里而不是出了账单问题才想起来。4.3 超时、重试与降级策略Agent 应用和普通接口最大的不同是一次请求可能要跑十几秒甚至更久而 Serverless 函数有超时时间限制Vercel 默认 10 秒可调整其他平台各有上限。所以超时策略不是“一味调大”而是分场景处理。解析请求因为前置管线固定我放宽到 30 秒对话请求是流式的设计成“开始输出即算成功”——如果模型迟迟没有响应15 秒时向前端推送一条“模型响应较慢请稍候”的事件然后重试一次。重试别用固定间隔我遇到过连续请求同一模型 API 触发限流的情况后来改成指数退避第一次等 1 秒、第二次 2 秒、第三次 4 秒效果好很多。降级方面我做了两个兜底一是评估工具偶尔返回空结果时从岗位关键词做简单的关键词匹配虽然粗糙但至少能用二是模型 API 完全不可用时直接返回最近一次评估缓存结果并标明“数据更新时间”至少不让用户对着报错的页面干瞪眼。生产环境中“有降级方案”比“完美方案”重要得多。5. 常见问题与排查技巧实录5.1 状态管理混乱为什么我的 Agent 总是“失忆”用 LangGraph 写多轮对话最容易出问题就是忘记配置 checkpointer或者 threadId 传错。我排查过好几次“上下文丢失”最后发现是前端在每次请求时重新生成 threadId导致 LangGraph 把每次请求当成全新会话。排查手段其实很简单把 config 里的 thread_id 打印出来和上一次请求对比一下问题立刻现形。另一个状态问题是 reducer 写错。LangGraph 默认的 reducer 是覆盖旧值而 messages 需要做数组合并。如果这里写错同一轮对话中节点 A 返回的 messages 可能覆盖节点 B 的导致输出不完整。建议一开始就把 channels 的 reducer 行为写在纸上列清楚不要凭感觉写。尤其是 messages 这类需要累积的字段一定要用合并类 reducer。5.2 工具调用一直失败或重复循环Agent 循环最常见的怪问题是模型反复调用同一个工具结果相同但始终不退出。这通常是两个原因——第一个是工具没有返回真正的结果只返回了“调用成功”却没有数据第二个是 maxIterations 设得太大模型在一条死路上反复横跳。我的处理是双管齐下从工具侧保证每次调用返回的内容是有信息增量的哪怕结果为空也要明确返回“未找到相关数据建议用户补充 XX 信息”从图侧设置 maxIterations 10并在第 8 轮时注入一条系统消息“请直接给用户一个答案不要再调用工具”。实测这个注入能让模型大概率跳出循环。还有一个细节工具函数的返回值必须是字符串或可序列化的对象。我有一次图省事直接返回了 JavaScript 对象结果 LangGraph 在序列化时报错排查了半天才发现是工具返回格式的问题。工具函数的边界一定要处理干净返回前统一做字符串化。5.3 流式输出在 Next.js 里容易踩的坑SSE 流式输出有两个高频坑。一个是缓冲问题很多云函数的网关会缓存整个响应体导致你明明用 ReadableStream 推了 5 秒数据前端却在最后一刻一次性收到。解决办法是把响应头的 Cache-Control 设为 no-cache同时在 SSE 事件之间用注释行保持连接活跃防止连接被判定为超时。另一个坑更隐蔽Next.js App Router 的 Route Handler 可以按路由单独指定 Runtime但如果你把用了 pdf-parse 的逻辑放在 Edge Runtime 路由里整个路由会直接报错或行为异常。不要把 PDF 解析逻辑放在 Edge Runtime 路由里把 upload 接口单独设为 Node.js Runtime其余接口再按需选择省得半夜收到线上告警。这些坑是我在这个项目里真正遇到过的。写框架的人不会在文档里告诉你“SSE 可能被网关吞掉”“Edge Runtime 不支持某些 Node 库”只有自己把流程完整跑一遍、被报错信息折腾几次才会真正理解这些边角问题为什么重要。做 Agent 落地很多时候比模型选型更考验人的恰恰是这些琐碎的工程细节。