多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于Next.js与LangGraph.js的简历优化Agent实践

基于Next.js与LangGraph.js的简历优化Agent实践 这个项目是我今年下半年一直在折腾的东西一个基于 Next.js 和 LangGraph.js 的简历优化工具。简单说用户上传简历和岗位 JDAgent 自动做匹配分析、找出差距、生成定制化修改建议和求职信。这类工具市面上不少但多数是一段超长 Prompt 调一次 GPT的伪 Agent我一开始也是这么干的效果一言难尽后来全部推倒重来用 LangGraph.js 把流程真正编排成了状态机。这篇文章把我的踩坑过程、架构设计和核心代码完整复盘一遍适合准备用 TypeScript 技术栈做 Agent 应用、或者想从提示词管道升级到真 Agent的开发者参考。1. 简历工具为什么非要用 Agent 架构1.1 我踩过的一条提示词走天下的坑最早版本的简历工具逻辑看起来很简单把用户上传的简历文本和 JD 拼在一起塞给 GPT-4 让它分析匹配度并给出建议。听起来没毛病但实际用起来问题非常集中。第一个问题是输出不可控。大模型面对两段长文本经常把简历总结和JD 分析混在一起写用户想要的表格式匹配度评分经常变成一大段散文。第二个更致命的问题是它不会追问。真实场景里用户的简历往往信息不全比如没有写具体年份、没写离职原因、项目描述含糊。人来看简历时知道哪些地方要追问但一次性 Prompt 只能基于已有信息硬着头皮生成生成出来的建议自然浮于表面。第三个问题是你没法让它在中间步骤使用工具。比如我需要解析 PDF 里的表格、需要联网查目标公司的技术栈、需要调一个评分函数来计算技能重合度——这些在一次性调用里全都要塞进上下文又贵又乱。1.2 简历场景里 Agent 的三个不可替代的价值重新梳理需求之后我发现简历优化本质上是一个多阶段决策任务不是一次生成任务先解析简历把非结构化文本变成结构化数据工作经历、技能、教育背景再解析 JD提取岗位硬性要求、软性要求、加分项然后做匹配度计算找出差距最后才谈得上生成优化建议——而且建议要能细分到简历上怎么改面试时怎么补。这个链路天然适合用 Agent 的节点式编排来表达。LangGraph.js 带来的三个能力是普通 Prompt 管道做不到的一是每个节点可以独立调用不同模型或工具成本可控二是状态在节点之间显式流动中间结果可被检查和人工编辑三是可以设置条件边让 Agent 在信息不足时回到上一个节点追问用户形成闭环。1.3 LangGraph.js 在 JS 生态里的独特位置选 LangGraph.js 而不是自己撸状态机理由很简单它已经把图执行、状态管理、流式输出、checkpointer 这些底层东西全部做好了。你只需要定义 State 和节点函数它就负责调度。相比直接写 if/else 串行调用 LLM 的代码LangGraph 让流程变成声明式的图后续加节点、改路由都只需要动一两行。而且它和 LangChain.js 是一套体系内置工具调用、模型封装、prompt 模板不用在 TypeScript 项目里去拼字符串。对我这种主要写前端、不想在 Node 和 Python 之间来回切换的人来说这是最舒服的方案。2. 项目骨架Next.js 如何当 Agent 的宿主2.1 技术栈分配谁负责什么整个项目我用 Next.js 14 的 App Router 做宿主前端页面和 API 层都在同一个项目里。Agent 的核心运行在服务端 Route Handler 里通过 fetch 流式接口和浏览器通信。让我把职责切分讲清楚一点。Next.js 在这套架构里不是顺便用一下它是整个 Agent 的运行时容器——API 路由负责接收上传、创建图实例、执行流式生成Server Component 负责渲染首屏客户端组件负责流式消费和交互。LangGraph.js 完全跑在服务端不会在前端 bundle 里出现。另外我用了 Prisma Postgres 存用户和简历记录用 Vercel Blob 存原始文件。这里有个设计要点Agent 的状态只放在内存和数据库里不塞进 Next.js 的缓存体系避免 Next 的缓存机制干扰图的状态流转。2.2 初始化项目与目录结构项目初始化用 create-next-appTypeScript Tailwind App Router。核心依赖就几个npm install langchain/langgraph langchain/openai langchain/core zod pdf-parselangchain/openai是模型封装层zod用来给 Agent 的结构化输出定义 schemapdf-parse处理简历 PDF 的文本抽取。目录结构我刻意做了分层让 Agent 相关代码和应用代码隔离src/ app/ api/ analyze/route.ts # Agent 流式接口 upload/route.ts # 文件上传 resume/[id]/route.ts # 查询历史 page.tsx # 主页面 components/ # 前端组件 agent/ graph.ts # 图定义 nodes.ts # 节点函数 state.ts # 状态类型 tools.ts # 工具节点 prompts.ts # 各节点 prompt这样做的原因是 Agent 的图逻辑和 UI 逻辑会同时演化混在一起后期绝对会想哭。2.3 避免 Vercel 函数超时的那点事这里有个很现实的坑。Vercel 的 Serverless 函数默认执行时长有限制Hobby 计划是 10 秒Pro 计划是 60 秒部分配置可到 300 秒。但一个完整的多节点 Agent 流程LLM 推理加工具调用跑个 30 到 90 秒非常常见。这意味着如果你直接把 Agent 跑在 Vercel 的普通 Serverless 函数里大概率超时。我的处理方式是双轨制短任务比如单看一份简历的快速评分走 Serverless长任务走自托管的 Node 服务通过 BullMQ 任务队列消化。Next.js 前端只需要 POST 一个任务然后轮询或 SSE 拿结果。文章后面我会专门展开并发和任务队列的部分。3. 把帮人改简历拆成一张状态机图3.1 State 定义Agent 的共享记忆LangGraph.js 里最重要的概念是 State。State 就是一张图上所有节点都能读写的共享对象它决定了 Agent 的记忆长什么样。我的简历 Agent 的 State 用 LangGraph 的 Annotation 来定义import { Annotation } from langchain/langgraph; export const AgentState Annotation.Root({ resumeText: Annotationstring, // 原始简历文本 jdText: Annotationstring, // 目标 JD 文本 fileName: Annotationstring, // 原始文件名用于展示 structuredProfile: Annotation{ experience: string[]; skills: string[]; education: string; summary: string }, jdRequirements: Annotation{ hardSkills: string[]; softSkills: string[]; yearsRequired: number }, matchGaps: Annotationstring[], // 差距列表 matchScore: Annotationnumber, // 匹配度 0-100 suggestions: Annotationstring, // 最终优化建议 markdown coverLetter: Annotationstring, // 求职信草稿 userFeedback: Annotationstring, // 用户的中途反馈用于循环 });这里每个字段都是节点的输出缓冲。比如 parseResume 节点写入 structuredProfileanalyzeJd 节点读 resumeText 和 jdText、写入 jdRequirements。LangGraph 的架构天然鼓励你把中间产物显式建模这比在一个巨大的 memory 对象里塞所有东西要清晰得多。3.2 节点编排的完整流程图本身用 StateGraph 构建。我第一次写这个图的时候把它设计成了严格线性流水线但后来加了两个反馈回路才真正体现出 Agent 的价值。import { StateGraph, START, END } from langchain/langgraph; import { AgentState } from ./state; import { parseResume, analyzeJd, calculateGap, generateSuggestions, qualifyUser } from ./nodes; const graph new StateGraph(AgentState) .addNode(parse_resume, parseResume) .addNode(analyze_jd, analyzeJd) .addNode(calculate_gap, calculateGap) .addNode(generate_suggestions, generateSuggestions) .addNode(qualify_user, qualifyUser) .addEdge(START, parse_resume) .addEdge(parse_resume, analyze_jd) .addEdge(analyze_jd, calculate_gap) .addEdge(calculate_gap, qualify_user) .addConditionalEdges(qualify_user, routeAfterQualify) .addEdge(generate_suggestions, END);routeAfterQualify是条件路由函数决定下一个节点是 generate_suggestions 还是回到 analyze_jd。这就是让 Agent 具备追问—修正能力的关键。3.3 条件边和反馈回路不再一条道走到黑我加的第一个反馈回路是信息澄清。calculateGap 节点算出差距之后发现简历里信息不足以支撑判断比如技能没有时间戳、项目描述过短它就把问题写进 userFeedback 并进入 qualifyUser 节点。qualifyUser 的角色是向用户发起追问——回传给前端等用户回答后再把答案 merge 回 resumeText。第二个回路是建议方向确认。generateSuggestions 之前先给用户看一眼我打算按这三个方向改简历补齐项目量化指标、重写技能栈排序、补充证书模块用户可以选择接受或修改。这个回路大幅度减少了AI 一顿输出但用户完全不想用的情况。条件路由函数里其实就一个简单的判断function routeAfterQualify(state: typeof AgentState.State) { if (state.userFeedback APPROVED) { return generate_suggestions; } return analyze_jd; // 拿到反馈后重新分析 }别小看这种简单的循环它把一个静态的给答案工具变成了一个会确认理解、会修正策略的对话系统。我的用户里至少有三分之一会在这一步给出额外信息比如我其实有 5 年的 React 经验简历上写少了这正是线下修改简历时最需要的人工干预点。3.4 工具节点让 Agent 具备调外部能力除 LLM 推理节点之外我还注册了一个 ToolNode里面有两个自定义工具。LangGraph 的 ToolNode 会自动选择需要调用的工具、执行、并把结果返回给模型继续生成你不需要自己写工具的调度逻辑。parseResumeFile根据文件 buffer 走 pdf-parse 抽取文本并清洗乱码fetchCompanyTechStack联网搜索目标公司的技术栈比如公司官网、招聘页补充 JD 里没写全的隐性要求。工具节点的写法参考 LangChain 的标准工具定义import { tool } from langchain/core/tools; import { z } from zod; export const fetchCompanyTechStack tool( async ({ company }) { const res await fetch(https://.../search?q${company}techstack); const data await res.json(); return JSON.stringify(data.slice(0, 5)); }, { name: fetchCompanyTechStack, description: 查询目标公司的技术栈和产品信息用于补充 JD 中未明确的技能要求, schema: z.object({ company: z.string() }), } );加了 ToolNode 之后我才真正感觉这个 Agent活了——它不再只看用户给的两段文字而是会主动去查招聘页和公司技术博客把外部信息内化到判断逻辑里。4. 核心代码一条工作流的完整落地4.1 简历文本抽取与清洗PDF 抽取是所有简历工具的噩梦。pdf-parse 在本地跑得好好的一上 Serverless 就会遇到字体渲染库缺失的问题中文简历容易抽出来一堆乱码。我最后的方案是 Node 服务上用 pdf-parse 加一行文本清洗import pdf from pdf-parse; // 清洗常见乱码和空行 function cleanText(raw: string) { return raw .replace(/\r\n/g, \n) .replace(/\u0000/g, ) .replace(/[ \t]/g, ) .replace(/\n{3,}/g, \n\n) .trim(); } export async function extractTextFromBuffer(buffer: Buffer) { const result await pdf(buffer); return cleanText(result.text); }注意别直接截断太长文本喂给模型。完整简历动辄 3000-6000 token加上 JD 和系统提示词很容易顶到上下文上限。我做了分块策略第一遍抽取前 2500 token 做结构化分析如果 Agent 判断信息不足再按需读取后面的内容这样可以省下大量 token 成本。4.2 Agent 主流程从接到请求到输出一份完整建议所有节点里最核心的是 calculateGap 和 generateSuggestions。calculateGap 要输出匹配度分数和差距列表我用结构化输出约束模型import { ChatOpenAI } from langchain/openai; const model new ChatOpenAI({ model: gpt-4o, temperature: 0.2, }); export const calculateGap async (state) { const gapModel model.withStructuredOutput( z.object({ matchScore: z.number().describe(0-100 的匹配度评分), gaps: z.array(z.string()).describe(简历与 JD 的具体差距列表), missingInfo: z.array(z.string()).describe(简历中缺失的关键信息), }) ); const result await gapModel.invoke([ { role: system, content: GAP_ANALYSIS_PROMPT }, { role: user, content: 简历${state.structuredProfile}\nJD${state.jdRequirements} }, ]); return { matchScore: result.matchScore, matchGaps: result.gaps, userFeedback: result.missingInfo.length 0 ? 以下信息缺失请补充${result.missingInfo.join(、)} : APPROVED, }; };这里要特别说一下withStructuredOutput。它比直接让模型返回 JSON 再手动解析稳定十倍因为 schema 会传给模型做 tool calling输出格式基本不会跑偏。简历这类对结构化要求极高的场景我强烈建议所有分析类节点都用这种方式约束输出。generateSuggestions 节点则是纯生成任务不加 schema 约束因为最终要输出的是给人读的 Markdown 文档。它会综合 structuredProfile、jdRequirements、matchGaps 和用户反馈生成一份按章节组织的优化建议包括简历开头摘要的重写、技能栈的排序建议、每条工作经历的具体改写示例、面试时如何解释 gap 的提示。4.3 用流式响应打通前后端Agent 跑起来之后每个节点之间是有明显停顿的。如果全部跑完才一次性返回用户只能盯着 spinner 发呆 60 秒。所以我从一开始就决定做流式。LangGraph 的.stream()支持多种 streamMode。我用updates模式会把每个节点的输出增量推出来export async function POST(req: Request) { const body await req.json(); const initialState { resumeText: body.resumeText, jdText: body.jdText, fileName: body.fileName, }; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const config { recursionLimit: 15 }; for await (const update of await graph.stream(initialState, config)) { const payload JSON.stringify({ node: Object.keys(update)[0], data: Object.values(update)[0], }); controller.enqueue(encoder.encode(data: ${payload}\n\n)); } controller.enqueue(encoder.encode(data: [DONE]\n\n)); controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }前端用fetch配合 ReadableStream 解析 SSE每收到一个节点更新就更新 UI 状态。这个体验比转圈 60 秒好了不止一个量级用户能实时看到 Agent 正在解析简历→分析 JD→计算差距信任感完全不一样。5. 前端体验设计让用户看着 Agent 干活5.1 节点级进度反馈把 Agent 的思考过程可视化一开始我以为前端只是接受结果的地方后来发现大错特错。对 Agent 应用来说前端的第一要务是让用户理解 Agent 在做什么、做到哪一步了。我的前端主界面做成一个纵向卡片流响应 SSE 的每个节点更新卡片一正在解析简历——展示抽取出的技能列表用户可以删除错误项卡片二正在分析 JD——展示硬性要求和软性要求卡片三匹配差距——展示差距列表和评分可展开看细节卡片四优化建议——最终 Markdown 渲染支持一键复制。这里有一个实用技巧因为streamMode: updates返回的数据是节点名到节点输出的映射前端可以很自然地按节点渲染。例如收到parse_resume的更新就把结构化结果填进卡片一。前端代码大致长这样const response await fetch(/api/analyze, { method: POST, body: payload }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events buffer.split(\n\n); buffer events.pop() ?? ; for (const event of events) { if (!event.startsWith(data:)) continue; const data event.replace(data: , ); if (data [DONE]) continue; const update JSON.parse(data); setNodeUpdates((prev) [...prev, update]); } }每来一个节点更新我就把对应节点标记为完成把输出数据渲染进卡片再推进进度条。这种做法让用户感觉到 Agent 是在一步步思考而不是一个黑盒在生成。5.2 中间结果可编辑人工干预不是 bug 是特性我在 3.3 节提到的反馈回路在前端就是节点卡片之间的等待状态。比如 calculateGap 之后如果 Agent 发现简历缺关键信息它不会硬着头皮生成建议而是停住弹出一个对话框问用户您的简历缺少量化成果描述请补充您最近一个项目的具体数字成果。这个对话框的输入会作为 userFeedback 写入 State然后图执行流会重新进入 analyze_jd。这个交互让 Agent 的自动和用户的主动形成了很好的互补。实测下来人工干预一次之后生成的简历建议用户满意度明显高于完全自动生成的版本——人总归希望保留对如何呈现自己的决定权。6. 上线后被问爆的问题Agent 怎么扛并发6.1 先搞清楚 Agent 的并发瓶颈在哪简历工具上线后访问量超出了预期我才开始认真面对AI Agent 怎么扛并发这个问题。先说结论Agent 应用的并发瓶颈通常不在 Web 服务器而在以下三个地方——模型 API 的 rate limit、长任务占用的连接资源、以及状态存储的读写压力。单次 Agent 调用平均要打模型 3 到 6 次即使单个用户请求不密集并发用户一多OpenAI 的 rate limit 会先把你卡住。所以我做了三层防护第一层按用户做令牌桶限流每人每分钟最多启动 3 次 Agent 分析第二层对完全相同的请求同简历同 JD做 Redis 缓存直接把结果复用不再消耗模型调用第三层把长任务切到后台队列执行Web 请求快速返回taskId前端通过轮询拿结果。这三层叠加之后单机扛住了日常流量模型 API 调用量反而下降了 40%因为缓存命中了大量重复请求。6.2 任务队列与长任务解耦对于生成求职信这种可能超过 60 秒的任务我在自托管的 Node 服务里引入了 BullMQ Redis。流程变成/api/analyze接到请求把 resumeText、jdText、文件名写入 Redis创建任务BullMQ worker 拉取任务执行 LangGraph 图把每个节点更新写回数据库前端轮询/api/task/:id拿到节点状态列表和最终结果。短任务比如 30 秒内仍然走直连流式不需要经过队列长任务才走队列。这个双通道设计在成本和体验之间取了平衡避免所有请求都背着队列的额外延迟。队列的好处还在于牟定恢复。如果某个任务在模型调用中途崩溃worker 重启后可以从 checkpointer 保存的状态恢复执行用户端不会感知到 Agent 重新从零开始。6.3 上下文窗口与 token 成本控制简历优化是 token 消耗大户因为每次调用都要带上大量简历文本和 JD。我做过一个统计一次完整分析平均消耗约 8000 token按 GPT-4o 的价格算单次成本接近 5 美分。不加控制的话一个月下来成本相当可观。我的成本控制三板斧第一结构化抽取阶段用便宜模型GPT-4o-mini只有最终建议生成才用 GPT-4o第二简历文本只保留前 2500 token 给快分析阶段信息不足才分段读全量第三对模型输出做长度兜底——用maxTokens限制每个节点输出长度特别是差距列表这种容易展开长篇大论的地方限制到 10 条以内。这三招加起来单次分析成本下降超过 60%且用户感知不到质量差异。如果你也在做 Agent 应用请一定从第一天就关注 token 成本不然后期优化会非常痛苦。7. 部署与监控心得7.1 部署形态Serverless 不是万能药前面提到过 Vercel 函数超时的问题这里再说一个部署层面的建议。如果你的 Agent 应用意味着每次请求都要跑一个完整的图那你要做好 Serverless 方案可能不合适的心理准备。我的最终部署形态是组件部署方式说明Next.js 前端与轻量 APIVercel承担上传、任务创建、短任务直连Node.js Agent 服务自托管 Docker PM2承担长任务和图执行RedisUpstash队列、缓存、限流PostgresNeons用户数据、简历记录、任务状态Node Agent 服务是无状态的横向扩容只需要在前面加一层负载均衡。因为 LangGraph 图的实例本身可以每次请求创建只要 checkpointer 和数据库共享所有实例都能恢复同一个任务状态。7.2 可观测性不知道 Agent 在干嘛就等着失眠Agent 应用比普通 CRUD 应用难调试得多。普通接口有问题一眼能看到报错Agent 是模型静默地给出了一个不太对的结果如果没有观测手段基本只能抓瞎。我接入的是 LangSmith每个图的一次完整执行会被记录成一条 trace能看到每个节点耗时、模型输入输出、token 用量、工具调用结果。我给自己定了三条监控红线一是节点成功率低于 99% 就告警二是单次任务耗时 P95 超过 90 秒就排查三是 token 成本环比涨幅超过 20% 就复盘。AI Agent 能跑通只是起点能稳定、可控、便宜地跑才是真正能支撑业务的形态。监控是这一切的地基别等到用户反馈结果变差了才想起来去看。最后分享一点个人心得。做这类 Agent 应用技术难点反而不在调通模型——任何会写 Prompt 的人都能让 LLM 输出一份不错的建议——难点在于把不可控的模型行为编织进一个可控的业务流程里。LangGraph 的价值恰恰在于让我们用工程手段把模型会自由发挥的部分围起来该结构化的地方强约束该让模型发挥的地方给足空间该让用户干预的地方停下来等输入。这套思路打通之后简历工具只是第一个落地项目我后续已经在把同样的图编排模式复制到其他文档处理场景里底层的 State、节点、条件边、checkpointer 几乎可以直接复用。如果你也在用 TypeScript 搭 Agent 应用建议你至少在项目里试一次把流程画成状态机图你会发现从调接口到编排思考过程这个转变本身就是 Agent 应用的核心体验。
返回列表