最近终于把折腾了快一个月的项目收尾了——一个用 Next.js + LangGraph.js 搭的简历工具 AI Agent。简单说,用户上传一份 PDF 简历,填上目标岗位,这个 Agent 会自动完成解析、评估、改写、导出这一整套流程,最后返回一份排版干净、突出亮点的 Markdown 简历。这个项目既是给身边朋友用的实用工具,也是我验证 LangGraph.js 落地能力的一个实验场。如果你正在纠结"AI Agent 到底怎么落地""LangGraph.js 和 Next.js 怎么配合",这篇文章应该能给你一些可复用的思路。
1. 为什么我会做这个简历 AI Agent
1.1 被简历投递折磨出来的需求
事情的起因特别朴素:我一个朋友投了两个月简历,面试邀约寥寥无几。我帮他看了简历,第一眼就发现问题——技能栈写在最后、项目经历全是"负责"开头、量化结果一个都没有。典型的技术型简历通病,不是能力不行,是表达方式太吃亏。
市面上简历优化工具不少,但大多数是模板生成器,要么让用户填一堆表单,要么就是把内容丢给大模型一次性重写。前者太机械,后者太粗暴。为什么?因为一份好简历不是"重写"出来的,而是"评估"出来的。你需要先知道哪里弱,再针对性地改。而且每个人对简历的预期不一样:有人要投外企,有人要投国企,有人想转行,有人只想在现有方向上升级。一个固定的提示词模板根本覆盖不了这些场景。
所以我想要的东西很明确:用户只管上传简历、输入目标岗位,剩下的解析、诊断、生成建议、重写、导出都由程序自动搞定。这正好是 AI Agent 该干的活——不是单次调用大模型,而是让模型在多个步骤之间做决策、调用工具、维护上下文。于是我决定自己写一个。
1.2 从提示词脚本升级到真正的 Agent 架构
一开始我确实只写了一组提示词:把简历文本塞进去,让模型输出优化后的版本。试了几天发现三个痛点没法绕开。
第一,PDF 解析不能靠模型。把 PDF 直接转成文本丢给大模型,格式乱得一塌糊涂,表格变成一串缩进,项目时间线全错位。第二,一次重写没有中间过程,模型经常"自作主张"把工作经历改成夸大其词,甚至出现原简历根本没有的荣誉奖项。这对求职者是大忌。第三,没有持久状态。用户调整一个关键词,整个对话就要重新开始,Token 消耗爆表,体验也割裂。
这些问题指向同一个结论:我需要的不是一个"更强的提示词",而是一个能分步执行的 Agent。它应该先解析简历,再诊断问题,然后给出修改建议,最后才是重写。每一步都要有工具支撑,每一步的输入输出都要被结构化保存。这就是 LangGraph.js 最擅长的场景。
2. 技术选型与整体架构拆解
2.1 为什么前端框架选了 Next.js
在项目初期,我认真考虑过纯前端方案:React SPA + 后端 FastAPI,或者干脆 Next.js 全栈一把梭。最后选了 Next.js,而且是 App Router,原因有三个。
一是服务端能力。简历解析、大模型调用、文件导出这些操作都需要服务端权限和密钥保护,Next.js Route Handler 天然支持把这些逻辑放在服务端,不需要单独维护一个后端项目。二是流式传输。AI Agent 执行一次任务通常在几秒到几十秒之间,用户不可能干瞪眼等进度,Next.js 对 ReadableStream 的支持让服务端到浏览器的流式推送变得非常简单,配合原生 fetch 就能逐字渲染。三是部署心智负担小。一个项目同时包含前端页面、API 路由、静态资源,推上 Vercel 就完事,省掉了前后端分离项目的跨域、环境变量同步、容器编排一堆问题。
当然,纯 SPA 也能做,但你需要额外解决 API 服务部署、CORS、多环境配置这些问题。对于个人项目来说,少一个服务就少一堆坑。Next.js 的"全栈单项目"模式在这个体量下非常舒服。
2.2 LangGraph.js 解决了状态与循环的痛点
如果你只用 LangChain.js 写过简单的链式调用(chain),你会发现它本质上是"线性管道":prompt 进,结果出,再进下一步。流程是死的,很难做条件跳转和循环。而真实的 Agent 场景充满变数:简历解析可能失败,模型可能认为不需要重写,用户可能中途修改目标岗位。这些都要让流程"活"起来。
LangGraph.js 的核心抽象是图:节点(Node)是执行单元,边(Edge)是流转关系,状态(State)是节点间共享的数据。你可以把整个 Agent 想成一条自动化流水线,状态就是传送带上的工件,每个节点是一个工位,做完指定操作再把工件放回传送带。关键是 LangGraph.js 支持条件边,也就是说,节点可以根据当前状态决定下一条走向,甚至把流程拉回去重跑。这正好契合我的需求。
我试过直接用 React 的 useReducer 手动管理状态、用 if/else 写控制流,前期还行,一旦新增节点和分支,代码立刻变成面条。LangGraph.js 让我把"流程控制"和"业务逻辑"彻底拆开,业务代码就是纯函数,编排逻辑交给图定义。后续改流程,只需要加节点或者改边的走向,不需要动核心逻辑。
2.3 整体流程与节点划分
这个 Agent 的完整执行链路是这样的:
- 接收用户上传的 PDF 简历和目标岗位描述
- 调用文件解析工具,把 PDF 转为结构化文本
- 诊断节点:分析简历弱点,按"结构完整性、内容量化、技能匹配度、表达质量"四个维度打分
- 判断节点:如果简历质量高于用户设定的阈值,直接进入生成节点;否则进入优化建议节点
- 优化建议节点:生成逐条修改建议,并选择性重写简历
- 生成节点:将原始简历信息与模型增强后的内容合并,渲染为 Markdown
- 返回结果并保存历史记录到数据库
用户看到的是一步步推进的过程,而不是一次"黑盒调用"。这个透明感很重要,因为简历修改必须可追溯,每一条改动都要让用户觉得合理。
3. 核心实现:状态、节点与工具
3.1 LangGraph.js 状态定义:整个 Agent 的共享便签
状态是 LangGraph.js 的灵魂。我刚才说状态是传送带上的工件,更准确地说,它是所有节点都能读写的共享便签。在代码里,我这样定义:
import { Annotation } from "@langchain/langgraph"; export const AgentState = Annotation.Root({ // 原始上传信息 originalFileName: Annotation<string>(), fileText: Annotation<string>(), targetJob: Annotation<string>(), // 解析结果 parsedResume: Annotation<ResumeData>({ reducer: (prev, next) => next ?? prev, }), // 诊断结果 diagnostics: Annotation<DiagnosisResult[]>({ reducer: (prev, next) => next ?? prev, }), // 重写后的简历文本 rewrittenResume: Annotation<string>(), // 当前 Agent 步骤名,用于前端进度展示 currentNode: Annotation<string>({ reducer: (prev, next) => next ?? prev, }), });每个 Annotation 都有独立的 reducer,决定这个字段如何被新值更新。默认是"覆写",但也可以定义成"合并"或"追加"。我没有把整个模型消息历史放进状态里,因为简历场景中,各节点关心的字段差异很大——解析节点关心 fileText,诊断节点关心 parsedResume,生成节点关心 diagnostics 和 rewrittenResume。把字段拆细,可以让每个节点只读写自己需要的那部分,减少 Token 重传,也避免状态膨胀。
有一个细节容易踩坑:LangGraph.js 的状态是不可变更新,如果你在 reducer 里直接对自己传入数组做push,会影响上一次的状态快照,导致回放和断点续跑时数据错乱。正确做法是先复制再返回新数组:
reducer: (prev: DiagnosisResult[], next: DiagnosisResult[]) => { return prev ? [...prev, ...next] : next; }3.2 三个核心节点:解析、诊断、生成
节点就是普通的 async 函数,接收当前 state,返回部分状态更新。我写了三个核心节点。
解析节点负责把上传的 PDF 转成纯文本,同时保留必要的结构信息。这里我主要做了三件事:提取文本、识别标题与段落、标记时间线与公司名。解析逻辑不复杂,但坑比较多,后面单独说。
诊断节点是重头戏。它读取 parsedResume 和目标岗位,调用大模型,输出结构化的诊断结果。我要求模型严格返回 JSON,格式如下:
{ "overallScore": 68, "dimensions": [ { "name": "结构完整性", "score": 80, "comment": "基本信息完整,但技能描述过于单薄" }, { "name": "内容量化", "score": 55, "comment": "只有 2 处使用数字,缺乏成果指标" } ], "suggestions": [ "将 '负责系统开发' 改为 '主导 3 个核心模块开发,系统响应时间降低 40%'" ], "shouldRewrite": true }为了强制模型输出合法 JSON,我用withStructuredOutput或.bindTools()绑定 JSON Schema,而不是单纯地写"请输出JSON"。实际测试下来,前者的失败率低很多。很多人在这一步偷懒,结果后面解析 JSON 时经常遇到多余说明文字、换行丢失、字段名变形等问题,排查起来非常浪费时间。
生成节点负责把原始经历和模型润色后的表达合并成最终简历。我强调的是"合并"而不是"重写"。做法是让模型基于诊断建议逐条修改,而不是直接甩出全文。这样每个改动点都有源可溯。最终输出的是 Markdown 字符串,前端用轻量级渲染组件展示,并提供复制和导出功能。
3.3 条件边:让 Agent 学会"决定是否需要重写"
LangGraph.js 里最让我觉得值回票价的是条件边。代码这样写:
import { StateGraph } from "@langchain/langgraph"; const workflow = new StateGraph(AgentState) .addNode("parse", parseNode) .addNode("diagnose", diagnoseNode) .addNode("rewrite", rewriteNode) .addNode("generate", generateNode) .addEdge("__start__", "parse") .addEdge("parse", "diagnose") .addConditionalEdges( "diagnose", (state) => { const shouldRewrite = Boolean(state.diagnostics?.some( (d) => d.score < state.rewriteThreshold || d.suggestions.length > 0 )); return shouldRewrite ? "rewrite" : "generate"; }, { rewrite: "rewrite", generate: "generate", } ) .addEdge("rewrite", "generate") .addEdge("generate", "__end__"); export const graph = workflow.compile();这里的设计逻辑是:诊断节点不只是打分,还充当"决策者"。如果诊断分数低于阈值或有修改建议,就走重写分支,否则直接生成最终文件。这个判断如果不放到图里,你就必须在 diagnoseNode 内部写分支调用别的函数,图结构就乱了。把它们拆开以后,我在前端展示流程时非常干净:每个节点名称都是状态的一部分,可以直接映射到前端进度条。
4. 工具集成:简历解析与文档生成
4.1 PDF 解析:没有想象中简单
简历上传格式最常见的就是 PDF。解析 PDF 文本的库不少,我对比了几个,最终选用了 unpdf。它在 Node.js 环境和边缘运行时都比较稳定,API 也很简洁:
import { extractText, extractMetadata } from "unpdf"; const buffer = await file.arrayBuffer(); const { text } = await extractText(new Uint8Array(buffer));选 unpdf 而不是 pdf-parse,主要原因是让我在 Next.js 的 Vercel 部署中少踩了很多沙箱环境的坑。pdf-parse 内部依赖一些 Node.js 的 Buffer 隐式调用,在边缘运行时经常报错,unpdf 则相对干净。
但无论用哪个库,PDF 解析的痛点都在"解析质量"上。很多简历是 Canva、WPS 导出的,文字被切成零散的片段,表格结构丢失,甚至出现阅读顺序错乱。我处理方案是两步:先用库抽文本,再用规则做乱序修复。比如以"项目经历""工作经历""教育背景"等关键词为锚点,把乱序片段重新分组拼接。这一步很糙,但能显著提升后续大模型诊断的准确率,值得做。
4.2 生成 Markdown 简历文件
诊断和重写结束后,生成节点输出 Markdown 字符串。这个选择带来两个直接好处:一是前端渲染简单,字符串转 HTML 的库很成熟;二是用户可以复制到任何在线文档,再转成 PDF 或 Word。
我还加了一个导出 DOCX 的能力。项目里用了docxnpm 包,把 Markdown 解析成文档结构,再生成二进制文件。这条链路的细节是:Markdown 转 DOCX 的排版必然有偏差,尤其是列表缩进和表格宽度。我最后妥协成"导出 DOCX 只保证内容完整,推荐用户复制 Markdown 自己微调"。这也符合实际场景,简历这种文档,用户大概率要手动改几次。
4.3 工具选型对比
| 需求 | 我用的方案 | 备选方案 | 选择理由 |
|---|---|---|---|
| PDF 文本抽取 | unpdf | pdf-parse、pdf.js | 边缘运行时兼容性好,API 简洁 |
| 结构化数据校验 | zod | 手写校验函数 | 类型自动推导,错误信息清晰 |
| Markdown 渲染 | react-markdown | marked + DOMPurify | 生态成熟,XSS 过滤直接可用 |
| DOCX 导出 | docx | html-to-docx | 直接操作文档对象,排版更可控 |
| 数据存储 | Vercel Postgres | SQLite、MongoDB | 与平台集成好,Serverless 友好 |
工具选型没有标准答案,但我始终坚持一个原则:尽量少引入有原生依赖的库。在 Serverless 环境里,原生模块构建失败、运行时崩溃的概率远高于纯 JS 库,每多一个原生依赖,部署就多一分不确定性。
5. 流式输出与 Next.js 前端接入
5.1 为什么要做流式输出
Agent 执行一次完整流程,在模型快速的情况下也要 10 到 20 秒。如果让用户点击按钮后白屏等待,体验几乎等于崩溃。流式输出不仅是为了"看起来快",更是为了让用户感知到系统在工作。
我选择的是 Server-Sent Events(SSE),而不是 WebSocket。原因很简单:这个场景是"服务端单向推送进度",用户不需要持续向服务端发送大量消息。SSE 基于 HTTP,天然兼容 Next.js Route Handler,实现成本远低于 WebSocket。
5.2 Route Handler 实现流式推送
核心代码如下:
// app/api/agent/route.ts import { graph } from "@/lib/agent/graph"; export async function POST(req: Request) { const { fileText, targetJob, threadId } = await req.json(); const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { const send = (event: string, data: unknown) => { controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); }; let currentStep = ""; try { const result = await graph.stream( { fileText, targetJob, parsedResume: null, diagnostics: [], rewrittenResume: "", originalFileName: fileText.slice(0, 20), currentNode: "parse", }, { recursionLimit: 10, configurable: { thread_id: threadId }, } ); for await (const chunk of result) { const stepName = Object.keys(chunk)[0]; const payload = chunk[stepName]; if (stepName === "parse") { currentStep = "解析简历结构"; send("progress", { step: currentStep, data: payload.parsedContent || {} }); } else if (stepName === "diagnose") { currentStep = "正在评估匹配度"; send("progress", { step: currentStep, data: payload.diagnostics || {} }); } else if (stepName === "rewrite") { currentStep = "重写优化"; send("chunk", { text: payload.rewrittenResume || "" }); } else if (stepName === "generate") { currentStep = "生成最终文件"; send("complete", { markdown: payload.rewrittenResume || payload.generatedFile || "" }); } else if (stepName === "__end__") { send("done", {}); } } } catch (err) { send("error", { message: (err as Error).message }); } finally { controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); }前端我用原生的fetch读取流,通过ReadableStream解析 SSE 格式。这里有个细节:SSE 的 data 字段如果包含换行,会被解析成多条 data,而我的 Markdown 文本恰恰满满都是换行。所以我选择把所有文本数据包一层JSON.stringify,放进单行 data 中,前端解析 JSON 再还原。用EventSource无法设置 POST 请求头,所以我最终用的是fetch+ 手动解析 SSE,这需要自己处理event:、data:字段的拆分逻辑。
5.3 前端交互:节点状态的可视化
前端我做了三步展示:第一步是解析阶段,展示提取出来的简历文本摘要;第二步是诊断阶段,展示四个维度的打分结果,用简单的进度条组件;第三步是重写阶段,逐字展示新简历内容。用户能看到流程走到哪一步,也能在不同步骤间切换查看。
这里我要提醒一个容易忽略的点:React 的 StrictMode 在开发环境下会重复调用 effect,如果 fetch 流式请求写在 effect 里,会出现"上一个请求还没取消,下一个请求又开始"的竞态问题。解决方法是给请求加上AbortController,组件卸载时主动取消。
6. 踩坑实录:排查和修复的过程
6.1 LangGraph.js 在 Next.js 边缘运行时的问题
我最开始把所有逻辑放在一个 Route Handler 里,默认走 Edge Runtime,结果部署到 Vercel 后报错,错误信息指向 Buffer 未定义。排查后发现是pdf相关依赖在边缘运行时不兼容。
解决方式:在 Route Handler 文件顶部声明:
export const runtime = "nodejs"; export const maxDuration = 60;注意 maxDuration 在 Vercel 免费计划上限是 10 秒,升级到 Pro 才能到 60 秒。我的 Agent 一次执行在 15 到 25 秒之间,所以这个配置必须调整,否则很容易被平台中止。如果不想升级,只能把 Agent 拆成后台任务执行,用数据库轮询结果,这是另一个架构方案。
6.2 Token 消耗失控的三种修复手段
一开始的版本把整个简历文本和岗位描述每次节点都传给模型,Token 消耗非常夸张,一次完整流程要烧掉一万多 token。后来我做了三个针对性修改:
第一,解析节点之后,只把parsedResume的结构化字段传给诊断节点,不传原始文本。第二,诊断节点和重写节点使用不同的系统提示词,诊断提示词强调输出 JSON,重写提示词强调基于建议修改,不重复总结。第三,给模型调用设置maxTokens,诊断节点限制输出 800 token,重写节点限制 2000 token,超出部分直接截断,不让模型自由发挥。
做完这三步,一次流程的 token 消耗降到 3000 左右,费用从一次几毛钱降到几分钱。这个优化对生产环境非常重要,尤其是个人项目,成本不控制好根本不敢上线。
6.3 大模型输出 JSON 失败与重试策略
用语言模型输出结构化数据,无论怎么调提示词,总会有翻车的时候。我最常遇到的问题是:输出内容中夹杂 Markdown 代码块标记(```json),导致 JSON.parse 报错;或者字段值里带了换行符没转义。
我的处理策略是"三层兜底":第一层,尝试用模型的 function calling 或工具绑定输出结构化对象;第二层,如果模型没有遵守,自动剥离代码块围栏,再用 JSON.parse 解析;第三层,如果仍然失败,重新调用模型一次,并在 prompt 中附上"上次输出格式错误,请只输出合法 JSON"的提示。实测三层兜底可以把失败率从 10% 降到 1% 以下。
6.4 中文简历的解析乱序问题
中文简历和英文简历的排版习惯差异很大。很多中文模板使用两栏布局,PDF 解析出来之后,左栏技能、右栏工作经历会交替出现。如果直接丢给模型,模型经常会混淆"技能列表"和"工作成果"。
我加了一个后处理规则:按常见简历标题词(教育背景、工作经验、项目经历、技能专长、自我评价等)拆分段落,然后把不属于任何段落的零散文本归入前一个段落的补充信息。这个规则不完美,但已经能覆盖 80% 的常见简历模板。剩下的 20%,用户可以在前端手动编辑解析结果,再进入诊断节点。
7. 部署、监控与后续扩展方向
7.1 部署配置细节
整个项目部署在 Vercel,环境变量包括 OpenAI API Key、数据库连接串、用户会话密钥。LangGraph 的线程状态我存在 Vercel Postgres 里,方便用户断点续跑。部署时有一个注意点:LangGraph.js 的检查点(checkpointer)默认是基于内存的,Serverless 环境下实例是短命的,必须配置为外部存储。我用了官方提供的 PostgresSaver:
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres"; const checkpointer = await PostgresSaver.fromConnString(process.env.DATABASE_URL!); export const graphWithMemory = graph.compile({ checkpointer });这样用户每次调用如果传同一个thread_id,就能恢复上一次 Agent 执行的状态。简历修改这个场景非常适合断点续跑:用户先让 Agent 诊断,看完建议后觉得不满意,可以修改目标岗位再继续重写,不需要从零开始。
7.2 后续扩展:从单次优化到持续服务
项目跑通之后,我想过几个进一步扩展的方向。
一个是"多版本简历管理"。用户可能同时投递不同方向,比如前端开发和全栈开发,需要维护多份侧重点不同的简历。现有数据结构可以支持:每个版本对应一个 thread_id,列表页展示所有历史版本,随时对比或回滚。
另一个是"ATS 评分模拟"。现在只是模型打分,不够客观。可以做一个规则引擎,统计关键词匹配、时间线连续性、量化指标数量,和模型诊断结果交叉验证。两个维度都有分数,更可信。
还有一个是"面试问题生成"。简历确定后,基于重写内容,生成可能的面试追问清单。这能帮用户提前准备,也算是简历工具的增值功能。但这一步需要额外调用模型,成本会增加,要控制频率。
我在实际运行中还发现一个体验细节:很多用户上传简历后,并不清楚该填什么目标岗位。后来我在前端做了一组热门岗位关键词推荐,点击即可填入。这一步简单,但显著降低了使用门槛。工具类产品有时候不是功能不够,而是入口太隐蔽。
7.3 学习路径的一点个人建议
如果你也想做类似的东西,我的建议是别先学一大堆 Agent 框架概念,直接找一个具体任务开干。AI Agent 的主流架构无非是"模型 + 工具 + 状态 + 记忆",核心难点永远是:模型输出不稳定怎么办、工具调用失败怎么降级、状态怎么持久化。这些只有在你真正跑通一个端到端项目之后才会理解。
从"能调模型"到"能完成完整任务",中间隔着的不是模型能力,而是工程能力。LangGraph.js 把流程编排的骨架给你了,但每个节点的容错、每条边的判断、每份状态的管理,还是得自己一点点打磨。
8. 最后分享一个我路上的体会
我自己最大的感受是:AI Agent 落地,60% 的精力要花在工程细节上,而不是模型调参上。流式输出的边界情况、PDF 解析的脏数据、JSON 格式的偶发错误、Serverless 环境的超时限制,这些才是真正决定一个工具能不能"涨用户"的东西。
如果让我重来一遍,我会在项目第一天就确定两件事:一是 Agent 的状态字段必须从一开始就按"每个节点只读所需字段"来设计,二是尽早用数据库做检查点存储,而不是先跑通内存模式再迁移。这两件事回头改起来非常痛苦。
这个项目现在已经稳定跑起来了,身边几个朋友试用后反馈不错。后面我会继续完善简历模板库和不同岗位的专项优化策略。如果你也在做类似的 AI 工具,欢迎一起交流,工程上踩过的坑,大多数都是相通的。