最近我把一个压了很久的想法真正落地了:用 Next.js 做前端和 API 层,用 LangGraph.js 编排 AI Agent,再把这个 Agent 包装成一个能改简历、能按岗位要求重写简历段落的在线工具。它不是那种调一次接口返回一段 Markdown 的玩具,而是把简历解析、信息抽取、多轮对话、按需调用工具、流式输出、结果导出整个链路串起来的完整应用。
这篇内容适合三类人看:用 Next.js 做过几个项目、想往 AI Agent 方向深入的前端开发者;已经在写 Python 版 LangGraph、但不太清楚 JS 生态该怎么落地的人;以及不想只做“套壳调用 LLM”,而是想理解 Agent 状态流转、任务编排和并发处理的工程师。我会把项目拆开讲,包括为什么要这么选型、核心代码怎么组织、实际跑起来踩了哪些坑,以及最后上线的时候怎么扛住并发。
1. 为什么选 Next.js + LangGraph.js 搭简历 Agent
1.1 简历工具到底在解决什么问题
很多人以为简历工具就是“把一段经历丢给大模型,让它润色一下”。实际上,用户真正需要的是一套完整的工作流:上传一份 PDF 或 Word 简历,系统先把非结构化的文本抽成结构化数据;然后用户贴一个目标岗位 JD,Agent 要判断当前简历里哪些项目和技能跟岗位匹配;接着针对匹配度低的模块做定向优化,而不是从头到尾乱改;最后把结果生成一份新的文档让用户下载。
如果只调用一次大模型,这些问题基本做不好。原因很简单:简历信息抽取需要走文件解析,岗位匹配需要对比关键词和语义,改写需要控制篇幅和语气,导出又依赖前面所有步骤的结果。这些步骤不是线性问答,而是有条件分支和循环的。用户说“我觉得项目经验写得太平了”,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,我定义了一个全局 State:
import { 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; charset=utf-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 前端静态部署到 CDN,API 层单独抽出来部署到容器服务,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-cache |
| PDF 解析文本乱序 | 双栏排版、表格干扰 | 分块后交给大模型抽取,不要用正则硬拆 |
| 简历优化后出现不存在的内容 | 大模型幻觉 | 加“禁止新增经历事实”约束,增加事实一致性校验节点 |
| 同一用户并发请求报错 | 没有做会话级锁 | 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 和部署都来得及。