ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Next.js + LangGraph.js 构建多步骤 AI Agent 简历优化工具实战

2026/10/8 4:50:44 拓冰建站 浏览量
Next.js + LangGraph.js 构建多步骤 AI Agent 简历优化工具实战 1. 为什么我选择用 Next.js LangGraph.js 来做一个简历工具先说结论这个项目本质上是一个多步骤、有状态、需要反复调用大模型的 AI Agent而不是那种“输入一段文字、吐出一段结果”的单次问答。简历这个场景天然就是多轮迭代的——用户给你一段粗糙的经历描述你要帮他拆成结构化字段再润色成专业表达再检查关键词匹配度最后还要根据目标岗位做定向优化。这一连串动作如果全靠一个 prompt 硬塞效果会非常不稳定。我最早是用一个单体 prompt 试的把“解析 润色 打分 建议”全写在一个系统提示里结果模型经常顾此失彼润色做完了忘了打分打分给了又丢了结构化输出。后来换成 LangGraph.js 之后整个流程被拆成一个个节点每个节点只干一件事状态在节点之间流转可控性直接上了一个台阶。那为什么前端选 Next.js因为简历工具的用户交互很重——用户要实时看到 Agent 每一步在干什么要能中途修改、回退、重新生成。Next.js 的 App Router 配合 Server Actions 和流式响应天然适合这种“边算边展示”的场景。而且前后端同构我不用再单独维护一个后端服务Route Handler 直接跑 Agent 逻辑部署也简单。这个项目适合谁参考如果你已经会写 React懂一点 Node.js想从“调 API 玩 prompt”进阶到“真正搭一个能用的 AI Agent 产品”那这个组合非常值得上手。它不要求你懂 Python全栈 JavaScript 就能跑通对前端背景的同学特别友好。2. 整体架构设计与技术选型拆解2.1 为什么是 LangGraph.js 而不是直接调 OpenAI SDK很多人第一反应是我直接fetch调模型不就行了为什么要引入 LangGraph 这么个框架我一开始也这么想直到我遇到三个绕不过去的问题。第一个是状态管理。简历优化不是一次调用而是“解析 → 结构化 → 润色 → 评分 → 建议”五六个步骤。如果手写你得自己维护一个 context 对象每一步手动传参、手动合并结果代码很快就变成意大利面。LangGraph 的核心概念就是StateGraph你定义一个状态结构比如{ rawText, parsed, polished, score, suggestions }每个节点读取状态、返回增量更新框架帮你合并。这跟 Redux 的思路很像但它是为 LLM 流程设计的。第二个是条件分支。比如评分低于某个阈值时要回到润色节点重新来一遍而不是直接给用户。手写这个循环很容易出 bugLangGraph 用addConditionalEdges把分支逻辑声明式地表达出来图长什么样一目了然。第三个是可观测性。每个节点的输入输出都能单独打日志出问题的时候我知道是解析错了还是润色跑偏了而不是面对一坨黑盒输出干瞪眼。import { StateGraph, Annotation } from langchain/langgraph; const ResumeState Annotation.Root({ rawText: Annotation({ reducer: (_, b) b, default: () }), parsed: Annotation({ reducer: (_, b) b, default: () null }), polished: Annotation({ reducer: (_, b) b, default: () null }), score: Annotation({ reducer: (_, b) b, default: () 0 }), retryCount: Annotation({ reducer: (_, b) b, default: () 0 }), });上面这段就是状态定义。reducer决定新值怎么覆盖旧值默认是直接替换。retryCount用来防止无限循环这个后面会细说。2.2 Next.js 在这个项目里承担了什么角色Next.js 在这里不是简单的“页面壳子”它承担了三件事。第一是流式 UI。Agent 跑起来可能要十几秒用户不能干等着。我用 Route Handler 返回一个ReadableStream每跑完一个节点就往前端推一段 SSE 事件前端用useChat或者自己写的 hook 消费用户能看到“正在解析……正在润色……正在评分”的实时进度。这个体验差距非常大实测用户留存能差一倍。第二是 Server Actions 做表单提交。简历的原始输入、目标岗位、行业偏好这些参数用 Server Action 提交比传统 API 路由更简洁类型还能端到端共享。第三是 Edge Runtime 的取舍。LangGraph.js 依赖一些 Node 特有的 API所以 Agent 逻辑我放在 Node.js Runtime 的 Route Handler 里而不是 Edge。但静态页面和部分轻量接口走 Edge兼顾速度。2.3 目录结构怎么组织才不乱我踩过的坑是一开始把所有 Agent 逻辑塞在一个route.ts里写到 800 行的时候彻底没法维护了。后来改成这样的结构app/ api/ agent/ route.ts # 流式入口只负责编排 resume/ page.tsx # 主界面 lib/ agent/ graph.ts # StateGraph 定义 nodes/ parse.ts # 解析节点 polish.ts # 润色节点 score.ts # 评分节点 suggest.ts # 建议节点 prompts/ parse.prompt.ts polish.prompt.ts schema/ resume.ts # Zod schema结构化输出用核心原则是节点逻辑、prompt、schema 三者分离。prompt 单独放文件改措辞不用动逻辑schema 用 Zod 定义既能做运行时校验又能直接转成 JSON Schema 喂给模型做结构化输出。3. 核心节点实现与关键细节3.1 解析节点把一段大白话拆成结构化字段解析节点是整个流程的地基它做不好后面全白搭。用户输入往往是一段流水账比如“我在某公司干了三年负责后端用 Java 和 MySQL做过订单系统带过两个人”。我要把它拆成{ company, duration, role, techStack, projects, highlights }这样的结构。这里的关键是用结构化输出而不是让模型自由发挥。LangGraph.js 配合 Zod 可以强制模型返回符合 schema 的 JSONimport { z } from zod; import { ChatOpenAI } from langchain/openai; const ResumeSchema z.object({ basics: z.object({ name: z.string().optional(), targetRole: z.string(), }), experiences: z.array(z.object({ company: z.string(), role: z.string(), duration: z.string(), techStack: z.array(z.string()), highlights: z.array(z.string()), })), skills: z.array(z.string()), }); const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0 }); const structuredModel model.withStructuredOutput(ResumeSchema);temperature: 0是必须的解析任务要的是稳定复现不是创意。withStructuredOutput会自动把 Zod schema 转成模型的 function calling 定义返回的对象直接通过类型检查省掉一堆手动JSON.parse和 try-catch。注意结构化输出不是 100% 可靠的尤其是模型遇到超长输入时可能截断。我在解析节点外面包了一层重试失败时把原始文本切短再试一次实测能把失败率从 5% 降到 0.5% 以下。3.2 润色节点让经历描述从“能看”到“能打”润色节点的目标是把highlights里的口语化描述改成招聘方爱看的表达。比如“做过订单系统”要变成“主导订单系统重构支撑日均 50 万订单P99 延迟降低 40%”。这里有个反直觉的经验不要让模型一次性润色所有条目。我试过把整个 experiences 数组丢进去让它批量改结果模型会偷懒后面的条目质量明显下降。后来改成逐条润色虽然调用次数多了但每条质量都稳定。成本上用gpt-4o-mini逐条跑一份简历也就几分钱完全可接受。prompt 里我固定了几个约束每条 highlight 控制在 40 字以内必须包含至少一个量化指标没有的话让模型基于常识合理推断并标注动词开头用“主导/搭建/优化/推动”这类强动词const polishPrompt 你是一位资深简历顾问。请将以下工作经历改写为专业表达。 要求 1. 每条不超过40字 2. 动词开头使用强动词 3. 尽量包含量化结果若原文无数据可基于行业常识合理推断 4. 保持事实准确不要编造具体公司名或项目名 原始经历 {highlight} 目标岗位{targetRole};3.3 评分节点给简历打个可解释的分评分节点不是简单给个数字而是要给分维度评分 理由。我设计了四个维度关键词匹配度、量化程度、表达专业度、结构完整度每个维度 0-25 分总分 100。为什么这么设计因为用户看到“72 分”是懵的但看到“关键词匹配 18/25缺少目标岗位要求的 Kubernetes 经验”就知道该补什么了。这个节点同样用结构化输出const ScoreSchema z.object({ dimensions: z.array(z.object({ name: z.string(), score: z.number().min(0).max(25), reason: z.string(), })), total: z.number(), missingKeywords: z.array(z.string()), });missingKeywords是我特意加的它直接驱动后面的建议节点。评分节点把“缺什么”明确列出来建议节点就不用再猜了。3.4 条件边与重试机制让 Agent 自己决定要不要返工这是 LangGraph 最香的地方。我在评分节点后面加了一个条件边graph.addConditionalEdges(score, (state) { if (state.score.total 70 state.retryCount 2) { return polish; // 分数太低回去重新润色 } return suggest; // 分数够了进入建议节点 });retryCount 2是硬性保护。我踩过的坑是一开始没加这个限制结果模型润色完分数还是低又回去润色来回跑了七八次token 烧了一大截还卡死。加上重试上限后最多跑两轮成本可控。实操心得重试的时候最好把上一轮的评分理由也塞进润色的 prompt 里告诉模型“上次因为缺少量化被打低分这次重点补量化”。这样第二轮的成功率明显更高而不是盲目重跑。4. 流式输出与前端实时反馈怎么做4.1 用 SSE 把 Agent 进度推给前端Agent 跑一轮要十几秒如果用户盯着转圈圈体验极差。我的做法是在 Route Handler 里用ReadableStream手动推 SSEexport async function POST(req: Request) { const { rawText, targetRole } await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const send (event, data) { controller.enqueue( encoder.encode(event: ${event}\ndata: ${JSON.stringify(data)}\n\n) ); }; send(status, { step: parsing }); const parsed await runParseNode(rawText); send(status, { step: polishing }); const polished await runPolishNode(parsed, targetRole); send(status, { step: scoring }); const score await runScoreNode(polished, targetRole); send(result, { parsed, polished, score }); controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }前端用EventSource或者fetchReadableStream消费。我用的是后者因为EventSource只支持 GET而我要传 JSON body。4.2 前端状态机怎么跟 Agent 节点对齐前端我维护了一个简单的状态机跟 Agent 的节点一一对应const [stage, setStage] useState(idle); // idle - parsing - polishing - scoring - done每收到一个status事件就更新stageUI 上对应显示不同的骨架屏和文案。这里有个细节不要用 loading 转圈要用进度条 文案。实测用户对“正在润色第 3 条经历”这种具体反馈的耐心远高于一个转圈的 spinner。4.3 中途取消怎么处理用户可能等不及想重新输入。我在前端加了一个 AbortController取消时直接中断 fetch服务端检测到req.signal.aborted就停止后续节点。LangGraph 本身支持传入signal但要注意每个节点内部如果有多次模型调用得手动检查signal.aborted否则取消不彻底。5. 常见问题与排查实录5.1 结构化输出偶尔失败怎么办这是最高频的问题。表现是模型返回的 JSON 缺字段或者类型不对。我的排查顺序是现象可能原因解决方式字段缺失输入太长被截断切分输入分段解析后合并类型错误模型把数字写成字符串Zod 加.coerce强制转换完全不是 JSON模型没走 function calling检查 schema 是否过于复杂简化嵌套偶发失败模型随机性temperature 设 0加重试独家技巧给 schema 的每个字段加.describe()把字段含义写清楚。模型对带描述的 schema 遵循度明显更高尤其是highlights这种容易理解偏的字段。5.2 重试循环停不下来前面提过根因是没设重试上限。但还有一种情况设了上限但分数一直上不去两轮跑完还是 60 分。这时候不要硬刚直接进建议节点把“当前分数偏低建议重点补充 XX”作为结果返回给用户。用户自己补充信息比 Agent 反复瞎猜更有效。5.3 流式输出在部署后失效本地跑得好好的部署到 Serverless 平台后 SSE 变成一次性返回。原因是某些平台会缓冲响应。解决办法是在响应头里加X-Accel-Buffering: no并且确保 Route Handler 用的是 Node.js Runtime 而不是 Edge。我实测在 Vercel 上Node Runtime 正确的 header 就能正常流式。5.4 token 消耗比预期高一份简历跑完整流程如果重试一次大概消耗 8000-12000 token。优化手段有三个一是解析节点用gpt-4o-mini而不是大模型二是润色节点逐条跑时复用 system prompt利用 prompt caching三是把评分节点的输入精简只传润色后的 highlights 而不是整个 parsed 对象。6. 部署与成本控制的实战经验6.1 部署选型的考量这个项目对冷启动敏感因为用户点一下就要等 Agent 跑。我对比过几种方案纯 Serverless 冷启动 1-2 秒用户能感知到常驻 Node 服务冷启动几乎为零但成本高。最后我选的是 Serverless 预热策略在流量高峰前定时打一个轻量请求保持实例温热。环境变量管理上API Key 绝对不能出现在客户端。所有模型调用都在 Route Handler 里前端只跟自己的 API 通信。这一点新手特别容易踩坑把 key 写进NEXT_PUBLIC_开头的变量里等于公开泄露。6.2 成本估算与控制按一份简历平均 10000 token、gpt-4o-mini的价格算单次成本大概在 0.01-0.02 元。如果日活 1000一天也就十几块钱。但如果用大模型成本直接翻几十倍。所以我的策略是解析和评分用 mini润色这种对表达质量要求高的用稍好的模型混合搭配。另外加了一个简单的限流同一 IP 每分钟最多 5 次请求。不是为了防攻击是防止有人写脚本刷把成本刷爆。6.3 后续可以扩展的方向这个 Agent 骨架其实很通用。把节点换一换就能做求职信生成、面试问题预测、岗位匹配度分析。我最近在试的是加一个“模拟面试官”节点基于简历内容生成追问问题用户回答后再给反馈。LangGraph 的状态图让这种扩展变得很自然——加节点、加边就行不用重构。我个人在实际操作中的体会是AI Agent 的难点从来不是调模型而是把业务流程拆成清晰的节点并且设计好状态流转和失败兜底。模型能力再强流程设计得烂产品照样不能用。LangGraph.js 的价值就在于它逼着你把流程想清楚而不是把所有希望寄托在一句“万能 prompt”上。