做简历类AI工具这件事我前后折腾了小半年最大的体会是真正好用的功能根本不是聊天窗口里问一句“帮我优化简历”而是一条看得见进度的流水线——用户丢进一份简历和一份职位描述系统自动完成信息提取、JD要点拆解、差距分析、逐条优化、质量校验最后给出一份能直接用的新版简历。这篇文章要聊的项目就是用 Next.js 搭全栈外壳用 LangGraph.js 做 AI Agent 工作流编排把这条流水线完整落了地。想复制这条路子的朋友不管你现在是刚入门 AI 应用开发还是已经写过不少 LangChain 脚本但觉得“Agent 不落地”这篇内容都适用。我会把架构决策、状态图设计、关键代码、部署维护以及踩过的坑全部摊开讲。你不一定照抄我的方案但至少能少走很多弯路。1. 项目定位为什么简历工具需要 Agent 编排1.1 单次 Prompt 永远解决不了简历优化先说一个现状市面上一堆“AI 简历优化”产品本质就是一个预设 Prompt 的聊天框用户粘贴简历和 JD模型输出一段建议。这种方案不是不能用但效果非常随机。原因不复杂——简历优化不是一个“单轮生成任务”它是一个典型的“分析-生成-校验-重写”多阶段任务。你让模型直接改简历它根本不知道应该在哪个维度上发力。是项目经历不够量化还是技能关键词缺失还是工作描述和 JD 匹配度不够这些判断需要先建立“差距分析”再基于差距去重写。而且重写之后还需要一个质检环节不然模型很容易在“优化”过程中编造工作经历或者把简历改得越来越不像人话。单次 Prompt 做不到这种带反馈环的流程控制。这个项目最开始我也试过用 LangChain.js 的 AgentExecutor 做但它的问题是所有决策都交给 LLM 自由发挥缺少硬约束。比如我希望它“先分析、后生成、再检查”AgentExecutor 很难保证这个顺序。于是我把目光转向了 LangGraph.js。1.2 Agent 工作流设计的核心思路LangGraph.js 的核心概念是“状态图”。你可以把一次简历优化任务定义成一张有向图每个节点是一个具体操作节点之间的连线决定了执行顺序图里还有一个全局状态对象不断被节点更新。这和我们写业务代码时常用的状态机、工作流引擎很像只不过每个节点内部可以调用 LLM、调用工具、读取外部数据。举个例子我的简历优化图大概是这样的分析节点模型接收“简历全文JD全文”输出一个结构化的差距分析结果包括技能缺失清单、经历描述问题、关键指标不足等。生成节点拿着差距分析逐条重写简历内容生成完整的新版简历。校验节点让模型自己扮演 HR 审阅者对新简历打分并判定是否需要二次重写。条件路由如果分数低于阈值且重写次数没到上限就跳回生成节点否则结束。这套结构的好处是执行过程完全可控。用户能看到当前走到哪一步失败时可以精确知道是哪个节点出了问题。而且LLM 不是在“自由发挥”它被约束在一条我们预设的流水线里每一步的职责都单一清晰。1.3 产品形态与技术栈概览产品形态上我做的不是一个纯工具站点而是一个挂了真实业务逻辑的全栈应用。用户上传 PDF 简历输入目标 JD点击运行前端通过 Server-Sent EventsSSE流式展示 Agent 每个阶段的输出。后端 API 负责文件解析、LangGraph 状态图执行、结果缓存。技术栈其实很克制Next.js 14 App Router 做前端页面和 API Route前后端一体部署简单。LangGraph.jslangchain/langgraph做 Agent 工作流编排。模型用 OpenAI 兼容接口通过 langchain/openai 接入。pdf-parse、mammoth 做简历文档内容提取。这套组合的最大优势是整个项目只用 TypeScript 一种语言状态流转和文件处理都在 Node 环境里做掉不需要额外起 Python 服务。后面我会详细说为什么我也不推荐在这个场景硬上 Python。2. 技术选型拆解架构决策背后的思考2.1 为什么是 Next.js 而不是“React FastAPI”我见过不少 AI 项目是 React 前端 Python FastAPI 后端 Celery 任务队列的组合。这当然成熟但对一个中小体量的简历工具来说工程复杂度是失控的。你需要维护两个服务、两套部署流程、前端还要处理跨域。Next.js 的 Route Handler 天然就是 API 端点一个函数就能处理流式响应而且支持 Node.js runtime可以直接执行 LangGraph.js 的异步迭代器。更重要的是Next.js 能让我把“页面初始化时的默认模板渲染”“历史任务结果查看”这类普通 Web 功能和 Agent 执行逻辑放在同一个应用里不需要额外抽服务。对于个人开发者或者小团队这就是实打实的效率提升。当然如果任务量真的上来了比如每天几千次长耗时任务那确实应该把 Agent 执行抽成独立 Worker 服务用 Redis 或队列做解耦。但在这个项目的起步阶段单体 Next.js 完全够用也是最快能验证产品逻辑的方式。还有一个小细节Next.js 的抗并发能力。简历优化任务往往是长耗时请求如果页面侧用默认的 Serverless 函数处理可能会有超时限制。我最后选择自托管 Node 服务因为这样才能稳定支撑十几秒甚至几十秒的长连接。这个坑后面单独讲。2.2 LangGraph.js 带来的结构性优势LangGraph.js 最吸引我的不是它能做 Agent而是它给 Agent 提供了“内存”和“控制”。这里的“内存”不是模型上下文窗口而是状态图里的全局 State 对象。每个节点函数接收当前状态执行逻辑返回状态增量图框架自动合并更新。这意味着你可以让一个节点只负责解析另一个节点只负责生成彼此通过 State 通信互不干扰。这种架构下迭代重写逻辑变得非常自然。我只需要在生成节点和校验节点之间加一个条件边并根据校验结果决定是否跳到生成节点就实现了一个带循环的 Agent。如果用传统的 Prompt 链去做这个循环会写得极其别扭而且很难控制退出条件。LangGraph.js 还内置了流式执行能力。graph.stream() 方法会把每个节点的执行结果按“updates”模式吐出来天然适合 SSE 推送给前端。你在界面上能实时看到“正在分析差距”“正在重写工作经历”“HR 校验评分 7 分进入第二轮重写”这种过程透明感是用户信任一个 AI 工具的重要来源也是普通单次调用 API 根本做不到的。2.3 为什么“工具调用”在简历优化里是刚需很多教程把 Agent 工具调用讲得很玄乎好像一定得是订机票、查天气、操控浏览器。但在简历优化这个场景里工具调用一样能发挥价值。我设计了一个很朴素的需求分析节点在判断“简历和 JD 是否匹配”的时候如果只靠 LLM 估算有时会瞎说。比如 JD 里写了要求“具备 Kubernetes 生产经验”模型读简历时如果没看到这几个字它可能还会说“具备容器编排经验符合要求”。这不是模型笨而是文字推理本身就容易跑偏。解决办法是给模型一个计算工具。这个工具接收 JD 里提取出的关键技能词列表去简历文本里做精确匹配返回覆盖率。模型可以把这个客观指标与自己的语义分析结合得到更靠谱的差距判断。这个模式其实就是 ReAct 范式模型决定调用工具拿到结构化反馈再继续推理。在 LangGraph.js 里工具调用会被封装成一个独立的节点可以通过 LangChain 的 ToolNode 机制管理也可以直接在自己定义的节点里调用一个普通函数。后者的实现成本更低我实际采用的是后者。3. 核心实现从初始化到完整落地3.1 项目初始化与依赖安装项目用 Next.js 14 起步Node 版本建议 18 以上。初始化命令没什么特别的npx create-next-applatest resume-agent依赖方面除了 Next.js 自带的核心就这些npm install langchain/langgraph langchain/langchain langchain/openai npm install pdf-parse mammoth npm install zod这里说几个安装依赖时的注意点。pdf-parse 在 npm 上的版本比较老了它不是专门为 ESM 设计的模块在 Next.js 的 API 路由里直接用会有模块导入的问题。建议在服务端代码里用动态导入const pdfParse (await import(pdf-parse)).default;mammoth 用来处理 docx 格式的简历接口很简单把 buffer 传进去返回 html 或 rawText。注意它是异步的调用时不要在渲染进程里跑。3.2 模型接入与环境配置模型接入我封装了一个工厂函数避免在多个文件里重复初始化模型。项目里用的是 OpenAI 兼容接口所以只需要配置 baseURL 和 apiKey。import { ChatOpenAI } from langchain/openai; const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0.2, apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL, });为什么 temperature 设 0.2简历优化这类任务追求稳定性和事实保留过高的温度会让模型自由发挥编造项目经历的风险会指数级上升。我实测过 temperature 从 0.2 调到 0.7 之后输出质量波动非常明显所以宁可让模型保守一点。3.3 简历与 JD 文本预处理链路这一步是很容易被忽略的。很多开发者直接拿 PDF 的原始内容扔给模型结果文本里充满无用的换行、页眉页脚、乱码极大的影响分析质量。我的预处理流程是上传文件存到内存 buffer不落盘。根据 MIME 类型分发解析器PDF 用 pdf-parsedocx 用 mammoth。对文本做清洗去掉多余空行、连续空格、非必要特殊字符。把清洗后的简历文本、JD 文本作为初始状态传入 LangGraph 状态图。额外说明一下扫描件 PDF 的问题。pdf-parse 只能提取文字层如果是纸质简历扫描件需要 OCR。这个项目我暂时不处理前端会提示用户提供可复制文本的 PDF。从产品角度说这降低了体验上限但保住了处理成功率。3.4 核心代码基于 LangGraph.js 的简历优化状态图这部分是整个项目的内核。我把状态图代码简化后贴出来去掉了一些业务噪声但保留了完整结构。首先定义状态。LangGraph.js 里用 Annotation.Root 声明状态字段关键是可以给字段配 reducer。我一直强调 reducer 很重要因为它决定节点返回的数据怎么合并进全局 State。默认的普通字符串字段是直接覆盖数组字段默认不会自动合并必须显式声明 reducer。import { Annotation, StateGraph, START, END } from langchain/langgraph; const ResumeState Annotation.Root({ // 输入 resumeText: Annotationstring, jdText: Annotationstring, // 分析结果 gapAnalysis: Annotationstring, // 生成后的优化简历 optimizedResume: Annotationstring, // 迭代计数reducer 是叠加 iteration: Annotationnumber({ reducer: (current, incoming) (current ?? 0) (incoming ?? 0), }), // 校验结果 needsRewrite: Annotationboolean(), matchRate: Annotationnumber(), });然后定义节点。分析节点是最复杂的它做了两件事一是让模型提取 JD 里的关键技能词二是调用一个关键词覆盖率计算函数把结果塞进状态。async function analyzeGapNode(state: typeof ResumeState.State) { const { resumeText, jdText } state; const analysisPrompt 你是资深技术简历顾问。根据下面的简历和 JD输出一份结构化的差距分析包括 1. JD 中要求但简历完全没有体现的技能或关键词list 形式 2. 项目经历描述质量问题每条具体指出最好带原文引用 3. 综合匹配度评分0-100 一定要基于简历事实不要编造。如果简历里没有明确写“未体现”。 简历 ${resumeText} JD ${jdText} ; const analysis await model.invoke(analysisPrompt); // 这里可以补充一个工具计算用模型输出的关键词列表去简历里做精确匹配 const matchRate computeKeywordMatchRate(resumeText, jdText); return { gapAnalysis: analysis.content as string, matchRate, }; }computeKeywordMatchRate 是普通函数不是 LLa 工具。因为在这个场景里不需要模型决定“要不要调用”它是分析节点的固定步骤function computeKeywordMatchRate(resumeText: string, jdText: string): number { const keywordPatterns [ react, vue, typescript, node, docker, kubernetes, mysql, redis, 微服务, 高并发, 项目管理, ]; const matched keywordPatterns.filter((kw) resumeText.toLowerCase().includes(kw.toLowerCase()) ); return Math.round((matched.length / keywordPatterns.length) * 100); }注意别把这个函数写成完美方案。真实产品里关键词列表应该是模型动态生成的我这里是简化成固定列表让逻辑能跑通。更好的做法是用 zod 定义结构化输出后面我会提。生成节点拿到分析结果重写简历async function generateResumeNode(state: typeof ResumeState.State) { const response await model.invoke( 基于以下差距分析重写简历内容。 要求 - 只重写不足之处已有亮点保留 - 保持事实一致禁止编造项目经历和数据 - 用项目符号组织突出量化成果 差距分析 ${state.gapAnalysis} 原简历 ${state.resumeText} 请输出完整的优化后简历。 ); return { optimizedResume: response.content as string, iteration: 1, // 配合 reducer 做累加 }; }校验节点让模型扮演 HR给优化后的简历打分并决定是否重写async function reviewNode(state: typeof ResumeState.State) { const response await model.invoke( 你是一名严格的 HR 技术面试官评估以下优化后的简历质量。 从三方面打分每项 0-10真实性、匹配度、表达清晰度。 总分为三项平均分。 如果总分低于 7 分请以 JSON 格式输出 {needsRewrite: true, score: 5} 否则输出 {needsRewrite: false, score: 8}。 简历内容 ${state.optimizedResume} ); const content response.content as string; const match content.match(/\{[\s\S]*\}/); const parsed match ? JSON.parse(match[0]) : { needsRewrite: false, score: 7 }; return { needsRewrite: parsed.needsRewrite, }; }最后组装图const graph new StateGraph(ResumeState) .addNode(analyze, analyzeGapNode) .addNode(generate, generateResumeNode) .addNode(review, reviewNode) .addEdge(START, analyze) .addEdge(analyze, generate) .addEdge(generate, review) .addConditionalEdges(review, reviewRouter, { rewrite: generate, pass: END, }) .compile(); function reviewRouter(state: typeof ResumeState.State) { if (state.needsRewrite (state.iteration ?? 0) 2) { return rewrite; } return pass; }这里最关键的是条件边。reviewRouter 根据校验结果决定是回到 generate 节点还是结束。iteration 加了一个上限防止模型无限自我重写既控制成本也保证任务一定会在有限步骤内结束。3.5 在 Next.js 路由里跑流式输出状态图编译好之后要在 Next.js 的 route handler 里执行它并把阶段输出实时推给前端。我直接用 Web 标准的 ReadableStream。import { NextRequest } from next/server; export const runtime nodejs; export const maxDuration 60; export async function POST(req: NextRequest) { const { resumeText, jdText } await req.json(); const graph buildResumeGraph(); const stream await graph.stream( { resumeText, jdText }, { streamMode: updates } ); const encoder new TextEncoder(); const readableStream new ReadableStream({ async start(controller) { try { for await (const update of stream) { const payload data: ${JSON.stringify(update)}\n\n; controller.enqueue(encoder.encode(payload)); } } finally { controller.close(); } }, }); return new Response(readableStream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }这段代码有几个要注意的点。第一export const runtime nodejs 必须写。LangGraph.js 内部依赖 Node 的异步迭代器、stream 处理等能力如果默认跑了 Edge runtime会直接报错。第二maxDuration 要设置成符合场景的值。默认的 Serverless 函数超时可能只有 10 秒而一次简历优化带校验重写30 秒以上很常见。我设置成 60 秒。如果平台不支持这个配置那就得换自托管 Node 服务。第三graph.stream 的 streamMode 我用了 updates。它会在每个节点更新状态后立即产生一条数据。这比等完整结果输出完再返回要清晰得多前端可以一条一条地渲染“正在分析”“正在生成”“进入第二轮重写”等信息。3.6 前端交互让用户看到 Agent 在干什么前端我做得不算花哨但有一个原则进度必须可见。用户上传文件和 JD 后页面立刻进入执行状态右侧显示一个阶段流水左边保留原始简历文本做对照。前端核心是一个 fetch 请求读取 SSE 流。const response await fetch(/api/agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ resumeText, jdText }), }); const reader response.body!.getReader(); const decoder new TextDecoder(); while (true) { const { value, done } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); const events chunk.split(\n\n).filter(Boolean); for (const eventText of events) { if (!eventText.startsWith(data:)) continue; const data JSON.parse(eventText.slice(5)); updateProgressUI(data); // 根据节点名展示阶段状态 } }这里我不推荐用 EventSource。因为我们需要 POST 传文件内容和 JDEventSource 只支持 GET要么就得把参数塞 query string要么就得搞中间 token非常麻烦。用 fetch 读流是更直接的方式。当 Agent 全部执行完后后端会返回最终 state前端拿到 optimizedResume 展示在结果区并提供“复制全文”按钮。整个交互结束用户不需要理解 LangGraph 是什么但能感受到这个工具是一条龙干活的。4. 踩坑实录与问题排查4.1 状态合并出问题数组字段被覆盖最开始我把 gapAnalysis 设成字符串数组想着可以一次放多条分析。结果发现第二次重写时前一轮的 gap 分析直接没了。当时第一反应是“代码逻辑写错了”排查到最后才发现是 LangGraph.js 的状态合并机制。普通字段的 reducer 默认是覆盖所以数组不声明 reducer 的话每次节点返回都会覆盖旧数组。正确做法是给数组字段声明合并 reducerAnnotationstring[]({ reducer: (current, incoming) [...(current ?? []), ...(incoming ?? [])], })这个坑几乎所有用 LangGraph.js 的新手都会踩一次。建议项目一开始就把 State 里所有字段的合并策略想清楚而不是等出问题了再补。4.2 流式中断、超时和连接自动断开简历优化任务耗时较长很容易触发平台超时或者用户中途切走页面导致连接断开。我遇到过一个现象后端任务还在执行但前端的 SSE 连接已经断掉数据全丢了用户回来只看到一个空白结果页。后来我做了两件事。第一任务结果持久化。前端发起请求时先生成一个 taskId后端每完成一个节点就把中间结果写进 Redis同时通过 SSE 推送给前端。前端断线重连时直接用 taskId 从 Redis 拉取已经完成的部分。这样即使连接断了任务不会白跑。第二给所有流式响应加注释行。有些代理服务器对长时间没有数据输出的 SSE 连接会默认超时断开。我在每个数据事件之间每隔 15 秒发一个 SSE 注释行以冒号开头保持连接活跃。这是老协议里的心跳技巧实测能解决大多数中间层断连问题。4.3 模型输出 JSON 不稳定我在校验节点里让模型输出 JSON定义得很清楚但模型还是偶尔输出多余文字比如开头写“好的我是 HR 审阅员”或者用 Markdown 代码块把 JSON 包起来。如果直接 JSON.parse必然报错。我的兜底方案分三层先用正则从文本里提取第一个合法的 JSON 对象块。如果提取失败把 response 里的 JSON 片段去掉代码块标记后再试一次。还是失败就默认 needsRewritefalse不把不确定性传导给用户。实际运行中这三层兜底能挽回九成以上的解析异常。如果模型换成支持工具调用的版本更好的做法是用 withStructuredOutput 让模型直接返回符合 zod schema 的结构化对象连 JSON.parse 都省了。我后来把分析节点的输出也改成了这种方式稳定性提升明显。4.4 长简历超出上下文窗口简历文本虽然不长但加上 JD、多轮重写历史、系统提示词之后很容易在第二轮重写时逼近上下文限制。尤其是用 mini 型号时窗口更紧张模型会开始丢信息。我是这么解决的分析节点只用“简历全文JD 全文”输出精炼的 gapAnalysis 作为中间产物。生成节点不直接使用简历全文而是分段构造提示词只把需要重写的原始段落和差距分析交给模型。如果简历实在太长先用脚本按工作经历切块然后并行调用模型对每块做优化最后再拼接。这一步相当于把 LangGraph.js 的节点内部实现成 Map-Reduce 模式。不要小看这个优化它直接决定了这个工具能不能处理那些写满四页纸的资深工程师简历。4.5 高频问题速查表问题现象根本原因解决方案Edge runtime 下 LangGraph 报错Edge 不支持 Node 异步迭代器route 文件显式声明 runtime nodejs数组字段被覆盖未给数组字段声明 reducer用 Annotation 的 reducer 手动合并JSON.parse 失败模型输出夹杂多余文字正则提取 JSON 块或改用 withStructuredOutput前端收不到流式数据SSE 连接被代理超时加心跳注释行持久化中间结果第二轮重写信息丢失上下文窗口不够生成节点改用分段输入避免塞全量文本模型编造项目数据temperature 过高temperature 降到 0.2 以下并在提示词中强调事实约束5. 部署、成本与后续扩展5.1 容器化部署与资源配置部署这块我没有用 Vercel 的 Serverless原因是长任务超时不可控。我最终是 Docker 部署在一台 2C4G 的轻量服务器上Node 镜像pm2 守护进程。Dockerfile 很常规不展开完整文件只说两个关键点。一是不要用 alpine 镜像因为 pdf-parse 依赖一些原生模块在 alpine 上会遇到兼容问题直接用 node:20-slim 更省心。二是记得把 .next 构建产物复制进镜像不要把 node_modules 整个打进去能用 npm ci --omitdev 就尽量用。服务器内存方面LangGraph.js 执行过程中会把状态和中间结果保存在内存里如果并发同时跑多个任务内存占用会明显上升。2G 内存大概能撑 3-5 个并发任务如果你是做给团队内部用基本够了。要是面向公网开放还是得加一层队列做并发控制或者把 Agent 执行抽出去。5.2 Token 成本控制简历优化是一个典型的多轮调用场景一次完整任务可能消耗 1-3 万 token成本不可忽视。我的优化思路有三条限制最大迭代次数。当前设了 2 次重写上限大多数简历一次重写就能过极少需要第二轮。把上限调成 2既保证质量又能控制成本。使用模型缓存。同一个用户的同一份简历在短时间内重复请求直接返回上一轮结果。我目前用 Redis 以 resumeText jdText 的 hash 作为 key 缓存最终结果命中率很高。优先使用 mini 型号做分析和校验只在最后生成优化稿时用更强模型。事实证明分析任务对模型推理能力要求没那么高用 mini 型号可以省一大笔钱。5.3 从简历优化到更多 AI Agent 场景做完这个项目之后再回看LangGraph.js 的价值不在于“能画一张图”而在于它逼着你想清楚 Agent 的状态是什么、每一步的输入输出是什么、什么条件下循环、什么条件下退出。这套思维方式放之四海皆准。我后续已经在往两个方向扩展这套架构。一个方向是“面试问答助手”基于简历内容和 JD 生成面试问题清单状态图里增加面试官角色节点、候选人回答评估节点最后输出综合反馈。另一个方向是“社媒内容复写器”把一篇长文拆成标题、摘要、正文、标签四段让 Agent 按不同角色依次处理这就是内容流水线。不管哪个方向骨架都是一样的输入解析节点、分析节点、生成节点、校验节点、条件路由。唯一变化的是每一层里的提示词和业务逻辑。所以如果看完这篇文章你只记住一件事我希望是这句话Agent 落地的关键不是把 Prompt 写得花哨而是设计出清晰的状态流转和退出机制。剩下的LangGraph.js 会帮你兜住。最后补一句我这半年最真实的体会不要一上来就搭复杂架构先用一个最小的状态图把核心流程跑通再逐步加节点、加工具、加缓存。这个最小闭环的价值远大于一开始就完美设计。