☰
用Next.js和LangGraph.js编排AI Agent工作流,构建简历优化工具
2026/10/8 16:19:12 网站建设 项目流程

最近在做一款简历工具的 AI 改造,需求本身不复杂:用户上传一份 PDF 简历,系统自动解析内容,再结合目标岗位 JD 生成优化建议、定制化改写,甚至模拟面试问答。真正麻烦的是这些功能不是一次调用就能完成的,中间涉及解析、结构化、多轮改写、人工确认、生成面试题等多个环节,而且每一步都可能失败或者需要回退。试过用传统的硬编码流程串多个 API 调用,结果代码越写越绕,状态散落在各个函数里,出错之后根本追不到是哪一步出了问题。

后来换了 LangGraph.js 做工作流编排,前端继续用 Next.js,前后端一套 TypeScript 搞定。这套组合落地下来,最大的感受是:AI Agent 项目真正的复杂度不在模型调用,而在状态管理和流程控制。今天这篇就把整个项目的设计思路、核心代码、踩坑记录完整梳理一遍,想用 Next.js + LangGraph.js 搭 AI Agent 的朋友可以直接参考。

1. 项目整体设计与技术选型思路

1.1 为什么是 Next.js 而不是单独拆前后端

简历工具这类产品有一个特点:交互密度高但页面逻辑不算复杂。用户上传文件、看到解析进度、预览优化建议、确认修改、查看面试题,整个流程是线性的,又需要不少中间状态。如果用传统的 Vue/React 前端 + Java/Go 后端两套团队维护,通信成本和联调成本都不小。

我最终选了 Next.js 14 的 App Router,原因很实际:

  • API Routes 可以直接承担后端职责,LangGraph.js 工作流跑在服务端,前端页面通过 fetch 调用接口,不需要单独部署后端服务。
  • Server Actions 适合处理表单提交这类轻量交互,简历的上传和基础信息保存可以直接走这块,少写一层接口。
  • 流式渲染本身对 AI 输出友好,工作流每生成一段结果就能推给前端,用户感知延迟低。
  • 部署到 Vercel 或者任意 Node 服务器都方便,团队里只需 TypeScript 一种语言栈。

这里不是否定微服务架构,而是说简历工具这种中小型 Agent 项目,Next.js 全栈是最省力的路径。团队小、迭代快,把精力放在 Agent 逻辑本身,而不是基础设施。

1.2 为什么用 LangGraph.js 编排 Agent 而不是手写状态机

初期我试过最朴素的方式:写一个 async 函数,按顺序调用解析接口、调用大模型、处理结果、返回给前端。代码确实短,但问题很快暴露出来:

  • 用户可能修改简历内容后重新生成面试题,此时不希望从零开始跑整个流程,需要中途接管。
  • 解析 PDF 可能失败,需要重试或者让用户手动补充信息,这要求流程可以在特定节点停下来等输入。
  • 每步之间都要传递大量上下文(简历原文、结构化结果、JD 要求、优化建议),手写传参很容易漏字段。
  • 上线之后要排查问题,普通日志根本看不清哪一步用了多少 token、哪个分支被命中。

LangGraph.js 的核心优势是它把 Agent 流程变成了显式的图结构。每个节点是独立函数,节点之间通过共享状态对象通信,边的走向可以由条件判断动态决定。这比手写状态机清晰得多,也比 LangChain 那种链式调用灵活得多——链是固定的,图是有分支的。

而且要提一嘴,LangGraph.js 和 LangGraph Python 不是一回事。我见过不少团队先写了 Python 版的 Agent 核心,再让 Node 服务通过 HTTP 调用,结果每次排查问题要跨两个语言栈。现在 LangGraph.js 生态已经够成熟,前端项目直接用 JS 版能省掉这层通信开销。

1.3 简历工具 Agent 的系统架构总览

整个系统的模块划分如下:

模块职责技术选型
前端页面上传简历、展示进度、确认修改、查看结果Next.js App Router,TailwindCSS
API 层承接前端请求,启动或恢复工作流Next.js Route Handler
Agent 编排简历解析、匹配分析、改写、面试题生成的状态流转LangGraph.js StateGraph
文件解析PDF 转文本pdf-parse(服务端)+ pdfjs-dist(前端预览)
模型接入大模型文本生成、结构化输出兼容 OpenAI 协议的服务,Function Calling
数据存储工作流状态、用户会话、历史记录PostgreSQL + 内存 Checkpointer

这里有个设计要点:Agent 工作流应该无状态运行,状态全部丢给 LangGraph 的 Checkpointer 管理。每个节点只从状态里取自己需要的字段,处理后写回新字段。这样某个节点崩溃了,可以从最近一个成功的检查点恢复,而不是整个流程重跑。

2. 核心功能模块与数据流转设计

2.1 状态图的设计:从上传到面试题生成需要几个节点

简历工具听起来功能不少,但抽象成状态图后其实很清晰。我最终定义了 6 个核心节点:

  • parse_resume:接收上传的 PDF 文件路径,解析文本,提取基本信息。
  • structure_resume:把文本简历结构化,输出 JSON(包含工作经历、项目经历、技能标签)。
  • analyze_jd:读入 JD 文本,提取关键要求。
  • match_score:对比简历和 JD,生成匹配度评分和改进建议。
  • optimize_resume:根据改进建议改写简历,输出优化版本。
  • generate_interview:基于优化后的简历和 JD 生成模拟面试题。

它们之间的关系不完全是线性的。比如analyze_jd和match_score可以合并,但拆开后更便于后续单独替换 JD 分析策略;generate_interview依赖优化后的简历,但它也可以选择用原始简历,作为条件分支处理。

状态对象的设计是这个图能不能跑顺的关键。我的 ResumeState 长这样(简化版):

interface ResumeState { filePath?: string; rawText?: string; structuredData?: StructuredResume; jdText?: string; jdRequirements?: string[]; matchResult?: { score: number; suggestions: string[]; }; optimizedResume?: string; interviewQuestions?: InterviewQuestion[]; error?: string; status: 'idle' | 'processing' | 'awaiting_input' | 'done' | 'failed'; }

每个节点只认自己需要的字段。比如optimize_resume只读structuredData和matchResult.suggestions,它不关心rawText里有多少杂乱的换行符。这种职责隔离让单个节点的测试变得非常简单——喂一个最小 state 进去,看输出是否符合预期。

2.2 条件分支:什么时候停下来等用户确认

简历改写不能全自动覆盖用户原稿,这是产品层面的硬约束。AI 改完的版本必须让用户确认,用户可能只接受部分修改。这个需求体现在图上就是optimize_resume之后要有个条件边。

LangGraph.js 里条件边是这样用的:

const workflow = new StateGraph<ResumeState>({ channels: { filePath: { value: null }, rawText: { value: null }, structuredData: { value: null }, jdText: { value: null }, matchResult: { value: null }, optimizedResume: { value: null }, status: { value: 'idle' }, }, }) .addNode('parse_resume', parseResume) .addNode('structure_resume', structureResume) .addNode('analyze_jd', analyzeJd) .addNode('match_score', matchScore) .addNode('optimize_resume', optimizeResume) .addNode('generate_interview', generateInterview) .addEdge('parse_resume', 'structure_resume') .addEdge('structure_resume', 'analyze_jd') .addEdge('analyze_jd', 'match_score') .addEdge('match_score', 'optimize_resume') .addConditionalEdges('optimize_resume', async (state) => { // 如果用户没有确认修改,工作流挂起,等待前端回传确认结果 if (!state.optimizedResumeConfirmed) { return 'wait_for_user'; } return 'generate_interview'; }) .addEdge('generate_interview', END);

这里有个容易忽略的细节:LangGraph.js 的addConditionalEdges返回值可以是节点名数组,也可以只是一条边。如果你需要同时在多个分支继续执行,返回数组即可。但默认情况下,每个节点执行完后会继续走它定义好的边,因此要在条件边里明确返回 END,避免无限循环。

2.3 状态持久化与 Checkpointer:断了也能接上

用户上传简历后可能会离开页面,过几分钟再回来看结果。这种场景要求工作流状态必须能持久化。LangGraph.js 的 Checkpointer 提供了一种基于内存或者数据库的快照机制。每次节点执行完,它会把整个 state 记录保存下来,并生成一个thread_id。

我的实现思路是:

import { MemorySaver } from '@langgraph/langgraph'; // 生产环境建议换成基于 Redis 或 Postgres 的持久化实现 const checkpointer = new MemorySaver(); async function startResumeWorkflow(filePath: string, jdText: string, threadId: string) { const app = workflow.compile({ checkpointer }); const finalState = await app.invoke( { filePath, jdText, status: 'processing' }, { thread_id: threadId } ); return finalState; }

这里thread_id就相当于一次完整会话的标识。用户刷新页面后,只要带上同一个 thread_id 重新 invoke,LangGraph 会自动从最近保存的检查点恢复,不需要前端重新上传文件。

我第一次跑通这个机制的时候还挺感慨的:以前做长流程业务,恢复现场全靠自己写日志和补偿逻辑,现在框架层面就给了。不过要注意,MemorySaver 只适合单机、重启即失的演示场景,生产环境务必换成带外部存储的 Checkpointer,否则进程一挂,用户的状态全丢。

3. 实操过程与核心环节实现

3.1 简历解析:PDF 转文本比想象中难

简历解析是整个项目里最不"AI"但最容易翻车的环节。PDF 文件看起来是纯文本,但实际的文本层、字体编码、表格布局各不一样。常见的坑如下:

  • 部分 PDF 是扫描件,没有文本层,必须走 OCR,而这会显著增加耗时和成本。
  • 中文简历在 PDF 里经常有全角/半角混用、换行丢失的问题。
  • 表格类简历(比如把工作经历放在表格里)用 pdf-parse 提取时,列顺序会乱。

我的处理方案是分两步。第一步用 pdf-parse 在 Node 服务端快速抽取文本,绝大部分文本型 PDF 都能覆盖。第二步写了一个简单的清洗函数,处理换行符、制表符和连续空格,再喂给结构化节点。

import pdfParse from 'pdf-parse'; export async function extractTextFromPdf(filePath: string): Promise<string> { const dataBuffer = await fs.readFile(filePath); const data = await pdfParse(dataBuffer); let text = data.text; // 清理常见噪声 text = text.replace(/\r/g, '\\n'); text = text.replace(/[\\t ]+/g, ' '); text = text.replace(/\\n{3,}/g, '\\n\\n'); return text.trim(); }

如果你的产品要接受扫描件简历,提前把 OCR 服务(比如阿里云/腾讯云的文档识别 API)也接入到parse_resume节点里,根据 PDF 是否包含文本层做分支。扫描件直接走 OCR 节点,普通 PDF 走本地方案,两者产出的都是统一格式的rawText。

3.2 结构化输出:如何稳定拿到可用的 JSON

把一段自然语言简历变成 JSON,是 Agent 的核心能力之一。这里推荐用模型的 Function Calling 能力,而不是让模型直接返回 JSON 字符串。后者在遇到长文本时,很容易出现截断、括号不匹配、字段缺失。

我习惯在 LangGraph 节点里给模型声明一个工具:

const structuredResumeTool = { name: 'save_structured_resume', description: '将原始简历文本转换为结构化数据', parameters: { type: 'object', properties: { name: { type: 'string' }, contact: { type: 'object' }, workExperience: { type: 'array', items: { type: 'object', properties: { company: { type: 'string' }, position: { type: 'string' }, duration: { type: 'string' }, achievements: { type: 'array', items: { type: 'string' } }, }, }, }, projects: { type: 'array' }, skills: { type: 'array', items: { type: 'string' } }, }, required: ['name', 'workExperience', 'projects', 'skills'], }, };

节点代码的核心思路是:把rawText作为上下文,调用模型,让它强行输出一个save_structured_resume的函数调用,然后把函数参数里的 JSON 写入状态。

const res = await model.invoke([ { role: 'system', content: '你是资深简历解析助手,请将用户简历转换为结构化数据。' }, { role: 'user', content: rawText }, ], { tools: [structuredResumeTool], tool_choice: 'required' }); const toolCall = res.tool_calls?.[0]; if (!toolCall) { throw new Error('模型未返回结构化结果'); } return { structuredData: JSON.parse(toolCall.function.arguments), status: 'processing', };

3.3 匹配评分与改写:把大模型输出约束成可用建议

匹配评分不能只给一个分数,要让用户知道下一步该做什么。我设计了两个节点配合:match_score负责分析,optimize_resume负责执行改写。

match_score输出设计为:

  • score:0-100 的整数,团队成员约定这个分数的计算逻辑 = JD 关键词覆盖率 70% + 项目经验相关性 30%。
  • suggestions:一批具体、可执行的改进建议,每条不超过 50 字。

这个节点我用了 few-shot prompt。给模型两个示例,一条是"简历写得假大空,JD 要求数据驱动",另一条是"简历项目经验丰富但缺少量化结果"。实践下来,示例的质量比 prompt 里的规则更有用。

optimize_resume节点需要小心处理:它要在保留用户真实经历的前提下,针对 JD 需求优化表达。因此 prompt 必须明确"不要编造经历,不要修改公司时间和职位名称"。我甚至在后端加了规则,改写完成后做一个简单的一致性校验——原简历出现过的公司名和职位名必须在新文本里出现,否则标记为疑似幻觉,要求模型重新生成。

3.4 LangGraph 节点的实现与图编译细节

单个节点实现要遵循一个原则:输入输出都走 state,不要用全局变量。这样既方便测试,也方便检查点恢复。我的写法是这样的:

async function matchScore(state: ResumeState): Promise<Partial<ResumeState>> { const { structuredData, jdText } = state; // 计算匹配度并生成建议 const result = await runMatchAnalysis(structuredData, jdText); return { matchResult: result, status: 'processing' }; }

这里有个小技巧:每个节点返回的是 Partial 类型,LangGraph 会自动把这些字段合并回总状态。如果某次执行中你不想覆盖旧字段,只要不返回它就行。

图编译完成后,我建议把图对象缓存起来。因为编译后的图在每次 invoke 时还要做校验和初始化,放在模块级或者用懒加载,能节省一点时间。尤其在 serverless 环境里,每次冷启动都编译图会相当浪费。

4. 常见问题与排查技巧实录

4.1 流式输出在 Next.js API Route 里怎么打通

Agent 跑在服务端,前端需要展示"正在处理简历""正在分析 JD"这类实时进度。LangGraph.js 支持 stream 模式,可以在每个节点开始和结束时输出事件。Next.js Route Handler 里用 ReadableStream 把事件推到前端。

但这里有一个大坑:如果直接在生产模式部署到 Vercel,Serverless Function 的默认超时时间是 10 秒(Hobby 套餐底层是 10 秒,付费套餐也只有 60 秒)。而一个完整的简历优化工作流光调用模型可能就要 20-30 秒,更不用说排队时间。所以必须做两件事:

  • 前端调用接口后,接口立即返回thread_id,工作流在后台以异步任务方式运行,前端通过轮询或者 SSE 订阅结果。
  • 或者把 API Route 的export const maxDuration = 60提高超时限制,配合runtime = 'nodejs'。实测下来 Vercel 60 秒对单次模型调用够用,但跑完整图还是建议异步化。

我的选择是:把工作流的启动和查询拆成两个接口。POST /api/resume/start负责启动任务并返回 thread_id,GET /api/resume/status?id=xxx负责查询当前状态。后端用内存 Map 维护工作流进度。比直接 SSE 更简单,也更好处理断线重连。

4.2 并发上来之后:LangGraph 实例会不会互相干扰

评论区总有朋友问"AI Agent 怎么扛并发"。我实际压测下来的结论是:LangGraph 本身是无状态图,并发安全取决于你给每个用户分配的 thread_id 是否唯一,以及你的模型 API 限流策略。

建议把 thread_id 设计成 UUID,由服务端生成,不要由前端传入。前端只拿着查询凭证,这样就算用户刷新页面,状态还在,也不会因为重试同一请求导致状态覆盖。

另外模型 API 的限流不可忽视。简历解析这种场景里,用户可能批量上传,而你的 OpenAI 或 Claude API Key 有 RPM 限制。我加了简单的令牌桶限流器,把超过阈值的任务排队,而不是直接报错。线上实测效果很好,用户感知只是结果晚出来几秒,但不会看到报错。

4.3 模型幻觉:简历被"优化"出了不存在的经历

这是项目上线后收到最严重的一类反馈。用户说"AI 给我加了一段我从来没做过的项目"。排查之后发现,问题出在我的optimize_resume节点 prompt 里,用了"补充相关经历"这类描述,模型就会自动脑补。

修复方法是在 prompt 里加一条硬约束 + 在代码里做规则校验:

  • prompt 里明确写:"只能基于用户提供的经历改写表达,不得新增公司、职位、项目。"
  • 代码里把 structuredData 中所有公司名、职位名、项目名提取为白名单,改写结果必须包含这些实体且不包含白名单外的实体。

校验不通过时,节点返回status: 'failed',并由条件边指向一个repair_resume节点重新生成,最多重试一次。这个兜底逻辑在 LangGraph 里做特别顺手,因为它天然支持循环和重试。

4.4 排查问题的手段:从日志到 Checkpointer 快照

AI Agent 出问题的最大困难是复现困难。两个用户同样的输入,模型可能给出不同结果。我的排查经验是三层:

  • 第一层,给每个节点加 id 前缀日志,LangGraph stream 模式下能直接看到当前节点名。
  • 第二层,把每次工作流的关键输入输出(rawText 前 200 字、jdText 前 200 字、matchResult.score)存到数据库,方便反向定位。
  • 第三层,遇到疑难问题,直接用同一个 thread_id 调getState方法,把完整的 Checkpointer 快照打出来。这里能看到所有中间状态,基本能判断是模型输出问题还是节点逻辑问题。
const app = workflow.compile({ checkpointer }); const state = await app.getState({ thread_id: 'xxx' }); console.log(JSON.stringify(state.values, null, 2));

5. 部署上线与性能调优经验

5.1 模型成本从哪里省:用便宜模型处理中间步骤

简历工具整个流程中,真正需要强大推理能力的只有match_score和optimize_resume两个节点。而parse_resume、structure_resume这类任务,用能力稍弱但响应快的模型完全够用。

我的模型分配如下:

节点使用模型理由
structure_resumegpt-4o-mini结构化输出能力足够,价格便宜
match_scoregpt-4o需要深入理解 JD 和简历的语义匹配
optimize_resumegpt-4o改写质量直接影响用户满意度
generate_interviewgpt-4o-mini题目生成不需要太强推理

这样分配后,一次完整工作流的 token 成本大约降了一半。千万别用同一个模型跑所有节点,也没必要在每个节点都上最强模型。

5.2 Next.js 全栈部署的注意事项

如果你也选择部署到 Vercel,有几点要提前确认:

  • 文件上传体积限制:Vercel 默认请求体限制约 4.5MB,简历 PDF 一般没问题,但要处理超大扫描件就建议走对象存储直传。
  • 内存限制:Node runtime 内存约 1GB,pdf-parse 处理大文件时峰值可能冲到 200-300MB,够用但是要监控。
  • 无状态:Vercel 的函数是无状态的,全局单例和内存缓存都可能丢失,工作流状态必须外部化。我上面用的 MemorySaver 在本地开发可以,线上一定换 Redis 或者其他持久化方案。

如果你要部署到自己的服务器,就用 PM2 或者 Docker 跑一个 Node 服务,把 Next.js 的 standalone 模式打开,产出会干净很多。LangGraph 图的编译在这个模式下也很稳定。

5.3 前端体验:进度展示比结果展示更重要

简历优化这种任务耗时较长,用户等待时最怕的是页面毫无反馈。我在前端做了一个简单的状态轮询界面,根据后端返回的 status 字段展示"正在解析简历""正在匹配 JD 要求""正在生成优化建议""正在准备面试题"。

这里有一个细节:不要把状态名直接映射成 UI 文案。你把节点名当作接口协议,前端做个映射表,份文案以后想改就改。另外每个状态持续较长时,可以随机切换几条提示文案,比如"正在逐字阅读你的工作经历",缓解用户的焦虑感。

实测下来,加了进度反馈之后,用户中途放弃的比例下降很明显。这个投入比再调几个 prompt 都值。

6. 把一个项目真正"落地"还需要什么

聊到最后,我想说点实在的。很多人以为 AI Agent 项目最难的是写 Prompt、接模型、画流程图,真正落地的时候才会发现,工程上的细节才决定成败。

简历解析不是调一个 API 就能完美输出,要清洗噪声,要区分扫描件和文本 PDF;AI 改写不是让模型自由发挥,要有白名单校验防止幻觉;工作流不是跑完就完了,要可恢复、可观测、可控成本。这些经验不是看两天文档能总结出来的,都是在真实业务里一点点踩出来的。

我们这个项目目前还在持续迭代,下一步打算把面试题生成节点升级成多轮对话式模拟面试,让用户直接在页面里和 AI 完成一场模拟面试。技术上还是在现有的 LangGraph 图里加两个节点,状态设计不用推翻重来,这是当初选图编排带来的最大红利——加节点比改节点容易得多。

如果你是第一次做类似的项目,我的建议是:先把最少可用版本跑通,一个图三个节点,不要贪多。跑通之后再往里面加分支、加兜底、加状态恢复。AI Agent 最大的优势是灵活,最大的陷阱也是灵活——没有清晰的状态图,灵活很快就会变成失控。

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

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

立即咨询