ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用LangGraph.js构建简历处理Agent:从状态机设计到生产部署全记录

用LangGraph.js构建简历处理Agent:从状态机设计到生产部署全记录 我做过不少所谓“AI项目”大多数停在demo阶段一个输入框、一个调GPT的按钮、一份流式输出的结果就算“AI赋能”了。这次不一样。我要做的不是一个“套了壳的聊天机器人”而是一个真正能替代人工完成整套简历处理流程的Agent系统。用户上传一份PDF简历系统内部独立完成“解析—评分—诊断—分项优化—复核—导出”的完整闭环期间不需要用户一点一点喂上下文也不需要人工来回搬数据。项目技术栈锁定在Next.js与LangGraph.js前端用Next.js App Router承载交互后端用LangGraph.js做Agent状态编排中间穿插PDF解析、OCR、结构化输出校验、流式推送、Server Actions等一堆实操细节。这篇文章把整个落地过程摊开讲重点写清楚三件事为什么选LangGraph.js而不是自己维护状态机、简历场景里Agent工作流如何拆解成可运行的图结构、以及从本地联调到生产部署我都踩过哪些坑。适合已经会写React和基础Node接口、想跨进Agent工程化但还没摸清全貌的开发者也适合打算把LLM从“玩具聊天”升级为“业务工具”的团队参考。文章里所有方案都是我实际跑通并上线过的不写概念只写动手过程。1. 为什么简历场景值得上Agent架构从“一个API调用”到“一条工作流”先说结论简历工具是当前最适合练手Agent工程化的场景之一。原因很简单——它天然是一条多步骤、有状态、需要工具调用的流水线而不是一次问答能解决的事。我在设计之初也犹豫过简历解析和优化拆成几个独立接口不就行了前端先调解析接口再把结果喂给GPT最后再调一次生成PDF接口根本不需要Agent的“智能体”概念。这样想没错但做出来的东西会有三个硬伤。1.1 只调LLM的简历工具差在哪第一个硬伤是上下文断层。一次API调用意味着一次请求只能看到这一次传入的数据。简历分析需要综合“原始文本、结构化字段、各维度评分、行业对标信息、历史修改记录”五类上下文。如果你用普通接口就必须把所有数据拼进prompt里Token翻倍不说关键信息的权重还会被稀释。第二个硬伤是错误处理。PDF解析出乱码、某段工作经历写得太水评分过低、优化结果不合格……这些情况如果散落在一堆if/else里代码会变成一座屎山维护成本远超Agent。第三个硬伤是流程不可观测。用户想知道“我的简历哪里有问题”你需要展示中间评分、诊断结论、优化前后对比而这些状态在处理过程中是不断变化的。普通接口只能返回最终结果中间过程一片黑盒。1.2 LangGraph.js在这里扮演什么角色LangGraph.js的核心价值是把Agent从“一个函数调LLM”升级成“一张有状态的工作流图”。你可以把整个处理过程定义成一张有向图图的每个节点是一个独立函数比如“解析节点”“评分节点”“优化节点”节点之间用边连接边可以带条件比如“评分低于60分才进入优化节点否则直接跳过”。这种建模方式跟简历处理流程的契合度非常高因为工作流天然适合用图表达而不是用线性代码硬串。具体到实现上LangGraph.js会替你管理整个流程的状态。节点函数接收一个State对象处理完返回部分State更新LangGraph内部会把所有节点的返回数据合并起来传给下一个节点。这相当于给整条工作流建了一个共享内存每个节点只管自己那一段逻辑互相之间不用写一堆参数传递代码。1.3 整体系统拓扑与请求主链路我在生产环境跑通的链路分成四层。最上层是Next.js服务端路由负责接收上传文件、鉴权、触发工作流第二层是LangGraph.js定义的工作流编排层内部包含解析、评分、优化、复核四个主节点第三层是工具层包括PDF解析、文档转文本、文本切片等函数最底层是模型与向量层我用的是OpenAI的gpt-4o-mini做主要推理模型部分高要求的评分节点会临时切到gpt-4o向量检索只在比对岗位要求与简历技能时用到。一次完整请求的路径是这样的用户在浏览器上传PDFNext.js的Server Action接收到文件流保存到临时目录然后调用工作流的入口节点。LangGraph先跑解析节点把PDF转成纯文本再让LLM从纯文本中抽取结构化字段得到一份NormalizedResume对象。接着进入评分图分支并行执行四个评分子节点硬技能、软技能、叙事能力、量化成果各自写回评分结果。汇总评分后进入诊断节点产出整体诊断报告。如果诊断结论命中“需要优化”的条件边就进入优化子图按板块逐段改写每次改写后自动做一轮复核复核不通过就退回重写最多迭代3次。最后把优化结果和原始文本一起写回状态通过流式通道推送到前端。这段主链路看起来复杂但落实到代码里就是一张清晰的状态图。我在代码目录里把工作流拆成了graph/index.ts、nodes/、edges/、state/四个模块项目结构如下src/ app/ api/analyze/route.ts actions/upload.ts agent/ graph.ts state.ts nodes/ parseNode.ts scoreNode.ts optimizeNode.ts reviewNode.ts edges/ conditionalEdges.ts utils/ pdfParser.ts textSplitter.ts llmClient.ts zodSchemas.ts2. 状态层设计先把“Agent的记忆”画出来再写代码做LangGraph.js项目最容易犯的错误是一上来就写节点函数结果状态越传越乱。我的经验是先把状态Schema完整定义出来就像给数据库建表一样再做节点设计。状态Schema决定了你的Agent能记住什么、遗忘什么也决定了每个节点能读到什么数据。2.1 状态Schema建模以NormalizedResume为核心的类型体系我定义了一个ResumeState接口它是整张工作流图的“全局记忆”。字段分四组原始数据组originalFile、rawText、结构化数据组resume、处理过程组scores、diagnosis、optimizations、reviewFeedback、元信息组currentStep、iterationCount、error。export interface ResumeState { // 原始数据 originalFile?: FileData; rawText: string; // 结构化简历 resume?: NormalizedResume; // 评分与诊断 scores?: ScoreSet; diagnosis?: DiagnosisReport; // 优化结果 optimizations?: OptimizationResult[]; reviewFeedback?: string[]; // 流程控制 currentStep: StepId; iterationCount: number; error?: string; }这个Schema里最值得注意的设计是iterationCount字段。它专门用来防止优化节点陷入无限循环。我在条件边上写了判断逻辑如果复核节点给出的反馈里连续两次出现“无实质改进”或者迭代次数超过3次就直接终止优化流程把当前版本标记为“需人工复核”。这也是Agent工程化和普通脚本最大的区别——脚本遇到问题就崩Agent要学会在可控范围内自我收敛。2.2 工作流的四个阶段解析、体检、开方、复核我把整条流水线分成四个语义阶段。解析阶段不直接调LLM做抽取而是先用正则和文件解析库把PDF转成结构化初步数据再让LLM做字段补全与纠错。这样可以省大量Token因为LLM不需要重新理解版面只处理缺失字段。体检阶段对应评分与诊断产出量化得分和文本诊断意见。开方阶段是优化节点按“工作经历、项目经历、技能描述、自我评价、教育背景”五个板块分别重写每个板块互相独立方便并行执行。复核阶段负责对优化后的文本打分对比判断是否符合优化预期。2.3 条件边与迭代上限防止优化循环失控条件边是LangGraph.js里最灵活也最危险的地方。危险在于如果条件写得不严谨工作流会在不该分流的地方分流或者在该终止的地方继续跑。我的做法是把所有条件判断集中在edges/conditionalEdges.ts里统一导出判断函数。export function shouldOptimize(state: ResumeState): optimize | finalize { const overall calculateOverall(state.scores); return overall 75 ? optimize : finalize; } export function shouldRewrite(state: ResumeState): rewrite | finalize { if (state.iterationCount 3) return finalize; const lastFeedback state.reviewFeedback?.[state.reviewFeedback.length - 1]; return lastFeedback?.includes(无实质提升) ? finalize : rewrite; }shouldOptimize控制的是“是否需要优化”shouldRewrite控制的是“优化后复核不过是否重写”。两条边配合再加上iterationCount上限整个工作流就不会出现死循环。实测中绝大多数简历在第二轮重写时已经能通过复核第三轮基本是兜底保障。3. 简历解析这根硬骨头PDF、DOCX、扫描件的实战打法如果说Agent工作流是骨架那解析节点就是最容易漏血的血管。我第一版天真地以为PDF转文字是件小事实际被现实毒打了两天。简历PDF的格式复杂程度超乎想象多栏排版、字体嵌入、表格嵌套、图片型PDF、扫描件、加密文件……每一种都能让解析结果变得像被猫踩过的键盘。3.1 为什么解析是整条链路里最“脏”的环节简历解析的难点不在技术本身而在“未知的输入”。同样一份工作经历有人写成“负责XX项目的开发和维护”有人写成“2019.03-2021.06 | 高级工程师 | XX科技有限公司主导XX系统重构提升性能30%”。格式千变万化但字段就那么几类。所以解析节点不能用单一的硬规则我采用的是“硬解析兜底LLM抽取补全”的混合策略先用解析库跑一遍能拿到的字段直接用拿不到的字段或者明显错乱的字段切割成文本块交给LLM做结构化补全。3.2 文本类文件的解析与容错对于文本型PDF我用了pdf-parse这个库做提取。它的优点是轻量不需要外挂二进制工具在服务端环境部署非常省事。但它的提取逻辑对多栏布局不友好会把左右两栏的内容混在一起读。我的解决方案是先检测PDF页面宽度和文本块坐标如果发现文本分布呈左右两栏特征就按坐标切分成左栏/右栏分别读取再按阅读顺序合并。import pdf from pdf-parse; export async function parseTextPDF(buffer: Buffer): Promisestring { const data await pdf(buffer); // 按坐标切分处理多栏排版 return reorderByColumns(data.text, data.pages); }DOCX解析我用的是mammoth它可以把docx转成HTML再抽取纯文本保留段落结构。docx文件在招聘场景里占比不低很多人用WPS导出的简历模板本身就是docx格式。mammoth对中文支持没问题关键是要把转换后的HTML里多余的空行和样式标签清干净否则会浪费LLM的Token空间。3.3 扫描件与图片简历OCR的取舍扫描件才是最考验耐心的地方。我实际测试过清晰扫描件用Tesseract.js就能有不错效果但手机拍的歪斜照片、带着水印的截图、低分辨率的扫描件准确率直接塌方。后来我换了一条路先让Tesseract出来一版结果同时把整页图扔给多模态模型做一次“看图识字版面理解”两版结果交给一个轻量校验函数取交集。多模态模型的识别准确率明显高因为它能结合版面语义判断“这是公司名”“这是时间段”成本也更高但我只对检测到“图片占比过高”的页面启用这条链路日常简历基本用不到。3.4 让LLM稳定吐出结构化JSONZod校验与重试解析节点的最后一个步骤是把rawText规整成NormalizedResume对象。这里不能直接依赖LLM的JSON输出因为gpt-4o-mini偶尔会在JSON里加注释、截断、或者输出多余的markdown标记。我的办法定了两个第一在system prompt里只给JSON Schema示例不给对话式引导第二在代码侧用Zod定义Schema对LLM输出做严格校验校验失败就自动重试最多重试两次。const ResumeSchema z.object({ personal: z.object({ name: z.string().optional(), email: z.string().optional(), phone: z.string().optional(), location: z.string().optional(), }), workExperience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string(), description: z.string(), })), education: z.array(z.object({ school: z.string(), degree: z.string(), major: z.string().optional(), })), skills: z.array(z.string()), projects: z.array(z.object({ name: z.string(), role: z.string().optional(), description: z.string(), highlights: z.array(z.string()), })), });这里有个实测经验值得分享给LLM的JSON示例里日期格式必须明确。我在第一版里让LLM“自由发挥日期”结果出现了“2020年3月”“Mar 2020”“2020/03”三种格式后续所有评分逻辑都要额外做归一化。后来我直接在示例里统一成“2020-03”并把格式要求写死进约束里这类脏数据一下子就不见了。4. 评分Agent把“我主观觉得不错”变成可复查的结论评分节点是整个系统里最影响用户体验的部分。用户上传一份简历最想看到的就是“我的简历几分哪里扣分了”。但“简历质量”本质上是个主观概念同一个人的简历HR和程序员看完能给出完全不同的评价。所以我在设计评分Agent时刻意没有做成“一个LLM打分”而是做成了“一组评分函数并行执行”。4.1 评分维度设计ATS、硬技能、叙事质量、量化成果总共四个维度。ATS兼容性维度检查有无明显影响机器筛选的问题比如是否为图片型简历、技能是否可被关键词匹配、日期格式是否统一。硬技能维度检查岗位相关技能是否有足够证据支撑而不只是技能列表里堆名词。叙事质量维度检查每条工作经历是不是有“动词开头做了什么怎么做的结果如何”的完整结构。量化成果维度检查有没有用数字、比例、等级来证明影响力。“为什么设计四个维度而不是一个总分”这是我在做评分节点时反复问自己的问题。答案很简单一个总分没法指导用户改简历。用户看到“总分72分”不会知道该改哪里但看到“量化成果维度只有35分因为你的工作经历里只有1处出现数字”就知道该补充什么了。四个维度会写入诊断报告每个维度下面还会生成扣分项明细方便优化节点做定向改写。async function scoreResume(resume: NormalizedResume): PromiseScoreSet { const [atsScore, hardSkillsScore, narrativeScore, quantScore] await Promise.all([ scoreATS(resume), scoreHardSkills(resume), scoreNarrative(resume), scoreQuantifiedResults(resume), ]); return { ats: atsScore, hardSkills: hardSkillsScore, narrative: narrativeScore, quantified: quantScore }; }4.2 流式输出与图表实时渲染的实现细节评分结果不能等全部算完再返回用户体验太差。我做了两步第一步是前端发起分析请求后界面立刻进入“分析中”状态后端先推送一条“已收到文件开始解析”的事件第二步是四个评分节点各自算完后立即推送对应维度的得分前端用轻量折线图和雷达图实时渲染。每一帧只更新一个维度的数据视觉上比“转圈5秒后突然全部出现”要自然很多。流式推送的实现并不复杂。Next.js端定义一个POST请求的Route Handler内部调用LangGraph工作流并把事件的EventEmitter挂到响应对象上前端用ReadableStream读取。4.3 局部评分函数与快速失败机制评分节点里我实现了“快速失败”机制。所谓快速失败不是指报错而是指“当前维度分数过低时提前结束这个维度的后续评分细节”。比如ATS维度如果已经检测到“简历是图片型”那就不用再检查日期格式和关键词密度了直接给低分并标记原因。这样可以节省Token调用也能避免把低分简历硬分析出一堆没用的细项。在评测的案例里这个方法让单份简历的评分Token消耗平均下降了18%。5. 优化Agent多轮改写、上下文记忆与人工兜底评分之后是优化这是最容易做成“大杂烩”的环节。我在初版里试过“一次提示词让LLM把整份简历改好”效果惨不忍睹工作经历部分改得不错但技能描述开始瞎编自我评价写得像AI说明书。后来我彻底放弃了“一次成型”的思路把优化拆成三步子流程。5.1 为什么优化不能一次生成拆成“诊断-改写-复核”三步第一步是生成每个板块的具体修改建议不直接改原文。比如工作经历板块先让LLM指出“第3条经历缺乏结果描述、第5条经历动词单一、项目描述篇幅过长”输出诊断型文本。第二步是把诊断建议和对应原文块交给改写函数让LLM基于建议重写。第三步是复核把改写后的文本和原文一起交给评审函数让评审函数从“真实准确、简洁有力、信息增量”三个维度做对比打分。三项都在合理范围才通过否则标记为“建议人工介入”。这套“诊断-改写-复核”结构有个额外好处它天然适合可观测的Agent。用户在前端能看到每一轮的诊断结论、改前改后对比以及评审打分。没有人会把修改后的简历无脑采纳所以透明度比“生成一条龙”重要得多。5.2 MemorySaver让多轮对话不丢前文LangGraph.js提供了MemorySaver这个checkpointer实现它的作用是保存工作流每次运行的状态快照允许你在后续节点里读取历史状态。我在优化环节里用了两层记忆一层是线程级记忆保存整份简历的原始数据与评分结果另一层是对话级记忆保存每一轮优化迭代中的诊断、改写、复核反馈。这样在改写第三个板块时Agent能记住它已经优化过第二个板块的风格不会出现前后风格断层。import { MemorySaver } from langchain/langgraph; const memory new MemorySaver(); const workflow new StateGraph(ResumeStateSchema) .addNode(parse, parseNode) .addNode(score, scoreNode) .addNode(optimize, optimizeNode) .addNode(review, reviewNode) .addEdge(parse, score) .addConditionalEdges(score, shouldOptimize, [optimize, finalize]) .addEdge(optimize, review) .addConditionalEdges(review, shouldRewrite, [optimize, finalize]) .compile({ checkpointer: memory });5.3 优化结果的版本管理与人工编辑兜底这里说一个容易忽略的点优化后的简历不能直接覆盖用户原稿。我在数据模型里设计了optimizations数组每一项都带有originalText、optimizedText、板块名、修改理由。前端界面上用户可以逐条查看并勾选是否采纳某一条修改。被采纳的修改才会合并进最终版本的简历里。这个设计一开始被团队成员说是“过度设计”上线后验证非常必要——有超过三成的用户会拒绝一部分AI改动尤其是项目经历里的技术栈描述AI可能会生成一个用户实际没用到过的技术名词。额外给一条防坑建议不要在任何环节让LLM“补充”用户实际的技能。用户没写不确定会这是简历工具的基本伦理。一旦优化结果里出现用户不掌握的技术栈对面试来说是灾难。我在优化节点的system prompt里写死了这句话“仅润色原有信息禁止新增用户未提及的经历、技能、证书或成果。”并在复核节点的评审规则里加入“信息幻觉检查”检测到新增等级、奖状、公司名等实体时直接不通过。6. Next.js前端胶水层Server Actions、流式推送与部署避坑Agent工作流再强大用户最终面对的还是一个网页。Next.js在这场项目里的角色是把LangGraph状态机接到浏览器上的胶水层。我用了App Router加Server Actions的组合实现对普通后端接口模式的简化。6.1 Server Actions与流式响应既能提交又能推送初次联调时我有个困惑Server Actions设计上是“提交后返回”的模型而我的工作流是“边跑边推送”的二者似乎冲突。试了几种方案后我确定下来的做法是文件上传用Server Action因为上传本身是一次性的但分析过程的流式推送不走Server Action而是用一个Route Handler。也就是“提交走Server Action进度走Streaming API”一个负责写一个负责读各司其职。理论上来讲Server Actions内部也可以返回ReadableStream但实测在App Router的某些版本上表现不稳定所以我没选这条路。6.2 前端流式解析协议按块拆包前端连接的是GET /api/analyze/stream?taskIdxxx这个接口后端通过Server-Sent Events格式推送事件。之所以用SSE而不是WebSocket是因为这个场景是单向数据流服务端→客户端不需要持久的双向通道SSE更轻、断开自动重连、在浏览器兼容性上也够稳。事件格式我定义成这样event: status data: {type:parsing,message:正在解析PDF...} event: score data: {dimension:ats,score:45,detail:检测到图片型简历内容占比过高} event: output data: {type:optimization,section:workExperience,status:done}前端用一个EventSource对象连接根据事件类型分发到不同的状态切片。这里有个小坑LangGraph工作流内部如果跑太久比如单份简历超过60秒SSE连接可能被中间代理切断。我的对策是给SSE加心跳注释行每15秒发一个: keep-alive注释代理看到数据流还在就不会断开。6.3 部署时的三个坑超时、运行时、API费用部署到生产环境时踩了三个具体坑都值得记录。第一个坑是函数超时限制。Vercel的Serverless函数默认最长执行时间是10秒Hobby计划而我解析一份大型PDF加上评分流程经常超过30秒。我的解决方案是上传文件后立即持久化任务记录分析流程放到后台任务系统执行前端通过轮询或SSE拿到任务状态。前台接口永远是秒回繁重的Agent工作流则异步跑。如果你不用Serverless而是自己部署Node服务就没有这个限制但你得自己处理并发队列与任务持久化。第二个坑是运行时环境。LangGraph.js默认依赖Node.js API比如Buffer、process、fs这在Edge Runtime下会直接报错。我一开始图快把API都挂在Edge Runtime上结果发现解析库和LangGraph都依赖Node全局对象被迫把所有相关Route Handler切回Node Runtime。如果你也要上Vercel记得在route.ts里导出export const runtime nodejs;或者直接在next.config里设置全局runtime为nodejs。第三个坑是Token与API费用。评分节点如果每次都上gpt-4o成本会非常感人。我的成本控制策略是解析和优化用gpt-4o-mini评分里只有“叙事质量”和“量化成果”两个主观维度用gpt-4oATS与硬技能维度用规则引擎加轻量模型。一套组合拳下来单份简历的模型成本大约在0.02-0.05美元之间性能没有明显下降。7. 全程复盘我从这个项目里真正拿走的东西项目上线跑通之后我最大的体会是Agent工程化不是“把多个LLM调用串起来”而是“把决策和控制流交还给程序把理解和生成交还给模型”。LangGraph.js让我用图的思维设计了整个工作流每个节点职责单一、状态显式传递、条件分支写在明面上代码的维护性和可观测性都远超我之前用纯代码串流程的做法。如果要给正在做类似项目的朋友三条建议我会说第一先定状态Schema再写节点。状态就是Agent的记忆记忆混乱的Agent写多少节点都是白搭。第二流式输出不是可选项是刚需。用户对“等待AI处理”的耐心非常有限让中间过程可见能显著降低焦虑感同时也会让你的系统看起来更专业。第三给Agent设计刹车。无论条件边还是迭代上限都要让工作流在可控范围内结束永远不要相信模型会自己“适可而止”。这个简历工具下一步我打算加上岗位JD匹配模块让用户贴一段岗位描述Agent再基于岗位关键词做定向优化。那个场景对结构化状态和条件边的要求会更高但因为有现在这套工作流骨架打底我对可行性很有信心。项目完整代码我已经整理好在本地跑通后会上线到个人仓库到时候再写一篇部署实战出来。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进