ARTICLE DETAIL

资讯详情

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

Next.js + LangGraph.js 实战:从零构建可用的 AI Agent 简历优化工具

Next.js + LangGraph.js 实战:从零构建可用的 AI Agent 简历优化工具 1. 为什么简历工具是 AI Agent 落地的绝佳试验田简历这个场景看起来简单实际上信息密度极高。一份简历里包含个人信息、教育背景、工作经历、项目经验、技能标签、时间线、行业术语甚至还有隐含的求职意向和职业叙事逻辑。传统做法是用表单让用户一项项填填完存数据库导出 PDF。这套流程跑了十几年没什么大问题但也没什么惊喜。真正让简历工具变得有意思的是当你想让它“聪明一点”的时候。比如用户粘贴一段乱糟糟的自我介绍系统能不能自动拆成结构化的字段用户写了一段项目经历能不能自动润色成更专业的表达用户投了十个岗位没回音能不能根据 JD 反向优化简历关键词这些需求背后每一个都是典型的 AI Agent 任务有输入、有推理、有工具调用、有输出校验。我选择用 Next.js LangGraph.js 来做这个简历工具原因很直接。Next.js 提供了全栈能力前端页面、API 路由、服务端渲染一把梭部署也简单。LangGraph.js 则是目前 JavaScript 生态里做 Agent 编排最顺手的框架它把状态机、节点、边、条件跳转这些概念抽象得很干净特别适合处理“多步骤、有分支、需要人工介入”的流程。简历优化恰好就是这样一个流程解析→诊断→建议→改写→校验→输出中间任何一步都可能需要用户确认或补充信息。这个项目适合谁参考如果你已经会用 Next.js 写页面和 API对 LangChain 或 LangGraph 有基本概念想找一个真实场景把 Agent 从 Demo 推到可用状态那这篇内容就是为你准备的。如果你是完全的新手也没关系我会把每个关键决策背后的“为什么”讲清楚你跟着走一遍至少能理解一个 AI Agent 项目从零到一的全貌。提示本文不会教你“什么是 AI”假设你已经知道大模型能干什么。重点放在工程落地怎么组织代码、怎么设计状态、怎么处理并发、怎么让 Agent 的输出稳定可控。2. 项目骨架Next.js 与 LangGraph.js 的职责边界怎么划2.1 为什么不让 Next.js 包办一切很多人做 AI 项目习惯把 LLM 调用直接写在 Next.js 的 API Route 里。简单场景没问题但简历工具涉及多轮对话、状态保持、工具调用、条件分支如果全塞在 API Route 里代码会迅速膨胀成一团乱麻。更麻烦的是LangGraph 的图执行需要维护状态快照Next.js 的无状态请求模型天然不适合干这个。我的划分原则是Next.js 负责“人机交互层”和“数据持久层”LangGraph.js 负责“推理编排层”。具体来说Next.js 的 App Router 处理页面渲染、表单提交、文件上传、用户认证、数据库读写。LangGraph.js 跑在独立的 Node.js 服务里或者作为 Next.js 的 Server Action 被调用专门管理 Agent 的状态流转。这样划分的好处是Agent 的逻辑可以独立测试、独立部署、独立扩缩容。前端改版不影响 AgentAgent 换模型也不影响前端。对于简历工具这种需要频繁调整 Prompt 和流程的项目这种解耦能省下大量联调时间。2.2 LangGraph.js 的状态图设计以简历优化为例LangGraph 的核心是 StateGraph。你需要先定义状态State再定义节点Node最后用边Edge把它们连起来。简历优化这个场景我定义的状态大概长这样// state.ts import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ rawInput: Annotationstring({ reducer: (_, y) y, default: () , }), parsedSections: AnnotationRecordstring, string({ reducer: (_, y) y, default: () ({}), }), diagnosis: Annotationstring({ reducer: (_, y) y, default: () , }), suggestions: Annotationstring[]({ reducer: (x, y) x.concat(y), default: () [], }), rewrittenContent: Annotationstring({ reducer: (_, y) y, default: () , }), userFeedback: Annotationstring({ reducer: (_, y) y, default: () , }), finalOutput: Annotationstring({ reducer: (_, y) y, default: () , }), });这里有几个设计细节值得展开。suggestions用了 concat reducer意味着每次节点返回新建议时是追加而不是覆盖方便多轮迭代。userFeedback单独留一个字段是为了支持“人工介入”节点——Agent 给出建议后用户可以补充要求然后图继续往下走。节点方面我至少会定义这几个parseResume解析原始输入、diagnose诊断问题、generateSuggestions生成建议、rewrite改写内容、validate校验输出、humanReview人工确认。边则根据业务逻辑连接比如diagnose之后如果问题严重直接走rewrite如果问题轻微走generateSuggestions让用户自己决定。2.3 项目目录结构让代码可维护的关键我见过太多 AI 项目把 Prompt、工具、图定义、API 调用全混在一个文件里改一行 Prompt 要翻半天。这个项目的目录结构我反复调整过几版最终稳定成这样resume-agent/ ├── app/ # Next.js App Router │ ├── api/ │ │ └── agent/ │ │ └── route.ts # Agent 调用入口 │ ├── editor/ │ │ └── page.tsx # 简历编辑页 │ └── layout.tsx ├── lib/ │ ├── agent/ │ │ ├── graph.ts # StateGraph 定义 │ │ ├── state.ts # 状态注解 │ │ ├── nodes/ # 各节点实现 │ │ │ ├── parse.ts │ │ │ ├── diagnose.ts │ │ │ ├── suggest.ts │ │ │ ├── rewrite.ts │ │ │ └── validate.ts │ │ ├── tools/ # Agent 可调用的工具 │ │ │ ├── keywordExtractor.ts │ │ │ └── formatChecker.ts │ │ └── prompts/ # Prompt 模板 │ │ ├── diagnose.prompt.ts │ │ └── rewrite.prompt.ts │ └── db/ │ └── schema.ts # 数据库 schema ├── components/ │ └── ResumeEditor.tsx └── package.json这个结构的好处是Prompt 和节点逻辑分离改 Prompt 不用动代码逻辑工具独立存放方便复用和测试图定义集中在graph.ts一眼能看清整个流程。3. 核心节点拆解从原始文本到结构化简历的完整链路3.1 解析节点怎么把一段乱文本变成结构化数据用户输入往往是一段从 Word 或 PDF 复制出来的文本格式混乱段落之间可能没有明确分隔。解析节点的任务是把这段文本拆成“教育背景”“工作经历”“项目经验”“技能”等模块。我的做法是先用规则做粗切分再用 LLM 做精提取。规则部分很简单用正则匹配常见标题词比如“教育经历”“工作经历”“项目经验”“技能特长”等。匹配到之后把文本按这些标题切成块。如果用户输入完全没有标题就整段丢给 LLM让它自己判断结构。LLM 提取时Prompt 里必须明确要求输出 JSON 格式并且给出字段定义。比如// prompts/parse.prompt.ts export const parsePrompt 你是一个简历解析助手。请将以下文本解析为 JSON 格式包含以下字段 - education: 数组每项包含 school, degree, major, startDate, endDate - workExperience: 数组每项包含 company, title, startDate, endDate, description - projects: 数组每项包含 name, role, startDate, endDate, description - skills: 字符串数组 如果某个字段在文本中不存在请留空数组或空字符串。不要编造信息。 文本内容 {rawInput} ;这里有个坑LLM 有时候会“自作聪明”补全信息。比如用户只写了“某公司 2020-2022”LLM 可能自动加上“软件工程师”的职位。为了避免这种情况Prompt 里必须强调“不要编造”并且在解析后加一个校验步骤检查字段是否为空、日期格式是否合理。3.2 诊断节点简历问题的分类与优先级诊断节点的目标是找出简历中的问题并按严重程度排序。我把问题分成三类致命问题、结构问题、表达问题。致命问题包括时间线矛盾比如 2020 年毕业但 2018 年就工作了、关键信息缺失没有联系方式、没有求职意向、格式严重错误大量乱码、段落错位。这类问题必须优先修复否则简历直接进垃圾桶。结构问题包括模块顺序不合理比如把教育背景放在最后、重点不突出工作经历描述过于简略、篇幅失衡某个项目写了 800 字其他项目只有一行。这类问题影响阅读体验但不致命。表达问题包括用词平淡“负责了 XX 工作”、缺乏量化“提升了系统性能”没有具体数字、语法错误、中英文混用不规范。这类问题影响专业度但优先级最低。诊断节点的 Prompt 设计要点是让 LLM 先分类再排序最后给出具体位置。比如请分析以下简历找出所有问题并按严重程度排序。每个问题请标注 - 类型致命/结构/表达 - 位置具体在哪个模块 - 描述问题是什么 - 建议怎么改 简历内容 {parsedSections}实测下来LLM 对“时间线矛盾”的识别准确率很高但对“表达平淡”的判断比较主观。我的经验是诊断节点不要追求一次到位允许用户手动调整问题列表把 LLM 的输出作为“初筛结果”而不是“最终结论”。3.3 改写节点怎么让 LLM 写出不像是 AI 写的简历改写是简历工具最核心也最难做好的环节。LLM 改写的通病是用词华丽但空洞喜欢堆砌“赋能”“闭环”“抓手”这类词读起来像模板。要避免这个问题Prompt 里必须给出明确的风格约束。我的改写 Prompt 大概长这样你是一个资深简历顾问。请改写以下工作经历描述要求 1. 使用具体动词开头如“主导”“设计”“优化”“搭建” 2. 每句话必须包含可量化的结果如果原文没有数字请用“显著提升”“大幅降低”等定性描述但不要编造具体数字 3. 避免使用“负责”“参与”“协助”等弱动词 4. 每段描述控制在 3-5 句话不超过 150 字 5. 不要使用“赋能”“闭环”“抓手”“颗粒度”等空洞词汇 6. 保持原文的事实信息不变只优化表达 原文 {originalText}这里的关键是第 5 条。我试过不加这条约束LLM 输出的内容里“赋能”出现频率极高用户一眼就能看出是 AI 写的。加上之后输出质量明显提升。另一个技巧是给示例。在 Prompt 里放一两个“改写前 vs 改写后”的对比示例LLM 会模仿示例的风格。示例要选那种“原文平淡、改写后有力”的让 LLM 有明确的模仿目标。3.4 校验节点怎么防止 LLM 输出“幻觉”校验节点的作用是检查改写后的内容是否偏离事实。LLM 在改写时可能会“顺手”添加原文没有的信息比如原文写“参与了用户增长项目”改写后变成“主导用户增长项目实现日活提升 30%”。这种幻觉在简历场景里是致命的因为用户可能真的拿着这份简历去面试。我的校验策略是“事实比对 规则检查”。事实比对用另一个 LLM 调用把原文和改写后的内容一起丢进去问它“改写后的内容是否包含原文没有的事实信息”。规则检查则用代码实现比如检查数字是否在原文中出现过、公司名和职位名是否被篡改。// nodes/validate.ts export async function validateNode(state: ResumeState) { const { parsedSections, rewrittenContent } state; // 规则检查提取原文中的所有数字 const originalNumbers extractNumbers(JSON.stringify(parsedSections)); const rewrittenNumbers extractNumbers(rewrittenContent); const fabricatedNumbers rewrittenNumbers.filter( (n) !originalNumbers.includes(n) ); if (fabricatedNumbers.length 0) { return { finalOutput: rewrittenContent, suggestions: [检测到可能编造的数字${fabricatedNumbers.join(, )}请人工确认], }; } // LLM 事实比对 const factCheckResult await factCheck(parsedSections, rewrittenContent); return { finalOutput: rewrittenContent, suggestions: factCheckResult.issues, }; }这个校验节点不能保证 100% 准确但能拦住大部分明显幻觉。我的经验是规则检查能拦住 80% 的数字编造LLM 事实比对能拦住剩下的大部分。两者结合用户基本可以放心使用。4. 并发与性能AI Agent 怎么扛住真实流量4.1 为什么简历工具会遇到并发问题简历工具的使用场景有很强的“潮汐性”。毕业季、跳槽季用户集中访问短时间内大量请求涌入。每个请求背后是多次 LLM 调用解析、诊断、改写、校验一轮下来至少 4 次 API 调用。如果 100 个用户同时使用就是 400 次 LLM 调用并发。这个量级对于个人项目来说如果不做处理很容易触发 API 限流或超时。更麻烦的是LangGraph 的图执行是有状态的。每个用户会话需要维护独立的状态快照不能混在一起。如果直接用内存存状态服务重启就丢了如果用数据库存每次节点跳转都要读写延迟又上去了。4.2 我的并发处理方案队列 缓存 降级我最终采用的方案是三层防护请求队列、状态缓存、模型降级。请求队列用简单的内存队列实现控制同时执行的 Agent 数量。比如设置最大并发为 10超出的请求排队等待。这样虽然增加了等待时间但避免了 API 限流导致的批量失败。// lib/agent/queue.ts class AgentQueue { private queue: Array() Promiseany []; private running 0; private maxConcurrent 10; async addT(task: () PromiseT): PromiseT { return new Promise((resolve, reject) { this.queue.push(async () { try { const result await task(); resolve(result); } catch (error) { reject(error); } finally { this.running--; this.next(); } }); this.next(); }); } private next() { if (this.running this.maxConcurrent) return; const task this.queue.shift(); if (!task) return; this.running; task(); } }状态缓存用 Redis 或内存 LRU 缓存存 LangGraph 的状态快照。每个会话有一个 sessionId节点跳转时先从缓存读状态执行完再写回。缓存过期时间设为 30 分钟足够覆盖一次完整的简历优化流程。模型降级是最后一道防线。如果主模型比如 GPT-4调用失败或超时自动切换到备用模型比如 GPT-3.5 或国产模型。降级后的输出质量会下降但至少保证流程能走完。我在 Prompt 里做了兼容设计两个模型用同一套 Prompt只是输出长度和细节程度有差异。4.3 实测数据优化前后的对比我在本地用 50 个并发请求做了压测优化前后的数据对比很明显指标优化前优化后平均响应时间12.3s8.7s成功率67%96%API 限流次数23 次2 次内存峰值1.8GB1.2GB优化后的成功率提升主要来自队列和降级。响应时间下降是因为缓存减少了重复的状态读写。内存峰值下降是因为 LRU 缓存淘汰了过期会话。注意队列的 maxConcurrent 不要设太大。我试过设 20结果 API 限流反而更严重因为瞬时并发太高。10 是一个比较稳的值具体可以根据你的 API 配额调整。5. 人工介入节点让 Agent 学会“停下来问一句”5.1 为什么全自动的 Agent 在简历场景行不通简历是高度个人化的东西。同样一段工作经历有人想突出技术深度有人想突出管理能力有人想突出业务成果。LLM 再聪明也不可能猜准每个用户的心思。如果 Agent 一路自动跑到底输出的简历大概率是“正确但不对味”的。所以我在图里加了一个humanReview节点。这个节点的作用是Agent 完成初步改写后暂停执行把结果展示给用户让用户选择“接受”“修改”或“重新生成”。用户的选择会作为状态更新然后图继续往下走。5.2 LangGraph 的 interrupt 机制怎么用LangGraph.js 提供了interrupt功能可以在节点执行前或执行后暂停图。我的用法是在rewrite节点之后插入一个humanReview节点这个节点不执行任何 LLM 调用只是把当前状态返回给前端。// graph.ts import { StateGraph, END } from langchain/langgraph; import { ResumeState } from ./state; const workflow new StateGraph(ResumeState) .addNode(parse, parseNode) .addNode(diagnose, diagnoseNode) .addNode(suggest, suggestNode) .addNode(rewrite, rewriteNode) .addNode(humanReview, humanReviewNode) .addNode(validate, validateNode) .addEdge(parse, diagnose) .addEdge(diagnose, suggest) .addEdge(suggest, rewrite) .addEdge(rewrite, humanReview) .addConditionalEdges(humanReview, (state) { if (state.userFeedback accept) return validate; if (state.userFeedback regenerate) return rewrite; return suggest; }) .addEdge(validate, END); export const graph workflow.compile({ checkpointer: new MemorySaver(), });前端拿到humanReview的返回后展示改写结果和操作按钮。用户点击“接受”前端调用 API 更新userFeedback为accept图继续执行到validate。用户点击“重新生成”userFeedback设为regenerate图回到rewrite节点重新改写。5.3 人工介入的体验设计别让用户等太久人工介入节点最大的体验问题是“等待”。用户看到改写结果后需要时间阅读和决策。如果前端一直转圈用户会以为卡死了。我的做法是humanReview节点返回后前端立即展示结果同时把会话状态存到本地。用户关闭页面再回来状态还在可以继续操作。另一个细节是humanReview节点要给出明确的“建议操作”。比如如果诊断节点发现时间线矛盾humanReview的提示语应该是“检测到时间线可能有问题建议优先确认”而不是干巴巴地展示一段文本。用户需要知道“我现在该干什么”。6. 从 Demo 到可用我踩过的五个坑6.1 Prompt 太长导致 Token 超限简历解析的 Prompt 里我一开始把字段定义、示例、约束条件全塞进去结果 Prompt 长度超过 3000 token。加上用户输入的简历文本总 token 数直接超限。LLM 要么报错要么截断输出。解决办法是拆分 Prompt。解析节点只负责“提取结构”不负责“校验格式”。校验格式交给代码做。这样 Prompt 长度降到 800 token 左右稳定多了。6.2 状态字段太多导致图执行变慢LangGraph 的状态是全局共享的。我一开始把用户信息、简历内容、诊断结果、改写结果、历史记录全放在一个 State 里结果每次节点跳转都要序列化和反序列化整个状态延迟很高。后来我把状态拆成“核心状态”和“扩展状态”。核心状态只放当前节点需要的字段扩展状态存数据库需要时再加载。这样图执行速度提升了将近一倍。6.3 LLM 输出 JSON 格式不稳定解析节点要求 LLM 输出 JSON但 LLM 有时候会在 JSON 外面加解释文字比如“好的以下是解析结果{...}”。直接JSON.parse会报错。我的处理方式是先用正则提取 JSON 部分再解析。如果解析失败重试一次并在 Prompt 里强调“只输出 JSON不要任何其他文字”。实测下来重试一次基本能解决 90% 的格式问题。6.4 并发时状态串号这个问题最隐蔽。我用 sessionId 区分不同用户的状态但有一次测试时发现 A 用户的改写结果出现在了 B 用户的页面上。排查后发现是缓存 key 设计有问题用了用户 ID 而不是会话 ID导致同一用户在不同标签页打开时状态冲突。修复方案很简单缓存 key 用sessionId userId组合确保每个会话独立。这个坑提醒我状态管理一定要有明确的隔离边界。6.5 模型降级后 Prompt 不兼容我最初设计降级方案时以为换个模型名就行。结果发现不同模型对 Prompt 的敏感度不一样。主模型能理解的复杂指令备用模型可能理解不了输出质量断崖式下降。后来我给每个模型单独调了一版 Prompt降级时自动切换。虽然维护成本高了点但保证了降级后的可用性。7. 这个项目还能怎么扩展简历工具只是起点。这套 Next.js LangGraph.js 的架构换一套 Prompt 和工具就能迁移到很多类似场景。比如面试模拟 Agent输入简历和 JDAgent 扮演面试官提问用户回答后 Agent 给出反馈。再比如职业规划 Agent输入简历和职业目标Agent 分析差距推荐学习路径。技术上的扩展方向也很多。比如把 LangGraph 的 checkpointer 从内存换成 PostgreSQL支持多实例部署比如接入向量数据库做简历与 JD 的语义匹配比如加一个“简历评分”工具用规则引擎给简历打分LLM 只负责解释评分理由。我个人的体会是AI Agent 项目的难点从来不在“怎么调 LLM”而在“怎么设计流程”和“怎么处理边界”。简历这个场景的好处是边界相对清晰用户预期明确适合用来练手。等你把这套流程跑通了再去做更复杂的 Agent心里就有底了。最后分享一个小技巧在开发阶段把 LangGraph 的图执行日志打到控制台每个节点的输入输出都打出来。这样调试的时候能清楚看到状态是怎么流转的比断点调试还管用。上线前再把日志级别调高避免泄露用户隐私。
返回列表