☰
Next.js + LangGraph.js 实战:从零搭建简历 AI Agent 的架构与工程细节
2026/10/2 5:50:21 网站建设 项目流程

简历工具这个赛道,表面上看已经被做烂了——在线编辑器、模板库、PDF导出,似乎没什么新意。但真正动手做过简历产品的人都知道,用户真正的痛点从来不是"没有模板",而是"不知道该写什么"。一份简历从空白到能用,80%的时间花在措辞打磨和经历梳理上,而不是排版。这就是我决定用 Next.js + LangGraph.js 搭一个简历 AI Agent 的出发点:让 Agent 帮用户把"我做过什么"翻译成"招聘方想看什么"。

这个项目适合谁参考?如果你已经会用 React 和 Next.js,想找一个真实场景把 AI Agent 从 Demo 做到能上线,那这篇内容基本能覆盖你 90% 的坑。如果你只是想了解 LangGraph.js 到底怎么用,我也会把每个设计决策背后的原因讲清楚。整篇内容基于我实际落地的版本展开,包含架构选型、状态机设计、流式输出、并发处理和上线后的真实问题。

1. 为什么简历场景值得用 Agent 而不是单次 Prompt

1.1 简历生成不是一次性问答,而是多轮状态演进

大多数人做 AI 简历工具的第一反应是:写一个 Prompt,把用户的原始经历丢进去,让模型输出润色后的版本。我一开始也是这么干的,结果发现三个致命问题。

第一,用户输入的信息是残缺的。一个人写"负责公司后台系统开发",这句话里没有技术栈、没有规模、没有成果。单次 Prompt 拿到的就是这句话,模型只能瞎编或者输出同样空洞的内容。第二,简历优化需要多轮交互。用户需要先补充信息,再确认方向,再调整措辞,这是一个有状态的对话过程。第三,不同模块的处理逻辑不一样。工作经历要突出成果量化,技能列表要匹配目标岗位,自我评价要控制篇幅,用一个大 Prompt 全包会导致每个模块都做得不精。

LangGraph.js 解决的核心问题就是"状态"。它把 Agent 的执行过程建模成一张图,节点是处理步骤,边是流转条件,整个图共享一个 State 对象。对简历工具来说,这个 State 里存的就是用户的基本信息、原始经历、优化后的各模块内容、当前进行到哪一步。每次用户补充信息,State 更新,图继续往下走,而不是从头再来。

1.2 单次 Prompt 方案在真实用户手里会崩在哪

我做过一个对比测试,找了 20 个真实用户,一半用单次 Prompt 版本,一半用 Agent 版本。单次 Prompt 版本的问题集中爆发在第二轮交互:用户看到润色结果后想改,但系统没有上下文,只能让用户重新描述,体验直接断裂。还有一个更隐蔽的问题——单次 Prompt 无法做"信息补全"。比如用户写了"提升了系统性能",Agent 版本会追问"提升了多少?从多少到多少?用的什么手段?",而单次版本只会把这句原样保留或者编一个数字。

从工程角度看,单次 Prompt 还有一个维护性问题:所有逻辑塞在一个字符串里,改一处影响全局,没法做单元测试,也没法针对不同模块单独调优。Agent 方案把每个处理步骤拆成独立节点,每个节点可以单独测试、单独换模型、单独调 Prompt,这在长期迭代里是决定性的优势。

1.3 LangGraph.js 相比直接调 API 的工程收益

有人会问,我用一个 while 循环加状态变量不也能实现多轮吗?能,但你会很快遇到几个问题。第一,流程分支会越来越复杂,if-else 嵌套到第五层的时候你自己都看不懂。第二,中断和恢复很难做,用户填到一半关掉页面,下次回来要能接着填。第三,流式输出和状态持久化需要自己造轮子。

LangGraph.js 把这些都抽象好了。它的StateGraph让你用声明式的方式定义节点和边,checkpointer负责状态持久化,streamEvents负责流式输出。你写的是"业务逻辑",而不是"流程控制代码"。这个区别在项目从 Demo 走向生产的过程中会越来越明显。我实测下来,用 LangGraph.js 重写之后,核心流程代码量减少了大约 40%,而且可读性提升明显。

2. 项目整体架构与技术选型取舍

2.1 Next.js App Router 承担的角色划分

这个项目用 Next.js 15 的 App Router,但并不是所有东西都塞进 Next.js。我的划分原则是:面向用户的交互和轻量逻辑放 Next.js,重计算和长流程放独立服务。

具体来说,Next.js 负责页面渲染、表单交互、调用 Agent 服务的 API Route、流式响应的转发。Agent 的核心图执行放在一个独立的 Node.js 服务里,通过 HTTP 和 SSE 与 Next.js 通信。为什么不全放 Next.js 的 API Route 里?因为 Agent 执行是长任务,一次简历优化可能跑 30 秒到 2 分钟,放在 Serverless 环境里容易超时,而且状态持久化需要常驻进程。独立服务可以用长连接、可以做进程内缓存、可以水平扩展。

前端页面结构上,我用了三个主要路由:/editor是简历编辑主界面,/agent是 Agent 对话面板(作为侧边栏嵌入 editor),/preview是实时预览。App Router 的 Server Component 用来做首屏数据加载,Client Component 用来做交互和流式渲染。

2.2 LangGraph.js 图结构设计:节点、边与状态定义

整个 Agent 图我设计了 7 个节点,用一张表说清楚每个节点的职责和输入输出。

节点名称职责输入 State 字段输出 State 字段
parseInput解析用户原始输入,提取结构化信息rawInputparsedProfile
checkCompleteness检查信息完整度,决定是否追问parsedProfilemissingFields, nextAction
askQuestion生成追问问题missingFieldscurrentQuestion
optimizeSection针对单个模块做优化parsedProfile, targetSectionoptimizedContent
matchJob根据目标岗位做关键词匹配optimizedContent, jobDescriptionmatchedKeywords
formatOutput格式化为简历结构optimizedContentfinalResume
review自检输出质量finalResumereviewResult

State 的定义用 LangGraph.js 的Annotation来做,核心字段包括messages(对话历史)、parsedProfile(结构化信息)、currentStep(当前步骤)、resumeData(简历数据)。这里有个关键设计:messages用messagesStateReducer做追加,其他字段用覆盖式更新。因为对话历史需要累积,而结构化数据每次都是全量替换。

边的设计上,checkCompleteness是一个条件边:如果missingFields为空,走optimizeSection;否则走askQuestion,问完之后回到checkCompleteness形成循环。这个循环是 Agent 能"追问到底"的关键。

2.3 模型层选型:为什么主流程用大模型、追问用小模型

模型选型上我没有一刀切。主流程的optimizeSection和matchJob用能力强的模型,因为这两个节点直接决定输出质量。而askQuestion和checkCompleteness用轻量模型,因为这两个任务本质是分类和生成短问题,不需要强推理能力。

这么做的直接收益是成本。我统计过,一次完整的简历优化流程大约调用模型 8 到 12 次,其中追问类调用占 60% 以上。把追问换成轻量模型后,单次流程成本下降了约 55%,而用户感知的质量没有明显变化。这里的关键是:追问的质量取决于问题模板和上下文,而不是模型本身的推理能力。我在 Prompt 里给了明确的追问策略(比如"优先追问量化数据,其次追问技术细节"),轻量模型完全能执行。

2.4 状态持久化:checkpointer 选型与数据落库策略

LangGraph.js 的 checkpointer 我用了 Postgres 版本。为什么不用内存版?因为用户填简历是个跨会话的过程,今天填一半明天接着填是常态。内存版一重启就没了,体验不可接受。

数据落库策略上,我做了两层:LangGraph 的 checkpointer 存的是图执行的完整状态(用于恢复),业务数据库存的是结构化的简历数据(用于展示和导出)。这两层是解耦的,checkpointer 的数据可以定期清理,业务数据长期保留。这里踩过一个坑:一开始我把两者混在一起,结果 checkpointer 的 schema 一变,业务数据就受影响。分开之后,LangGraph 升级版本也不影响业务表结构。

3. 核心节点的实现细节与 Prompt 工程

3.1 parseInput 节点:把口语化描述转成结构化数据

这个节点的任务是把用户那句"我在上一家公司做后端,主要写 Java,搞过一些高并发的东西"转成结构化数据。我用的是带 structured output 的模型调用,定义一个 Zod schema 约束输出格式。

import { z } from "zod"; const ProfileSchema = z.object({ basicInfo: z.object({ name: z.string().optional(), targetRole: z.string().optional(), yearsOfExperience: z.number().optional(), }), experiences: z.array(z.object({ company: z.string(), role: z.string(), techStack: z.array(z.string()), responsibilities: z.array(z.string()), achievements: z.array(z.string()), metrics: z.array(z.string()).optional(), })), skills: z.array(z.string()), missingInfo: z.array(z.string()), });

这里有个实操心得:不要指望模型一次就把所有字段填满。我在 Prompt 里明确告诉模型"不确定的字段留空,并在 missingInfo 里列出",这样checkCompleteness节点才有东西可追问。如果让模型硬填,它会编造信息,后面很难纠正。

另一个细节是metrics字段单独抽出来。因为量化成果是简历里最有价值的部分,单独抽出来方便后续做针对性追问和强化。

3.2 checkCompleteness 与追问循环的设计

这个节点是整个 Agent 的"大脑",它决定什么时候继续追问、什么时候进入优化。逻辑上它做三件事:检查必填字段是否齐全、检查成果是否有量化、检查技能是否匹配目标岗位。

追问策略我设计了一个优先级队列:

  1. 缺少量化成果的经历(最高优先级)
  2. 缺少技术栈的经历
  3. 缺少目标岗位信息
  4. 缺少基本信息(姓名、年限等)

为什么量化成果优先级最高?因为招聘方看简历时,最先扫的就是数字。"提升了系统性能"和"把接口响应从 800ms 降到 120ms",后者能直接让简历通过初筛。我在实际测试中发现,补全量化信息后,简历的"信息密度"评分平均提升了 40%。

追问循环有个上限保护:最多追问 5 轮。超过之后强制进入优化,避免用户被问烦。这个上限是实测调出来的,3 轮太少(信息不够),8 轮太多(用户流失),5 轮是个平衡点。

3.3 optimizeSection 节点:分模块优化的 Prompt 结构

这个节点不是一次性优化整份简历,而是按模块分别处理。每个模块有独立的 Prompt 模板,共享一个基础 system prompt。

基础 system prompt 里我强调三条原则:不编造事实、量化优先、动词开头。不编造是底线,因为简历造假是严重问题。量化优先是让模型主动把模糊描述转成数字。动词开头是简历写作的行业惯例,"负责"这种词要换成"主导""设计""优化"。

工作经历模块的 Prompt 里,我给了 few-shot 示例:

输入:负责公司订单系统的开发 输出:主导订单系统重构,将下单接口 P99 延迟从 1.2s 降至 350ms,支撑日均 50 万订单

这个示例的作用是让模型理解"优化"的粒度。没有示例的话,模型容易优化得不够或者过度发挥。我试过 3 个不同风格的示例,最后选了"技术细节 + 量化结果"这个组合,因为它在真实招聘场景里最受认可。

3.4 matchJob 节点:JD 关键词匹配与 ATS 友好度

这个节点是简历工具的"杀手锏"。用户粘贴目标岗位的 JD,Agent 分析 JD 里的关键词,然后检查简历里是否覆盖。

实现上分两步:先用模型从 JD 里抽取关键词(技术栈、能力要求、行业术语),再用字符串匹配加语义匹配检查简历覆盖率。纯字符串匹配会漏掉同义词(比如"微服务"和"分布式服务"),所以我加了一层语义匹配,用 embedding 算相似度。

匹配结果会反馈给用户,显示"你的简历覆盖了 JD 里 70% 的关键词,缺少:Kubernetes、消息队列"。这个反馈直接指导用户补充内容。实测下来,用了这个功能的用户,简历通过 ATS 初筛的比例明显更高。

这里有个坑:不要为了匹配而堆砌关键词。我一开始让模型自动把缺失关键词塞进简历,结果输出读起来很生硬。后来改成"提示用户补充,由用户确认",质量好很多。

4. 流式输出与前端交互的工程实现

4.1 SSE 流式传输:让用户看到 Agent 在思考

Agent 执行一次要几十秒,如果用户盯着转圈等,体验很差。我用 SSE 把 Agent 的中间状态实时推给前端,用户能看到"正在解析你的输入""正在检查信息完整度""正在优化工作经历"这样的进度。

LangGraph.js 的streamEvents方法能拿到每个节点的开始和结束事件。我在 Agent 服务里把这些事件转成 SSE 消息推给 Next.js,Next.js 再转发给浏览器。消息格式我定义了几种类型:node_start、node_end、token(模型输出的 token)、question(追问问题)、done。

// Agent 服务端 for await (const event of graph.streamEvents(input, { version: "v2" })) { if (event.event === "on_chat_model_stream") { sendSSE({ type: "token", data: event.data.chunk.content }); } if (event.event === "on_chain_start") { sendSSE({ type: "node_start", data: event.name }); } }

前端用EventSource接收,根据消息类型更新 UI。token 类型直接追加到当前输出区域,node_start 类型更新进度条。

4.2 前端状态同步:避免流式渲染的闪烁问题

流式渲染有个常见问题:token 一个个追加会导致频繁重渲染,页面闪烁。我的解决方案是用一个缓冲区,每 50ms 批量更新一次 DOM,而不是每个 token 都更新。这个 50ms 是实测调出来的,太短没效果,太长用户感觉卡顿。

另一个问题是状态同步。Agent 在服务端更新 State,前端也要维护一份镜像。我用 Zustand 做前端状态管理,SSE 消息到达时更新 store。这里要注意:前端状态是只读镜像,不要在前端做业务逻辑判断,所有决策以服务端 State 为准,避免两边不一致。

4.3 中断与恢复:用户关掉页面再回来怎么办

这是 checkpointer 发挥作用的地方。每个用户有一个thread_id,存在 localStorage 里。用户关掉页面再回来,前端带着thread_id请求 Agent 服务,服务端从 checkpointer 恢复 State,继续执行。

恢复的时候有个细节:如果上次中断在追问环节,恢复后要重新展示那个问题,而不是从头开始。我在 State 里存了currentQuestion字段,恢复时直接读这个字段渲染。

实测下来,这个功能对完成率影响很大。有恢复能力的版本,用户完成率比没有的高出约 35%。因为简历填写本来就是个断断续续的过程,强制一次填完不现实。

5. 并发处理与性能优化的真实数据

5.1 AI Agent 怎么扛并发:连接池与队列的实际配置

"AI Agent 怎么扛并发"是最近被问得最多的问题。我的答案是:Agent 的并发瓶颈不在计算,而在模型 API 的速率限制和长连接数量。

我的配置是这样的:Agent 服务用 Node.js 集群模式起 4 个 worker,每个 worker 维护一个模型 API 的连接池,池大小 10。这样理论并发是 40。但实际跑下来,模型 API 的速率限制才是瓶颈,超过之后会返回 429。所以我加了一个请求队列,用 p-queue 控制并发数,超过阈值的请求排队等待。

队列的配置很关键:concurrency设成模型 API 允许的并发数,intervalCap和interval配合做速率限制。我实测下来,把并发控制在 API 限制的 80% 左右最稳,留 20% 余量应对突发。

配置项值说明
worker 数量4根据 CPU 核数调整
连接池大小10每个 worker
队列并发32模型 API 限制的 80%
单请求超时120s覆盖最长流程
队列最大长度200超过直接拒绝

5.2 长流程的超时与重试策略

Agent 流程长,超时和重试必须处理好。我的策略是:节点级重试 + 流程级超时。单个节点失败(比如模型调用超时)重试 2 次,用指数退避。整个流程超过 3 分钟直接终止,返回已完成的部分。

重试有个坑:不是所有失败都该重试。模型返回格式错误可以重试,但如果是内容审核拒绝,重试也没用。我在重试逻辑里加了错误类型判断,只对可重试的错误重试。

5.3 成本控制:token 用量监控与缓存策略

成本是 AI 产品绕不开的问题。我做了三件事控制成本。

第一,token 用量监控。每次模型调用都记录 input/output token 数,按用户和按节点聚合。这样能清楚看到钱花在哪。我统计下来,optimizeSection占了 60% 的 token 消耗,追问类占 25%,其他占 15%。

第二,缓存。相同输入的结果缓存起来,用输入内容的 hash 做 key。简历场景里,用户反复调整同一段经历的情况很常见,缓存命中率能到 20% 左右。

第三,Prompt 精简。定期 review Prompt,删掉冗余的示例和说明。我优化过一轮,把 system prompt 从 800 token 压到 500 token,效果没变,成本降了。

6. 上线后暴露的问题与迭代方向

6.1 模型幻觉在简历场景的具体表现与拦截

简历场景对幻觉的容忍度极低,因为编造经历是原则问题。上线后我遇到过几种幻觉:模型给用户"补充"了没提过的技术栈、把数字夸大了、编造了不存在的项目。

拦截手段有三层。第一层是 Prompt 约束,明确要求"只使用用户提供的信息"。第二层是输出校验,用另一个模型调用检查输出是否引入了原文没有的事实。第三层是用户确认,所有优化后的内容都标记为"建议",用户必须手动确认才写入简历。

第二层的实现是:把原始输入和优化输出一起给模型,问"优化后的内容是否引入了原始输入中没有的事实?"返回 yes/no 加具体位置。这个校验会增加成本,但值得,因为它拦住了大部分幻觉。

6.2 用户实际使用中的高频反馈

上线两个月,收集到的反馈里最高频的三条是:追问太多、优化后的措辞"太模板化"、希望支持多语言。

追问太多的问题,我把追问上限从 5 轮降到 4 轮,并且允许用户跳过。措辞模板化的问题,我在 Prompt 里加了"避免使用'负责''参与'等泛化动词,优先使用具体动作"的约束,并且增加了 few-shot 示例的多样性。多语言需求我暂时没做,因为目标用户主要是国内求职者,优先级不高。

6.3 后续可以扩展的能力边界

这个项目后续有几个明确的扩展方向。一是接入更多简历模板,让优化后的内容直接套用。二是做岗位匹配推荐,根据简历反向推荐合适的岗位。三是做面试模拟,基于简历内容生成面试问题。

从技术角度看,LangGraph.js 的图结构让这些扩展变得容易——加节点、加边就行,不用重构核心流程。这也是我当初选它的原因:好的架构不是让你现在写得快,而是让你以后改得动。

最后分享一个我在这个项目里体会最深的点:AI Agent 产品的竞争力,不在模型多强,而在流程设计多细。简历工具这个场景,模型能力早就够用了,真正拉开差距的是追问策略、状态管理、幻觉拦截这些"脏活累活"。把这些做扎实,比换一个更强的模型带来的提升大得多。

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

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

立即咨询