☰
从Demo到产品:用LangGraph.js和Next.js构建简历AI Agent
2026/10/28 18:11:44 网站建设 项目流程

把AI Agent从一个能跑的Demo,做成一个用户每天愿意打开用的产品,最考验人的往往不是模型有多聪明,而是工程链路有没有把每个细节都兜住。这篇文章要聊的项目,是一套基于Next.js和LangGraph.js实现的简历工具AI Agent——用户上传简历,系统自动解析内容、评估亮点与短板、给出修改建议,甚至直接重写一版措辞更专业的简历。我从零开始搭,把完整闭环跑通了,今天把技术选型、架构设计、核心代码和排坑过程全部复盘一遍,给正在做Agent落地的朋友一个可参考的样本。

如果你手上正好有类似"让大模型处理真实用户文档"的场景,比如简历、合同、论文、复盘报告,这类结构化解析与内容生成混合的任务,这套方案的思路可以直接迁移。文章会从技术选型讲起,然后是LangGraph.js的状态图设计、流式接口实现、并发压力下的应对,最后是实操中遇到的典型问题和解决实录。考虑到不同基础的读者,我不会只贴代码,还会把每个关键选择背后的原因讲清楚,这样你改到自己项目里时,能知道哪些要跟着调、哪些可以不动。

1. 项目定位与技术选型拆解

1.1 核心不是"AI"而是"流程"

很多人一听"AI简历工具",第一反应是"让GPT输出简历内容不就行了"。真做起来才发现,产品形态根本不是一次问答,而是一条流水线:用户上传的文件可能是PDF、可能是Word、也可能是纯文本;文件要先转成能喂给模型的文本;模型要先做结构分析,然后才轮到生成建议或重写;最后输出还得是排版好的Markdown,而不是一坨JSON。

这中间任何一个环节出错,用户感知都很直接:解析失败、生成卡住、结果是一段胡编的乱码。所以这个项目的关键难点在"流程控制"而非"模型能力"——你需要在代码层面把一个复杂的多步骤任务拆成可编排、可重试、可观测的单元。这也是我最终选择LangGraph.js而不是裸调LLM的根本原因。LangGraph.js把AI应用定义为一张图:有状态、有节点、有边,还能在节点之间做条件跳转和循环,天然适合简历这种"解析 → 分析 → 生成 → 校验 → 必要时重跑"的场景。

1.2 为什么选Next.js做前后端一体化

简历工具的产品形态是Web应用,那就绕不开前端。我试过把前端和后端拆成两个项目,React写页面、FastAPI或Node写接口,结果发现维护成本上来了,联调效率下去了。最后压缩到单个Next.js应用,页面、API路由、流式响应全部放一起,一个人也能维护整套系统。

Next.js在服务端有两个能力对这个项目意义很大。一个是用Route Handler轻松写POST接口,配合ReadableStream实现SSE(Server-Sent Events)流式输出;另一个是服务端渲染可以减少首屏等待,用户打开工具页面时不用白屏转圈。

这里有个权衡要说明白:如果你未来用户量很大,或者Agent任务特别重,更合理的架构是Next.js只做BFF(Backend for Frontend),把实际跑LangGraph.js图的服务独立拆出去,用专门的Node长驻服务扛。但对一个早期版本的简历工具来说,单应用方案足够,等并发真上来了再做拆分也不迟。

1.3 为什么选LangGraph.js而不是裸LangChain.js

我做这个项目之前,先用LangChain.js直接写过一版。裸写LangChain最大的问题是:链路是"死"的。所有节点按顺序执行,中间想根据结果决定下一跳,得自己在代码里写一堆if-else,代码越写越乱,还没办法在任意步骤停下来看状态。

换成LangGraph.js之后,整个流程变成了显式的图结构:开始、解析、评估、建议、重写、校验、结束,这些节点之间用边连接,还能定义条件边——比如"解析结果不完整"就回到"询问用户"节点,而不是直接走完。这种显式化最大的好处不是看起来高级,而是每个环节的输入输出、状态变化都变得可检查、可恢复。对生产项目来说,这可太重要了。

另外,LangGraph.js自带checkpointer机制,可以把每一步的状态保存下来,支持断点恢复,也能在用户刷新页面后恢复进度。简历工具这类多轮、长耗时任务,非常需要这个能力。

1.4 最终技术栈全景

项目用到的核心依赖如下:

模块选型作用
前端框架Next.js 14(App Router)页面、API路由、SSE流式输出
Agent编排LangGraph.js定义状态图、节点、条件边
模型接入LangChain.js + OpenAI SDK统一调用GPT-4o等模型
简历解析pdf-parse / mammoth解析PDF和Word文本
结构化输出Zod定义输出Schema,校验LLM返回结果
流式方案ReadableStream + SSE前后端实时推送生成进度
部署Vercel / 自有Node服务按场景选择,后文详细说明

这套组合的优点是:全栈TypeScript,类型贯穿前后端,数据模型不用两头维护;LangGraph.js做Agent编排确实比手写状态机省心;Next.js的流式接口让我不用额外搭一个实时服务。缺点也不是没有:LangGraph.js生态还在快速迭代,API有时会变,需要关注版本更新;Serverless环境下的冷启动和时长限制需要小心处理。这些坑后面具体展开。

2. Agent状态图设计与数据流转

2.1 六个节点拆解:从解析到校验

LangGraph.js把处理流程建模成一张图,每个节点是一个异步函数,接收当前状态,返回状态的部分更新。我最终把简历Agent拆成了六个节点:

  • parse_resume:读取上传文件,提取文本,做基本清洗,按段落切分。
  • assess_resume:让模型基于文本做整体评估,输出摘要、优点、不足。
  • extract_skills:单独从文本中提取技能关键词,方便后面做岗位匹配展示。
  • generate_suggestions:基于评估结果,生成可执行的修改建议列表。
  • rewrite_resume:按建议将简历重写成新的Markdown版本。
  • validate_output:对生成结果做质量检查,比如是否保留了关键经历、有没有明显编造。

这些节点之间不是简单的直线。举个具体例子,parse_resume如果发现文本为空或乱码比例太高,条件边会跳到ask_clarify节点,让用户粘贴原始文本;validate_output如果发现重写结果缺少了原文里的公司名或时间线,会条件跳回rewrite_resume重跑一次。这种"失败后自动重试"用LangGraph.js的addConditionalEdges很容易实现,放在手写代码里就是复杂的一堆状态变量。

六个节点看着多,但每个职责单一,测试起来很好写。我实际开发时给每个节点都单独做了单元测试,用样例简历验证输出结构,这样后面改提示词时能快速发现哪里被改坏了。

2.2 状态Schema与Reducer设计

LangGraph.js的核心是状态(State)。状态会一路穿过所有节点,每个节点可以读取并更新它。定义状态时最容易踩坑的点是数组字段的合并行为。默认情况下,节点返回的字段会直接覆盖原值,但如果你希望"追加"而不是"覆盖",就要给数组字段显式提供reducer。

我的状态定义是这样的:

import { Annotation } from "@langchain/langgraph"; const ResumeState = Annotation.Root({ rawText: Annotation<string>, cleanedText: Annotation<string>, analysis: Annotation<{ summary: string; strengths: string[]; weaknesses: string[]; }>, skills: Annotation<string[]>({ reducer: (prev, next) => [...prev, ...next], }), suggestions: Annotation<string[]>({ reducer: (prev, next) => [...prev, ...next], }), rewrittenMarkdown: Annotation<string>, status: Annotation<"parsing" | "analyzed" | "rewritten" | "failed">, });

看着简单,但背后的逻辑想明白很重要。skills和suggestions用reducer做合并,是因为extract_skills和generate_suggestions节点可能运行多次(比如重试场景),每次返回一批新条目,我们希望累积而不是清空重来。analysis用默认覆盖则是因为评估结果每次应当是最新一份。

我自己栽过一次跟头:一开始没给数组字段配reducer,第二次重跑节点时skills被整体替换,界面上的技能标签突然少了一半。排查半天才反应过来是状态合并策略问题。所以定义状态时一定要先把字段的"覆盖/追加"语义想清楚,别偷懒。

2.3 流式输出:LangGraph的Stream模式

简历重写是个长耗时操作,模型生成一篇完整Markdown可能要几十秒,如果让用户对着空屏等,再好的产品也留不住人。所以流式输出从一开始就是硬需求。

LangGraph.js的graph对象提供stream方法,支持不同的streamMode。我实际用的是两层结合:

const stream = await graph.stream( { rawText: text }, { streamMode: "messages", recursionLimit: 50 } ); for await (const chunk of stream) { if (chunk.event === "on_chat_model_stream") { const token = chunk.data?.chunk?.content; if (token) { // 通过SSE推给前端 } } }

streamMode: "messages"会把模型生成过程中的每个token事件暴露出来,前端就能实时看到生成进度。语言模型在生成的时候,用户能看到文字一个词一个词冒出来,体验上和心理预期完全不一样,这在简历重写环节尤其明显。

另外,streamMode: "values"会在每个节点结束后返回完整状态快照。我在调试阶段用这个模式看节点间的状态流转,哪里断了、哪一步状态不对,一眼就能定位,强烈建议调试时开着它。

2.4 并发控制与服务形态选择

简历工具天然有并发压力:用户集中在上班前、下班后上传简历,流量一下子涌进来。我做了三件事来应对,按优先级排列:

第一,务必开启流式响应。流式不只是体验问题,也是超时问题。Serverless函数有执行时长上限,如果用户等一个完整生成结果再返回,很容易触发超时;但流式模式下,服务端可以边生成边推,函数只要持续输出数据就不会被判定为卡死。这对续期很重要。

第二,用请求作用域创建graph实例。LangGraph.js的StateGraph实例本身不是线程安全的,多个请求共享同一个graph实例容易造成状态污染。正确做法是每次请求进来都创建新的图实例,成本很低,但能避免大量诡异问题。

第三,加一个简单的并发信号量。我基于内存计数实现了一个Semaphore,限制同时运行的Agent数量。超过阈值的请求直接返回"排队中"提示。这里透露一下真实压力测试:单机Node服务在同一份简历上,8个并发请求同时跑,内存占用约300MB,响应时间会从平均12秒拉长到25秒左右。所以并发上限不是越高越好,要看模型接口和内存能扛多少。

如果部署在Vercel这类Serverless平台,长耗时任务还要注意平台侧的执行限制。Hobby计划有硬性时长限制,Pro计划稍好但仍不适合重计算。简历Agent的完整跑完时间通常在15到40秒之间,建议上自有的Node长驻服务,或者用平台提供的专业实例类型。具体怎么选,看你的成本和运维意愿。

3. 核心链路实操:从上传简历到产出结果

3.1 项目初始化与目录规划

创建项目和装依赖就不啰嗦了,直接贴目录设计,这是我几次重构后觉得最顺手的结构:

app/ api/agent/route.ts # SSE接口入口 page.tsx # 主页面 components/ UploadZone.tsx # 拖拽上传组件 AnalysisResult.tsx # 评估结果展示 StreamedResume.tsx # 流式简历预览 lib/ agent/ state.ts # 状态定义 nodes.ts # 所有节点函数 graph.ts # 组装状态图 parse/ pdf.ts # PDF解析封装 text.ts # 文本清洗工具 prompt/ analyze.ts # 评估提示词 rewrite.ts # 重写提示词 stream/ sse.ts # SSE辅助函数

启动依赖安装:

npx create-next-app@latest resume-agent npm install @langchain/langgraph @langchain/openai zod pdf-parse npm install -D @types/pdf-parse

这里有个小提醒:pdf-parse的类型声明是社区维护的,版本匹配有时会出问题,装@types/pdf-parse时如果报类型冲突,直接在项目里写一个声明文件兜底就行,别浪费时间深挖。

3.2 简历解析环节:PDF和文本清洗

简历文件格式五花八门,我做了个取舍:MVP先只支持PDF和纯文本,Word(.docx)放到第二阶段,因为它的解析依赖额外的库,优先保证核心链路稳定。

PDF解析我用的是pdf-parse,代码很简短:

import { createRequire } from "module"; export async function extractTextFromPdf(uploadedFile: File): Promise<string> { const buffer = Buffer.from(await uploadedFile.arrayBuffer()); const pdfParse = createRequire(import.meta.url)("pdf-parse"); const data = await pdfParse(buffer); return data.text; }

pdf-parse对大文件支持尚可,但内存占用偏高。我为上传加了两个硬限制:单文件不超过2MB,每次只允许上传一个文件。超过直接在前端拦截并提示,避免把服务端资源耗尽。

真正让我费时间的不是解析本身,而是文本清洗。PDF导出的文本经常会带上各种意外内容:页眉页脚混入、多行空格、乱码问号、英文断字换行。清洗函数我做了这么几件事:按行分割去掉空行;合并单字符孤立行;把非UTF-8字符替换成占位符;最后把过长的连续空白压缩成单空格。这些都是笨功夫,但直接影响后面模型分析的质量,一定不能省。

3.3 结构化输出:Zod小巧但关键

让模型"自由发挥"在简历工具里是个灾难。你需要把评估结果、技能标签、建议列表变成结构化数据,前端才好渲染,也才好做后续的岗位匹配。我用Zod定义Schema,然后用LangChain的withStructuredOutput让模型严格按Schema输出。

import { z } from "zod"; export const analysisSchema = z.object({ summary: z.string().describe("简历整体概况,最多50字"), strengths: z.array(z.string()).describe("3-5条优点"), weaknesses: z.array(z.string()).describe("2-4条不足"), skillCategories: z.object({ languages: z.array(z.string()), frameworks: z.array(z.string()), tools: z.array(z.string()), }), suggestions: z.array(z.string()).describe("按优先级排序的修改建议"), }); const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0, }).withStructuredOutput(analysisSchema, { method: "functionCalling", });

temperature: 0这行建议直接抄走。我一开始用了默认的0.7,同一份简历跑两次结果差异巨大,用户会觉得产品"不稳定"。做结构化输出时温度设为0,模型每次给出的结构基本一致,效果提升立竿见影。

如果你用的模型不支持function calling,withStructuredOutput会退回到jsonMode或提示词约束,效果会弱一点。实测下来,GPT-4o系列对这套接口支持最好,结构化输出极少出错。Claude 3.5用bindTools也可以,但某些版本对Zod schema的嵌套对象处理有点迷,遇到再说。

3.4 前端实时交互:自写SSE Hook

SSE的接口这边,Route Handler里用ReadableStream输出事件,前端则需要手动读取流。我开始图省事想用EventSource,发现它只支持GET请求,而我需要POST传文件,所以最后还是自己写了一个基于fetch的消费Hook。

核心逻辑是读取响应体的流,按SSE协议的分隔符(两个换行)切分事件,再交给回调处理:

async function runAgent(formData: FormData, onToken: (token: string) => void) { const resp = await fetch("/api/agent", { method: "POST", body: formData }); if (!resp.ok || !resp.body) throw new Error(await resp.text()); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const frames = buffer.split("\n\n"); buffer = frames.pop() ?? ""; for (const frame of frames) { const line = frame.trim(); if (line.startsWith("data:")) { const payload = JSON.parse(line.slice(5).trim()); onToken(payload.token ?? ""); } } } }

流式Markdown渲染,我用react-markdown,给它一个固定高度的容器,避免每来一段就跳一下。这里有个体验细节:可以把AI正在输出的原始Markdown直接渲染,但表格生成期间会闪动,我最后选择只在整段内容完成后再渲染表格类内容,普通段落则逐字流式展示,整体观感好很多。

4. 问题排查与性能实录

4.1 状态被并发污染:MemorySaver的坑

有段时间线上反馈,用户A上传的简历偶尔会显示用户B的名字。排查之后发现是状态持久化方式出了问题。LangGraph.js默认的MemorySaver把状态放在进程内存里,在Serverless环境,多个请求可能复用同一个进程,不同用户的状态串了就出事。

解决方式分两步。第一步,每个请求内部创建独立的graph实例,并把checkpointer改为内存中的请求级实例,彻底不共享:

export function createAgentGraph() { const graph = new StateGraph(ResumeState) .addNode("parse_resume", parseNode) .addNode("assess_resume", assessNode) // ... 连接节点 .compile({ checkpointer: new MemorySaver(), // 请求级实例,不要做成全局单例 }); return graph; }

第二步,如果未来需要跨请求恢复状态,比如支持"继续上次的修改",就要把MemorySaver换成RedisCheckpointSaver,把状态持久化到Redis。这里也提醒一句:不要在代码里直接把MemorySaver作为模块级全局变量,这在Serverless并发下是典型的埋雷写法。

4.2 超时与慢响应:稳住用户体验

简历重写是典型的"慢任务",模型输出一分钟都不稀奇。头几次联调时,前端超过30秒还没收到任何数据,用户早就关页面了。我后来制定了三条硬规则:

  • 上传后立即返回"已收到文件",同时启动解析,不让用户在第一步等待。
  • 解析完成立即推送结构化分析结果,哪怕后面重写还在跑,用户已经能看到一部分价值。
  • 重写阶段确保第一帧data:在接口返回后1秒内就推出去,后续持续流动,绝不冷场。

另外,我给模型调用加了超时和重试配置。LangChain的ChatOpenAI支持timeoutMs和maxRetries参数,我都显式设置了。实测中,某个第三方模型的p99响应时间波动很大,一个请求卡了90秒才回,如果没有超时熔断,整个Agent链路会被拖死。

4.3 同一份简历,两次结果不一致

这个问题的根因,一半是上面说的温度设置,另一半是提示词里缺少约束。我写的分析提示词里明确要求"只基于原文内容,不得补充你没看到的信息",并在每个结构化字段的describe里都带上示例格式。多轮调优后,同一份简历跑5次,结果结构一致率从最初的不到一半提高到接近全部。

还有一个小技巧,我准备了一个包含20份不同风格简历的回归测试集。每次改动提示词或模型版本,就批量跑一遍,对比结构化输出的差异。别指望LLM输出完全一致,但要关注"关键事实"是否被保留,比如工作年限、公司名、技能名。这些如果都丢了,说明提示词约束失效了,需要立刻回滚或修正。

4.4 Token消耗实测与优化

最后聊钱。一份普通简历原文大约800到1500字,完整跑一遍Agent流程,包括分析、技能提取、建议、重写,整体Token消耗在8K到15K之间,具体取决于重写篇幅。用GPT-4o做全流程,单次成本大约人民币几毛钱,看起来不贵,但用户量上来就是实打实的成本。

我采用的优化策略是"混合模型"。解析、评估、技能提取这类任务用更便宜的模型,比如gpt-4o-mini,只有重写简历这个核心环节才上更强模型。同时,重写时只把解析后的结构化数据传给模型,不把原文所有文本一股脑塞进去,让输入更精炼,输出反而更聚焦。

再配合输出maxTokens限制,重写环节设一个5000字左右的上限,防止模型发疯输出几万token。这一套组合下来,整体成本降到原来的六成左右,核心效果没有明显变化。

最后分享几个我反复体会到的点。第一,LangGraph.js真正解决的不是"让AI更聪明",而是"让AI流程可控",你把它当工作流引擎用,比当AI框架用舒服得多。第二,做这类工具型Agent,第一版一定要敢做减法,比如只支持PDF、只支持单一模型、不搞多轮对话,先保证核心链路稳定,再谈扩展。第三,任何时候都不要信任LLM的默认输出,结构化校验、超时重试、状态隔离,这三件事做好了,Agent才算真正落地,而不是实验室里的玩具。

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

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

立即咨询