Next.js + LangGraph.js 构建简历 AI Agent 实战

发布时间:2026/11/2 4:50:04

Next.js + LangGraph.js 构建简历 AI Agent 实战 1. 为什么简历工具值得用 AI Agent 重做一遍简历这个赛道表面上看已经被各种模板站和在线编辑器做烂了但真正动手做过的人都知道传统简历工具的天花板非常低。用户打开一个网页填表单、选模板、导出 PDF整个链路是线性的、被动的。问题在于绝大多数求职者根本不知道自己该写什么、怎么写才算“对口”。他们缺的不是排版工具而是有人能看懂 JD、能对照自己的经历、能给出具体修改意见的“教练”。这就是我决定用 Next.js LangGraph.js 做一套简历 AI Agent 的出发点。它不是简单的“AI 润色一段话”而是一个有状态、有分支、能多轮协作的智能体系统。用户上传简历、粘贴目标岗位 JDAgent 会自己决定先做匹配度分析还是先做关键词提取或者直接进入逐条改写建议。整个过程由 LangGraph.js 的状态图来编排而不是靠一堆 if-else 硬编码。这套东西适合谁参考如果你已经会 React 和 TypeScript想找一个真实可落地的 AI Agent 项目练手那这个方向非常合适。它涉及前端交互、流式输出、状态机编排、提示词工程、并发控制几乎覆盖了 AI 应用开发的核心环节。而且简历工具的业务边界清晰不需要复杂的外部数据源本地就能跑通全流程。我先把整体架构说清楚。前端用 Next.js App Router负责页面渲染、文件上传、流式接收 Agent 输出。后端用 Next.js 的 Route Handler 作为 BFF 层承接前端请求并调用 LangGraph.js 构建的 Agent 图。LangGraph.js 运行在 Node.js 运行时里负责状态管理、节点调度、条件路由。模型层可以接任意兼容 OpenAI 接口的服务方便替换。数据层用轻量的 SQLite 或 Postgres 存会话和简历版本避免引入过重的基础设施。提示不要把 Agent 逻辑写在前端。前端只负责展示和收集输入所有状态流转必须放在服务端否则多轮对话一刷新就丢状态调试起来非常痛苦。为什么选 LangGraph.js 而不是直接手写调用链因为简历分析天然是一个有环、有分支的流程。比如匹配度低的时候要回到“补充经历”节点匹配度高才进入“措辞优化”节点。用普通函数调用会写成一大坨嵌套逻辑而 LangGraph.js 用节点和边把流程显式画出来每个节点的输入输出都是可观测的后期加节点、改路由都很干净。2. 整体架构设计与技术选型拆解2.1 Next.js 在这个项目里到底承担什么角色很多人一提到 Next.js 做 AI 应用第一反应就是“用 Server Actions 调模型”。但在这个项目里我把 Next.js 的职责拆得很清楚App Router 负责页面和布局Route Handler 负责 HTTP 接口和流式响应Server Actions 只用于简单的表单提交不参与 Agent 编排。这样做的好处是前后端边界清晰Agent 服务可以独立测试甚至以后拆成单独服务也不用大改。具体来说前端页面分三块简历上传区、JD 输入区、Agent 对话区。上传区支持 PDF 和纯文本粘贴PDF 解析放在服务端做避免把大文件传到浏览器再处理。JD 输入区就是一个大 textarea但我会在提交前做一次轻量清洗去掉多余空行和特殊符号。对话区用流式渲染Agent 每输出一段就追加到界面上用户能实时看到分析过程体验比等半天再一次性返回好得多。Route Handler 的设计也有讲究。我建了两个主要接口/api/agent/start用于启动一次新的分析会话/api/agent/continue用于多轮追问。两个接口都返回ReadableStream通过 Server-Sent Events 的格式把 Agent 的中间状态推给前端。这里没有用 WebSocket因为简历分析是请求-响应模式不需要双向长连接SSE 足够且更简单。注意Next.js 的 Route Handler 默认运行在 Node.js 运行时但如果你用了 Edge RuntimeLangGraph.js 的某些依赖可能不兼容。我实测下来涉及文件解析和数据库操作时老老实实用 Node.js 运行时最稳。2.2 LangGraph.js 的状态图怎么设计才不乱LangGraph.js 的核心概念是 StateGraph你需要先定义状态结构再往图里加节点和边。简历 Agent 的状态我设计成这样一个 TypeScript 接口interface ResumeAgentState { resumeText: string; jdText: string; matchScore: number; missingKeywords: string[]; suggestions: Suggestion[]; currentStep: string; messages: BaseMessage[]; retryCount: number; }这个状态里resumeText和jdText是输入matchScore、missingKeywords、suggestions是中间产物messages用来存对话历史retryCount控制重试次数防止死循环。每个节点只读取自己需要的字段写回自己负责的字段职责单一。图的结构是这样的入口节点是parseInput负责清洗文本和初步校验。然后进入analyzeMatch节点计算匹配度并提取缺失关键词。接下来是一个条件边如果matchScore低于阈值且retryCount小于 2走askForMore节点让用户补充经历否则走generateSuggestions节点生成逐条修改建议。最后进入formatOutput节点把结果整理成结构化数据返回。为什么要有retryCount因为实际用下来用户第一次粘贴的简历往往信息不全Agent 如果直接给建议质量很差。加一个“追问补充”的环节让用户补一段项目经历或技能再重新分析匹配度会明显提升。但也不能无限追问所以设了上限超过就直接给当前最优建议。2.3 模型选型与提示词分层策略模型这块我没有绑死某一家而是通过环境变量配置baseURL和apiKey只要兼容 OpenAI 的 chat completions 接口就能接。这样做的好处是本地开发可以用小模型快速迭代上线再换更强的模型。实测下来7B 到 14B 级别的模型在“关键词提取”和“匹配度打分”这两个任务上已经够用但“逐条改写建议”最好用更大的模型否则措辞会很生硬。提示词我分成三层系统提示词、节点提示词、输出格式提示词。系统提示词定义 Agent 的角色和总体原则比如“你是一名资深招聘顾问说话直接、不绕弯子”。节点提示词针对每个节点的具体任务比如analyzeMatch节点的提示词会明确要求输出 JSON 格式的匹配度和关键词列表。输出格式提示词则用 few-shot 示例约束模型输出结构减少解析失败。提示不要把所有指令塞进一个超长提示词里。分层的目的是让每个节点的提示词短而聚焦模型更容易遵循调试时也能快速定位是哪个环节出了问题。3. 核心细节解析与实操要点3.1 简历文本解析的坑与处理方案简历来源五花八门PDF、Word、纯文本、甚至直接复制粘贴的网页内容。我一开始只做了 PDF 解析用pdf-parse这个库结果发现很多简历是图片型 PDF解析出来全是空白。后来加了一个判断如果解析出的文本长度小于 100 字符就提示用户“这份 PDF 可能是扫描件请粘贴文本或上传文字版”。这个简单的兜底逻辑省掉了大量用户困惑。纯文本粘贴也有坑。用户从 Word 复制过来的内容经常带一堆不可见字符和奇怪换行。我在服务端做了一次清洗把连续三个以上换行压成一个去掉零宽字符把制表符统一替换成空格。清洗之后再做一次长度校验太短的直接拒绝避免浪费模型调用。还有一个细节是分段。简历通常有“工作经历”“项目经历”“教育背景”这些段落标题但格式不统一。我没有做复杂的 NLP 分段而是用正则匹配常见标题词比如“工作经历”“项目经验”“教育背景”“技能特长”匹配到就切段。匹配不到就整段处理虽然效果差一点但不会出错。3.2 匹配度打分与关键词提取的实现逻辑匹配度打分不是让模型随便给个分数而是拆成两步先提取 JD 里的硬性关键词再检查简历里是否出现这些关键词及其同义词。硬性关键词包括技术栈、工具名、证书、学历要求等。同义词映射我维护了一个小字典比如“React”和“React.js”算同一个“K8s”和“Kubernetes”算同一个。具体流程是先用一个节点让模型从 JD 里抽取关键词列表输出 JSON 数组。然后用代码做匹配统计命中率。最后把命中率、缺失关键词、简历原文一起交给模型让模型给出一个综合评分和简短理由。这样做的好处是评分有据可查用户能看到“为什么是 65 分”而不是一个黑盒数字。关键词提取的提示词里我明确要求模型“只提取 JD 中明确要求的技能和条件不要提取公司介绍、福利待遇等无关内容”。这个约束很重要否则模型会把“五险一金”“团建旅游”也当成关键词导致匹配度虚低。3.3 多轮对话状态保持与流式输出多轮对话的状态保持是很多 AI 应用的痛点。我的做法是每次请求都把完整的ResumeAgentState序列化后存在服务端用一个sessionId关联。前端每次请求带上sessionId服务端恢复状态后继续执行图。状态存储用 SQLite 就够了单机场景下读写很快没必要上 Redis。流式输出这块LangGraph.js 支持streamEvents可以监听每个节点的开始、结束和 token 输出。我把 token 输出通过 SSE 推给前端同时把节点切换事件也推过去前端根据事件类型决定是追加文本还是显示“正在分析匹配度...”这样的状态提示。这样用户能感知到 Agent 在干活而不是卡住了。注意SSE 连接要设置合理的超时和心跳。我遇到过代理层把长时间无数据的连接掐断的情况后来加了每 15 秒发一个注释行作为心跳问题就解决了。4. 实操过程与核心环节实现4.1 从零搭建项目骨架先初始化 Next.js 项目用create-next-app选 TypeScript 和 App Router。然后装 LangGraph.js 相关依赖npm install langchain/langgraph langchain/core langchain/openai npm install pdf-parse npm install better-sqlite3 npm install -D types/better-sqlite3目录结构我这样组织app/ api/ agent/ start/route.ts continue/route.ts page.tsx lib/ agent/ graph.ts nodes/ parseInput.ts analyzeMatch.ts askForMore.ts generateSuggestions.ts formatOutput.ts state.ts db/ session.ts pdf/ parser.ts components/ ResumeUploader.tsx JdInput.tsx AgentChat.tsx把 Agent 相关代码全部放在lib/agent下和 UI 完全隔离。这样我可以用 Node.js 脚本直接测试图不用启动整个 Next.js 服务。4.2 构建 LangGraph 状态图的完整代码先定义状态和节点再组装图。核心代码如下import { StateGraph, END } from langchain/langgraph; import { ResumeAgentState } from ./state; const workflow new StateGraphResumeAgentState({ channels: { resumeText: null, jdText: null, matchScore: null, missingKeywords: null, suggestions: null, currentStep: null, messages: null, retryCount: null, }, }); workflow.addNode(parseInput, parseInputNode); workflow.addNode(analyzeMatch, analyzeMatchNode); workflow.addNode(askForMore, askForMoreNode); workflow.addNode(generateSuggestions, generateSuggestionsNode); workflow.addNode(formatOutput, formatOutputNode); workflow.setEntryPoint(parseInput); workflow.addEdge(parseInput, analyzeMatch); workflow.addConditionalEdges(analyzeMatch, (state) { if (state.matchScore 60 state.retryCount 2) { return askForMore; } return generateSuggestions; }); workflow.addEdge(askForMore, analyzeMatch); workflow.addEdge(generateSuggestions, formatOutput); workflow.addEdge(formatOutput, END); export const resumeAgent workflow.compile();这里的关键是条件边的判断函数。matchScore低于 60 且重试次数小于 2 时回到askForMore节点让用户补充信息。补充完后重新进入analyzeMatch形成一个小循环。这个循环最多执行两次避免用户被反复追问。4.3 流式接口的 Route Handler 实现/api/agent/start的 Route Handler 大致长这样export async function POST(req: Request) { const { resumeText, jdText } await req.json(); const sessionId crypto.randomUUID(); const stream new ReadableStream({ async start(controller) { const encoder new TextEncoder(); const send (event: string, data: any) { controller.enqueue( encoder.encode(event: ${event}\ndata: ${JSON.stringify(data)}\n\n) ); }; try { const events resumeAgent.streamEvents( { resumeText, jdText, retryCount: 0, messages: [] }, { version: v1 } ); for await (const event of events) { if (event.event on_chat_model_stream) { send(token, { content: event.data.chunk.content }); } if (event.event on_chain_start) { send(node, { name: event.name }); } } send(done, { sessionId }); } catch (err) { send(error, { message: String(err) }); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端用EventSource接收但EventSource只支持 GET 请求所以实际项目里我用fetch加ReadableStream手动解析 SSE 格式。这样更灵活也方便加自定义请求头。4.4 并发控制与限流策略AI Agent 最怕的就是并发上来后模型调用排队用户等半天没反应。我在两个层面做了控制。第一层是接口层限流用内存计数器做简单的令牌桶每个 IP 每分钟最多发起 5 次分析请求。第二层是模型调用层用p-limit控制同时进行的模型请求数默认设为 3。这样即使有 20 个用户同时提交也不会把模型服务打爆。提示限流阈值要根据你实际用的模型服务来调。如果是本地跑的小模型并发数设低一点如果是云端 API可以适当放宽但一定要设上限。还有一个细节是超时处理。模型调用我设了 30 秒超时超过就中断并返回“分析超时请稍后重试”。LangGraph.js 的节点执行支持传入signal配合AbortController就能实现。5. 常见问题与排查技巧实录5.1 模型输出 JSON 解析失败怎么办这是最常见的问题。模型有时候会在 JSON 外面包一层 markdown 代码块或者加一句“好的以下是结果”。我的处理方式是写一个健壮的解析函数先尝试直接JSON.parse失败就用正则提取第一个{到最后一个}之间的内容再解析。如果还失败就记录原始输出并返回一个默认结构同时把retryCount加一让流程重试。更根本的解决办法是在提示词里加 few-shot 示例明确展示“只输出 JSON不要任何其他文字”。实测下来加了示例之后解析失败率从 15% 降到 3% 以下。5.2 匹配度分数忽高忽低怎么稳定同一个简历和 JD两次分析分数差 20 分这显然不可接受。原因在于模型打分有随机性。我的做法是把打分逻辑从“模型直接给分”改成“模型提取关键词 代码计算命中率 模型给微调系数”。命中率是确定性的模型只负责在命中率基础上加减最多 10 分并给出理由。这样分数波动就小多了。另外把temperature设成 0 或 0.1也能显著降低随机性。简历分析这种任务不需要创造性稳定比惊喜重要。5.3 用户上传大文件导致请求超时PDF 解析是 CPU 密集型操作一个 10MB 的 PDF 可能解析好几秒。如果直接在 Route Handler 里同步解析会阻塞事件循环。我把它放到worker_threads里跑主线程只等结果。同时限制上传文件大小不超过 5MB超过就提示用户压缩或粘贴文本。还有一个坑是某些 PDF 的编码格式特殊pdf-parse会抛异常。我加了 try-catch解析失败就返回友好提示而不是 500 错误。5.4 常见问题速查表问题现象可能原因排查方向解决方案流式输出中断代理层超时检查网关超时配置加心跳调大超时匹配度分数异常模型随机性检查 temperature设低 temperature改确定性打分JSON 解析失败模型输出格式不稳查看原始输出加 few-shot写健壮解析函数状态丢失sessionId 未持久化检查数据库写入每次请求后保存状态并发排队严重模型调用无限制检查并发数加令牌桶和 p-limitPDF 解析空白扫描件或加密检查文本长度提示用户粘贴文本5.5 几个我踩过的坑第一个坑是 LangGraph.js 的版本兼容性。早期版本和langchain/core的版本对不上会导致图编译报错。后来我锁定了版本号并且在package.json里用overrides强制统一依赖版本问题才消失。第二个坑是 SSE 的data字段不能包含换行符。如果模型输出的 token 里带换行直接塞进data会导致前端解析错乱。我的处理是把内容做 JSON 序列化后再放进去前端解析时再反序列化。第三个坑是 SQLite 的并发写入。多个请求同时写会话状态时better-sqlite3会报SQLITE_BUSY。我开了 WAL 模式并且把写操作串行化才解决这个问题。如果并发量再大建议直接换 Postgres。6. 这套 Agent 还能怎么扩展跑通基础流程之后我陆续加了几个扩展点效果都不错。第一个是“岗位定制化建议”根据 JD 里的行业属性调整建议的侧重点。比如投递技术岗重点突出项目难点和技术栈投递产品岗重点突出需求分析和跨部门协作。实现方式是在generateSuggestions节点前加一个detectIndustry节点把行业标签注入状态。第二个扩展是“简历版本对比”。用户可以保存多次分析结果Agent 能对比两个版本的差异指出哪些关键词补上了、哪些还没补。这个功能对反复修改简历的用户很有用。数据层加一张resume_versions表就能支持。第三个扩展是“模拟面试追问”。基于简历和 JDAgent 生成几个可能的面试问题并给出回答思路。这个功能复用了现有的状态图只是加了一个新的分支节点。LangGraph.js 的好处在这里体现得很明显加节点、加边不用动原有逻辑。提示扩展功能不要一次性全上。先把核心的分析-建议链路跑稳再逐个加分支。每加一个节点都要单独写测试用例确保状态流转正确。最后分享一个我在提示词工程上的小技巧把“不要做什么”写进系统提示词比只写“要做什么”更有效。比如“不要编造用户简历里没有的经历”“不要使用‘精通’‘熟练掌握’这类空洞词汇”“不要输出超过 200 字的单条建议”。这些负面约束能显著提升输出质量减少后期清洗的工作量。
延伸阅读

更多相关文章

2026/11/2 4:45:04

Higress底层原理:Gateway API、Envoy深度定制与Wasm沙箱机制解析

1. 为什么今天必须搞懂 Higress 的底层逻辑Higress 不是又一个“换个壳的网关”,它是一次对云原生流量治理范式的重新定义。如果你正在用 Kubernetes 做生产级服务编排,却还在用 Nginx Ingress 手写 rewrite 规则、靠 ConfigMap 硬 reload、为 TLS 终止反…

2026/11/2 4:45:04

AI工作流整合新趋势:Claude记忆打通Cowork与GPT-5.6登陆Kiro

1. 三条消息背后的共同信号:AI 工具链正在从"单点能力"转向"工作流整合"2026年8月26日这一天,AI 圈子里同时冒出来三条看起来八竿子打不着的消息:Claude 的记忆能力打通了 Cowork、GPT-5.6 登陆了 Kiro、Apple 发布了 2n…

2026/11/2 4:45:04

AI如何革新星际法律协议起草?

1. 项目概述:AI起草星际合作协议的时代背景当SpaceX的星舰完成首次载人火星飞行测试,当中国天宫空间站迎来第三批常驻宇航员,人类正以前所未有的速度迈向太空时代。在这个关键节点上,星际合作协议的起草工作却面临着传统法律文书处…

2026/11/2 5:40:06

VoiceStudio实战:从文本到成品的本地TTS语音工作流搭建指南

聊起VoiceStudio,先说句实在话——这两年AI配音工具多到让人眼花缭乱,但大多数产品要么是网页接口的“黑盒”,要么是单个模型包的“玩具”,真正能让人舒服地完成一段从文本到成品音频的完整工作链的,少之又少。我折腾V…

2026/11/2 5:40:06

储能辅助火电机组二次调频控制策略与容量优化配置

二次调频这件事,凡是碰过火电机组AGC逻辑的人都知道痛点在哪:调度指令一下来,机组要花几十秒甚至几分钟才能把出力顶上去,锅炉蓄热、汽压恢复、煤量跟得上跟不上,全是一笔烂账。储能辅助火电机组二次调频控制策略及容量…

2026/11/2 5:40:06

AI驱动的优化技术栈:从线性规划到Vibe Coding

1. 项目概述:当优化科学遇上AI革命十年前我第一次用Gurobi求解一个简单的线性规划问题时,光是配置License就花了三天时间。如今在开源求解器和AI辅助建模的加持下,同样的工作只需要三分钟——这就是技术迭代带给科研工作者的真实红利。这个融…

2026/11/2 5:40:06

keyCode、key与code:键盘事件编码链路及快捷键实战

调一个快捷键功能,按下去没反应,加个日志一看,e.keyCode打出来是 229。换一个键,变成 0。再换一个,明明按的是同一个物理位置,切换输入法之后又变成了别的数字。这种事我第一次碰到的时候盯着控制台看了半小…

2026/11/2 5:40:06

AB1500量产工装MP Tool 3.7.0.1:频偏校准与MAC防冲突

简介:Airoha.AB1500_FamilyMPTool_3.7.0.1.zip 是面向洛达AB1500系列蓝牙芯片开发者与量产测试人员的调试烧录工具包,可用于固件更新、功能测试与故障排查,覆盖从原型验证到批量生产的多个环节。压缩包共21个文件,以15个DLL动态库…

2026/11/2 5:35:06

西门子S7-1200与Modbus RTU通信全链路调试指南

1. 为什么这个“西门子1200 Modbus RTU”组合值得你花两小时认真读完我第一次在某高校自动化实验室调试一台旧款变频器时,被Modbus RTU的CRC校验卡了整整三天。PLC程序编译通过、接线反复确认过五遍、串口助手收发数据也看似正常——可就是读不到寄存器值。最后发现…

2026/11/1 0:01:26

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/1 1:01:33

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/11/2 1:04:04

工业级SPI隔离设计:硬件抗扰与信号完整性实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/11/2 1:04:04

PLFM_RADAR:主动探测数据通道,构建低误报的数据感知系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/11/2 1:04:04

工业SPI隔离设计:MAX14431与STM32F746硬件抗干扰实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/11/1 0:01:26

面向复杂光照与角度的宠物面部识别鲁棒性增强策略

在智慧养宠时代,基于视觉的宠物身份识别技术正逐步从实验室走向家庭应用。然而,与配合度高的人类不同,宠物是典型的“非合作生物识别对象”——它们不会主动配合摄像头调整姿态,更无法控制光照环境。实际应用中,我们常…

2026/11/1 1:01:33

SSM电子书店源码全解析:结构、原理、部署与排错实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑