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

发布时间:2026/10/1 9:24:29
基于Next.js与LangGraph.js的AI简历优化Agent实战 简历工具这个赛道看起来已经被各种模板站和在线编辑器做烂了但真正动手做一个能读懂简历、能给出针对性修改建议的 AI Agent和套一个模板生成器完全是两码事。我最近用 Next.js 搭配 LangGraph.js 完整落地了一个简历优化 Agent从需求拆解、图结构设计、流式输出到并发处理踩了个遍。这篇文章不讲空泛的概念只讲我在这个项目里真实做过的技术选型、写过的核心代码、以及那些文档里不会告诉你的坑。如果你正在找一个 AI Agent 的练手项目或者想把大模型能力真正嵌进一个 Web 产品里这篇内容应该能帮你少走不少弯路。1. 为什么简历工具值得用 Agent 重做一遍1.1 传统简历工具的瓶颈到底在哪先说说我为什么要做这个东西。市面上绝大多数简历工具本质上是表单 模板渲染你填字段它套模板导出 PDF。这类工具解决的是排版问题但求职者真正的痛点在内容——我的项目经历写得够不够有说服力这段描述是不是太流水账针对这个 JD我的简历该突出什么、砍掉什么这些问题传统工具一个都答不了因为它们不理解语义。而大模型恰好擅长这个。但如果你只是简单调一次 API把简历丢进去让它优化一下得到的结果往往很泛——它会给你一堆建议使用量化数据突出个人贡献这种正确的废话因为模型不知道你投的是什么岗位、你的原始经历里哪些是真正值钱的。这就是我要引入 Agent 而不是单次调用的原因。Agent 的核心价值在于多步骤的推理与工具调用它可以先解析简历结构再分析目标岗位的关键词然后逐段对照给出修改建议最后还能自己检查一遍建议是否自洽。这一连串动作需要状态管理、条件分支和循环正好是 LangGraph.js 的强项。1.2 这个 Agent 到底要解决哪几件事我把需求收敛成了三个核心能力这也是整个项目的主线简历结构化解析用户上传 PDF 或粘贴文本Agent 要能把它拆成基本信息、教育背景、工作经历、项目经历、技能这些结构化字段而不是一坨纯文本。岗位匹配分析用户贴一段 JDAgent 要能提取岗位关键词和简历做匹配指出你缺了哪些关键词哪些经历和这个岗位最相关。逐段优化建议针对每段经历给出具体的改写建议而不是笼统的评价。比如把负责后端开发改成主导订单系统重构QPS 从 200 提升到 1500。这三件事串起来就是一个完整的 Agent 工作流。下面我会一步步拆解怎么用 LangGraph.js 把这个流程搭出来。1.3 技术选型Next.js 和 LangGraph.js 为什么是绝配选 Next.js 做前端和 BFF 层理由很直接App Router 的 Route Handlers 天然适合做流式接口Server Components 能减少客户端状态管理的复杂度而且部署简单。更重要的是Next.js 的 API 层可以直接跑 Node.js 运行时LangGraph.js 就是 Node 生态的库两者在同一个进程里协作省去了跨服务调用的麻烦。LangGraph.js 相比直接手写状态机或者用 LangChain 的 Chain优势在于它把 Agent 的流程显式建模成一张有向图节点是处理步骤边是流转逻辑状态在节点间传递。这样做的好处是流程可观测、可中断、可恢复而且天然支持条件分支和循环——比如如果解析失败就重试这种逻辑用图来表达非常自然。提示LangGraph.js 和 Python 版的 LangGraph 概念一致但 API 细节有差异。如果你之前只看过 Python 的教程迁移到 JS 版时要注意状态定义用的是Annotation而不是 Python 的TypedDict这个后面会细讲。2. 用 LangGraph.js 把简历优化拆成一张状态图2.1 先想清楚状态里要装什么搭图之前最关键的一步是定义 State。State 就是在这张图里流动的数据所有节点都读写它。我一开始图省事把 State 定义得很随意结果写到一半发现节点之间传数据全靠猜返工重来。所以这一步值得花时间想清楚。我的 State 最终长这样用 TypeScript 描述import { Annotation } from langchain/langgraph; const ResumeState Annotation.Root({ rawText: Annotationstring({ reducer: (_, update) update, default: () , }), parsedResume: AnnotationParsedResume | null({ reducer: (_, update) update, default: () null, }), jobDescription: Annotationstring({ reducer: (_, update) update, default: () , }), jobKeywords: Annotationstring[]({ reducer: (_, update) update, default: () [], }), suggestions: AnnotationSuggestion[]({ reducer: (current, update) [...current, ...update], default: () [], }), retryCount: Annotationnumber({ reducer: (_, update) update, default: () 0, }), });这里有个细节值得展开reducer决定了当节点返回新值时State 怎么更新。大部分字段我用的是直接覆盖(_, update) update但suggestions用的是追加[...current, ...update]。因为优化建议是逐段生成的每处理一段就往数组里加一条用追加 reducer 就不用每次手动把旧数据读出来再拼回去。retryCount这个字段是给重试逻辑用的后面讲条件边的时候会用到。2.2 节点划分每个节点只干一件事图里的节点我划分得比较细原则是一个节点只做一件可描述的事。这样调试的时候哪个环节出问题一目了然。我的节点清单如下节点名职责输入输出parseResume把原始文本解析成结构化字段rawTextparsedResumeextractKeywords从 JD 提取关键词jobDescriptionjobKeywordsmatchAnalysis简历与岗位匹配度分析parsedResume, jobKeywords匹配报告generateSuggestions逐段生成优化建议parsedResume, jobKeywordssuggestionsselfCheck检查建议是否自洽suggestions校验结果parseResume这个节点我踩过坑。一开始我直接让模型输出 JSON结果它经常在 JSON 外面包一层 markdown 代码块或者字段名对不上。后来我改用了 LangChain 的withStructuredOutput配合 Zod schema 做约束稳定性提升了一大截import { z } from zod; const ResumeSchema z.object({ basicInfo: z.object({ name: z.string(), email: z.string(), phone: z.string().optional(), }), education: z.array(z.object({ school: z.string(), major: z.string(), degree: z.string(), period: z.string(), })), experiences: z.array(z.object({ company: z.string(), role: z.string(), period: z.string(), description: z.string(), })), skills: z.array(z.string()), }); const parser model.withStructuredOutput(ResumeSchema);用 Zod 定义 schema 的好处是它既能在运行时校验模型输出又能给 TypeScript 提供类型推导前后端共享同一套类型定义省心。2.3 条件边让 Agent 学会重试和跳过图真正有意思的地方在于条件边。我设计了两处条件分支第一处是parseResume之后。如果解析出来的parsedResume为空比如用户上传的 PDF 是扫描件OCR 没提取到文字就走到一个handleParseError节点提示用户重新上传而不是硬着头皮往下走。第二处是selfCheck之后。如果自检发现建议里有明显矛盾比如同一段经历给了两条冲突的修改方向就回到generateSuggestions重跑一次但retryCount加一最多重试两次避免死循环。const routeAfterParse (state: typeof ResumeState.State) { if (!state.parsedResume) return handleParseError; return extractKeywords; }; const routeAfterCheck (state: typeof ResumeState.State) { if (state.checkPassed || state.retryCount 2) return __end__; return generateSuggestions; };这里retryCount 2这个上限非常重要。我最早没设上限测试时遇到一个模型反复认为自己的建议有问题的 case直接跑飞了烧了不少 token。加上限之后最坏情况也能收敛。2.4 把图编译起来节点和边都定义好之后用StateGraph把它们组装起来import { StateGraph, START, END } from langchain/langgraph; const workflow new StateGraph(ResumeState) .addNode(parseResume, parseResumeNode) .addNode(extractKeywords, extractKeywordsNode) .addNode(matchAnalysis, matchAnalysisNode) .addNode(generateSuggestions, generateSuggestionsNode) .addNode(selfCheck, selfCheckNode) .addNode(handleParseError, handleParseErrorNode) .addEdge(START, parseResume) .addConditionalEdges(parseResume, routeAfterParse) .addEdge(extractKeywords, matchAnalysis) .addEdge(matchAnalysis, generateSuggestions) .addEdge(generateSuggestions, selfCheck) .addConditionalEdges(selfCheck, routeAfterCheck); const app workflow.compile();编译出来的app就是一个可执行对象调用app.invoke(initialState)就能跑完整条流程或者用app.stream()拿到流式输出。这个设计让我在开发阶段可以先用invoke跑通逻辑上线时再换成stream做流式体验。3. Next.js 侧流式接口与前端状态同步3.1 Route Handler 里怎么接住 Agent 的流Agent 跑起来之后最大的体验问题是等待。简历解析加建议生成一次完整流程可能要十几秒如果用户盯着一个转圈图标干等体验很差。所以流式输出是必须的。Next.js 的 App Router 里我用 Route Handler 返回一个ReadableStream把 LangGraph 的流式事件转发给前端// app/api/optimize/route.ts import { NextRequest } from next/server; export async function POST(req: NextRequest) { const { rawText, jobDescription } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const events await app.stream({ rawText, jobDescription, }); for await (const event of events) { const data JSON.stringify(event); controller.enqueue(encoder.encode(data: ${data}\n\n)); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }这里用的是 SSEServer-Sent Events格式每条消息以data:开头以两个换行结尾。前端用EventSource或者fetch的流式读取都能接。注意Next.js 的 Route Handler 默认有执行时间限制部署到某些平台时长任务可能被中断。如果你的 Agent 流程特别长建议把耗时步骤拆成多个接口或者用后台任务队列处理前端轮询结果。3.2 前端怎么把流式事件映射成 UI前端这边我用fetch配合ReadableStream读取而不是EventSource因为EventSource只支持 GET 请求而我要传简历文本用 POST 更合适async function runOptimize(rawText: string, jobDescription: string) { const res await fetch(/api/optimize, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ rawText, jobDescription }), }); const reader res.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 event JSON.parse(line.slice(6)); handleEvent(event); } } } }handleEvent里根据事件类型更新 UI解析完成就渲染结构化简历建议生成一条就追加一条。这样用户能看到内容长出来的过程等待感大大降低。这里有个容易忽略的细节buffer的处理。SSE 消息可能被 TCP 分片一次read()拿到的数据不一定是完整的消息。所以要用一个 buffer 累积按\n\n切分最后一段不完整的留在 buffer 里等下次。我最早没做这个处理偶尔会 JSON.parse 报错排查了半天才发现是分片问题。3.3 用 Server Component 还是 Client Component这个项目里我做了个取舍简历展示部分用 Server Component交互部分上传、触发优化、实时建议用 Client Component。原因是简历解析结果一旦生成就是静态的没必要在客户端重新渲染而优化过程是动态的必须放在客户端。具体做法是把 Agent 的调用封装成一个 Client Component它负责发起请求和管理流式状态解析好的简历数据通过 props 传给 Server Component 渲染。这样既保证了首屏性能又满足了交互需求。4. 并发处理AI Agent 最容易被问倒的地方4.1 单次请求为什么这么慢面试或者技术交流时你的 Agent 怎么扛并发这个问题几乎必被问到。要回答它先得搞清楚单次请求慢在哪。我实测下来一次完整的简历优化流程耗时分布大概是这样的阶段平均耗时占比简历解析3-5s30%关键词提取1-2s12%匹配分析2-3s18%建议生成5-8s40%可以看到建议生成是大头因为它要逐段处理每段都是一次模型调用。如果一份简历有 5 段经历那就是 5 次串行调用累加起来很可观。4.2 三个层面的优化思路针对这个耗时结构我从三个层面做了优化第一层能并行的就并行。建议生成里各段经历之间是独立的完全可以并行调用。LangGraph.js 支持在节点内部用Promise.all并发处理async function generateSuggestionsNode(state) { const tasks state.parsedResume.experiences.map((exp) generateOneSuggestion(exp, state.jobKeywords) ); const results await Promise.all(tasks); return { suggestions: results.flat() }; }这一改建议生成从5 次串行变成1 次并发耗时直接砍到原来的五分之一左右。但要注意并发调用会瞬间打高 API 的 QPS如果你的模型服务有速率限制得加个并发控制比如用p-limit限制同时最多 3 个请求。第二层能缓存的就缓存。简历解析结果、JD 关键词提取结果这些在短时间内重复请求的概率不低用户可能反复点重新生成建议。我用 Redis 做了缓存key 用内容的 hashTTL 设 1 小时。命中缓存时直接返回省掉最贵的解析步骤。第三层能流式就别等全量。前面讲的 SSE 流式输出本质上也是一种并发优化——它不减少总耗时但把等待时间转化成了可见进度用户感知上的响应速度提升明显。4.3 多用户并发时的资源隔离单用户优化完之后还要考虑多用户同时用的情况。这里最大的风险是共享状态污染。LangGraph 的 State 是每次调用独立创建的本身没问题但如果你在节点里用了模块级的全局变量比如缓存模型实例之外的任何可变状态就会出问题。我的做法是所有节点函数都是纯函数式的只依赖传入的 state 和外部只读的模型实例。模型实例本身是线程安全的可以全局复用。另外我给每个请求生成了一个traceId贯穿整个流程方便排查问题时定位到具体是哪次调用。提示如果你用的是 Serverless 部署要注意冷启动问题。模型客户端的初始化放在模块顶层让它随函数实例复用而不是每次请求都 new 一个。5. 那些文档里不会写的踩坑记录5.1 结构化输出偶尔不听话前面提到用withStructuredOutput约束输出但实测下来它也不是 100% 可靠。偶尔模型会返回一个字段缺失的对象或者数组里混进 null。我的应对是在 Zod schema 里给可选字段加.optional()并在节点里做一次兜底清洗const cleaned { ...parsed, experiences: (parsed.experiences || []).filter(Boolean), skills: parsed.skills || [], };别小看这几行它能避免下游节点因为undefined.map直接崩溃。生产环境里任何来自模型的输出都要当成不可信输入来对待。5.2 流式输出和结构化输出的冲突这是个比较隐蔽的坑。我一开始想同时要流式和结构化结果发现两者有点矛盾结构化输出要求模型返回完整 JSON 才能解析而流式是逐 token 返回的JSON 没闭合之前根本没法 parse。我的解决方案是分阶段处理解析阶段用结构化输出不流式反正用户看不到中间过程建议生成阶段用流式因为建议是自然语言可以逐字显示。这样既保证了数据可靠性又保证了体验。5.3 Token 成本控制跑了一段时间后我看账单发现成本比预期高不少。排查后发现两个浪费点一是重试逻辑没有上限时疯狂重跑二是把整份简历原文反复塞进每次建议生成的 prompt 里。针对第二点我做了优化生成单段建议时只传这一段经历和岗位关键词而不是整份简历。这样每次调用的输入 token 大幅减少。另外我把一些固定的系统提示词做了精简去掉那些你是一个专业的简历顾问之类的客套话——这些对输出质量影响不大但每次都要计费。5.4 错误处理要区分可重试和不可重试Agent 流程里会遇到各种错误网络超时、模型限流、输出格式错误。我的经验是这些错误要分类处理。网络超时和限流属于可重试退避几秒后重试往往能成功而输出格式错误如果重试两次还不行多半是 prompt 有问题应该直接报错而不是无限重试。我在节点里用了一个简单的错误分类function isRetryable(error: unknown): boolean { const msg String(error); return msg.includes(timeout) || msg.includes(rate limit); }配合前面说的retryCount上限整个流程的健壮性就上来了。6. 从练手项目到能用的产品还差什么6.1 简历解析的准确率是生命线做这个项目最大的体会是Agent 再聪明如果简历解析这一步就错了后面全是白搭。PDF 解析尤其麻烦不同排版、不同字体、双栏布局提取出来的文本顺序经常是乱的。我的做法是先用pdf-parse提取纯文本再用模型做结构化。对于双栏简历纯文本提取会串行我加了一个启发式判断如果提取出的文本里出现明显的左右栏交错特征就提示用户检测到复杂排版建议手动粘贴文本。与其硬解析出错不如引导用户走更可靠的路径。6.2 建议质量怎么评估优化建议生成出来之后怎么知道它好不好我做了两层评估一层是自动的用另一个模型调用做裁判判断建议是否具体、是否和岗位相关另一层是人工的我自己拿十几份真实简历跑了一遍逐条看建议是否靠谱。实测下来模型给的量化建议比如把提升了性能改成响应时间从 800ms 降到 120ms质量普遍不错但涉及行业黑话的部分偶尔会跑偏。所以我在 prompt 里加了一条约束不确定的行业术语不要硬编宁可建议用户补充真实数据。6.3 后续可以扩展的方向这个项目跑通之后我想到几个自然的扩展点。一是接入更多简历格式比如 Word 和在线链接二是做岗位定制版简历同一份经历针对不同 JD 生成不同侧重的版本三是加一个模拟面试节点根据简历和岗位生成可能的面试问题。这些都可以在现有的图结构上加节点实现不用重构。我个人在实际操作中的体会是LangGraph.js 这类图编排框架最大的价值不是让 Agent 更聪明而是让 Agent 的流程更可控。当你把每一步都显式建模成节点和边之后调试、优化、扩展都变得有章可循。简历工具只是一个小场景但这套状态图 流式输出 并发优化的组合拳放到任何 AI Agent 项目里都适用。