ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用 Next.js 和 LangGraph.js 落地简历 AI Agent 的完整实践

用 Next.js 和 LangGraph.js 落地简历 AI Agent 的完整实践 最近终于把折腾了快一个月的项目收尾了——一个用 Next.js LangGraph.js 搭的简历工具 AI Agent。简单说用户上传一份 PDF 简历填上目标岗位这个 Agent 会自动完成解析、评估、改写、导出这一整套流程最后返回一份排版干净、突出亮点的 Markdown 简历。这个项目既是给身边朋友用的实用工具也是我验证 LangGraph.js 落地能力的一个实验场。如果你正在纠结AI Agent 到底怎么落地LangGraph.js 和 Next.js 怎么配合这篇文章应该能给你一些可复用的思路。1. 为什么我会做这个简历 AI Agent1.1 被简历投递折磨出来的需求事情的起因特别朴素我一个朋友投了两个月简历面试邀约寥寥无几。我帮他看了简历第一眼就发现问题——技能栈写在最后、项目经历全是负责开头、量化结果一个都没有。典型的技术型简历通病不是能力不行是表达方式太吃亏。市面上简历优化工具不少但大多数是模板生成器要么让用户填一堆表单要么就是把内容丢给大模型一次性重写。前者太机械后者太粗暴。为什么因为一份好简历不是重写出来的而是评估出来的。你需要先知道哪里弱再针对性地改。而且每个人对简历的预期不一样有人要投外企有人要投国企有人想转行有人只想在现有方向上升级。一个固定的提示词模板根本覆盖不了这些场景。所以我想要的东西很明确用户只管上传简历、输入目标岗位剩下的解析、诊断、生成建议、重写、导出都由程序自动搞定。这正好是 AI Agent 该干的活——不是单次调用大模型而是让模型在多个步骤之间做决策、调用工具、维护上下文。于是我决定自己写一个。1.2 从提示词脚本升级到真正的 Agent 架构一开始我确实只写了一组提示词把简历文本塞进去让模型输出优化后的版本。试了几天发现三个痛点没法绕开。第一PDF 解析不能靠模型。把 PDF 直接转成文本丢给大模型格式乱得一塌糊涂表格变成一串缩进项目时间线全错位。第二一次重写没有中间过程模型经常自作主张把工作经历改成夸大其词甚至出现原简历根本没有的荣誉奖项。这对求职者是大忌。第三没有持久状态。用户调整一个关键词整个对话就要重新开始Token 消耗爆表体验也割裂。这些问题指向同一个结论我需要的不是一个更强的提示词而是一个能分步执行的 Agent。它应该先解析简历再诊断问题然后给出修改建议最后才是重写。每一步都要有工具支撑每一步的输入输出都要被结构化保存。这就是 LangGraph.js 最擅长的场景。2. 技术选型与整体架构拆解2.1 为什么前端框架选了 Next.js在项目初期我认真考虑过纯前端方案React SPA 后端 FastAPI或者干脆 Next.js 全栈一把梭。最后选了 Next.js而且是 App Router原因有三个。一是服务端能力。简历解析、大模型调用、文件导出这些操作都需要服务端权限和密钥保护Next.js Route Handler 天然支持把这些逻辑放在服务端不需要单独维护一个后端项目。二是流式传输。AI Agent 执行一次任务通常在几秒到几十秒之间用户不可能干瞪眼等进度Next.js 对 ReadableStream 的支持让服务端到浏览器的流式推送变得非常简单配合原生 fetch 就能逐字渲染。三是部署心智负担小。一个项目同时包含前端页面、API 路由、静态资源推上 Vercel 就完事省掉了前后端分离项目的跨域、环境变量同步、容器编排一堆问题。当然纯 SPA 也能做但你需要额外解决 API 服务部署、CORS、多环境配置这些问题。对于个人项目来说少一个服务就少一堆坑。Next.js 的全栈单项目模式在这个体量下非常舒服。2.2 LangGraph.js 解决了状态与循环的痛点如果你只用 LangChain.js 写过简单的链式调用chain你会发现它本质上是线性管道prompt 进结果出再进下一步。流程是死的很难做条件跳转和循环。而真实的 Agent 场景充满变数简历解析可能失败模型可能认为不需要重写用户可能中途修改目标岗位。这些都要让流程活起来。LangGraph.js 的核心抽象是图节点Node是执行单元边Edge是流转关系状态State是节点间共享的数据。你可以把整个 Agent 想成一条自动化流水线状态就是传送带上的工件每个节点是一个工位做完指定操作再把工件放回传送带。关键是 LangGraph.js 支持条件边也就是说节点可以根据当前状态决定下一条走向甚至把流程拉回去重跑。这正好契合我的需求。我试过直接用 React 的 useReducer 手动管理状态、用 if/else 写控制流前期还行一旦新增节点和分支代码立刻变成面条。LangGraph.js 让我把流程控制和业务逻辑彻底拆开业务代码就是纯函数编排逻辑交给图定义。后续改流程只需要加节点或者改边的走向不需要动核心逻辑。2.3 整体流程与节点划分这个 Agent 的完整执行链路是这样的接收用户上传的 PDF 简历和目标岗位描述调用文件解析工具把 PDF 转为结构化文本诊断节点分析简历弱点按结构完整性、内容量化、技能匹配度、表达质量四个维度打分判断节点如果简历质量高于用户设定的阈值直接进入生成节点否则进入优化建议节点优化建议节点生成逐条修改建议并选择性重写简历生成节点将原始简历信息与模型增强后的内容合并渲染为 Markdown返回结果并保存历史记录到数据库用户看到的是一步步推进的过程而不是一次黑盒调用。这个透明感很重要因为简历修改必须可追溯每一条改动都要让用户觉得合理。3. 核心实现状态、节点与工具3.1 LangGraph.js 状态定义整个 Agent 的共享便签状态是 LangGraph.js 的灵魂。我刚才说状态是传送带上的工件更准确地说它是所有节点都能读写的共享便签。在代码里我这样定义import { Annotation } from langchain/langgraph; export const AgentState Annotation.Root({ // 原始上传信息 originalFileName: Annotationstring(), fileText: Annotationstring(), targetJob: Annotationstring(), // 解析结果 parsedResume: AnnotationResumeData({ reducer: (prev, next) next ?? prev, }), // 诊断结果 diagnostics: AnnotationDiagnosisResult[]({ reducer: (prev, next) next ?? prev, }), // 重写后的简历文本 rewrittenResume: Annotationstring(), // 当前 Agent 步骤名用于前端进度展示 currentNode: Annotationstring({ reducer: (prev, next) next ?? prev, }), });每个 Annotation 都有独立的 reducer决定这个字段如何被新值更新。默认是覆写但也可以定义成合并或追加。我没有把整个模型消息历史放进状态里因为简历场景中各节点关心的字段差异很大——解析节点关心 fileText诊断节点关心 parsedResume生成节点关心 diagnostics 和 rewrittenResume。把字段拆细可以让每个节点只读写自己需要的那部分减少 Token 重传也避免状态膨胀。有一个细节容易踩坑LangGraph.js 的状态是不可变更新如果你在 reducer 里直接对自己传入数组做push会影响上一次的状态快照导致回放和断点续跑时数据错乱。正确做法是先复制再返回新数组reducer: (prev: DiagnosisResult[], next: DiagnosisResult[]) { return prev ? [...prev, ...next] : next; }3.2 三个核心节点解析、诊断、生成节点就是普通的 async 函数接收当前 state返回部分状态更新。我写了三个核心节点。解析节点负责把上传的 PDF 转成纯文本同时保留必要的结构信息。这里我主要做了三件事提取文本、识别标题与段落、标记时间线与公司名。解析逻辑不复杂但坑比较多后面单独说。诊断节点是重头戏。它读取 parsedResume 和目标岗位调用大模型输出结构化的诊断结果。我要求模型严格返回 JSON格式如下{ overallScore: 68, dimensions: [ { name: 结构完整性, score: 80, comment: 基本信息完整但技能描述过于单薄 }, { name: 内容量化, score: 55, comment: 只有 2 处使用数字缺乏成果指标 } ], suggestions: [ 将 负责系统开发 改为 主导 3 个核心模块开发系统响应时间降低 40% ], shouldRewrite: true }为了强制模型输出合法 JSON我用withStructuredOutput或.bindTools()绑定 JSON Schema而不是单纯地写请输出JSON。实际测试下来前者的失败率低很多。很多人在这一步偷懒结果后面解析 JSON 时经常遇到多余说明文字、换行丢失、字段名变形等问题排查起来非常浪费时间。生成节点负责把原始经历和模型润色后的表达合并成最终简历。我强调的是合并而不是重写。做法是让模型基于诊断建议逐条修改而不是直接甩出全文。这样每个改动点都有源可溯。最终输出的是 Markdown 字符串前端用轻量级渲染组件展示并提供复制和导出功能。3.3 条件边让 Agent 学会决定是否需要重写LangGraph.js 里最让我觉得值回票价的是条件边。代码这样写import { StateGraph } from langchain/langgraph; const workflow new StateGraph(AgentState) .addNode(parse, parseNode) .addNode(diagnose, diagnoseNode) .addNode(rewrite, rewriteNode) .addNode(generate, generateNode) .addEdge(__start__, parse) .addEdge(parse, diagnose) .addConditionalEdges( diagnose, (state) { const shouldRewrite Boolean(state.diagnostics?.some( (d) d.score state.rewriteThreshold || d.suggestions.length 0 )); return shouldRewrite ? rewrite : generate; }, { rewrite: rewrite, generate: generate, } ) .addEdge(rewrite, generate) .addEdge(generate, __end__); export const graph workflow.compile();这里的设计逻辑是诊断节点不只是打分还充当决策者。如果诊断分数低于阈值或有修改建议就走重写分支否则直接生成最终文件。这个判断如果不放到图里你就必须在 diagnoseNode 内部写分支调用别的函数图结构就乱了。把它们拆开以后我在前端展示流程时非常干净每个节点名称都是状态的一部分可以直接映射到前端进度条。4. 工具集成简历解析与文档生成4.1 PDF 解析没有想象中简单简历上传格式最常见的就是 PDF。解析 PDF 文本的库不少我对比了几个最终选用了 unpdf。它在 Node.js 环境和边缘运行时都比较稳定API 也很简洁import { extractText, extractMetadata } from unpdf; const buffer await file.arrayBuffer(); const { text } await extractText(new Uint8Array(buffer));选 unpdf 而不是 pdf-parse主要原因是让我在 Next.js 的 Vercel 部署中少踩了很多沙箱环境的坑。pdf-parse 内部依赖一些 Node.js 的 Buffer 隐式调用在边缘运行时经常报错unpdf 则相对干净。但无论用哪个库PDF 解析的痛点都在解析质量上。很多简历是 Canva、WPS 导出的文字被切成零散的片段表格结构丢失甚至出现阅读顺序错乱。我处理方案是两步先用库抽文本再用规则做乱序修复。比如以项目经历工作经历教育背景等关键词为锚点把乱序片段重新分组拼接。这一步很糙但能显著提升后续大模型诊断的准确率值得做。4.2 生成 Markdown 简历文件诊断和重写结束后生成节点输出 Markdown 字符串。这个选择带来两个直接好处一是前端渲染简单字符串转 HTML 的库很成熟二是用户可以复制到任何在线文档再转成 PDF 或 Word。我还加了一个导出 DOCX 的能力。项目里用了docxnpm 包把 Markdown 解析成文档结构再生成二进制文件。这条链路的细节是Markdown 转 DOCX 的排版必然有偏差尤其是列表缩进和表格宽度。我最后妥协成导出 DOCX 只保证内容完整推荐用户复制 Markdown 自己微调。这也符合实际场景简历这种文档用户大概率要手动改几次。4.3 工具选型对比需求我用的方案备选方案选择理由PDF 文本抽取unpdfpdf-parse、pdf.js边缘运行时兼容性好API 简洁结构化数据校验zod手写校验函数类型自动推导错误信息清晰Markdown 渲染react-markdownmarked DOMPurify生态成熟XSS 过滤直接可用DOCX 导出docxhtml-to-docx直接操作文档对象排版更可控数据存储Vercel PostgresSQLite、MongoDB与平台集成好Serverless 友好工具选型没有标准答案但我始终坚持一个原则尽量少引入有原生依赖的库。在 Serverless 环境里原生模块构建失败、运行时崩溃的概率远高于纯 JS 库每多一个原生依赖部署就多一分不确定性。5. 流式输出与 Next.js 前端接入5.1 为什么要做流式输出Agent 执行一次完整流程在模型快速的情况下也要 10 到 20 秒。如果让用户点击按钮后白屏等待体验几乎等于崩溃。流式输出不仅是为了看起来快更是为了让用户感知到系统在工作。我选择的是 Server-Sent EventsSSE而不是 WebSocket。原因很简单这个场景是服务端单向推送进度用户不需要持续向服务端发送大量消息。SSE 基于 HTTP天然兼容 Next.js Route Handler实现成本远低于 WebSocket。5.2 Route Handler 实现流式推送核心代码如下// app/api/agent/route.ts import { graph } from /lib/agent/graph; export async function POST(req: Request) { const { fileText, targetJob, threadId } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const send (event: string, data: unknown) { controller.enqueue(encoder.encode(event: ${event}\ndata: ${JSON.stringify(data)}\n\n)); }; let currentStep ; try { const result await graph.stream( { fileText, targetJob, parsedResume: null, diagnostics: [], rewrittenResume: , originalFileName: fileText.slice(0, 20), currentNode: parse, }, { recursionLimit: 10, configurable: { thread_id: threadId }, } ); for await (const chunk of result) { const stepName Object.keys(chunk)[0]; const payload chunk[stepName]; if (stepName parse) { currentStep 解析简历结构; send(progress, { step: currentStep, data: payload.parsedContent || {} }); } else if (stepName diagnose) { currentStep 正在评估匹配度; send(progress, { step: currentStep, data: payload.diagnostics || {} }); } else if (stepName rewrite) { currentStep 重写优化; send(chunk, { text: payload.rewrittenResume || }); } else if (stepName generate) { currentStep 生成最终文件; send(complete, { markdown: payload.rewrittenResume || payload.generatedFile || }); } else if (stepName __end__) { send(done, {}); } } } catch (err) { send(error, { message: (err as Error).message }); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端我用原生的fetch读取流通过ReadableStream解析 SSE 格式。这里有个细节SSE 的 data 字段如果包含换行会被解析成多条 data而我的 Markdown 文本恰恰满满都是换行。所以我选择把所有文本数据包一层JSON.stringify放进单行 data 中前端解析 JSON 再还原。用EventSource无法设置 POST 请求头所以我最终用的是fetch 手动解析 SSE这需要自己处理event:、data:字段的拆分逻辑。5.3 前端交互节点状态的可视化前端我做了三步展示第一步是解析阶段展示提取出来的简历文本摘要第二步是诊断阶段展示四个维度的打分结果用简单的进度条组件第三步是重写阶段逐字展示新简历内容。用户能看到流程走到哪一步也能在不同步骤间切换查看。这里我要提醒一个容易忽略的点React 的 StrictMode 在开发环境下会重复调用 effect如果 fetch 流式请求写在 effect 里会出现上一个请求还没取消下一个请求又开始的竞态问题。解决方法是给请求加上AbortController组件卸载时主动取消。6. 踩坑实录排查和修复的过程6.1 LangGraph.js 在 Next.js 边缘运行时的问题我最开始把所有逻辑放在一个 Route Handler 里默认走 Edge Runtime结果部署到 Vercel 后报错错误信息指向 Buffer 未定义。排查后发现是pdf相关依赖在边缘运行时不兼容。解决方式在 Route Handler 文件顶部声明export const runtime nodejs; export const maxDuration 60;注意 maxDuration 在 Vercel 免费计划上限是 10 秒升级到 Pro 才能到 60 秒。我的 Agent 一次执行在 15 到 25 秒之间所以这个配置必须调整否则很容易被平台中止。如果不想升级只能把 Agent 拆成后台任务执行用数据库轮询结果这是另一个架构方案。6.2 Token 消耗失控的三种修复手段一开始的版本把整个简历文本和岗位描述每次节点都传给模型Token 消耗非常夸张一次完整流程要烧掉一万多 token。后来我做了三个针对性修改第一解析节点之后只把parsedResume的结构化字段传给诊断节点不传原始文本。第二诊断节点和重写节点使用不同的系统提示词诊断提示词强调输出 JSON重写提示词强调基于建议修改不重复总结。第三给模型调用设置maxTokens诊断节点限制输出 800 token重写节点限制 2000 token超出部分直接截断不让模型自由发挥。做完这三步一次流程的 token 消耗降到 3000 左右费用从一次几毛钱降到几分钱。这个优化对生产环境非常重要尤其是个人项目成本不控制好根本不敢上线。6.3 大模型输出 JSON 失败与重试策略用语言模型输出结构化数据无论怎么调提示词总会有翻车的时候。我最常遇到的问题是输出内容中夹杂 Markdown 代码块标记json导致 JSON.parse 报错或者字段值里带了换行符没转义。我的处理策略是三层兜底第一层尝试用模型的 function calling 或工具绑定输出结构化对象第二层如果模型没有遵守自动剥离代码块围栏再用 JSON.parse 解析第三层如果仍然失败重新调用模型一次并在 prompt 中附上上次输出格式错误请只输出合法 JSON的提示。实测三层兜底可以把失败率从 10% 降到 1% 以下。6.4 中文简历的解析乱序问题中文简历和英文简历的排版习惯差异很大。很多中文模板使用两栏布局PDF 解析出来之后左栏技能、右栏工作经历会交替出现。如果直接丢给模型模型经常会混淆技能列表和工作成果。我加了一个后处理规则按常见简历标题词教育背景、工作经验、项目经历、技能专长、自我评价等拆分段落然后把不属于任何段落的零散文本归入前一个段落的补充信息。这个规则不完美但已经能覆盖 80% 的常见简历模板。剩下的 20%用户可以在前端手动编辑解析结果再进入诊断节点。7. 部署、监控与后续扩展方向7.1 部署配置细节整个项目部署在 Vercel环境变量包括 OpenAI API Key、数据库连接串、用户会话密钥。LangGraph 的线程状态我存在 Vercel Postgres 里方便用户断点续跑。部署时有一个注意点LangGraph.js 的检查点checkpointer默认是基于内存的Serverless 环境下实例是短命的必须配置为外部存储。我用了官方提供的 PostgresSaverimport { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer await PostgresSaver.fromConnString(process.env.DATABASE_URL!); export const graphWithMemory graph.compile({ checkpointer });这样用户每次调用如果传同一个thread_id就能恢复上一次 Agent 执行的状态。简历修改这个场景非常适合断点续跑用户先让 Agent 诊断看完建议后觉得不满意可以修改目标岗位再继续重写不需要从零开始。7.2 后续扩展从单次优化到持续服务项目跑通之后我想过几个进一步扩展的方向。一个是多版本简历管理。用户可能同时投递不同方向比如前端开发和全栈开发需要维护多份侧重点不同的简历。现有数据结构可以支持每个版本对应一个 thread_id列表页展示所有历史版本随时对比或回滚。另一个是ATS 评分模拟。现在只是模型打分不够客观。可以做一个规则引擎统计关键词匹配、时间线连续性、量化指标数量和模型诊断结果交叉验证。两个维度都有分数更可信。还有一个是面试问题生成。简历确定后基于重写内容生成可能的面试追问清单。这能帮用户提前准备也算是简历工具的增值功能。但这一步需要额外调用模型成本会增加要控制频率。我在实际运行中还发现一个体验细节很多用户上传简历后并不清楚该填什么目标岗位。后来我在前端做了一组热门岗位关键词推荐点击即可填入。这一步简单但显著降低了使用门槛。工具类产品有时候不是功能不够而是入口太隐蔽。7.3 学习路径的一点个人建议如果你也想做类似的东西我的建议是别先学一大堆 Agent 框架概念直接找一个具体任务开干。AI Agent 的主流架构无非是模型 工具 状态 记忆核心难点永远是模型输出不稳定怎么办、工具调用失败怎么降级、状态怎么持久化。这些只有在你真正跑通一个端到端项目之后才会理解。从能调模型到能完成完整任务中间隔着的不是模型能力而是工程能力。LangGraph.js 把流程编排的骨架给你了但每个节点的容错、每条边的判断、每份状态的管理还是得自己一点点打磨。8. 最后分享一个我路上的体会我自己最大的感受是AI Agent 落地60% 的精力要花在工程细节上而不是模型调参上。流式输出的边界情况、PDF 解析的脏数据、JSON 格式的偶发错误、Serverless 环境的超时限制这些才是真正决定一个工具能不能涨用户的东西。如果让我重来一遍我会在项目第一天就确定两件事一是 Agent 的状态字段必须从一开始就按每个节点只读所需字段来设计二是尽早用数据库做检查点存储而不是先跑通内存模式再迁移。这两件事回头改起来非常痛苦。这个项目现在已经稳定跑起来了身边几个朋友试用后反馈不错。后面我会继续完善简历模板库和不同岗位的专项优化策略。如果你也在做类似的 AI 工具欢迎一起交流工程上踩过的坑大多数都是相通的。
返回列表