
最近在做一款简历工具的 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 RouterTailwindCSSAPI 层承接前端请求启动或恢复工作流Next.js Route HandlerAgent 编排简历解析、匹配分析、改写、面试题生成的状态流转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 StateGraphResumeState({ 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 重新 invokeLangGraph 会自动从最近保存的检查点恢复不需要前端重新上传文件。我第一次跑通这个机制的时候还挺感慨的以前做长流程业务恢复现场全靠自己写日志和补偿逻辑现在框架层面就给了。不过要注意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): Promisestring { 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输出设计为score0-100 的整数团队成员约定这个分数的计算逻辑 JD 关键词覆盖率 70% 项目经验相关性 30%。suggestions一批具体、可执行的改进建议每条不超过 50 字。这个节点我用了 few-shot prompt。给模型两个示例一条是简历写得假大空JD 要求数据驱动另一条是简历项目经验丰富但缺少量化结果。实践下来示例的质量比 prompt 里的规则更有用。optimize_resume节点需要小心处理它要在保留用户真实经历的前提下针对 JD 需求优化表达。因此 prompt 必须明确不要编造经历不要修改公司时间和职位名称。我甚至在后端加了规则改写完成后做一个简单的一致性校验——原简历出现过的公司名和职位名必须在新文本里出现否则标记为疑似幻觉要求模型重新生成。3.4 LangGraph 节点的实现与图编译细节单个节点实现要遵循一个原则输入输出都走 state不要用全局变量。这样既方便测试也方便检查点恢复。我的写法是这样的async function matchScore(state: ResumeState): PromisePartialResumeState { 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 把事件推到前端。但这里有一个大坑如果直接在生产模式部署到 VercelServerless 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_idGET /api/resume/status?idxxx负责查询当前状态。后端用内存 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 内存约 1GBpdf-parse 处理大文件时峰值可能冲到 200-300MB够用但是要监控。无状态Vercel 的函数是无状态的全局单例和内存缓存都可能丢失工作流状态必须外部化。我上面用的 MemorySaver 在本地开发可以线上一定换 Redis 或者其他持久化方案。如果你要部署到自己的服务器就用 PM2 或者 Docker 跑一个 Node 服务把 Next.js 的 standalone 模式打开产出会干净很多。LangGraph 图的编译在这个模式下也很稳定。5.3 前端体验进度展示比结果展示更重要简历优化这种任务耗时较长用户等待时最怕的是页面毫无反馈。我在前端做了一个简单的状态轮询界面根据后端返回的 status 字段展示正在解析简历正在匹配 JD 要求正在生成优化建议正在准备面试题。这里有一个细节不要把状态名直接映射成 UI 文案。你把节点名当作接口协议前端做个映射表份文案以后想改就改。另外每个状态持续较长时可以随机切换几条提示文案比如正在逐字阅读你的工作经历缓解用户的焦虑感。实测下来加了进度反馈之后用户中途放弃的比例下降很明显。这个投入比再调几个 prompt 都值。6. 把一个项目真正落地还需要什么聊到最后我想说点实在的。很多人以为 AI Agent 项目最难的是写 Prompt、接模型、画流程图真正落地的时候才会发现工程上的细节才决定成败。简历解析不是调一个 API 就能完美输出要清洗噪声要区分扫描件和文本 PDFAI 改写不是让模型自由发挥要有白名单校验防止幻觉工作流不是跑完就完了要可恢复、可观测、可控成本。这些经验不是看两天文档能总结出来的都是在真实业务里一点点踩出来的。我们这个项目目前还在持续迭代下一步打算把面试题生成节点升级成多轮对话式模拟面试让用户直接在页面里和 AI 完成一场模拟面试。技术上还是在现有的 LangGraph 图里加两个节点状态设计不用推翻重来这是当初选图编排带来的最大红利——加节点比改节点容易得多。如果你是第一次做类似的项目我的建议是先把最少可用版本跑通一个图三个节点不要贪多。跑通之后再往里面加分支、加兜底、加状态恢复。AI Agent 最大的优势是灵活最大的陷阱也是灵活——没有清晰的状态图灵活很快就会变成失控。