ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于LangGraph.js与Next.js构建简历优化AI Agent实践复盘

基于LangGraph.js与Next.js构建简历优化AI Agent实践复盘 我去年年底给自己定了个小目标用 LangGraph.js 给简历工具场景做一个能跑的 AI Agent前端框架选 Next.js后端编排层全部用 LangGraph.js最终落地成一个“扔进 JD 和旧简历出来一版定向优化后的新简历”的完整工具。这个项目做完之后我最大的感受是AI Agent 的难点从来不是“调用大模型”而是“怎么把一个大任务拆成可控的步骤并在每一步之间稳定地传递状态”。简历工具恰恰是这样一个典型场景——输入是杂乱的 PDF/Word中间要经历解析、结构化、对标 JD、逐条改写、排版建议多个环节输出必须是能被 HR 一眼看中的文档。如果只靠一个巨大的 prompt 硬怼效果基本靠运气。这篇文章把整个项目的来龙去脉、架构设计、核心实现、部署运维和踩坑记录完整复盘一遍。适合两类人看一是正在用 Next.js 做 AI 应用、想引入 Agent 编排但不知道从哪下手的开发者二是已经看过 LangGraph 文档、但对“真实业务场景怎么落地”还没有体感的朋友。我会尽量写清楚每一步为什么这么做而不只是贴代码。1. 项目全貌与选型思路1.1 简历工具这个场景到底难在哪简历工具看起来简单做起来很烦。表面的流程是“上传简历 粘贴 JD 点击生成”但背后要处理的真实问题是杂乱的同一份经历在不同人嘴里表述天差地别“负责xx工作”这种低信息量描述到处都是JD 里的关键词可能分散在职责、要求、加分项三段简历里完全没有对应表达PDF 转出来的文本经常伴随断行、乱码、列表符号错位更麻烦的是一份简历往往有多个版本用户可能想“保留我的原始经历事实但换一套话术”。如果不用 Agent只用一个 Python 脚本调大模型也能做一版能用的东西。但很快会撞墙简历超过 10 页时上下文装不下用户想“只优化工作经历、不碰技能标签”时单次生成无法精准控制局部文案改完后想换一种语气重写又得把全文重新生成一遍。这些需求背后隐含的共同点是任务需要逐步完成、每步之间需要交换数据、分支逻辑和人工干预点都是自然存在的。这就是 Agent 编排该出场的地方。1.2 对比直接调大模型 手写流程 vs LangGraph.js先说结论LangGraph.js 不是银弹但对于简历工具这类“多步骤、可变流程、需要状态共享”的 AI 应用它比“手写 if/else 串 prompt”要舒服得多。传统做法是自己在代码里写一个 pipelineconst text await parsePdf(file) const sections await splitIntoSections(text) for (const section of sections) { const improved await callLLM(section, jd) ... }这套写法在流程固定时没问题但只要加入一个“根据上一步结果决定下一步走哪个分支”的逻辑代码就开始变成面条。比如“简历里没有项目经历就跳过项目优化节点改为生成项目经历补充建议”用普通函数写不是不行但状态很难追踪哪一步用了哪些 token、哪一步重试过、哪一步改了上下文、用户中途点了“停止”之后怎么恢复全部要自己维护。LangGraph.js 给的是图结构节点是函数边是路由规则所有中间数据放在一个 State 对象里。好处有三个一是流程可视化非常直观出问题能直接看出是哪条边断了二是每条边都支持条件路由不用写一长串嵌套 if三是断点续跑和人在回路human-in-the-loop内置在工具里很适合简历工具这种“生成完还得让用户自己改”的场景。我选 LangGraph.js 还有一个现实理由团队技术栈是 TypeScript 全家桶我不想为了一个 Agent 编排器再引一套 Python 生态。LangGraph.js 目前虽然比 Python 版年轻一些但核心概念已经完全对齐对 Next.js 应用来说是零额外服务负担的。1.3 整体功能边界这个项目最终锁定的功能范围是解析 PDF/Word/DOCX 格式的简历提取文本并归档成结构化字段支持粘贴一份完整 JDAgent 自动抽取关键要求与必填关键词按“教育经历、工作经历、项目经历、技能标签、个人总结”分模块重写提供三种改写风格保守、平衡、激进输出带批注的 Markdown并在前端渲染成可编辑表单保留原始简历内容只在用户确认的模块上做改动。不要试图做“空简历自动生成一份吹得天花乱坠的假简历”那既不可控也不道德。工具的价值是放大真实经历的表达力不能无中生有。2. 核心架构与状态流转设计2.1 从“调一次大模型”到“Agent 状态机”把大任务状态化的思路是 LangGraph 的灵魂。简历 Agent 的 State 我定义成如下结构type ResumeState { rawText: string // 原始解析文本 fileName: string jdText: string // 用户输入的职位描述 jdKeywords: string[] // 从 JD 抽出的关键词 sections: Section[] // 结构化的简历段落 optimizationPlan: PlanItem[] // 每一步要改什么怎么改 currentSectionIdx: number improvedSections: Section[] reviewNotes: string[] // 给用户看的说明 }这里的核心设计决策原始简历文本、JD 文本、结构化结果、改写结果、评审意见全部共存于 State。一开始我也想过只存改写结果后来发现不行——用户经常想对照“原简历怎么说的、Agent 改成什么了”如果没有原始内容前端做对比展示就是空话。另一个决策是不在解析节点里直接让大模型输出完整结构化 JSON而是先把纯文本抽出来再用一次性 prompt 做结构化抽取。原因是 PDF 解析出来的文字噪声太多如果直接喂给模型做端到端结构化很容易出现模型“幻觉补出原简历里不存在的内容”。2.2 图的节点划分与条件路由我把整个流程拆成六个节点parseResumeNode jdAnalyzeNode planNode rewriteNode reviewNode renderNode其中 parseResumeNode 是纯工具节点不调大模型jdAnalyzeNode 和 planNode 各调一次大模型rewriteNode 会根据 section 数量循环调用多次reviewNode 调一次模型做整体质感校验renderNode 是纯函数负责把结构化结果拼成 Markdown。有条件路由的地方有两处一是在 rewriteNode 内部如果某个 section 的内容为空比如“项目经历”整块缺失会走“补充建议生成”而不是“改写”二是在 reviewNode 结束后如果检测到改写内容与原简历事实冲突比如把“3 年经验”写成了“5 年”会回到 rewriteNode 重新处理对应段落重试上限两次。这个图结构不是一次定稿的。我在第一版里把 jdAnalyze 和 plan 合并成了一个节点后来发现 prompt 太长、输出不稳定拆开之后两个节点各自专注一件事准确率明显提升。2.3 Next.js 与 LangGraph 如何衔接Next.js 在这套架构里扮演两个角色对外提供 HTTP 接口对内承载 LangGraph 运行时。在 App Router 下我建了三个 API route/api/resume/parse接收文件上传返回解析后的结构化文本/api/resume/generate接收 JD 文本和解析结果执行整个 LangGraph 图返回流式更新/api/resume/save用户前端编辑完成后的保存接口不是 Agent 的核心链路。关键点在于LangGraph 的图对象是纯 TypeScript 类可以在 Next.js 的 Route Handler 里直接 import 并调用。不需要单独部署一个 Agent 服务也不需要引入消息队列这对中小型工具来说是最省事的方案。但要注意如果图中的节点要访问文件系统或长驻内存缓存就不能直接跑在 Vercel 的 serverless 函数里。我的解决办法是所有文件解析逻辑封装在独立的工具函数中临时文件落地到/tmp用完即删长耗时的 rewrite 循环用 streaming response 方式逐段推给前端。3. 核心实现从零搭一个可跑的简历优化 Agent3.1 项目初始化和依赖安装我用的是 Next.js 14 TypeScript。初始化命令就按标准模板来npx create-next-applatest resume-agent --typescript --app然后安装 LangGraph.jsnpm install langchain/langgraph langchain/openai npm install pdf-parse mammoth说明一下为什么额外装pdf-parse和mammoth我没用那些重型文档解析服务PDF 场景其实只需要把文本层抽出来pdf-parse够用docx 场景用mammoth转成 HTML 再剥离标签比直接读原始 XML 省事得多。扫描版 PDF 是另一个问题文本层缺失时pdf-parse返回空内容。我最终的方案是接入 OCR 能力但这块是独立服务不在 LangGraph 图里避免把同步功能卡死在不可靠的环节上。3.2 解析节点先格式化再进模型解析节点的核心代码不复杂但有几个坑必须说明import pdfParse from pdf-parse; async function parsePdfBuffer(buffer: Buffer): Promisestring { const data await pdfParse(buffer); return cleanPdfText(data.text); } function cleanPdfText(text: string): string { return text .replace(/\r\n/g, \n) .replace(/[ \t]/g, ) .replace(/\n{3,}/g, \n\n) .replace(/[•·]/g, -) .trim(); }这里的cleanPdfText是我被真实简历教育出来的。PDF 文字经常出现“每行末尾断词”“项目符号变成乱码”“行间空行数量不一致”等 问题。如果不先做清洗后面大模型会把“-”和断行错认为是列表结构导致结构化抽取时把一句话拆成两段。清洗之后我把它喂给一个专门的结构化抽取节点。这个节点的 prompt 包含严格指令只识别真实存在的段落不补全、不联想、不增删经历事实。我会在 prompt 里强调“如果你发现某个字段在原简历中没有对应值为空字符串”。3.3 JD 分析节点关键词抽取是重头戏JD 分析节点要解决的核心问题是把一份职位描述变成“可执行的优化约束”。我的实现是通过大模型输出结构化 JSONconst jdParserPrompt 你是招聘领域的文本分析器。请分析以下职位描述并输出严格的 JSON { role: 职位名称, keywords: [硬性技能关键词, 工具关键词, ...], mustHaves: [...], niceToHaves: [...], tone: 该 JD 体现的企业文化倾向 } 要求 1. 关键词只抽取原文中明确出现的词不要扩展。 2. mustHaves 是那些不满足几乎不可能进面试的要求。 3. niceToHaves 是“加分/优先”类要求。 4. 不写解释直接输出 JSON。 这段 prompt 看起来简单但我花了很多时间调“不要扩展”这一项。模型很容易把“熟悉 React 生态”扩展成“React、Next.js、Vue、Angular”——这在关键词匹配场景属于严重污染会让后面的 rewrite 节点拼命往简历里塞没有事实依据的词汇。JD 分析完成之后的结果会同时被 planNode 和 rewriteNode 使用。这也是 Agent 比纯 prompt 有优势的地方一个节点产出的结构化产物可以被后续多个节点引用不用每次重新计算。3.4 编排图结构与条件回圈LangGraph.js 的图编排 API 和 Python 版概念一致。我这里用简化代码示例展示核心逻辑import { StateGraph, END } from langchain/langgraph; const graph new StateGraph({ channels: [rawText, jdText, sections, improvedSections, reviewNotes], }) .addNode(parse, parseResumeNode) .addNode(analyzeJd, jdAnalyzeNode) .addNode(plan, planNode) .addNode(rewrite, rewriteNode) .addNode(review, reviewNode) .addNode(render, renderNode) .addEdge(parse, analyzeJd) .addEdge(analyzeJd, plan) .addEdge(plan, rewrite) .addConditionalEdges(rewrite, (state) state.currentSectionIdx state.sections.length ? rewrite : review) .addEdge(review, render) .addEdge(render, END); export const app graph.compile();第一次看这个代码的人可能会困惑rewrite节点连到自己头上是怎么回事这是 LangGraph 里非常常用的循环模式。因为简历里有多个 section一个 rewrite 节点可以反复执行每次通过state.currentSectionIdx判断是继续处理下一个 section 还是进入 review。循环节点的设计有个细节值得说必须保证每个循环轮次都在 State 里体现“进度”。我在 State 里加了currentSectionIdx和processedCount一方面是给图本身做路由另一方面是可以把这个进度推给前端让用户看到“正在优化第 2 段 / 共 6 段”体验比长时间转圈好太多。3.5 Next.js API Route 里的流式调用LangGraph 的运行结果可以用 callback 机制逐段推给前端。核心思路是在节点内部把增量文本推入一个流然后 API Handler 用 ReadableStream 输出。export async function POST(req: Request) { const body await req.json(); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const events []; await app.invoke(body, { callbacks: [{ handleNodeEnd: async (nodeName, state) { controller.enqueue(encoder.encode(JSON.stringify({ node: nodeName, state }) \n)); } }] }); controller.close(); } }); return new Response(stream, { headers: { Content-Type: text/event-stream } }); }前端用fetch读取这个流按行解析 JSON实时更新 UI。对 Next.js 来说这比一次性返回全部结果要友好得多尤其是 rewrite 循环要跑好几十秒的时候用户至少能看到进度而不是面对一个空白页面。初版我图省事用了普通 POST 返回 JSON用户反馈“不知道是不是卡死了”之后才改成流式。这个教训提醒我AI 应用的输出一定要有过程性反馈等待期的用户体验直接决定工具的口碑。4. 让简历 Agent 真正好用的五个细节4.1 流式输出与进度状态流式输出不只是用户体验问题也是工程可观测性问题。每次handleNodeEnd拿到的 state 快照顺手写进日志就是一个天然的 trace。我后来做了一个简单的“最近 10 次生成记录”页面每次生成的时间、节点耗时、token 消耗都能看见排查问题非常方便。进度状态上前端用了一个 reducer 管理type ProgressState { currentNode: string | null; finishedCount: number; totalCount: number; }在 API 返回的流式 JSON 里每行都带node和finishedCount/totalCount前端顺序更新即可。不需要额外的心跳或 WebSocket一个简单的 SSE 流就搞定了。4.2 上下文窗口怎么控简历 Agent 会遇到的上下文问题一份排版花哨的简历解析出来的原始文本可能达到 8000 到 15000 个 token加上 JD 分析结果、优化计划、改写指令很容易突破上下文上限。我采用的策略是分段进入模型而不是整篇塞进去。具体做法是rewriteNode 每次只处理一个 section把该 section 的原文、JD 关键词、优化计划作为上下文投喂给大模型。这样每一轮调用的输入控制在 2000 token 以内稳定且省钱。这里有一个必须强调的细节在分段改写时要把“已改写的其他部分摘要”塞进上下文否则模型会失去全局一致性。比如“你的简历中已有项目经历部分强调过 React 性能优化现在处理工作经历时不要再重复同样的描述”。我在 planNode 里为每个 section 生成了一句“协作约束”改写节点会带上这句约束实测可以减少不少重复表达。4.3 定向优化与关键词匹配定向优化是这个工具最有价值的功能。JD 里的关键词不是拿来堆砌的而是要“自然融合”。我在 rewriteNode 的 prompt 里做了三层指令事实层不得新增原简历不存在的经历、年限、项目。表达层使用 JD 中的关键词替换或优化原简历的笼统表述但前提是原事实支持这种表述。结构层每条经历尽量遵循“动作 工具/方法 可量化结果”的句式。这里的“可量化结果”是重灾区。模型特别喜欢把“负责xx项目”改成“负责xx项目提升效率 30%”。这个数值在原简历里根本没有属于幻觉。我的补救方式是在 rewriteNode 之后增加一个 reviewNode专门检查数值型内容是否能在原简历中找到出处找不到就标记给用户“请确认以下数据是否真实”而不是直接删掉或直接保留。后来我甚至把 reviewNode 的输出分成两类hardErrors和softSuggestions。hardErrors 必须打回重写softSuggestions 只做提示。这样既保证事实准确又保留了模型的表达空间。4.4 版本管理与按模块确认一个实用的简历工具一定要支持“按模块确认”而不是一键覆盖原简历。我实现了一个简单的版本管理生成结果以{baseResumeId, version, createdAt}存储到数据库前端每次展示会把原始 section 和改写 section 左右并排用户可以选择“采用新版”或“编辑后采用”也可以对单个 section 点击“还原为原始版本”。数据库表结构是CREATE TABLE resume_versions ( id SERIAL PRIMARY KEY, base_resume_id TEXT, version INT, content JSONB, used_sections TEXT[], created_at TIMESTAMP DEFAULT now() );这个功能虽然不在 Agent 图里但它决定了用户信任感。AI 工具最怕的是“改完之后我找不回原文了”有了版本对比用户的试错成本就大幅降低也更愿意调大生成力度。4.5 私有数据安全与合规细节简历是高度敏感的隐私数据这个点必须认真对待。我在项目里做了三层处理第一层传输与存储上传的文件只保留用于解析解析完成后立即从临时存储删除结构化内容允许用户选择“不保存到服务端”纯前端保留。第二层模型调用所有发给大模型的请求都经过统一的接口层日志中不记录简历原文只记录 token 量和节点类型。第三层理解保障在 UI 里明确写清楚“本工具将把你的简历内容发送给第三方大模型服务”让用户决定是否继续。这三层不是可有可无的。简历工具一旦口碑传开用户会把你当成职业数据管家任何一次数据泄露都会让项目归零。5. 实操过程中踩过的坑与排查思路5.1 PDF 解析的“隐形炸弹”PDF 解析是第一道关卡也是坑最多的地方。我强烈建议你在上线前准备一个“脏简历测试集”扫描版 PDF、双栏排版 PDF、中英文混排 PDF、包含各种特殊符号的 PDF。这个测试集能帮你尽早发现解析问题而不是等用户反馈。遇到的典型问题是pdf-parse对双栏 PDF 的文本顺序经常是乱序的——先读完左栏再读右栏或者按行交又读取。简历里最常见的两栏布局是“左侧技能标签右侧经历描述”解析出来之后模型结构化时会完全错乱。我的临时解法在解析后增加一步“按行重建区块”的后处理尽可能恢复阅读顺序。但说实话真正的解法是遇到复杂排版时提示用户“检测到复杂 PDF建议使用 Word 版或手动粘贴文本”。这个兜底策略在实际使用中反而最有效。5.2 长耗时任务在 serverless 环境下的超时Next.js 部署到 Vercel 时免费层的函数执行时间限制很严格。一个包含多轮大模型调用的 Agent 图很容易超过默认限制。我遇到的真实情况是一次生成包含 4 次 LLM 调用平均耗时 80 秒左右在 Vercel Hobby 套餐直接超时。解决方案很粗暴把生成任务改成“先返回任务 ID后台异步执行”前端轮询任务状态或者部署到带超时配置的 Node 服务上。如果坚持要 serverless 又不想改架构可以把 Agent 图拆成多次请求解析一次、分析 JD 一次、逐段改写多次。这样每个请求都在 10 秒内返回但需要自己实现状态持久化把 LangGraph 的 state 序列化到 Redis 或数据库。我们最后选择的是后者把整个 Agent 图变成一组可恢复的 API 接口。虽然失去了“一张图控制全部流程”的优雅但业务稳定性和超时问题都迎刃而解。5.3 大模型输出的 JSON 不稳定结构化抽取和 JD 分析节点都要求模型输出 JSON但模型偶尔会返回带注释的、带 markdown 代码块包裹的 JSON或者其他杂讯。我的处理方式是在解析函数里做了多层容错function extractJson(text: string): any { try { return JSON.parse(text); } catch { const match text.match(/json\s*([\s\S]*?)\s*/); if (match) return JSON.parse(match[1]); } // 兜底从字符串中寻找第一个 { 到最后一个 } 再尝试解析 const start text.indexOf({); const end text.lastIndexOf(}); return JSON.parse(text.slice(start, end 1)); }这个函数解决了 90% 的格式问题。剩下的 10%靠的是在 LangGraph 的条件路由里增加“JSON 解析失败重试节点”重试一次还失败就把这个节点标记为失败并整体降级成“简化流程”——跳过结构化直接返回原文本保证用户至少能拿到结果。5.4 提示工程的本地化问题简历优化工具的中英文混排是一个大坑。大模型对中文简历的表述习惯、英文简历的动词时态、中英文混合技能词的敏感度各不相同。初期我用一套英文 prompt 跑中英文简历结果英文简历没大问题但中文简历总会出现“表述过于翻译腔、句式生硬、关键词堆砌”等现象。后来我把 prompt 改成“语言跟随输入简历语言”的策略检测到简历主体是中文则优化指令里明确要求输出符合中文招聘语境、避免翻译腔检测到英文则要求使用地道的英文行为动词。这个策略听起来简单实际效果立竿见影。它也印证了一个经验写 agent prompt 时不要只写“做什么”还要写清楚“不做什么”和“风格边界”。5.5 常见问题速查表问题现象可能原因解决方案解析出的文本顺序错乱双栏/多栏 PDF提示用户改用文本上传做行序重建后处理模型生成的量化数据无出处prompt 未约束幻觉增加 reviewNode 校验数值标记让用户确认函数执行超时serverless 时长限制拆分图结构为多次 API状态持久化中文简历输出翻译腔prompt 语言策略单一检测简历语言后动态切换优化指令关键词堆砌感明显rewrite prompt 过度要求对齐 JD在指令中增加“自然融合”约束降低关键词直接复制权重重新生成后结果差异大LLM 采样温度/随机性设置较低 temperature输出后做一致性校验这些坑没有一个是 LangGraph 本身造成的但它们都是在引入 Agent 编排之后才暴露出来的。编排给了流程控制能力同时也把原本隐藏在单次 prompt 里的不确定性放大成了多步累积的不确定性。做 Agent 应用的人要有“整体校验”的思想不能天真地认为每步都调好模型就够了。6. 部署与上线后的迭代方向6.1 最小化部署方案项目最后选择的是传统 Node.js 部署在云服务器 PostgreSQL 存数据没有用 serverless。整体部署配置很简单Node 20 运行 Next.js 构建产物PM2 托管进程做自动重启Nginx 反向代理处理 HTTPSPostgreSQL 存用户简历版本和生成记录模型调用走 OpenAI 兼容接口。这套方案的成本很低但解决了 serverless 超时和状态持久化的大问题。如果读者是要做学习 Demo 而不是生产工具直接本地跑npm run dev就足够了LangGraph 图不需要任何外部服务支持。6.2 后续迭代方向完成基础版本的简历优化 Agent 之后我总结出三个可复用的扩展方向。第一个方向是增加“面试问题预测 Agent”。基于已优化的简历内容和目标 JD生成针对性的行为面试问题。这块本质上也是多步骤任务解析简历、抽取高频能力项、结合 JD 生成问题与评估要点。LangGraph 的图结构天然适合扩展。第二个方向是增加“简历与 JD 匹配度诊断报告”。在 rewriteNode 之前额外加一个 scoringNode给当前简历和 JD 的匹配度打分输出薄弱点清单。这样用户能先看到诊断结果再决定是否执行优化整个工具的使用路径更完整。第三个方向是多模型轮询和模型差异化调度。解析节点要求速度、可以用小模型rewrite 节点要求质量、用大模型review 节点要求稳定、可以用支持 JSON mode 的模型。在 LangGraph 里为不同节点配置不同模型非常方便只需要在节点内部调用不同的 Client 即可。最后分享一点个人体会这个项目让我最意外的不是 Agent 图本身而是“拆解任务”这件事比“写 prompt”重要得多。没有 LangGraph 之前我也会凭直觉把“优化简历”拆成几个步骤但代码实现里每个步骤之间的状态耦合全靠手写改起来非常痛苦。LangGraph.js 把状态流转放到了图结构里让我把精力真正集中到每个节点的输入输出上。如果你正准备做类似项目我的建议是从一个小闭环开始先实现“简历文本输入 - 单节点改写 - 结果展示”的最简版本跑通之后再逐渐增加 JD 分析、分段优化、review 校验。不要一上来就想着做一个复杂的多 Agent 系统。AI Agent 的落地过程其实是“把一个模模糊糊的大目标磨成一条条清晰可执行的边”的过程这个过程本身才是这个领域最有价值的部分。
RELATED READING

延伸阅读

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