☰
基于Next.js与LangGraph.js的简历优化Agent实践
2026/10/9 0:09:32 网站建设 项目流程

这个项目是我今年下半年一直在折腾的东西:一个基于 Next.js 和 LangGraph.js 的简历优化工具。简单说,用户上传简历和岗位 JD,Agent 自动做匹配分析、找出差距、生成定制化修改建议和求职信。这类工具市面上不少,但多数是"一段超长 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-app,TypeScript + Tailwind + App Router。核心依赖就几个:

npm install @langchain/langgraph @langchain/openai @langchain/core zod pdf-parse

@langchain/openai是模型封装层,zod用来给 Agent 的结构化输出定义 schema,pdf-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: Annotation<string>, // 原始简历文本 jdText: Annotation<string>, // 目标 JD 文本 fileName: Annotation<string>, // 原始文件名,用于展示 structuredProfile: Annotation<{ experience: string[]; skills: string[]; education: string; summary: string }>, jdRequirements: Annotation<{ hardSkills: string[]; softSkills: string[]; yearsRequired: number }>, matchGaps: Annotation<string[]>, // 差距列表 matchScore: Annotation<number>, // 匹配度 0-100 suggestions: Annotation<string>, // 最终优化建议 markdown coverLetter: Annotation<string>, // 求职信草稿 userFeedback: Annotation<string>, // 用户的中途反馈,用于循环 });

这里每个字段都是节点的输出缓冲。比如 parseResume 节点写入 structuredProfile,analyzeJd 节点读 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}+tech+stack`); 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。流程变成:

  1. /api/analyze接到请求,把 resumeText、jdText、文件名写入 Redis,创建任务;
  2. BullMQ worker 拉取任务,执行 LangGraph 图,把每个节点更新写回数据库;
  3. 前端轮询/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 应用的核心体验。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询