☰
基于Next.js与LangGraph.js的AI简历优化Agent实战
2026/10/1 9:24:20 网站建设 项目流程

简历工具这个赛道,看起来已经被各种模板站和在线编辑器做烂了,但真正动手做一个能"读懂"简历、能给出针对性修改建议的 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 要能把它拆成"基本信息、教育背景、工作经历、项目经历、技能"这些结构化字段,而不是一坨纯文本。
  • 岗位匹配分析:用户贴一段 JD,Agent 要能提取岗位关键词,和简历做匹配,指出"你缺了哪些关键词""哪些经历和这个岗位最相关"。
  • 逐段优化建议:针对每段经历,给出具体的改写建议,而不是笼统的评价。比如把"负责后端开发"改成"主导订单系统重构,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: Annotation<string>({ reducer: (_, update) => update, default: () => "", }), parsedResume: Annotation<ParsedResume | null>({ reducer: (_, update) => update, default: () => null, }), jobDescription: Annotation<string>({ reducer: (_, update) => update, default: () => "", }), jobKeywords: Annotation<string[]>({ reducer: (_, update) => update, default: () => [], }), suggestions: Annotation<Suggestion[]>({ reducer: (current, update) => [...current, ...update], default: () => [], }), retryCount: Annotation<number>({ reducer: (_, update) => update, default: () => 0, }), });

这里有个细节值得展开:reducer决定了当节点返回新值时,State 怎么更新。大部分字段我用的是"直接覆盖"((_, update) => update),但suggestions用的是"追加"([...current, ...update])。因为优化建议是逐段生成的,每处理一段就往数组里加一条,用追加 reducer 就不用每次手动把旧数据读出来再拼回去。

retryCount这个字段是给重试逻辑用的,后面讲条件边的时候会用到。

2.2 节点划分:每个节点只干一件事

图里的节点我划分得比较细,原则是"一个节点只做一件可描述的事"。这样调试的时候,哪个环节出问题一目了然。我的节点清单如下:

节点名职责输入输出
parseResume把原始文本解析成结构化字段rawTextparsedResume
extractKeywords从 JD 提取关键词jobDescriptionjobKeywords
matchAnalysis简历与岗位匹配度分析parsedResume, jobKeywords匹配报告
generateSuggestions逐段生成优化建议parsedResume, jobKeywordssuggestions
selfCheck检查建议是否自洽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", }, }); }

这里用的是 SSE(Server-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 用内容的 hash,TTL 设 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 项目里都适用。

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

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

立即咨询