资讯动态

Next.js 与 LangGraph.js 构建简历分析 AI Agent 实战

发布时间:2026/10/28 3:14:38 来源:尧图企业网站定制
1. 为什么我选择用 Next.js 加 LangGraph.js 来做简历工具 Agent1.1 从一份简历的痛点说起做招聘的朋友应该都有体会筛简历这件事本质上是个信息匹配问题但真正干起来远没有说起来那么轻松。一份简历里藏着教育背景、工作经历、项目经验、技能栈、时间线、职级变化等一大堆信息HR 平均花在一份简历上的时间可能只有十几秒。而求职者这边呢投出去的简历石沉大海根本不知道问题出在哪——是关键词没匹配上还是项目描述太虚还是格式让解析器读不出来。我最初做这个简历工具 AI Agent就是想解决这个双向的信息不对称。求职者上传一份简历Agent 自动做几件事解析结构化信息、对照目标岗位 JD 做匹配度打分、指出简历里的硬伤比如时间线断层、技能与岗位不匹配、量化成果缺失、给出针对性的修改建议。听起来像是个普通的简历优化工具但真正让它有价值的是 Agent 的推理链路——它不是简单地做关键词匹配而是像一个有经验的招聘顾问那样先理解岗位到底要什么再逐条对照简历找证据最后给出有依据的判断。这个项目我选的技术栈是 Next.js 做全栈框架LangGraph.js 做 Agent 编排。为什么是这两个后面会详细拆。先说说这个 Agent 到底能干什么适合谁来参考。如果你是想入门 AI Agent 开发的前端或全栈工程师这个项目是个很好的练手场景——它涉及结构化输出、多步骤推理、状态管理、流式响应这些 Agent 开发的核心问题但又不像做通用助手那样抽象。如果你是做招聘产品或 HR SaaS 的开发者这套架构可以直接迁移到简历筛选、人才匹配、面试问题生成等场景。如果你只是想给自己做一份能过 ATS 的简历那这个工具本身也能用。1.2 为什么是 Next.js 而不是纯后端很多人做 AI Agent 第一反应是 Python 后端加个前端或者干脆用 FastAPI 起个服务。我一开始也这么想但实际做下来发现 Next.js 的全栈能力在这个场景里优势很明显。简历工具的核心交互是上传文件、等待处理、看到结果这个流程里前端和后端的边界其实很模糊。用 Next.js 的 App Router我可以在同一个项目里写页面、写 API Route、写 Server Action不用维护两套工程。更重要的是流式响应——Agent 推理是个耗时操作用户不可能盯着转圈等十几秒。Next.js 的 Route Handler 原生支持 ReadableStream配合 Vercel AI SDK 或者自己手写 SSE可以把 Agent 的中间步骤实时推给前端用户能看到正在解析简历正在分析岗位要求正在生成建议这样的进度体验完全不一样。另一个考虑是部署。Next.js 可以一键部署到 Vercel也可以自己用 Node 跑Serverless 和长连接两种模式都支持。对于简历工具这种流量波动大的场景Serverless 按需扩容很划算。当然这里有个坑后面会讲——Agent 推理时间长了会撞上 Serverless 的超时限制得做特殊处理。1.3 LangGraph.js 解决的是什么问题如果用 LangChain.js 的 AgentExecutor 或者简单的链式调用做单轮问答没问题但简历分析是个多步骤、有状态、可能分支的流程。比如解析完简历后要根据岗位类型决定走哪条分析路径——技术岗重点看项目和技术栈管理岗重点看团队规模和业务结果。这种带条件分支和循环的流程用普通链式调用写出来会非常乱。LangGraph.js 的核心价值是把 Agent 的执行流程建模成一张图。节点是具体的处理步骤边是步骤之间的流转条件整个图有一个共享的 State 在节点间传递。这样做的好处是流程可视化、可调试、可中断恢复。我可以在图里加一个人工审核节点让 Agent 分析到一半停下来等用户确认确认完再继续——这在简历修改建议这种场景里特别有用用户可以先看看 Agent 的判断对不对再决定要不要采纳。LangGraph.js 还有个我特别喜欢的特性是 checkpointer。它可以把图的执行状态持久化下来用户关掉页面再回来Agent 还能从上次中断的地方继续。对于简历这种需要反复修改的场景这个能力很关键。2. 整体架构设计与核心模块拆解2.1 一张图看懂整个系统整个系统的数据流是这样的用户在 Next.js 前端上传简历文件PDF 或 Word前端把文件传给 API RouteAPI Route 负责文件解析和预处理然后把纯文本交给 LangGraph.js 的 Agent 图。Agent 图内部有多个节点依次做信息抽取、岗位匹配、问题诊断、建议生成每个节点的输出都写进共享 State。最后前端通过流式响应拿到每个节点的中间结果和最终报告。这里有个设计决策值得说一下文件解析放在 Agent 图外面还是里面。我一开始把 PDF 解析也做成了一个节点后来发现没必要。文件解析是确定性的 IO 操作不涉及 LLM 推理放在图里反而增加了状态管理的复杂度。现在的做法是 API Route 先把文件转成纯文本再交给图处理。这样图里的节点都是纯推理逻辑测试起来也方便。2.2 State 设计Agent 的记忆怎么组织LangGraph.js 的 State 是整个 Agent 的核心它决定了节点之间能传递什么信息。我用的是 Annotation 来定义 State 的结构大致包含这几块import { Annotation } from langchain/langgraph; const ResumeAgentState Annotation.Root({ resumeText: Annotationstring, jobDescription: Annotationstring, parsedResume: AnnotationResumeStructure, jobRequirements: AnnotationJobRequirement[], matchAnalysis: AnnotationMatchResult, issues: AnnotationIssue[], suggestions: AnnotationSuggestion[], currentStep: Annotationstring, });这里每个字段都有明确的用途。resumeText和jobDescription是原始输入parsedResume是结构化后的简历jobRequirements是从 JD 里抽出来的要求列表matchAnalysis是匹配结果issues是诊断出的问题suggestions是修改建议。currentStep用来标记当前执行到哪一步前端靠它显示进度。用 Annotation 而不是普通对象的好处是LangGraph 会自动处理状态的合并和更新。每个节点返回一个 Partial State框架负责把它合并到全局 State 里。这里要注意如果某个字段是数组类型默认的合并策略是替换而不是追加需要显式指定 reducer。比如issues这个字段如果多个节点都可能往里加问题就得写成issues: AnnotationIssue[]({ reducer: (existing, update) [...existing, ...update], default: () [], }),这个细节我踩过坑一开始没注意导致后面节点加的问题把前面节点加的覆盖了排查了半天。2.3 节点划分每个节点只做一件事Agent 图里的节点划分遵循一个原则每个节点只做一件明确的推理任务输入输出都是 State 里的字段。我目前划分了五个核心节点。第一个是parseResume节点负责把简历纯文本转成结构化数据。这个节点用 LLM 做信息抽取输出一个包含基本信息、教育经历、工作经历、项目经验、技能列表的对象。这里的关键是结构化输出LangChain.js 提供了withStructuredOutput方法配合 Zod schema 可以强制 LLM 返回符合格式的 JSON。第二个是extractRequirements节点从岗位 JD 里抽取硬性要求和软性要求。硬性要求比如学历、年限、特定技术栈软性要求比如沟通能力、团队协作。这个节点输出一个要求数组每条要求带类型和权重。第三个是matchAnalysis节点把简历和岗位要求做逐条匹配。这个节点是 Agent 推理的核心它不是简单做关键词匹配而是让 LLM 判断简历里是否有证据支撑某条要求并给出匹配程度和理由。第四个是diagnoseIssues节点诊断简历本身的问题。比如时间线是否有断层、项目描述是否缺少量化结果、技能列表是否与工作经历矛盾。这个节点独立于岗位匹配是通用的简历质量检查。第五个是generateSuggestions节点基于前面的匹配结果和问题诊断生成具体的修改建议。每条建议要指明针对哪个问题、怎么改、改完有什么好处。节点之间用边连接基本是线性流程但在matchAnalysis之后有个条件分支如果匹配度低于某个阈值会多走一轮diagnoseIssues做深度诊断。这个分支逻辑用 LangGraph 的addConditionalEdges实现。2.4 为什么用结构化输出而不是让 LLM 自由发挥简历分析这个场景对输出的格式要求很高。前端要渲染匹配度进度条、问题列表、建议卡片如果 LLM 返回的是自由文本前端根本没法解析。所以我全程用结构化输出每个节点的输出都定义了严格的 schema。用 Zod 定义 schema 的好处是类型安全TypeScript 能在编译期就发现字段不匹配的问题。而且 Zod schema 可以直接转成 JSON Schema 传给 LLM让模型知道要返回什么格式。实测下来用withStructuredOutput配合 Zod结构化输出的成功率比在 prompt 里写请返回 JSON高很多基本不会出现格式错误。不过有个坑要注意结构化输出会消耗额外的 token因为 schema 本身要传给模型。对于简历这种长文本token 消耗不小。我的优化是只在关键节点用结构化输出一些中间步骤用普通文本输出后面再解析。3. 核心节点的实现细节与实操要点3.1 简历解析节点怎么让 LLM 准确抽取信息简历解析看起来简单实际上坑很多。最大的问题是简历格式千奇百怪有人的工作经历是按时间倒序有人是正序有人把项目经验写在工作经历里面有人单独列出来。如果直接让 LLM 抽取它可能会把项目经验当成工作经历或者漏掉一些信息。我的做法是分两步走。第一步先用规则做预处理把简历文本按章节切分。常见的章节标题有工作经历项目经验教育背景技能特长等用正则匹配这些标题把文本切成块。第二步再把每个块交给 LLM 做结构化抽取这样 LLM 只需要关注块内的信息不用处理章节归属的问题。const sectionPatterns [ { key: work, patterns: [/工作经历/, /工作经验/, /职业经历/] }, { key: project, patterns: [/项目经验/, /项目经历/, /主要项目/] }, { key: education, patterns: [/教育背景/, /教育经历/, /学历/] }, { key: skills, patterns: [/技能/, /专业技能/, /技术栈/] }, ];抽取工作经历时我用的 prompt 大致是这样的给一段工作经历文本要求抽取公司名、职位、起止时间、主要职责、量化成果。这里的关键是要求 LLM 把主要职责和量化成果分开因为后面诊断问题时需要判断是否有量化成果。如果混在一起就没法准确判断。注意简历里的时间格式非常不统一有2020.03-2022.05有2020年3月至今有2020-2022。我写了一个时间归一化函数把这些格式统一转成{ start: 2020-03, end: 2022-05 }的结构。这个函数用规则处理比让 LLM 做更可靠。3.2 岗位要求抽取区分硬性要求和软性要求岗位 JD 的抽取比简历简单一些但也有讲究。JD 里经常混着公司介绍、福利待遇、岗位职责、任职要求真正需要的是任职要求部分。我的做法是先让 LLM 识别出任职要求段落再从中抽取具体要求。抽取出来的要求要分类。硬性要求是那些可以明确判断是否满足的比如本科以上学历3年以上工作经验熟悉 React。软性要求是那些比较主观的比如有良好的沟通能力有团队协作精神。分类的目的是后面匹配时权重不同硬性要求不满足基本就挂了软性要求可以放宽。每条要求还要带一个权重。权重的来源有两个一是 JD 里明确写了必须要求的权重高写了优先加分的权重低。二是根据要求出现的频率和位置出现在前面的、反复强调的权重高。这个权重计算我用了一个简单的规则加 LLM 判断的混合方案。3.3 匹配分析节点Agent 推理的核心匹配分析是整个 Agent 最有价值的部分也是最难做好的部分。简单做法是拿简历里的技能列表和 JD 里的要求做交集但这样太粗糙了。比如 JD 要求有大型项目架构经验简历里写负责过日活百万的系统重构这明显是匹配的但关键词对不上。我的做法是让 LLM 对每条要求做证据检索。具体来说把简历的结构化数据和要求一起给 LLM让它判断简历里是否有证据支撑这条要求并给出匹配等级完全匹配、部分匹配、不匹配。同时要求 LLM 引用简历里的具体内容作为证据。const matchPrompt 岗位要求${requirement.text} 要求类型${requirement.type} 候选人简历 ${JSON.stringify(parsedResume, null, 2)} 请判断候选人是否满足这条要求返回 - level: full | partial | none - evidence: 简历中支撑判断的具体内容如果没有则返回空字符串 - reason: 判断理由 ;这里有个技巧要求 LLM 必须引用原文作为证据能大幅减少幻觉。如果 LLM 找不到证据它就只能返回不匹配而不是编一个理由说匹配。匹配结果汇总后算一个总分。硬性要求不满足的扣分多软性要求不满足的扣分少。最后给出一个 0-100 的匹配度分数前端用进度条展示。3.4 问题诊断节点找出简历的硬伤问题诊断节点独立于岗位匹配检查的是简历本身的通用问题。我定义了几类常见问题时间线问题工作经历之间有超过 3 个月的空档或者时间重叠。空档不一定是问题但需要提醒用户准备解释。时间重叠则可能是写错了。量化缺失工作经历或项目经验里没有数字。比如负责优化系统性能就没有量化负责优化系统性能接口响应时间从 800ms 降到 200ms就有量化。我让 LLM 检查每条经历是否有量化成果没有的标记出来。技能与经历矛盾技能列表里写了某个技术但工作经历里完全没提到。这不一定有问题但可能是技能堆砌需要提醒。描述空泛用了大量负责参与协助这类词但没说具体做了什么、有什么结果。这个判断比较主观我让 LLM 给每条经历打一个具体度分数低于阈值的标记出来。实操心得问题诊断的 prompt 里要明确告诉 LLM宁可漏报不要误报。因为误报会让用户觉得工具不靠谱漏报只是少发现一个问题。我在 prompt 里加了只标记你有充分把握的问题这样的约束误报率明显下降。3.5 建议生成节点从问题到可执行的修改建议生成节点要把前面的问题诊断和匹配分析结果转化成具体的修改建议。这里的关键是建议要可执行不能是建议加强项目描述这种空话而要是把负责系统优化改成主导订单系统重构QPS 从 500 提升到 3000P99 延迟降低 60%这种可以直接抄的。我的做法是让 LLM 针对每个问题生成一条包含三部分的建议问题描述、修改方向、示例改写。示例改写是最有价值的用户可以直接参考。为了让示例改写质量高我在 prompt 里放了几个 few-shot 例子展示什么样的改写是好的。建议还要排序。我按影响程度排序影响匹配度的建议排前面影响简历质量的排后面。每条建议带一个优先级标签前端用不同颜色展示。4. 前后端联调与流式响应的实现4.1 Next.js API Route 怎么接 LangGraphNext.js 的 App Router 里API Route 写在app/api/xxx/route.ts里。接 LangGraph 的基本流程是解析请求里的文件转成文本初始化 Agent 图调用stream方法把结果流式返回。export async function POST(req: Request) { const formData await req.formData(); const file formData.get(resume) as File; const jd formData.get(jd) as string; const resumeText await parseFile(file); const stream new ReadableStream({ async start(controller) { const graph buildResumeGraph(); const events await graph.stream({ resumeText, jobDescription: jd, }); for await (const event of events) { controller.enqueue( new TextEncoder().encode(JSON.stringify(event) \n) ); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream }, }); }这里用的是 SSE 格式每条消息是一个 JSON前端按行解析。LangGraph 的stream方法会返回每个节点执行完的事件事件里包含节点名和该节点更新后的 State。前端根据节点名显示不同的进度提示。4.2 前端怎么消费流式数据前端用 fetch 拿到流之后用getReader()逐块读取按换行符切分解析每条 JSON。每收到一个事件就更新对应的 UI 状态。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); buffer lines.pop() || ; for (const line of lines) { if (!line.trim()) continue; const event JSON.parse(line); handleEvent(event); } }handleEvent根据事件里的节点名更新对应的状态。比如收到parseResume节点的事件就把解析出的简历信息渲染出来收到matchAnalysis节点的事件就更新匹配度进度条。注意流式响应里如果某个节点执行失败要能优雅处理。我的做法是在图里加一个错误处理节点捕获异常后往 State 里写一个 error 字段前端收到后显示错误提示而不是让整个流断掉。4.3 文件解析的坑PDF 和 Word 怎么处理简历文件主要是 PDF 和 Word 两种格式。PDF 解析用pdf-parse或者pdfjs-distWord 用mammoth。这两个库在 Node 环境里都能跑但有几个坑。PDF 解析最大的问题是排版。有些 PDF 是扫描件解析出来是空的有些 PDF 是多栏排版解析出来文字顺序是乱的。扫描件我目前不支持直接提示用户上传文字版。多栏排版的问题我试过用pdfjs-dist的坐标信息重新排序但效果一般后来改成提示用户如果解析结果不对可以手动粘贴文本。Word 解析相对简单mammoth能把 docx 转成 HTML再提取纯文本。但要注意表格里的内容mammoth默认会把表格转成 HTML 表格提取文本时要特殊处理否则表格里的信息会丢失。4.4 部署时的超时问题与解决Agent 推理是个耗时操作完整跑一遍五个节点快的话十几秒慢的话半分钟。这在本地开发没问题但部署到 Serverless 平台就会撞上超时限制。Vercel 的免费版函数超时是 10 秒Pro 版是 60 秒勉强够用但不保险。我的解决方案是把长任务拆开。前端先调一个接口做文件解析和简历结构化这部分快几秒内能完成。然后再调一个接口做匹配分析和建议生成这部分慢但可以流式返回只要第一个字节在超时前返回后面的流就不会被切断。另一个方案是用 Edge Runtime它的超时限制更宽松而且支持流式响应。但 Edge Runtime 不支持 Node 的一些 API比如文件系统操作所以文件解析那部分还得放在 Node Runtime。我的做法是文件解析用 Node Runtime 的接口Agent 推理用 Edge Runtime 的接口两者分开部署。5. 常见问题排查与避坑经验5.1 LLM 输出格式不稳定怎么办结构化输出虽然比自由文本稳定但也不是 100% 可靠。我遇到过几种情况LLM 返回的 JSON 里多了 markdown 代码块标记、字段类型不对该是数组的返回了字符串、必填字段缺失。应对策略是三层防护。第一层是在 prompt 里明确要求只返回 JSON不要有任何其他内容。第二层是用withStructuredOutput配合 Zod schemaLangChain 会自动做格式校验和重试。第三层是在代码里做兜底如果解析失败用默认值填充并记录日志。const safeParse (text: string, schema: z.ZodSchema) { try { const cleaned text.replace(/json\n?|\n?/g, ).trim(); return schema.parse(JSON.parse(cleaned)); } catch (e) { console.error(Parse failed:, e, text); return null; } };5.2 Token 超限怎么处理简历加上 JD再加上各个节点的 prompt 和 schema很容易超过模型的上下文窗口。我遇到过几次 token 超限的报错后来做了几个优化。一是简历文本做截断。如果简历超过一定长度只保留最近的工作经历和项目经验早期的经历做摘要。二是 JD 只保留任职要求部分公司介绍和福利待遇直接丢掉。三是节点之间传递数据时只传必要的字段不要把整个 State 都塞进 prompt。如果还是超限就得换模型或者做分块处理。分块处理是把简历切成多块分别做匹配最后汇总。但这样会丢失跨块的信息比如工作经历和项目经验的关联所以是最后的选择。5.3 匹配结果不准怎么调匹配不准通常有几个原因。一是简历结构化没做好信息抽取错了后面匹配自然不准。二是 prompt 里对匹配标准的描述不清晰LLM 理解偏差。三是模型能力不够判断不了复杂的语义匹配。排查顺序是先看结构化结果对不对再看 prompt 有没有歧义最后考虑换模型。我实测下来同样的 prompt不同模型的匹配准确率差别很大。对于简历匹配这种需要语义理解的场景建议用能力强的模型不要为了省钱用小的。5.4 常见问题速查表问题现象可能原因排查方向解决方案流式响应中断Serverless 超时查看函数日志拆分接口用 Edge Runtime结构化输出失败prompt 有歧义打印 LLM 原始输出加 few-shot 例子用 Zod 校验匹配度普遍偏低要求抽取太严检查要求列表调整抽取 prompt放宽标准简历解析乱序PDF 多栏排版查看解析文本提示用户手动粘贴Token 超限输入太长统计 token 数截断简历精简 prompt建议太笼统prompt 缺少约束检查建议内容加 few-shot要求给示例改写5.5 几个我踩过的坑第一个坑是 State 的 reducer。前面提过数组字段默认是替换多个节点往同一个数组里加数据会覆盖。这个坑让我丢了一整轮的问题诊断结果排查了很久才发现是 reducer 的问题。第二个坑是 LangGraph 的节点命名。节点名不能重复而且会出现在流式事件里前端靠它判断当前步骤。我一开始用了中文节点名结果在某些环境下编码有问题后来全改成英文。第三个坑是文件上传的大小限制。Next.js 默认的 body 大小限制是 4MB简历文件一般不会超但如果用户上传的是扫描版 PDF可能就超了。需要在next.config.js里调整bodyParser的大小限制或者用流式上传。第四个坑是并发问题。如果多个用户同时用每个请求都会创建一个新的 Agent 图实例。LangGraph 的图实例是可以复用的但 State 是每次请求独立的。我一开始把图实例做成了全局单例结果 State 串了后来改成每次请求创建新实例或者用 checkpointer 做隔离。6. 这套架构还能怎么扩展6.1 从简历工具到招聘全流程这套架构的核心是文档解析加多步骤推理加结构化输出这个模式可以迁移到招聘的其他环节。比如面试问题生成——输入简历和 JDAgent 分析候选人的强项和弱项生成针对性的面试问题。再比如候选人排序——批量处理简历按匹配度排序输出推荐名单。扩展的关键是把 Agent 图做成可配置的。不同的任务用不同的节点组合共享底层的解析和匹配能力。LangGraph 支持子图可以把通用的节点封装成子图不同任务复用。6.2 加入人工反馈循环现在的 Agent 是单向的分析完就结束了。更好的做法是加入人工反馈让用户对 Agent 的判断做确认或修正修正结果反馈给 Agent 做学习。LangGraph 的 checkpointer 和 interrupt 机制很适合做这个——Agent 分析到某个节点停下来等用户确认确认完继续。比如匹配分析节点Agent 给出匹配结果后暂停让用户看看判断对不对。如果用户觉得某条判断错了可以手动修正修正后的结果作为后续建议生成的输入。这样既提高了准确性又让用户有参与感。6.3 多模型路由降本不同节点的推理难度不一样没必要都用最贵的模型。简历解析这种信息抽取任务用中等模型就够了匹配分析这种需要语义理解的用强模型建议生成这种需要创造性的也用强模型。可以在节点级别配置模型LangGraph 的节点函数里可以自由选择用哪个模型。更进一步可以根据输入复杂度动态选模型。简历简单、JD 简单的用便宜模型复杂的用贵模型。这个判断本身也可以用一个小模型来做成本很低。6.4 本地化部署的考虑有些用户对数据隐私敏感不希望简历上传到云端。这套架构可以改成完全本地部署——Next.js 跑在本地LangGraph 跑在本地LLM 用本地部署的开源模型。虽然本地模型能力不如云端但对于简历解析这种任务中等规模的开源模型也能用。本地部署的关键是模型选型和量化。7B 到 13B 的模型经过 4-bit 量化在消费级显卡上能跑。推理速度慢一些但简历分析不是实时交互用户能接受等待。我在实际使用中发现这套架构最值钱的地方不是某个具体的技术点而是把文档理解加多步骤推理加结构化输出这个模式跑通了。一旦跑通换任何文档类型、任何分析任务改的都是 prompt 和 schema架构本身不用动。这也是我为什么推荐用 LangGraph.js 而不是自己手写流程控制——它把状态管理、流程编排、中断恢复这些脏活累活都封装好了你只需要关注每个节点做什么。最后分享一个小技巧调试 Agent 的时候把每个节点的输入输出都打到日志里用console.log或者写文件都行。Agent 的问题往往不是出在某个节点而是节点之间的数据传递出了问题。能看到每个节点的输入输出排查效率会高很多。我现在的做法是在开发环境把每个节点的 State 快照存下来出问题时直接回放比一步步打断点快多了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑