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 流程设计的。
第二个是条件分支。比如评分低于某个阈值时,要回到润色节点重新来一遍,而不是直接给用户。手写这个循环很容易出 bug,LangGraph 用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 的 JSON:
import { 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手动推 SSE:
export 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或者fetch+ReadableStream消费。我用的是后者,因为EventSource只支持 GET,而我要传 JSON body。
4.2 前端状态机怎么跟 Agent 节点对齐
前端我维护了一个简单的状态机,跟 Agent 的节点一一对应:
const [stage, setStage] = useState("idle"); // idle -> parsing -> polishing -> scoring -> done每收到一个status事件就更新stage,UI 上对应显示不同的骨架屏和文案。这里有个细节:不要用 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”上。