做AI Agent,最怕的不是模型不聪明,而是整个流程不受控。最近我基于Next.js和LangGraph.js落地了一个简历工具AI Agent,从上传简历、解析内容、评估匹配度、生成优化建议,到最终输出一份针对目标岗位的定制简历,整个Agent不是简单的一问一答,而是一条有状态、有分支、能中途停下来问用户要信息的任务流水线。这篇文章把完整落地过程拆开讲:为什么选这套组合、状态图怎么设计、每个节点怎么实现、在Next.js里怎么用SSE把过程推给前端,以及实际踩坑后的修复方案。适合正在用大模型做工具型产品的同学,尤其是想弄明白LangGraph.js怎么用在真实业务里、而不是只停留在demo阶段的人。
1. 为什么是Next.js + LangGraph.js?先想清楚再动手
1.1 Next.js真的只是前端框架吗?
很多人一听到Next.js,第一反应是“React脚手架、SSR、SEO友好”。但做简历工具这个场景,我更看重的是它能把前后端放在同一个代码仓里,并且原生支持Route Handler、Server Action、流式响应和文件上传处理。简历Agent不是一个纯粹的聊天页面,它需要接收PDF、调用LLM、维护会话状态、把Agent的中间步骤实时吐给前端。如果用纯Vite做前端,再另起一个Express或FastAPI服务,也完全可行,但多一套服务就多一层部署、鉴权和联调成本。
Next.js在App Router模式下可以用app/api/agent/route.ts直接暴露一个POST接口,接口里跑LangGraph的graph,前端用同一个域名请求,跨域问题直接不存在。同时设置export const runtime = "nodejs"后,Node端的Buffer、文件流、pdf-parse这些库都能正常跑。Edge Runtime虽然启动快,但对很多传统Node库不友好,简历解析偏偏又离不开这些库,所以这一步选型很关键。
另外,简历Agent通常需要一次性返回结构化结果,但耗时可能十几秒甚至几十秒。Next.js的Route Handler可以返回ReadableStream,用text/event-stream做SSE推流,前端就能像看进度条一样看到Agent当前在做什么。这种体验是传统请求-响应模式给不了的。
1.2 LangGraph.js凭什么比手写状态机强?
LangGraph.js是LangChain生态里的状态图框架。它把Agent定义成一张有向图:节点(Node)负责干活,边(Edge)决定下一步走到哪,状态(State)在节点之间传递。相比自己写一个while循环、靠改Prompt来维持上下文,LangGraph最大的优势是可控制、可检查点、可恢复。
类比一下,手写Agent流程就像临时起意做一道复杂的菜,炒到一半发现缺食材,只能搁置,前面做过什么都含糊。LangGraph则像一份带“检查点”的菜谱:每一步做完都能保存现场,缺食材时暂停,等你买完食材回来继续炒。简历工具里有一种典型场景——模型解析完简历后,发现“工作年限描述不清”或者“目标岗位只有一个模糊方向”,这时Agent应该停下来,问用户一句“请补充X信息”,而不是自顾自地生成一堆假设。LangGraph里的Interrupt机制就是为这种“人机协作”设计的,可以暂停、持久化状态、等待用户回答后再恢复执行。
此外,LangGraph的StateGraph支持条件边,可以做到真正的“图状流程”:解析失败走“重新上传”分支,信息不足走“澄清”分支,信息完整走“评估”分支。这不是简单的链式调用能实现的。
1.3 AI Agent在简历场景中的边界
简历工具听起来像个聊天机器人,但并不能把它做成一个自由对话的bot。用户核心需求很直接:给我一份针对目标岗位的优化简历。所以Agent应该是一个任务型Agent,目标明确,步骤可拆解:解析→评估→澄清→建议→生成→导出。
在范围控制上,我把“自动投递”“实时抓取职位”“模拟面试”这些边缘功能都砍掉了,只保留一个MVP闭环。因为AI Agent最忌讳的是能力边界模糊,什么都接,最后每个环节都不稳定。清晰界定Agent的职责后,状态图的设计和测试也会容易很多。
2. 整体设计:把简历工具拆成一张状态图
2.1 核心状态定义与数据Schema
LangGraph的第一步是定义全局状态。在TypeScript里,可以用LangGraph的Annotation来声明每个状态字段,以及它们如何合并。这一步非常关键,状态字段写错了,后面所有节点都会跟着乱。
下面是我在简历Agent里用的状态定义:
import { Annotation } from "@langchain/langgraph"; const ResumeState = Annotation.Root({ // 原始上传文件转出的文本 rawText: Annotation<string>, // 结构化后的简历信息 parsedResume: Annotation<Record<string, any>>, // 用户填写的目标岗位/岗位描述 jobDescription: Annotation<string>, // 澄清问题与用户回答 clarifications: Annotation<{ question: string; answer?: string }[]>, // 评估结果 evaluation: Annotation<Record<string, any>>, // 优化建议列表 suggestions: Annotation<Array<Record<string, any>>>, // 最终生成的新简历 Markdown generatedResume: Annotation<string>, // 对话消息流 messages: Annotation<any[]>({ reducer: (left, right) => left.concat(right), }), });这里最容易踩的坑是messages。如果只写Annotation<any[]>,新消息会把旧消息整体覆盖掉。必须给一个reducer,告诉LangGraph“新老消息要拼接,而不是替换”。Annotation的底层逻辑是:后一个节点返回的状态片段,按每个字段的reducer合并到全局State上。没有定义reducer的字段,默认是“后者覆盖前者”。
2.2 Agent主流程与节点划分
简历Agent的整体流程可以拆成6个节点,加上若干条件边:
| 节点 | 职责 | 输出 |
|---|---|---|
parseResume | 解析上传的PDF/文本,提取结构化信息 | parsedResume |
evaluateResume | 结合岗位JD,评估简历匹配度 | evaluation |
clarify | 如果信息不足,中断并询问用户 | clarifications |
generateSuggestions | 生成逐条改进建议 | suggestions |
generateResume | 根据建议和原始信息生成新简历 | generatedResume |
exportResume | 转换成Word/PDF下载格式 | 文件链接 |
流程从用户上传简历和目标岗位开始:
parseResume解析原始文本。如果解析结果为空,走条件边回到上传入口,让用户重新提交。evaluateResume读取岗位描述,对简历做多维评分。如果评估发现关键字段缺失(比如没有工作年限、没有联系方式),走clarify节点。clarify节点通过Interrupt暂停执行,等待用户补充信息。拿到回答后,把回答写入clarifications,然后回到evaluateResume重新评估。- 信息和评分都完整后,进入
generateSuggestions,生成具体可执行的建议。 generateResume根据原始简历和建议生成新的Markdown简历。exportResume将Markdown渲染成模板,再导出为文档文件。
用LangGraph的术语来说,clarify到evaluateResume是一条“回边”(back edge),这正好是普通链式调用做不到的。图结构允许Agent在执行过程中跳回之前的节点,重新处理更新后的状态。
2.3 为什么用图而不是用链
LangChain里有一种常见的Chain概念,或者说LCEL管道,节点之间是固定顺序、单向流动的。对付简单的QA没问题,但简历工具这种“可能要停下来问用户,问完还要重新评估”的场景,链就卡壳了。
图比链多了两个关键能力:循环和分支。循环让Agent可以“自我修正”,比如澄清之后重新评估;分支让Agent可以应对不同的输入情况,比如解析失败和解析成功走完全不同的路径。LangGraph的addConditionalEdges可以基于状态动态决定下一步节点,逻辑上相当于把if/else显式建模成了图的一部分。
在实际运行中,这种显式建模带来的最大好处是“可解释”。每次执行完,你可以打印出当前节点、下一节点和状态变化,方便定位问题。手写while循环往往一跑起来就变成黑盒。
3. 核心节点实现:从模型调用到工具调用
3.1 简历解析节点:不只是把PDF变成文本
简历解析如果只做“PDF转文本”就太粗糙了。真正的解析是要把一段无结构的文本,变成姓名、联系方式、教育经历、工作经历、技能这些字段。我采用的方案是:先用pdf-parse抽取文本,再用大模型做结构化输出。
import { NextResponse } from "next/server"; import { ChatOpenAI } from "@langchain/openai"; import { z } from "zod"; const ResumeSchema = z.object({ name: z.string(), contact: z.object({ email: z.string().optional(), phone: z.string().optional(), github: z.string().optional(), }), education: z.array(z.object({ school: z.string(), major: z.string(), period: z.string(), })), workExperiences: z.array(z.object({ company: z.string(), role: z.string(), period: z.string(), achievements: z.array(z.string()), })), skills: z.array(z.string()), }); async function parseResumeNode(state: ResumeState) { const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0, }).withStructuredOutput(ResumeSchema); const text = await extractPdfText(state.rawText); const parsed = await model.invoke( `请将以下简历文本解析为结构化JSON。只提取原文中明确存在的信息,不要推断,不要补全。\n简历文本:\n${text}` ); return { parsedResume: parsed, rawText: text, }; }这里temperature: 0非常重要。解析节点要的是稳定输出,不是创造力。另一个关键是prompt里明确“只提取原文中明确存在的信息,不要推断”,否则模型会脑补一段不存在的阿里工作经历。
3.2 评估与优化建议节点
评估节点可以做成纯结构化输出,也可以做成工具调用。我推荐工具调用的方式,因为后续还要复用评估工具做别的事情,比如对比多份简历。
先定义一个工具:
import { DynamicStructuredTool } from "@langchain/core/tools"; const evaluateResumeTool = new DynamicStructuredTool({ name: "evaluate_resume", description: "根据目标岗位JD,从关键词覆盖、成果量化、结构清晰度三个维度评估简历,输出评分和改进方向", schema: z.object({ keywordScore: z.number().min(0).max(10), quantifiedScore: z.number().min(0).max(10), structureScore: z.number().min(0).max(10), missingFields: z.array(z.string()), suggestion: z.string(), }), func: async ({ keywordScore, quantifiedScore, structureScore, missingFields, suggestion }) => { return JSON.stringify({ keywordScore, quantifiedScore, structureScore, missingFields, suggestion, }); }, });然后让模型决定何时调用这个工具。LangGraph的节点里可以绑定工具,再让模型在循环中调用:
async function evaluateResumeNode(state: ResumeState) { const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0.2, }).bindTools([evaluateResumeTool]); const result = await model.invoke([ { role: "system", content: `你是一位资深HR。请结合目标岗位JD评估下面这份简历,并调用工具返回结果。目标岗位:${state.jobDescription}`, }, { role: "user", content: `简历内容:${JSON.stringify(state.parsedResume)}` }, ]); const toolCall = result.tool_calls?.[0]; const args = toolCall?.args; return { evaluation: { keywordScore: args.keywordScore, quantifiedScore: args.quantifiedScore, structureScore: args.structureScore, missingFields: args.missingFields, }, }; }评估提示词里建议加入HR视角的细则:关键词覆盖不是“越多越好”,而是看JD里出现的核心硬技能是否命中;成果量化则关注有没有数字、动词和影响范围。比如“负责推广活动”得2分,“主导校园推广活动,覆盖3000+学生,注册转化率提升23%”得9分。
3.3 澄清节点:LangGraph.js的interrupt怎么做人机交互
简历场景里,信息缺口很常见。用户上传的简历可能没有写期望薪资,或者岗位目标写的是“随便”;又或者评估后发现教育经历时间线缺失。这时候Agent不应该硬着头皮往下走,而是应该停下来问用户。
LangGraph.js里实现这个动作非常简洁:
import { Interrupt } from "@langchain/langgraph"; async function clarifyNode(state: ResumeState) { const missing = state.evaluation.missingFields; if (!missing || missing.length === 0) { return {}; } return new Interrupt( missing.map((field: string) => `请补充:${field}`) ); }节点返回Interrupt对象后,graph的执行会立即停止,当前状态通过checkpointer持久化保存下来。前端会收到一个特殊的中断事件,然后向用户展示“请补充:工作年限”这样的问题。用户输入回答后,前端调用graph.resume(threadId, payload)恢复执行,从刚才中断的地方继续往下走,而不是从头开始。
这里需要特别说明:resume的payload会作为Interrupt节点的下一轮输入。所以在设计clarifyNode时,如果检测到state.clarifications已有回答,就不要再返回新的Interrupt,而是返回一个空对象,让流程按边走到后续节点。
3.4 生成与导出节点
生成节点负责把评估建议落到一份新的简历里。为了保证输出格式稳定,我用了一个固定的结构化模板,让模型按Markdown输出。
async function generateResumeNode(state: ResumeState) { const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0.4, }); const result = await model.invoke( `基于原始简历信息和优化建议,生成一份新的简历。要求: 1. 只使用原始简历中出现的事实信息,可以用建议中的措辞优化描述,但不能新增经历。 2. 按以下Markdown模板输出: # 姓名 ## 联系方式 ## 教育经历 ## 工作经历 ## 技能清单 3. 突出与目标岗位JD匹配的关键词。 原始简历:${JSON.stringify(state.parsedResume)} 评估建议:${JSON.stringify(state.suggestions)} 目标岗位:${state.jobDescription}` ); return { generatedResume: result.content as string }; }导出节点就简单很多了。前端拿到Markdown后,用react-markdown预览;用户确认后,点击“导出Word”按钮,调用一个Route Handler把Markdown转成docx。我这里用的是html-to-docx,本质是把Markdown先渲染成HTML,再转成Word文件。转换过程不会消耗LLM的token,所以放到前端或轻量接口都行。
4. Next.js 接入:让Agent在Web里跑起来
4.1 API Route如何编排LangGraph
在Next.js App Router下,我建了一个app/api/agent/route.ts,专门负责接收前端请求,调用LangGraph graph,并把执行过程以SSE流的形式返回。
export const runtime = "nodejs"; export async function POST(req: Request) { const { threadId, message } = await req.json(); const graph = getOrCreateGraph(); const stream = await graph.stream( { messages: [{ role: "user", content: message }] }, { configurable: { thread_id: threadId } } ); const encoder = new TextEncoder(); const readable = new ReadableStream({ async start(controller) { try { for await (const step of stream) { const event = `data: ${JSON.stringify(step)}\n\n`; controller.enqueue(encoder.encode(event)); } } catch (error) { controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: String(error) })}\n\n`)); } finally { controller.close(); } }, }); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); }有几个细节要注意。第一,runtime必须显式声明为nodejs。Edge Runtime里pdf-parse和很多Buffer相关的操作会直接报错。第二,SSE格式里每条数据必须以\n\n结尾,否则前端getReader()解析时会出现粘包。第三,graph.stream()拿到的step结构是{ nodeName: state }或类似结构,前端可以借此展示“当前正在执行哪个节点”。
4.2 服务端状态存储与历史会话
LangGraph的checkpointer机制是整个Agent可控性的灵魂。如果编译graph时不指定checkpointer,中断、恢复、历史会话都会失效。开发阶段可以用内存版本:
import { InMemoryCheckpointSaver } from "@langchain/langgraph/checkpoint/memory"; const graph = new StateGraph(ResumeState) .addNode("parseResume", parseResumeNode) // ... addNode 和 addEdge .compile({ checkpointer: new InMemoryCheckpointSaver() });但Next.js部署在Serverless环境时,内存并不稳定。生产环境我建议用Redis实现checkpointer,@langchain/langgraph-checkpoint-redis。每个用户开一个会话,用thread_id作为Redis的key。这样用户刷新页面、甚至隔天再回来,还能继续之前的Agent流程。
调用时传入config:
await graph.invoke(initialState, { configurable: { thread_id: userId, }, });这里有个经验:简历工具里一个用户同一时间段可能只需要一个thread_id,所以可以直接把userId作为threadId使用,省去建会话表的麻烦。当然,如果要做“同一用户多份简历对比”,就需要额外维护一个thread_id列表。
4.3 前端交互组件设计
前端我用了Next.js的App Router,“use client”组件里负责发请求和解析SSE流。核心代码不复杂,但要小心编码问题。
"use client"; async function runAgent(message: string) { const res = await fetch("/api/agent", { method: "POST", body: JSON.stringify({ threadId: userId, message }), }); const reader = res.body!.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split("\n\n"); buffer = events.pop() || ""; for (const event of events) { if (!event.startsWith("data: ")) continue; const data = JSON.parse(event.slice(6)); // 更新当前步骤状态 setSteps(prev => [...prev, data]); } } }UI上我分成三块:左侧是上传/简历预览,中间是聊天式交互区,右侧是Agent步骤可视化。步骤可视化很有意思,LangGraph的每次stream事件都会告诉我们当前走到哪个节点,前端用一个列表把“正在解析简历”“正在评估匹配度”“需要补充信息”这些状态实时展示出来。用户能看到Agent不是黑盒,而是一步一步在执行,信任感一下就上来了。
5. 踩坑实录与常见问题排查
5.1 状态reducer写错导致messages被覆盖
这是我遇到的第一个坑。最初我把messages字段简单写成:
messages: Annotation<any[]>结果每次节点返回新消息时,旧消息全被顶掉了。LangGraph对没有reducer的字段默认“覆盖”,而不是“追加”。虽然很多节点并不关心历史消息,但一旦涉及多轮澄清、多次评估,旧消息丢失会让模型失去上下文。
修复方法就是前面写的,给messages加一个拼接reducer:
messages: Annotation<any[]>({ reducer: (left, right) => left.concat(right), }),所以设计State时,每个字段都要思考:这个字段是“一次性快照”还是“累加记录”。比如evaluation是快照,直接覆盖没问题;messages和clarifications是累加记录,需要reducer。
5.2 Interrupt恢复不当:重复执行澄清节点
第二个坑出在恢复执行时。前端拿到Interrupt事件后,我直接调用了graph.invoke重新传入用户回答,结果Agent又从第一个节点开始,而不是从暂停的地方继续。
后来才意识到,恢复执行必须用同一个thread_id,并且调用graph.resume(threadId, payload),而不是invoke。resume会从checkpoint读取之前保存的graph状态,把payload作为中断节点的输入。如果误用invoke,等于重新开始一个全新会话,之前的解析、评估结果全部丢失。
另外要注意,如果用户在一次中断后连续补充了多个信息,可以用数组型的payload,或者在clarifyNode里设计循环:每次都只问一个最关键的问题,问完再评估,还有缺失就再问。这样虽然交互次数多,但每一步都更可靠。
5.3 流式输出在Next.js上的三种坑
第一次联调SSE时,前端偶尔会收到乱码,排查后发现是三个问题叠加。
第一,没有设置export const runtime = "nodejs",默认的Edge Runtime把pdf-parse相关代码搞崩了。只要涉及Buffer、文件流或Node内置模块,都要显式指定Node运行时。
第二,SSE事件没有以\n\n结尾,多个事件粘在一起,前端按行分割时就错位。解决方法是服务端每条事件统一用data: xxx\n\n格式。
第三,前端用reader.read()读流时,decode要使用TextDecoder("utf-8"),并且stream: true。如果一次性解码,碰到中文字符跨buffer边界会乱码。
5.4 模型幻觉与简历数据校验
模型在生成优化简历时,最容易出现的问题就是“润色过度”——把“参与项目”扩写成“主导项目,提升效率35%”。这种幻觉在简历场景里是致命的,用户可能没仔细看就下载了,结果面试时被问得露馅。
我的对策有三层:
- 解析节点:约束只提取原文信息,不推断。
- 生成节点:Prompt里明确“只能用原始简历中出现的事实信息”。
- 校验节点:生成后对结果做一次自动检查,用LangGraph加一个
validateResumeNode,对比生成内容里的公司名、日期、职位关键词是否都在原始parsedResume里出现过。如果发现新增内容,就返回generateResume重新生成一次,并在Prompt里标注“上次生成违反了事实约束”。
这个校验节点不一定是大模型,我实验下来用简单的集合匹配就够了。比如提取生成文本里的公司实体,查是否在原始文本的实体集合里。虽然笨,但可解释、运行快。
6. 落地后的性能、效果与扩展方向
6.1 实测流程耗时与token成本
在正式环境里跑了几十轮测试,一个完整的简历优化流程(上传简历→解析→评估→澄清→建议→生成)平均耗时约35秒,其中大头是模型调用,大概占总耗时90%。token消耗视简历长度不同,大概在8k到15k之间,用gpt-4o-mini跑解析和评估、gpt-4o跑最终生成,成本一次大约几毛钱人民币。如果全部用gpt-4o-mini,成本还能再降一半,但生成质量会略差。
这里有个优化策略:解析节点用gpt-4o-mini足够,因为结构化抽取任务相对固定;最终生成节点建议用更强的模型,因为用户感知到的直接产物就是这份简历。
6.2 效果表现与用户反馈
用户最满意的点反而不是简历生成得有多“华丽”,而是整个流程每一步都看得见。特别是Interrupt澄清机制,当模型发现简历里没有联系方式时,会主动说“请补充手机号或邮箱”,而不是默默地输出一份不完整的简历。这种“主动承认信息不足”的交互,比假装一切正常更能赢得信任。
当然也有翻车案例:某些排版花哨的PDF,文字提取顺序很乱,导致教育经历和工作经历串行。后来我在解析节点前面加了一步“文本清洗”,把乱序文本按常见简历板块重新分段,效果提升明显。
6.3 后续扩展方向
做完这个MVP后,我发现LangGraph的图结构非常适合继续扩。比如可以加一个“招聘方视角”节点,让Agent同时扮演HR和求职者,对同一份简历给出两方评价,再交叉综合;也可以接入外部搜索,自动获取目标岗位的最新要求。另一个有意思的方向是“多Agent协作”:一个Agent负责拆解JD,一个Agent负责改写简历,两个Agent之间通过状态图同步信息。
我个人最满意的一点是:整个流程的每一步都有迹可循,用户可以随时介入。工具型Agent最值钱的地方,不是替用户做所有决定,而是把决定权交还给用户。这套Next.js + LangGraph.js的组合,恰好让这个理念落地得很舒服。