
最近我把一个压了很久的想法真正落地了用 Next.js 做前端和 API 层用 LangGraph.js 编排 AI Agent再把这个 Agent 包装成一个能改简历、能按岗位要求重写简历段落的在线工具。它不是那种调一次接口返回一段 Markdown 的玩具而是把简历解析、信息抽取、多轮对话、按需调用工具、流式输出、结果导出整个链路串起来的完整应用。这篇内容适合三类人看用 Next.js 做过几个项目、想往 AI Agent 方向深入的前端开发者已经在写 Python 版 LangGraph、但不太清楚 JS 生态该怎么落地的人以及不想只做“套壳调用 LLM”而是想理解 Agent 状态流转、任务编排和并发处理的工程师。我会把项目拆开讲包括为什么要这么选型、核心代码怎么组织、实际跑起来踩了哪些坑以及最后上线的时候怎么扛住并发。1. 为什么选 Next.js LangGraph.js 搭简历 Agent1.1 简历工具到底在解决什么问题很多人以为简历工具就是“把一段经历丢给大模型让它润色一下”。实际上用户真正需要的是一套完整的工作流上传一份 PDF 或 Word 简历系统先把非结构化的文本抽成结构化数据然后用户贴一个目标岗位 JDAgent 要判断当前简历里哪些项目和技能跟岗位匹配接着针对匹配度低的模块做定向优化而不是从头到尾乱改最后把结果生成一份新的文档让用户下载。如果只调用一次大模型这些问题基本做不好。原因很简单简历信息抽取需要走文件解析岗位匹配需要对比关键词和语义改写需要控制篇幅和语气导出又依赖前面所有步骤的结果。这些步骤不是线性问答而是有条件分支和循环的。用户说“我觉得项目经验写得太平了”Agent 就得回到“分析简历内容”的环节而不是直接告诉你一段废话。这种有状态、能分支、能循环的结构正好是 LangGraph.js 这类编排框架擅长处理的。1.2 技术选型三个决定性理由我一开始也纠结过直接用 Next.js API Route 调大模型不就行了吗为什么要多引入 LangGraph.js。后来想明白关键差别在“可控性”。第一个理由是状态管理。普通 API 调用是无状态的每次请求都是全新上下文。简历 Agent 不一样用户上传简历之后Agent 需要长期持有解析结果、用户选择的目标岗位、当前优化进度。LangGraph.js 的 StateGraph 把整个流程拆成多个节点每个节点都能读写一份全局 State。我可以在任意节点拿到前面所有步骤的数据不用自己想复杂的内存数据结构。第二个理由是循环工具调用。优化简历时Agent 需要调用“提取简历”“生成项目描述”“检查关键词覆盖率”等工具。LLM 不是一次就能确定该调哪个工具经常是先说要调用 generateProjectSummary把参数填好返回结果后还要再判断一次“现在够不够好”。这种 loop 在普通代码里写起来很别扭但 LangGraph.js 天然支持条件边模型一旦返回 tool_calls状态图就自动跳转到工具节点执行完再跳回来直到模型认为任务完成。第三个理由是前后端技术栈统一。项目本身要用 Next.js 做服务端渲染、路由和文件上传如果再引一套 Python Agent 框架就要维护两个服务。LangGraph.js 和 Next.js 都是 TypeScript 生态前端组件、API Route、Agent 编排可以写在同一个仓库里类型定义还能共用。简历数据结构在前后端用同一份类型推导开发体验比跨语言好太多。2. 整体设计与核心链路2.1 系统架构和一次完整请求的旅程整个项目分成四层前端页面、Next.js API 层、LangGraph.js Agent 核心层、外部服务层。外部服务包括大模型 API、对象存储、Redis 任务队列和数据库。一次完整请求大概是这样的用户上传简历文件后前端先把文件传到 Next.js 的/api/upload接口接口把文件内容存到对象存储然后调起文件解析服务把 PDF 或 Word 转成纯文本。纯文本进入简历信息抽取节点大模型根据预设 schema 抽出姓名、工作经历、项目经历、技能标签等字段。这一步得到的数据会写入会话状态同时存一份到数据库方便下次对话直接复用不用重新解析。接下来用户贴 JD 文字点击“开始优化”。这个请求会创建一个 Agent 运行任务把“当前简历结构化数据”“JD 文本”“用户需求”塞进 LangGraph.js 的初始状态。StateGraph 先跑一个分析节点用大模型算匹配度和差距再把结果交给优化节点。优化节点可能会反复调用工具比如“为某个项目写一条 STAR 结构描述”“根据 JD 关键词重写技能栏”每次调用结果都写回状态。最后生成节点把新简历渲染成 HTML再转成 PDF 返回给前端。整个链路里最容易被忽视的是超时问题。简历解析有时需要几秒Agent 多轮调用大模型可能超过十几秒如果前端一直傻等会很痛苦。我的做法是Agent 过程用流式接口让用户看到实时输出文件解析和 PDF 生成这类“无对话”的异步任务用任务状态跟踪前端轮询。两层结合用户体感才舒服。2.2 LangGraph.js 状态图把 Agent 的每一步“钉死”LangGraph.js 的核心概念是 StateGraph。你在图上定义节点和边数据在节点之间流动每条边可以带条件。对于简历 Agent我定义了一个全局 Stateimport { Annotation, StateGraph, START, END } from langchain/langgraph; const ResumeState Annotation.Root({ // 多轮对话消息 messages: Annotation({ reducer: (cur, update) cur.concat(update ?? []), }), // 简历结构化数据 resume: Annotation(), // 目标岗位 JD jobDescription: Annotation(), // 当前要执行的动作类型 nextAction: Annotation(), // 最终生成的简历文本 output: Annotation(), });这里messages使用了 reducer因为 LangGraph.js 每个节点返回的新消息会被自动追加到原来的数组里不用手动维护历史。resume和jobDescription是普通字段节点之间直接覆盖读取。然后我建五个节点parseResume、analyzeMatch、optimizeResume、generateOutput、chatRespond。图的结构是const graph new StateGraph(ResumeState) .addNode(parseResume, parseResumeNode) .addNode(analyzeMatch, analyzeMatchNode) .addNode(optimizeResume, optimizeResumeNode) .addNode(generateOutput, generateOutputNode) .addNode(chatRespond, chatRespondNode) .addEdge(START, parseResume) .addEdge(parseResume, analyzeMatch) .addConditionalEdges(analyzeMatch, decideNextAction, { optimize: optimizeResume, respond: chatRespond, }) .addConditionalEdges(optimizeResume, decideAfterOptimize, { continue: optimizeResume, done: generateOutput, }) .addEdge(generateOutput, END) .addEdge(chatRespond, END) .compile();有人会问为什么要用条件边而不是在节点里写 if关键原因是可观测性和可恢复性。LangGraph.js 每次节点执行完会把新的状态写进去条件边的判断结果也会被记录。一旦某个环节出错我能从日志里看到“Agent 决定继续优化因为关键词覆盖率只有 40%”而不是只能看到一段大模型的原始输出。这种可观测性对排查问题太重要了。2.3 工具调用循环是怎么转起来的简历 Agent 最核心的工具是“改写项目描述”和“生成技能清单”。在 LangGraph.js 里工具调用通常不是独立节点而是 Agent 节点和工具节点之间来回跳转。我的optimizeResumeNode做两件事先调用大模型告诉它当前有哪些工具可以用。如果大模型返回的响应里包含了tool_calls节点就把这个响应原样返回同时nextAction字段标记为tool。条件边看到nextAction是tool就跳到工具节点。工具节点执行真实的工具函数把结果作为新的消息追加到messages然后条件边再跳回optimizeResumeNode让大模型看到工具结果后决定下一步。用 LangGraph.js 手写这个循环也不复杂关键伪代码是这样async function optimizeResumeNode(state: typeof ResumeState.State) { const res await modelWithTools.invoke([ ...state.messages, { role: system, content: 你是资深简历顾问判断是否需要调用工具来优化简历。, }, ]); if (res.tool_calls?.length) { return { messages: [res], nextAction: tool, }; } return { messages: [res], nextAction: done, }; } async function toolNode(state: typeof ResumeState.State) { const lastMessage state.messages[state.messages.length - 1]; const results []; for (const call of lastMessage.tool_calls ?? []) { if (call.name rewriteProject) { results.push(await rewriteProject(call.args)); } if (call.name generateSkills) { results.push(await generateSkills(call.args)); } } return { messages: results, nextAction: continue, }; }这样设计的好处是以后新增工具只需要写一个真实工具函数再把它注册到给模型可见的 tools 数组里状态图的循环结构完全不用改。项目做完之后我已经把同一个 Agent 核心复用到了“周报生成”和“岗位 JD 分析”两个场景只换了工具定义和提示词。3. 关键模块实现细节与踩坑记录3.1 Next.js Route Handler 实现流式输出Next.js App Router 的 Route Handler 支持直接返回ReadableStream。简历优化过程中大模型返回 token 是一个个蹦出来的如果等全部生成完再返回用户会觉得“卡死了”。我用 Server-Sent Events 的格式做流式输出但不用浏览器原生EventSource因为需要 POST 请求我选择用fetch读取流。服务端代码大致是这样export async function POST(req: Request) { const { resumeId, jobDescription } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { const events await runResumeAgent(resumeId, jobDescription); for await (const event of events) { controller.enqueue( encoder.encode(data: ${JSON.stringify(event)}\n\n) ); } } catch (error) { controller.enqueue( encoder.encode( data: ${JSON.stringify({ type: error, message: String(error) })}\n\n ) ); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }这里runResumeAgent返回的是一个异步迭代器它内部通过 LangGraph.js 的streamEvents把 Agent 状态变化和大模型 token 都抛出来。前端解析比较直接我用eventsource-parser这类库把流内容拆成一个个 event根据event.type分别处理“节点开始”“节点结束”“token 增量”和“最终完成”。踩坑点是流式输出过程中千万不要启用 Next.js 的压缩中间件否则事件流会被缓冲用户那边感觉不到实时效果。我最初没注意本地跑得好好的部署后流式输出变成一秒一次刷新排查了一下才发现是压缩和缓冲配置的问题。3.2 简历解析PDF、Word 与乱排版文本简历解析是整个项目里最脏最累的活。用户上传的 PDF 分两类一类是文字型 PDF可以直接提取文本另一类是扫描件或图片型 PDF必须先做 OCR。文字型 PDF 我用pdf-parse提取但经常遇到双栏排版、表格和页眉页脚干扰提取出来的文本顺序不对。我的处理思路是把提取出的文本按行拆开先做基本的空白清理和分块然后把块交给大模型做结构化抽取而不是直接写复杂正则硬解析。让大模型输出 JSON字段包括experience、projects、skills、education。这一步看似简单实际需要谨慎的是不要丢信息尤其是“时间倒序”这种简历常见特征大模型经常把最近一段经历放在前面但抽取时偶尔会漏掉整个段落。Word 文件我用mammoth把.docx转成 HTML再基于 HTML 分块。很多用户的简历是表格布局转 HTML 后 table 标签还在分块逻辑需要专门处理表格单元格。在解析节点里我用了函数调用让大模型输出结构化结果const extractSchema { type: function, function: { name: extractResume, description: 从简历文本中抽取结构化信息, parameters: { type: object, properties: { experiences: { type: array, items: { type: object } }, projects: { type: array, items: { type: object } }, skills: { type: array, items: { type: string } }, education: { type: array, items: { type: object } }, }, required: [experiences, projects, skills, education], }, }, };抽取结果一定要校验不能直接进状态。我写了一个校验函数如果大模型漏掉skills或experiences为空就重新调用一次。连续两次失败后不再继续抽取而是返回“这份简历内容太少请补充信息”。OCR 方案我选的是开源项目 PaddleOCR部署为独立服务。简历 Agent 需要 OCR 时通过 HTTP 调用。这个独立服务最耗内存所以没有放进 Next.js 进程里。每次 OCR 前我会先判断 PDF 是否包含文本层有文本层就走快速路径没有才触发 OCR能省不少成本。3.3 对话记忆和会话恢复怎么做Agent 不是跑一次就结束用户会针对优化结果继续追问。LangGraph.js 的状态默认只在单次运行内有效要想跨请求恢复就必须做持久化。我在数据库里建了一张agent_session表字段包括sessionId、resumeId、messages、resumeData、updatedAt。每次 Agent 节点执行完把最新状态序列化成 JSON 更新到对应 session。下一次用户发消息时把messages恢复成大模型消息数组继续往后传。这里有一个关键细节每次恢复对话时不能把大模型之前生成的全部历史都塞进上下文否则 token 消耗会失控。我的经验是只保留最近 20 条消息更早的消息用一段固定摘要代替。简历结构化数据放在系统提示词里不放在对话消息里这样能显著减少 token。4. 并发、可靠性和成本控制4.1 Agent 类应用怎么扛并发简历工具的用户量不一定爆炸但 AI Agent 的请求往往比普通 Web 请求更脆弱单个请求耗时长、占用资源大、还会因为大模型超时而整个失败。所以“扛并发”的核心不是拼命加服务器而是做隔离和限流。我的方案是分两层。第一层是同步流式接口乐观场景下用户发起优化Agent 在十几秒内完成前端用流式输出等待。这个接口设置并发上限我用 Redis 做了一个简单的令牌桶每个用户同时只能有一个 Agent 任务在跑。如果已经有一个任务第二个请求直接返回“正在优化中请稍后”。第二层是异步任务兜底。对于那些不需要实时交互的步骤比如 PDF 生成、批量解析、简历重新打分全部丢进 Redis 任务队列用独立 Worker 消费。Worker 数量根据账号的 API 限流和 GPU/CPU 资源动态调整。这样做的好处是高峰期即使有几百个优化请求同步接口也只承担一部分其他请求进入队列前端轮询任务状态用户体验依然是可接受的。具体实现里我在数据库里维护一个agent_task表每次新建任务生成taskId。前端请求时带着clientRequestId我用它做幂等。同一个clientRequestId如果已经存在成功记录直接返回上次的结果避免用户重复点击造成重复消费大模型 token。4.2 模型选型与提示词工程别让 Agent 自由发挥大模型本身很聪明但在简历优化这种场景里必须给它强约束否则输出会变成“正确的废话”。我同时接了几个模型包括 OpenAI 系、Claude 系、以及性价比更高的开源模型。主流程用能力最强的模型做分析和改写用来提取信息的简单任务用便宜模型。提示词工程这块我总结出一个有效套路给 Agent 固定“工作流提示词”要求它每一步都必须写在结构化的 JSON 字段里并配合工具调用。比如优化项目经历时我要求输出必须包含star_situation、star_task、star_action、star_result四个字段每个字段有字数限制。如果不加这个约束模型经常写出一大段没有重点的文字。代码层面也做了结果校验。所有大模型生成的 JSON 都用zod校验失败时自动重试一次重试时在提示词里追加一条“上次输出格式不符合要求请严格按照 schema 输出”。这比单纯把温度调到 0 更有效。另外一个容易被忽略的点大模型会“编造”简历内容。比如用户没有写某项技能模型在优化时可能自作主张补上。我在系统提示词里明确写了“禁止添加原始简历和 JD 中不存在的经历和技能只能优化已有内容的表达”。即使这样最终结果还必须经过一个“事实一致性检查”节点把生成内容里的专有名词和原始简历做对比出现不一致就标记出来让用户确认。4.3 评估集与回归测试简历 Agent 是典型“改一行提示词效果可能全变”的项目没有评估集就是盲人摸象。我建了 30 份不同行业的简历样本覆盖技术、产品、运营、设计、销售五个岗位每份样本带一个目标 JD。评估时跑三个指标关键词覆盖率、格式完整度、事实一致性。关键词覆盖率是把 JD 里的核心关键词和优化后简历做匹配看有多少出现在简历里格式完整度检查 STaR 结构是否齐全事实一致性由另外一个模型打分。每次改完提示词或工具逻辑跑一遍评估集对比分数变化。这个评估集不需要自动化到多复杂哪怕只是把结果输出到一个 JSON 文件再人工看也比不评估好得多。我见过太多项目上线后才发现模型在特定岗位的简历上把整段丢掉了。5. 部署与上线后的可观测性5.1 部署形态选择Next.js 应用可以部署到 Serverless 平台但简历 Agent 不适合纯 Serverless。原因很简单Agent 单次执行时间太长很多 Serverless 环境对请求时长有限制而且 LangGraph.js 状态图运行是有内存状态的不想在每次请求之间频繁序列化。我选择把 Next.js 以 Node.js 模式部署到一台云服务器上进程管理用 PM2。文件上传的临时目录用本地磁盘正式存储用对象存储。Redis 负责队列和限流Postgres 负责会话和任务状态。这套部署最大的优势是架构简单没有引入太多中间件。如果是在大流量场景正确姿势应该是 Next.js 前端静态部署到 CDNAPI 层单独抽出来部署到容器服务Agent 核心再独立成一个 Worker 服务。简历工具还没到那个量级但代码层面我已经把 Agent 核心做成了独立包没有和 Next.js 路由耦合太深将来拆服务不用大改。5.2 日志、追踪和 token 成本监控Agent 应用最怕的是“黑盒”前端报错后端看不到是大模型超时、工具调用失败还是解析节点崩溃。我接入了一个简单的追踪方案把每一次 Agent 运行的节点名、耗时、token 消耗、大模型响应片段记录到日志表。LangGraph.js 本身支持在节点前后埋点我给每个节点包了一层 decorator自动记录开始时间和结束时间。所有追踪数据统一写到一张agent_trace表字段包括sessionId、nodeName、durationMs、promptTokens、completionTokens、status。每次请求完成之后我还会算一下单次任务的总 token 成本把模型计价表写在配置里方便看平均用户成本。有了这张表很多问题就能直接查出来。比如某个节点突然变慢在表里看到该节点的durationMs明显上升某个用户的简历一直解析失败翻日志能看到具体是哪个字段缺失。这也是我推荐在项目初期就建立追踪的原因等量大了再补成本更高。6. 常见问题与排查实录6.1 问题速查表下面是我实际使用过程中整理出来的高发问题你可以直接对照排查。现象可能原因解决办法前端收不到流式消息压缩/缓冲配置、浏览器缓存关闭对 SSE 路径的压缩设置Cache-Control: no-cachePDF 解析文本乱序双栏排版、表格干扰分块后交给大模型抽取不要用正则硬拆简历优化后出现不存在的内容大模型幻觉加“禁止新增经历事实”约束增加事实一致性校验节点同一用户并发请求报错没有做会话级锁Redis 令牌桶按用户维度限制同时执行的任务数Agent 在多轮工具调用后超时每轮都携带完整上下文只保留最近 20 条消息历史关键信息放到系统提示词摘要模型输出 JSON 校验失败提示词未给 schema 示例在提示词里附数据结构示例校验失败时自动重试一次任务进程意外重启后会话丢失状态只存在内存持久化状态到 Postgres恢复时从数据库加载6.2 我踩过的最深的几个坑第一个坑是 LangGraph.js 的状态 reducer 写错。刚开始定义messages字段时我用了普通字段结果每个节点返回的消息把之前的历史直接覆盖了。多轮工具调用时模型上下文里永远只有最近一轮对话表现得像失忆一样。后来改成reducer: (cur, update) cur.concat(update ?? [])消息才正常累积。第二个坑是流式输出和工具调用的冲突。我用streamEvents把大模型 token 发给前端但工具调用返回的对象里往往包含大段 JSON容易不小心也作为流式消息发给前端。后来在事件类型里做了过滤只有on_chat_model_stream才推送 token节点状态变化单独用on_chain_start和on_chain_end通知前端才能正确区分“模型在说话”和“Agent 在调用工具”。第三个坑是并发限流把正常用户误伤。最开始我按 IP 限流结果一个办公室同一个出口 IP 的多个人同时使用后面的人全被拒绝。改成按用户 ID 限流后问题立刻消失。对于没有登录的访客我会用浏览器生成的匿名 ID 作为限流 key不采用 IP。最后再分享一个小技巧简历 Agent 做完之后我发现整个项目里最值得复用的不是 LangGraph.js 的图结构而是“结构化输出 校验重试 事实一致性检查”这套组合。后来做任何 Agent 项目我都先把这三个环节写进模板。如果你想快速验证一个 Agent 思路是否可行不要一上来就弄前端和数据库先用 LangGraph.js 写好状态图把核心节点的输入输出打印出来跑几个真实样例看效果。效果立得住再花时间套 Next.js 和部署都来得及。