
把一份 PDF 简历拖进网页系统自动解析出教育经历、工作履历、技能清单再粘一条目标岗位的 JD几十秒内返回匹配分数、逐条修改建议、甚至生成一版改写后的完整简历——这个需求如果交到你手上你会怎么设计如果按传统思路大概会走一条很长的线上传文件 - 解析文本 - 调大模型抽字段 - 再调一次大模型打分 - 再调一次大模型写建议……每一步靠 if-else 串联状态散落在各个函数里跑到一半崩了还不知道从哪恢复。我这次没有这么干直接用 Next.js LangGraph.js 把整个流程做成了一个有状态、可中断、可流式输出、可恢复的 AI Agent。这篇文章完整记录从技术选型、状态图设计、核心代码、并发处理到生产环境踩坑的全部过程适合准备做 Agent 落地的全栈开发者或者想给自己的工具类产品接入 Agent 能力的同学。1. 为什么是简历工具为什么是 Next.js LangGraph.js1.1 简历场景为什么适合先跑通 Agent很多人一聊 AI Agent就想着做个万能助手什么都往里塞。我的建议是先从边界清晰的工具型场景开始。简历工具正好是这类场景的典型代表——它有明确的输入简历 PDF 目标 JD、明确的输出结构化评估 修改建议、中间有清晰的步骤解析、抽取、匹配、建议、生成而且每一步都有可衡量的结果。它不像帮我写个方案那么开放也不像翻译一句话那么简单是一个恰到好处的 Agent 练手项目。这类场景做完之后业务价值是立竿见影的。我自己实测下来一份排版混乱、措辞平淡的简历经过 Agent 评估和改写之后信息密度明显提升重点经历能够对齐目标岗位的术语体系。用户不会关心你底层是不是用了 LangGraph他们只关心从上传到拿到结果是不是够快、建议是不是够准。1.2 技术栈评估LangGraph.js 比裸写状态机强在哪一开始我也犹豫过直接裸写流程不就行了解析完简历就调用大模型拿到结果再调用下一个大模型代码也不复杂。但真正跑起来你会发现三个问题状态管理靠全局变量请求一多就串数据。某个环节失败之后没有断点恢复能力用户只能重新上传。想要做用户补充信息后继续这种人机交互裸写逻辑非常痛苦。LangGraph.js 把这一整套东西抽象成了状态图每个节点是纯函数节点的输出合并进全局状态边决定下一步走向checkpointer 负责把状态落盘。它最直观的价值是你不用自己维护数据流到哪了这件事框架替你管了。对比一下就更清楚了对比项裸写状态机LangGraph.js状态管理自己定义全局对象容易脏Annotation reducer可控失败恢复重新跑一遍全流程checkpointer 从断点恢复人机交互自己实现回调中断interrupt / Command 原生支持流式输出手工拼接stream / streamEvents 直接吐测试调试靠日志图可视化、逐节点检查1.3 和主流方案的横向对比现在市面上 Agent 编排方案不少Python 侧有 FastAPI LangChain LangGraph低代码侧有各种可视化平台还有人用 Rust 做核心调度的。我选 Next.js LangGraph.js 不是因为它比其他方案高级而是因为在简历工具这个场景里它最省事前端、后端、Agent 编排全部 TypeScript一套语言类型定义可以在前后端共享。比如记录状态的ResumeEvaluationState类型前端轮询结果时也能复用同一份类型推断。部署链路短一个应用就能同时处理页面渲染和 API 路由不用单独起一个 Python 服务再处理跨域问题。LangGraph.js 的运行时能力已经覆盖了 Checkpoint、流式、中断、人机协同这些关键特性很多云厂商 Agent 白皮书里讲的计划、记忆、工具、执行四要素在这个框架里都有对应实现。当然如果团队主力是 Python或者要深度依赖 Python 生态的数据处理库那走 FastAPI LangGraph 的路线也完全合理。工具是服务于场景的不是服务于信仰的。2. 先设计状态图再写业务代码2.1 ResumeEvaluationState 状态定义跑 Agent 和写普通接口最大的区别是你先把整个流程涉及哪些数据、每个数据如何更新定义清楚再动手写业务逻辑。LangGraph.js 里用Annotation来定义状态字段的合并规则。我这里定义了一个ResumeEvaluationStateimport { Annotation, StateGraph, START, END } from langchain/langgraph; const ResumeState Annotation.Root({ // 原始文本一旦设置就覆盖 rawText: Annotationstring({ reducer: (_, next) next, }), // 解析出来的结构化简历 resumeJson: AnnotationResumeData({ reducer: (_, next) next, }), // 目标岗位 JD jdText: Annotationstring({ reducer: (_, next) next, }), // 匹配分数 score: Annotationnumber({ reducer: (_, next) next, }), // 建议列表允许多个节点追加 suggestions: Annotationstring[]({ reducer: (pre, next) [...(pre ?? []), ...(next ?? [])], }), // 最终报告 report: Annotationstring({ reducer: (_, next) next, }), });这里有个关键点reducer决定了这个字段被更新时是覆盖还是追加。rawText、resumeJson这种单一值用覆盖即可suggestions这类的数组则用展开合并这样不同节点返回的建议不会互相覆盖。这个规则如果不写对后面会出现明明回来两条建议最后只剩一条的诡异问题。2.2 七个节点的职责划分有了状态下一步就是把任务拆成节点。我把简历评估流程拆成了七个节点loadResume读取用户上传的原始内容统一转成纯文本。extractResume让大模型把纯文本简历转成结构化 JSON。loadJd根据用户输入的岗位关键词搜索或获取目标 JD。matchScore结构化简历与 JD 做匹配算出各项评分。quickSuggest快速诊断模式输出几条关键建议就结束。depthSuggest深度优化模式逐模块给出改写建议并生成新版简历。askMoreInfo信息不足时中断流程向用户追问。节点的粒度不能太粗否则状态更新会乱也不能太细否则图会变得很难维护。我个人的标准是一个节点只做一件事节点内部最多包含一次大模型调用外加必要的解析封装。2.3 条件路由什么时候走人机交互状态图比线性代码强的地方在于节点之间的关系可以是条件跳转。我用matchScore的输出结果做路由graph.addConditionalEdges(matchScore, (state) { if (state.score 60) return askMoreInfo; if (state.score 80) return quickSuggest; return depthSuggest; });分数低于 60 分时说明简历和岗位需求差距过大直接给建议没有意义这时候触发askMoreInfo节点用interrupt()暂停图执行等用户补充更多项目细节、或者确认方向后再继续往下跑。这也是 LangGraph.js 里 Human-in-the-loop 的核心用法Agent 不是全自动闷头跑完而是知道什么时候该停下来问人。实际产品里这个交互对用户来说就是系统弹了一个问题说信息不足让我补充一段项目描述体验比直接给一份毫无根据的建议好太多了。2.4 为什么不是线性 if-else你可能会问上面这些我用 if-else 串起来不也一样吗短期看确实能跑但一旦流程要扩展比如将来要支持多轮追问、要支持用户中途改 JD 重新匹配、要支持不同的简历模板解析策略if-else 的函数调用链会变得很僵硬因为每一步的数据流动都是隐式的排查问题的时候只能靠打断点。状态图把数据流显式化了。任何时刻整个 Agent 跑到哪个节点、状态里有什么数据都是可以检查和恢复的。这个心智模型的改变才是 LangGraph.js 带来的真正价值。3. 关键代码逐个给初始化、节点、工具、流式3.1 初始化项目和依赖直接用 Next.js 官方脚手架App Router 模式npx create-next-applatest resume-agent --typescript --app cd resume-agent npm i langchain/langgraph langchain/core langchain/openai zod pdf-parse依赖里需要解释一下langchain/core它是 LangChain 生态的基础库提供tool、消息类型、模型调用抽象LangGraph.js 依赖它来定义工具和消息结构。zod用来定义结构化输出的格式后面会提到为什么必须加这个。如果你看到包名变成了langgraph也不用慌那是统一进了新包名的版本核心 API 基本一致。3.2 构建 StateGraph 的骨架代码节点函数接收当前状态返回一个部分状态框架自动按 reducer 合并。下面是最核心的骨架import { StateGraph, START, END, MemorySaver } from langchain/langgraph; const parseResume async (state: typeof ResumeState.State) { const text await extractTextFromPdf(state.rawText); return { resumeJson: await parseResumeJson(text) }; }; const evaluateNode async (state: typeof ResumeState.State) { const result await evaluateResume(state.resumeJson, state.jdText); return { score: result.score, suggestions: result.suggestions }; }; const buildReport async (state: typeof ResumeState.State) { const report await generateReport(state.resumeJson, state.jdText, state.suggestions); return { report }; }; const graph new StateGraph(ResumeState) .addNode(parseResume, parseResume) .addNode(evaluate, evaluateNode) .addNode(buildReport, buildReport) .addEdge(START, parseResume) .addEdge(parseResume, evaluate) .addEdge(evaluate, buildReport) .addEdge(buildReport, END) .compile({ checkpointer: new MemorySaver() });一个容易忽略的点节点函数第一个参数是完整的当前状态而不是只传它需要的字段。所以节点内部要注意只读取自己关心的字段返回时也只需要返回自己负责更新的字段。3.3 工具的注册细节PDF 解析和 JD 搜集LLM 自己没法读 PDF也没法实时抓取招聘网站的 JD所以这些能力要封装成工具。LangGraph.js 里工具就是一个带 schema 描述的 async 函数import { tool } from langchain/core/tools; import { z } from zod; const pdfDownloadTool tool(async ({ url }) { // 下载 PDF 并提取文本的封装逻辑 return await extractPdfFromUrl(url); }, { name: download_and_extract_pdf, description: 从 URL 下载 PDF 文件并提取纯文本内容用于解析用户简历, schema: z.object({ url: z.string().describe(PDF 文件的直链地址), }), }); const jdSearchTool tool(async ({ keyword }) { // 调用职位搜索接口返回 JD 列表 return await searchJobDescriptions(keyword); }, { name: search_jd, description: 根据岗位关键词搜索招聘 JD返回包含职责和要求的列表, schema: z.object({ keyword: z.string().describe(例如前端工程师、Java 后端、产品经理), }), });这里有两条实战心得。第一工具description一定要写清楚什么时候该用、传什么参数因为大模型是靠描述来决定是否调用工具的。写太笼统模型可能完全不理你这个工具。第二一个工具只做一件事。不要做一个万能文件解析工具而是拆成下载 PDF 的工具和解析文本的工具这样模型更易理解和组合。3.4 Next.js 路由里跑图的两种姿势核心逻辑写完之后需要在 Next.js 的 Route Handler 里把图跑起来。这里有两种姿势第一种同步等待结果返回。适合内部测试或者不要求实时反馈的场景。用graph.invoke等整条流程跑完再把report拿出来返回。export async function POST(req: Request) { const { rawText, jdText } await req.json(); const result await graph.invoke({ rawText, jdText }); return Response.json({ report: result.report, score: result.score }); }第二种流式输出。我推荐生产环境用这种。用graph.stream配合 SSE 协议把节点运行进度和 token 逐个推给前端export async function POST(req: Request) { const { rawText, jdText } await req.json(); const encoder new TextEncoder(); const readableStream new ReadableStream({ async start(controller) { const config { configurable: { thread_id: user_123 } }; const stream await graph.stream( { rawText, jdText }, { ...config, streamMode: messages } ); for await (const chunk of stream) { const content chunk?.[0]?.content; if (content) { controller.enqueue(encoder.encode(data: ${JSON.stringify({ content })}\n\n)); } } controller.close(); }, }); return new Response(readableStream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }注意这里我在graph.stream的配置里传了thread_id这是 LangGraph 区分会话的依据。同一个 thread 下图的状态会按时间线累积这也实现了用户在浏览器里刷新页面之后依然能从断点继续追问或查看历史。3.5 前端消费 SSE 的最小实现前端拿到这条 SSE 流用 fetch 读取即可不需要引入额外 SDKconst response await fetch(/api/evaluate, { method: POST, body: JSON.stringify({ rawText, jdText }), }); const reader response.body!.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const text decoder.decode(value); const lines text.split(\n\n).filter(Boolean); for (const line of lines) { if (line.startsWith(data: )) { const data JSON.parse(line.slice(6)); // 这里把 data.content 追加到生成结果区域 } } }核心点在于不要让用户傻等十秒什么都不显示。流式输出下用户可以第一时间看到Agent 正在解析简历、正在匹配 JD、正在生成建议这些过程体验完全不一样。3.6 Checkpointer状态持久化与断点恢复上面代码里我用了MemorySaver它把状态存在进程内存里重启就没了。本地开发没问题线上这么干必然出事因为 Serverless 环境每次请求都可能落到不同实例上状态根本对不上。生产环境要换成持久化的 checkpoint 实现LangGraph 官方提供了 Postgres 和 Redis 的版本。我是用 Postgres 的import { PostgresSaver } from langchain/langgraph-checkpoint-postgres; const checkpointer PostgresSaver.fromConnString(process.env.DATABASE_URL!); await checkpointer.setup(); const graph builder.compile({ checkpointer });这里踩过坑PostgresSaver在使用前必须调用一次setup()来初始化表结构不调用的话运行时会报错而且这个报错信息还比较隐蔽容易让你怀疑是连接池的问题。我第一次部署上线时就被这个坑卡了一个小时。4. 并发与成本上线前必须处理的现实问题4.1 一次评估任务的时间线拆解在做并发设计之前先得知道一次评估任务到底多长时间。我实测下来的典型耗时分摊环节耗时说明PDF 解析1~2 秒取决于文件大小和服务器资源简历结构化抽取3~8 秒大模型对整份简历做转换JD 搜索/获取1~3 秒网络请求耗时匹配打分3~5 秒需要让模型逐项评估生成建议报告5~10 秒输出长文本最耗时总时长在 15~25 秒之间而且几乎全是 LLM 调用时间。这决定了你不可能用同步请求等结果的方式扛并发。4.2 用流式降低体感延迟流式输出只是优化了用户体验并没有减少后端工作量。但它确实是第一层必须做的优化因为用户能看到进度心理等待阈值会大幅提升。另一个容易被忽视的点是LLM 的流式响应可以边生成边持久化。我在buildReport节点里每收到一部分 token 就把它追加写入到 report 字段的临时缓冲区。这样即使最终响应中断用户刷新后也能看到已经生成的部分而不是全部丢失。4.3 任务队列和并发上限流式解决了体感但解决不了上游 LLM API 限流。公开 API 通常有每分钟请求数限制比如 60 RPM 级别的配额十几秒一个任务同时来十几个用户就触顶了。我的处理方式是加一层任务队列。把用户请求先放进队列任务在后台消费前端通过轮询或 WebSocket 订阅任务状态。实现上可以直接用 BullMQ 配 Redis也可以用托管服务比如 Inngest 或 Trigger.dev。现在很多托管平台对 AI Agent 场景做了专门的适配直接把接入 Graph 的逻辑做成 Step 就可以。队列方案的另一个好处是你可以精确控制同时运行的 Graph 实例数比如固定 concurrency 为 5避免瞬间打满模型 API 配额。4.4 成本控制三板斧模型分级、缓存、Token 预算简历工具这类场景LLM 调用次数多token 开销是明确可算的。一份简历文本平均 2K~4K tokens加上 prompt 模板和输出一次评估大约消耗 8K~15K tokens。如果每天都有人用成本不能不看。我有三个实践模型分级。解析、抽取、打分这种固定流程走便宜模型比如 mini 档位的模型深度改写和建议走更强模型。早先我全程用顶配模型成本几乎翻倍但效果提升有限。结果缓存。同一个用户、同一份简历、同一个 JD在 24 小时内重复提交直接返回缓存报告。我用userId 简历内容hash JD hash做缓存键省了 40% 的调用量。Token 预算控制。简历文本超过 8000 tokens 时先让模型做摘要压缩再进后续流程。不要一封简历动辄几万字全塞进上下文价格和响应时间都受不了。5. 踩坑实录四个问题让我重新理解了这套架构5.1 坑一状态合并的 reducer 没写对建议被吞了跑通第一版后测试人员反馈有时候用户报告里只有一条建议明明应该在多个环节生成多条。排查过程很痛苦因为不同用户的表现还不一样。我一开始怀疑是模型输出问题后来加了日志追踪每个节点的返回值才发现suggestions字段的 reducer 写错了用的覆盖逻辑(_, next) next后面节点返回的建议把前面节点覆盖了。修复方法就是前面写的改成数组展开合并suggestions: Annotationstring[]({ reducer: (pre, next) [...(pre ?? []), ...(next ?? [])], }),这个坑提醒我状态字段的合并规则不是小事它决定了一套流程能不能正确累积中间产物。每个字段都要明确是覆盖还是累加。5.2 坑二SSE 流式被平台缓冲前端等了个寂寞本地调试流式输出完全没有问题一部署到线上前端就变成要等整个请求完成之后才一次性拿到全部内容。打开浏览器 Network 面板发现响应头里Content-Type确实是text/event-stream但带上线的环境干了一件坏事——缓冲。中间经过一层网关或反向代理的时候把 SSE 数据攒到一个阈值才返回。排查链路是这样的先确认代码没问题再用 curl 直接打线上接口发现同样被缓冲然后停掉网关直接绕到源站发现流式正常最后定位到是代理环境的 buffer 没有关闭。处理方法有两个一是手动设置响应头加入X-Accel-Buffering: no这可以禁用类 Nginx 环境的代理缓冲二是把平台侧的强制禁用缓冲打开。我在代码中新加了一个头headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, X-Accel-Buffering: no, }加上之后线上流式恢复。5.3 坑三PDF 解析乱码结构化信息缺了一半测试用户上传的简历千奇百怪有从招聘平台导出的有 WPS 生成的有扫描件转的。pdf-parse在解析含中文文本层的 PDF 时基本正常但遇到扫描版出来的就是乱码或者空文本。这个问题直接冲击后续所有节点因为rawText是空的后面的抽取和匹配全是在瞎编。我的处理策略分三层优先用pdf-parse解析文本型 PDF这个库对标准中文文本层的支持还可以。解析之后检查文本长度太短或者乱码比例过高时自动转 OCR 方案。OCR 我用的远程接口要注意延迟和成本。前端同时提供直接粘贴简历文本的入口作为兜底。上线后我发现这个粘贴文本的入口使用率不低于上传 PDF因为很多用户简历是从网页复制粘贴的。最后这条经验很实用不要把所有希望寄托在文件解析上给用户一条手动粘贴的路径省心得多。5.4 坑四结构化输出偶发字段丢失用 schema 兜底让模型把简历转成结构化 JSON偶尔会出现 missing 字段。比如education整段丢失或者workExperience里缺了时间字段。排查之后发现单纯在 prompt 里写请严格按照 JSON 格式输出是不够的模型偶尔还是会自由发挥。正确写法是绑定结构化输出让模型输出自然遵循 Zod schemaconst parserModel model.withStructuredOutput(resumeSchema); const result await parserModel.invoke(请提取以下简历中的结构化信息\n${rawText});resumeSchema用 Zod 定义好之后模型输出会自动套一层 schema 校验。我在节点内部再包一层 try-catch一旦解析失败就重试一次。两次失败才走兜底逻辑让用户确认是不是上传的文件有问题。还有一个隐藏问题若模型的输出会让 JSON 解析抛错LangGraph 节点内部抛的异常会一路传到graph.stream的调用方。所以生产环境一定要在跑流的外层做错误捕获不要把它当成同步接口那样裸奔。6. 落地后的个人体会整个项目从原型到上线大概花了两周多。最深的体会是AI Agent 真正难的不是智能而是把流程确定化。把简历解析、JD 匹配、报告生成拆成带状态的节点把用户的临时干预做成中断和恢复这套架构带来的收益在项目前期不显眼一旦开始加功能、修问题、扛并发优势就非常明显了。我也越来越认同一个观点Agent 落地不需要一上来就整最复杂的多智能体协作从一个目标、一套工具、几个节点的小工具开始你反而能更快跑通从模型调用到产品交付的全链路。简历工具只是第一个场景。同样的架构我已经在往合同审查、项目复盘这类有固定步骤的辅助工具上迁移每次迁移的成本都比我预想的低不少。