尧图精选

Next.js + LangGraph.js 实战:用 AI Agent 重做简历优化工具

🕒 发布时间:2026/10/1 9:32:11 📁 来源:尧图网络
1. 为什么简历工具值得用 AI Agent 重做一遍简历这个赛道看起来已经卷到不能再卷了。随便搜一下模板站、在线编辑器、AI 润色工具一抓一大把功能大差不差填信息、选模板、导出 PDF。但真正做过招聘或者帮人改过简历的人都知道一份简历从能看到能过筛中间隔着的不是排版问题而是内容与岗位的匹配度。传统的简历工具解决的是格式问题而 AI Agent 要解决的是决策问题。这两件事的难度差了一个量级。格式问题是确定性的模板引擎加数据绑定就能搞定决策问题是非确定性的需要理解岗位描述、分析候选人经历、判断哪些内容该突出、哪些该弱化、用什么措辞更打动人。这正是 Agent 擅长的场景——它不是一次性的文本生成而是多步骤的推理与工具调用。我这次做的项目技术栈选的是Next.js LangGraph.js。Next.js 负责前端交互和服务端 API 路由LangGraph.js 负责编排 Agent 的推理流程。为什么不用更简单的方案比如直接调一次大模型 API 返回结果因为简历优化天然是一个多阶段、有状态、需要人工介入的流程。用户上传简历后Agent 需要先解析结构再分析岗位匹配度然后逐段给出修改建议用户可能对某条建议不满意要求重新生成也可能手动调整后让 Agent 继续优化下一段。这种带循环、带分支、带中断恢复的流程用简单的链式调用会写得非常痛苦而 LangGraph 的状态图模型刚好对味。这篇文章适合谁看如果你已经会用 Next.js 做全栈项目想找一个真实场景把 AI Agent 落地那这篇可以直接抄作业。如果你还在观望 AI Agent 到底能干什么这个项目也能让你看到一个完整的、能跑起来的、有实际价值的 Agent 应用长什么样。我会把架构设计、核心代码、踩过的坑、以及那些文档里不会写的经验全部摊开讲。2. 整体架构设计与技术选型思路2.1 为什么是 Next.js 而不是纯后端框架很多人做 AI 应用的第一反应是 Python 后端加前端分离。这个思路没错但在这个项目里我选了 Next.js 全栈原因有三个。第一流式输出。Agent 的推理过程是逐步产生的用户需要看到正在分析岗位描述正在匹配项目经历正在生成建议这样的实时反馈。Next.js 的 Route Handler 天然支持流式响应配合 Vercel AI SDK 或者原生的 ReadableStream前端用fetch加ReadableStream读取整个链路非常顺。如果前后端分离还要额外处理跨域、SSE 连接管理、断线重连工作量翻倍。第二部署简单。一个 Next.js 项目可以同时部署前端页面和 API 路由不需要维护两套服务。对于个人项目或者小团队来说运维成本是实打实的。第三LangGraph.js 本身就是 JS 生态。既然 Agent 编排层用 JS 写那前后端统一语言能省掉很多类型定义同步的麻烦。共享 TypeScript 类型定义前端拿到的 Agent 状态和后端定义的结构完全一致不会出现字段对不上的问题。当然如果你的团队 Python 积累很深或者需要用到某些 Python 独有的 NLP 库那用 FastAPI LangGraph 的 Python 版本也完全合理。技术选型没有绝对的对错只有适不适合当前团队和场景。2.2 LangGraph.js 的状态图模型到底解决了什么问题LangGraph 的核心概念是状态图。你可以把它理解成一个带条件的流程图每个节点是一个处理函数节点之间通过边连接边可以是固定的也可以是根据状态动态决定的。在简历工具这个场景里状态图长这样入口节点接收用户上传的简历文本和岗位描述解析节点把非结构化的简历文本解析成结构化数据教育经历、工作经历、项目经历、技能等分析节点对比岗位描述计算匹配度找出差距建议节点针对每个差距生成具体的修改建议人工审核节点暂停等待用户确认或修改应用节点把用户确认的建议应用到简历上输出节点生成最终简历如果没有状态图这些步骤用 if-else 和 while 循环也能写但代码会变得非常难维护。状态图的好处是每个节点的职责单一状态流转清晰而且天然支持中断和恢复。用户关掉页面第二天再回来Agent 还能从上次中断的地方继续因为整个状态是持久化的。LangGraph.js 还提供了checkpointer机制可以把每一步的状态存到数据库或者内存里。我用的是内存版做开发生产环境换成了 Postgres。这个后面会详细讲。2.3 核心依赖清单与版本选择项目的主要依赖如下{ next: 14.2.x, react: 18.3.x, langchain/langgraph: ^0.2.x, langchain/openai: ^0.3.x, langchain/core: ^0.3.x, zod: ^3.23.x, tailwindcss: ^3.4.x }这里重点说几个选型考量。LangGraph.js 版本0.2.x 相比 0.1.x 在 API 稳定性上有很大提升特别是StateGraph的泛型支持更完善了。如果你现在开始做直接用 0.2 以上。模型选择我用的是 OpenAI 的 GPT-4o mini 做大部分节点复杂分析节点用 GPT-4o。原因很简单简历解析和建议生成这种任务mini 模型在结构化输出上已经够用成本只有十分之一。只有在做深度岗位匹配分析时才需要更强的推理能力。如果你用其他模型只要支持 function calling 和 JSON mode替换起来很容易。Zod这个必须加。Agent 的输出需要结构化用 Zod 定义 schema配合 LangChain 的withStructuredOutput能保证模型返回的数据格式正确。没有 Zod 的话你会花大量时间处理模型返回的格式错误。Tailwind纯属个人偏好换成其他 CSS 方案完全没问题。简历预览区域需要精细的样式控制Tailwind 写起来比较快。3. Agent 核心流程的详细拆解3.1 简历解析从非结构化文本到结构化数据简历解析是整个流程的第一步也是最容易出问题的一步。用户上传的简历格式五花八门有 PDF 转文本的、有直接粘贴的、有从招聘网站导出的。这些文本的共同特点是结构混乱但信息密度高。我的做法是先用一个轻量级的预处理步骤把明显的噪音去掉比如页眉页脚、重复的空行、特殊符号。然后交给模型做结构化提取。import { z } from zod; import { ChatOpenAI } from langchain/openai; const ResumeSchema z.object({ basicInfo: z.object({ name: z.string(), email: z.string().optional(), phone: z.string().optional(), location: z.string().optional(), }), education: z.array(z.object({ school: z.string(), degree: z.string(), major: z.string(), startDate: z.string(), endDate: z.string(), })), workExperience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string(), description: z.string(), highlights: z.array(z.string()), })), projects: z.array(z.object({ name: z.string(), role: z.string(), description: z.string(), technologies: z.array(z.string()), highlights: z.array(z.string()), })), skills: z.array(z.string()), }); const parserModel new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }).withStructuredOutput(ResumeSchema);这里有几个关键点。temperature 设为 0因为解析任务需要确定性输出不需要创造性。withStructuredOutput会自动处理 JSON schema 的生成和解析比手动写 prompt 要求模型返回 JSON 可靠得多。实际跑下来模型对中文简历的解析准确率大概在 85% 左右。主要错误集中在日期格式比如2020.03-2022.06和2020年3月至今混用和项目经历的归属有些简历把项目写在公司下面有些单独列出来。我的处理方式是在 schema 里把日期统一成字符串后续在代码里做标准化而不是让模型去猜。注意不要指望模型一次就能完美解析所有简历。一定要在解析后加一个校验步骤检查必填字段是否为空日期是否合理。我遇到过模型把至今解析成2099年的情况虽然少见但校验能兜住。3.2 岗位匹配分析让 Agent 学会挑刺解析完简历后下一步是拿岗位描述做匹配分析。这一步的核心不是简单计算关键词重合度而是理解岗位真正需要什么以及候选人经历中哪些能证明这些能力。我的做法是把岗位描述也做一次结构化解析提取出硬性要求学历、年限、特定技术栈软性要求沟通能力、团队协作、抗压能力加分项特定行业经验、开源贡献、证书然后让模型逐项对比简历输出匹配度评分和差距分析。const MatchAnalysisSchema z.object({ overallScore: z.number().min(0).max(100), hardRequirements: z.array(z.object({ requirement: z.string(), matched: z.boolean(), evidence: z.string().optional(), gap: z.string().optional(), })), softRequirements: z.array(z.object({ requirement: z.string(), matched: z.boolean(), evidence: z.string().optional(), })), suggestions: z.array(z.object({ priority: z.enum([high, medium, low]), target: z.string(), issue: z.string(), suggestion: z.string(), })), });这个 schema 的设计有个小心思evidence 字段。我要求模型在判断匹配时必须给出简历中的具体证据比如候选人在 XX 公司负责过日活百万的系统重构证明其具备高并发经验。这样做有两个好处一是减少模型瞎编二是用户看到建议时知道依据是什么更容易接受。实际使用中我发现模型对软性要求的判断经常过于乐观。比如岗位要求有较强的抗压能力模型看到简历里写了在紧迫的 deadline 下完成项目就判定匹配。但实际上这句话在简历里太常见了几乎等于没说。我的处理方式是在 prompt 里明确要求软性要求的匹配必须有具体的行为证据泛泛而谈不算。3.3 建议生成从哪里不行到怎么改匹配分析给出的是问题清单建议生成要解决的是怎么改。这一步是整个 Agent 里最考验 prompt 工程的地方。我的建议生成节点接收两个输入匹配分析的结果以及原始简历的对应段落。输出是具体的修改建议包括原文是什么问题在哪里建议改成什么为什么这样改更好const SuggestionSchema z.object({ suggestions: z.array(z.object({ section: z.string(), original: z.string(), problem: z.string(), improved: z.string(), reason: z.string(), })), });这里的关键是improved 字段。很多 AI 简历工具只告诉用户你的项目描述不够量化但不给出具体改法。用户知道要量化但不知道怎么量化。我的做法是让模型直接给出改写后的版本用户可以直接用也可以在此基础上调整。举个例子。原文是负责公司官网的开发与维护。模型给出的改进版是主导公司官网重构将首屏加载时间从 3.2s 降至 1.1s移动端转化率提升 18%。这个改写不一定完全符合事实但给用户提供了一个方向用数字说话用结果证明价值。用户看到这个建议后会去回忆自己项目里真实的数字然后替换进去。实操心得建议生成节点的 prompt 里一定要加一句不要编造数据如果原文没有数据用占位符如 [具体数字] 提示用户补充。我一开始没加这句模型经常自己编数字用户直接用了就出问题了。3.4 人工审核节点Agent 不是全自动才叫好很多人做 Agent 有个误区觉得越自动化越好用户最好什么都不用管。但在简历这个场景里用户必须参与。因为简历是用户自己的经历模型不可能比用户更了解真相。LangGraph 的interrupt机制就是为这种场景设计的。在建议生成之后流程会暂停把建议列表返回给前端。用户逐条查看可以选择接受、拒绝、或者手动修改。确认后流程继续把接受的建议应用到简历上。import { interrupt } from langchain/langgraph; async function reviewNode(state: AgentState) { const userDecision interrupt({ suggestions: state.suggestions, message: 请审核以下建议选择接受或修改, }); return { acceptedSuggestions: userDecision.accepted, rejectedSuggestions: userDecision.rejected, }; }这个中断可以持续任意长时间。用户关掉浏览器第二天打开只要 checkpointer 还在流程就能从断点恢复。我用 Postgres 做持久化每条会话的状态都存在数据库里用户随时可以回来继续。4. 实操过程与核心环节实现4.1 项目初始化与目录结构先用create-next-app初始化项目然后安装 LangGraph 相关依赖。npx create-next-applatest resume-agent --typescript --tailwind --app cd resume-agent npm install langchain/langgraph langchain/openai langchain/core zod目录结构我这样组织src/ app/ api/ agent/ route.ts # Agent 流式接口 resume/ parse/route.ts # 简历解析接口 page.tsx # 主页面 lib/ agent/ graph.ts # LangGraph 状态图定义 nodes.ts # 各节点实现 state.ts # 状态定义 schemas.ts # Zod schema db/ checkpointer.ts # 持久化配置 components/ ResumeUploader.tsx SuggestionList.tsx ResumePreview.tsx这个结构的好处是Agent 逻辑和 UI 逻辑完全分离。lib/agent下面的代码可以独立测试不依赖 React。UI 组件只负责展示和交互不包含业务逻辑。4.2 状态定义与图构建状态是整个 Agent 的核心。我定义的状态包含以下字段import { Annotation } from langchain/langgraph; export const AgentState Annotation.Root({ resumeText: Annotationstring(), jobDescription: Annotationstring(), parsedResume: AnnotationResume | null(), parsedJob: AnnotationJobRequirement | null(), matchAnalysis: AnnotationMatchAnalysis | null(), suggestions: AnnotationSuggestion[](), acceptedSuggestions: AnnotationSuggestion[](), finalResume: Annotationstring | null(), error: Annotationstring | null(), });Annotation.Root是 LangGraph.js 0.2 的写法每个字段用AnnotationT()定义类型。这样 TypeScript 能自动推断出状态的完整类型写节点函数时不用手动标注。图构建的代码import { StateGraph, END } from langchain/langgraph; import { AgentState } from ./state; import { parseResume, parseJob, analyzeMatch, generateSuggestions, reviewNode, applySuggestions } from ./nodes; const workflow new StateGraph(AgentState) .addNode(parseResume, parseResume) .addNode(parseJob, parseJob) .addNode(analyzeMatch, analyzeMatch) .addNode(generateSuggestions, generateSuggestions) .addNode(review, reviewNode) .addNode(apply, applySuggestions) .addEdge(__start__, parseResume) .addEdge(parseResume, parseJob) .addEdge(parseJob, analyzeMatch) .addEdge(analyzeMatch, generateSuggestions) .addEdge(generateSuggestions, review) .addConditionalEdges(review, (state) { if (state.acceptedSuggestions.length 0) return apply; return END; }) .addEdge(apply, END); export const agent workflow.compile({ checkpointer });这里有个细节review节点之后用了条件边。如果用户接受了至少一条建议就进入apply节点如果全部拒绝直接结束。这种动态分支正是状态图的价值所在。4.3 流式接口的实现前端需要实时看到 Agent 的进度所以 API 路由要用流式响应。// src/app/api/agent/route.ts import { NextRequest } from next/server; import { agent } from /lib/agent/graph; export async function POST(req: NextRequest) { const { resumeText, jobDescription, threadId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const events agent.streamEvents( { resumeText, jobDescription }, { configurable: { thread_id: threadId }, version: v2 } ); for await (const event of events) { if (event.event on_chain_end event.name parseResume) { controller.enqueue(encoder.encode(data: ${JSON.stringify({ step: parsed, data: event.data })}\n\n)); } // 其他事件处理... } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }streamEvents是 LangGraph 提供的流式接口会发出各种事件节点开始、节点结束、LLM token 流等。前端根据事件类型更新 UI。比如收到parseResume的结束事件就显示简历解析完成收到 LLM token 流就逐字显示建议内容。注意thread_id是会话标识同一个用户的多次请求要用同一个 thread_id这样 checkpointer 才能正确恢复状态。我用的是用户 ID 加时间戳的组合存在 localStorage 里。4.4 前端交互的关键细节前端这块我不展开讲所有代码只说几个容易踩坑的地方。第一流式数据的解析。浏览器原生的EventSource只支持 GET 请求而我们需要 POST 传简历文本。所以要用fetch加ReadableStream手动解析 SSE 格式。代码大概长这样const response await fetch(/api/agent, { method: POST, body: JSON.stringify({ resumeText, jobDescription, threadId }), }); 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 lines buffer.split(\n\n); buffer lines.pop() || ; for (const line of lines) { if (line.startsWith(data: )) { const data JSON.parse(line.slice(6)); // 更新 UI } } }第二中断恢复的处理。当 Agent 在review节点中断时前端会收到一个特殊事件。这时候要展示建议列表让用户操作。用户确认后再发一个请求带上thread_id和用户的决定Agent 会从断点继续。第三简历预览的实时更新。用户接受建议后简历预览要立即反映变化。我的做法是前端维护一份简历的本地状态用户接受建议时直接更新本地状态同时后台异步调用 Agent 的 apply 节点。这样用户感觉是即时的不用等网络请求。5. 常见问题与排查技巧实录5.1 模型输出格式错误怎么办这是最常见的问题。即使用了withStructuredOutput模型偶尔还是会返回不符合 schema 的数据。比如要求返回数组它返回了对象要求字段是字符串它返回了数字。我的处理方式是加一层重试机制。在节点函数里捕获解析错误如果失败把错误信息和原始输出一起塞回 prompt让模型重新生成。通常重试一次就能成功。async function withRetryT(fn: () PromiseT, maxRetries 2): PromiseT { for (let i 0; i maxRetries; i) { try { return await fn(); } catch (e) { if (i maxRetries) throw e; console.warn(第 ${i 1} 次尝试失败重试中...); } } throw new Error(unreachable); }另外prompt 里要明确给出示例。比如在要求返回 JSON 时附上一段正确的 JSON 示例模型模仿能力很强有示例的情况下格式错误率会大幅下降。5.2 并发请求下的状态污染这个问题我踩了很久才找到原因。LangGraph 的 checkpointer 是按thread_id隔离的但如果多个请求用了同一个thread_id状态就会互相覆盖。我的场景是用户可能同时打开多个标签页每个标签页都在跑 Agent。如果它们共享同一个thread_id就会出现 A 标签页的解析结果跑到 B 标签页去了。解决方案很简单每个标签页生成独立的thread_id。用crypto.randomUUID()生成存在sessionStorage里而不是localStorage这样不同标签页天然隔离。实操心得如果你要做多用户版本thread_id的命名规则建议是userId:sessionId这样既能按用户查询历史又能隔离不同会话。5.3 长简历导致的 token 超限有些用户的简历特别长加上岗位描述一次请求可能超过模型的上下文窗口。我的处理方式是分段处理。具体做法是先把简历按章节切分教育、工作、项目、技能每个章节单独做解析和匹配分析最后汇总。这样每次请求的 token 量可控而且并行处理还能加快速度。切分的逻辑不能太粗暴不能简单按字数切。我的做法是用模型先识别章节边界返回每个章节的起止位置然后按位置切分。这样能保证一个完整的项目经历不会被切成两半。5.4 常见问题速查表问题现象可能原因排查方法解决方案模型返回格式错误prompt 不够明确打印原始输出加示例、加重试状态串了thread_id 重复检查请求参数每会话独立 ID流式响应中断网络超时查看浏览器 Network加心跳、设超时解析结果为空简历格式特殊打印预处理后文本加强预处理建议质量差prompt 太泛检查 prompt加具体约束和示例恢复后状态丢失checkpointer 未持久化检查数据库连接换 Postgres6. 部署与性能优化的实战经验6.1 从内存到 Postgres 的迁移开发阶段用MemorySaver就够了但生产环境必须换持久化存储。LangGraph.js 提供了PostgresSaver配置很简单import { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer PostgresSaver.fromConnString(process.env.DATABASE_URL); await checkpointer.setup();setup()会自动建表不用手动写 DDL。表结构是 LangGraph 内部定义的存的是每个节点的状态快照。迁移过程中遇到一个问题连接池配置。Next.js 的 API 路由是短生命周期的每次请求都可能创建新连接。如果不配连接池Postgres 很快就会被连接数打满。我的做法是用pg的 Pool全局单例在模块加载时初始化一次。6.2 缓存策略哪些结果可以复用Agent 的每一步都有成本能缓存的一定要缓存。我的缓存策略分三层第一层简历解析结果缓存。同一份简历文本解析结果是一样的。用文本的 hash 做 key存 Redis有效期 24 小时。用户反复调整岗位描述时不用重复解析简历。第二层岗位解析结果缓存。同理同一份岗位描述解析结果固定。但岗位描述变化频率比简历高有效期设短一点6 小时。第三层匹配分析结果缓存。这个不能简单按文本 hash 缓存因为简历和岗位的组合才是 key。我用resumeHash:jobHash做 key有效期 1 小时。用户在同一份简历和岗位之间反复调整建议时能省掉重复分析。实测下来缓存能减少约 60% 的模型调用。对于按 token 计费的 API 来说这是实打实的成本节省。6.3 并发处理AI Agent 怎么扛住压力AI Agent 怎么扛并发是最近很热的话题。我的经验是Agent 的并发瓶颈不在模型调用而在状态管理和流式连接。模型调用本身是可以并行的OpenAI 的 API 支持高并发。真正的问题是每个会话的状态要独立存储不能互相干扰流式连接要保持不能因为服务端重启就断掉长流程的 Agent 会占用连接资源我的做法是把 Agent 的执行和 HTTP 连接解耦。用户发起请求后API 路由只负责启动 Agent 并返回一个taskIdAgent 在后台执行状态写入数据库。前端通过轮询或者 WebSocket 获取进度。这样即使 HTTP 连接断了Agent 还在跑用户重新连接后能拿到最新状态。这个架构的代价是复杂度上升需要额外的任务队列和状态查询接口。但对于需要扛并发的生产环境来说这是值得的。6.4 成本控制的几个实用技巧做 AI 应用成本是绕不开的话题。我总结了几个实用的省钱技巧模型分级。不是所有节点都需要用最强的模型。解析、格式化这种任务用 mini 模型完全够用只有深度分析才需要上大模型。我的项目里80% 的调用走的是 mini 模型成本只有大模型的十分之一。Prompt 精简。prompt 越长token 消耗越大。我定期审查 prompt删掉那些模型已经能理解的冗余说明。一个 2000 token 的 prompt 精简到 1200 token单次成本降 40%。输出长度限制。在 prompt 里明确要求简洁回答不超过 X 字。模型默认倾向于长篇大论限制长度能显著减少输出 token。批量处理。如果多个用户的请求可以合并就合并成一次调用。比如夜间批量处理简历优化任务把多个简历打包成一个请求发给模型分摊系统 prompt 的成本。7. 这个项目还能怎么扩展做完基础版本后我陆续加了一些扩展功能这里分享几个我觉得最有价值的。多版本简历管理。用户针对不同岗位维护多份简历Agent 在分析时自动选择最匹配的版本。这个功能需要在前端加一个版本切换器后端在状态里加一个resumeVersions字段。面试问题预测。基于简历和岗位的匹配分析Agent 可以预测面试官可能问的问题并给出回答思路。这个扩展很自然因为匹配分析已经找出了简历和岗位的差距这些差距正是面试官会追问的点。投递追踪。用户记录投递了哪些公司、进展如何Agent 根据反馈优化后续的简历版本。比如某个岗位被拒了Agent 分析可能的原因在下一版简历里调整。团队协作。求职教练或者导师可以查看学员的简历优化过程给出额外建议。这需要加权限管理和评论功能架构上要把单用户状态扩展成多用户共享状态。这些扩展不需要推翻现有架构都是在状态图里加节点、在状态里加字段。LangGraph 的模块化设计让扩展变得很轻松这也是我选它的重要原因。最后分享一个我在实际使用中的体会Agent 的价值不在于全自动而在于把重复的、有规律的思考过程自动化把人的精力释放到真正需要判断的地方。简历优化这个场景里Agent 负责找出问题、给出建议、执行修改用户负责判断建议是否合理、补充真实数据、做最终决定。这种人机协作的模式比纯自动或者纯手动都更高效。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →