ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Next.js + LangGraph.js 实战:构建可控的简历优化 AI Agent

Next.js + LangGraph.js 实战:构建可控的简历优化 AI Agent 1. 为什么我选择用 Next.js LangGraph.js 做简历工具 Agent1.1 从“改简历”这件小事说起先说说我为什么会盯上“简历工具”这个方向。去年帮几个朋友内推前前后后看了大概两百多份简历越看越觉得这事有规律可循大部分人不是能力不行而是不会把能力“翻译”成招聘方想看的样子。有人把三年经验写成流水账有人把核心项目藏在最后一段还有人一份简历投所有岗位关键词完全对不上。这种活儿本质上就是“信息重组 语义匹配”恰好是大模型擅长的。但直接用 ChatGPT 网页版改简历有个致命问题你得反复复制粘贴上下文一长就丢而且它不知道你投的是哪个岗位、哪家公司。我想要的是一个能记住我全部经历、能针对具体 JD 做定向优化的工具而不是一个每次都要重新交代背景的聊天框。这就是我做这个项目的起点一个跑在自己服务器上的简历优化 Agent前端用 Next.jsAgent 编排用 LangGraph.js。选这两个技术栈不是跟风下面我把选型逻辑掰开讲。1.2 为什么是 Next.js而不是纯后端 前端分离很多人做 AI 应用的第一反应是“后端 FastAPI 前端 React”这没错但简历工具这个场景有几个特殊性。第一它需要流式输出。Agent 改简历不是一问一答而是“读取简历 → 分析 JD → 逐段改写 → 自检 → 输出”中间每一步用户都希望看到进度。Next.js 的 Route Handler 原生支持流式响应配合 React Server Components我可以把 Agent 的执行状态直接推到前端不用额外搭 WebSocket。第二它需要文件处理。简历基本是 PDF 或 Word解析、存储、预览都在同一个应用里完成最省事。Next.js 的 API Routes 可以直接处理文件上传配合 Vercel Blob 或者本地存储链路很短。第三它需要快速迭代。我一个人开发不想维护两套工程。Next.js 的全栈能力让我在一个仓库里搞定前后端部署也简单。提示如果你的团队已经有成熟的后端体系Next.js 只做 BFF 层也完全可行。但个人项目或者小团队全栈 Next.js 的开发效率优势非常明显。1.3 LangGraph.js 解决的核心痛点Agent 不能是“一锤子买卖”最初我用的是最简单的 Chain 模式把简历和 JD 拼成一个 Prompt丢给模型等结果。跑了几次就发现三个问题。问题一无法回溯。模型改完简历我想知道它为什么这么改改之前是什么样Chain 模式没有中间状态全丢了。问题二无法干预。有些改写我不满意想让它只改工作经历部分别动教育背景。Chain 模式只能重新跑一遍浪费 token。问题三无法自检。简历优化有个硬性要求不能编造经历。模型有时候会“脑补”一些不存在的技能我需要一个校验环节但 Chain 模式是线性的加校验就得再写一套逻辑。LangGraph.js 恰好解决这三个问题。它把 Agent 建模成状态图每个节点是一个处理步骤边是流转条件整个执行过程有一个共享的 State。这意味着我可以随时暂停、回退、分支也可以在图里加一个“事实校验”节点专门检查模型有没有胡说。举个具体例子。我的简历 Agent 图大概长这样入口节点解析简历文件提取结构化信息分析节点读取 JD提取关键词和硬性要求匹配节点对比简历和 JD找出差距改写节点逐段优化简历内容校验节点检查改写后是否引入虚假信息输出节点生成最终简历和修改说明如果校验不通过边会指回改写节点让它重来。这种“带环”的流程用 Chain 模式写会非常别扭用 LangGraph 就是加一条边的事。1.4 这个项目适合谁参考如果你符合下面任意一条这篇内容应该对你有用想学 LangGraph.js 但找不到合适练手项目的开发者正在做 AI 应用纠结 Agent 编排方案的技术负责人想给自己或团队做一个内部简历优化工具的人对“AI Agent 怎么落地到具体场景”感兴趣的产品同学我不打算讲太多理论重点放在我怎么搭的、踩了哪些坑、哪些地方可以抄作业。代码会给关键片段但不会贴全量因为每个人的业务细节不一样给思路比给代码更重要。2. 项目整体架构与核心模块拆解2.1 一张图看懂数据流先把整体架构说清楚。我的项目分成四层第一层是前端交互层用 Next.js App Router 实现。用户在这里上传简历、粘贴 JD、查看优化结果。关键页面有三个上传页、优化进行页、结果对比页。第二层是 API 层用 Next.js Route Handlers 实现。负责接收文件、调用 Agent、流式返回结果。这里不写业务逻辑只做参数校验和转发。第三层是 Agent 编排层用 LangGraph.js 实现。这是核心包含状态定义、节点实现、边条件、检查点存储。第四层是模型与工具层包括大模型调用、简历解析工具、向量检索用于匹配历史简历模板。数据流是这样的用户上传 PDF → API 层接收并存储 → 触发 Agent → Agent 解析简历文本 → 读取 JD → 执行图流程 → 流式返回每个节点的输出 → 前端实时渲染。2.2 状态设计Agent 的“记忆”怎么组织LangGraph.js 的核心是 State。我定义了一个ResumeAgentState包含这些字段interface ResumeAgentState { resumeText: string; // 原始简历文本 resumeStructured: ResumeData; // 结构化后的简历 jdText: string; // 岗位描述 jdKeywords: string[]; // 提取的关键词 gaps: GapAnalysis[]; // 差距分析结果 rewrittenSections: Section[]; // 改写后的段落 validationResult: ValidationResult; // 校验结果 retryCount: number; // 重试次数 finalOutput: string; // 最终输出 }这里有个经验State 字段不要设计得太细也不要太粗。太细会导致节点之间耦合严重太粗会让节点不知道自己该读什么。我的原则是每个节点至少有一个明确的“输入字段”和一个“输出字段”其他字段作为上下文存在。比如改写节点读gaps和resumeStructured写rewrittenSections。校验节点读rewrittenSections和resumeStructured写validationResult。这样每个节点的职责很清晰调试的时候也容易定位问题。2.3 节点实现每个节点只做一件事我把 Agent 拆成了六个节点每个节点都是一个独立的 async 函数。这样做的好处是可测试我可以单独给每个节点写单元测试不用跑整张图。解析节点负责把 PDF 文本转成结构化数据。这里我没有用大模型而是用了规则 正则。原因是简历格式相对固定用规则解析更快、更便宜而且不会出现模型“理解偏差”。只有遇到特别复杂的排版才 fallback 到模型解析。分析节点负责从 JD 里提取关键词。这里用了大模型因为 JD 的写法千变万化规则覆盖不全。Prompt 大概是“从以下岗位描述中提取硬性技能要求、软性要求、经验年限要求以 JSON 格式返回。”匹配节点负责对比简历和 JD。这个节点不调用模型纯逻辑计算简历里有哪些关键词JD 要求哪些差集就是 gaps。这样做的好处是可解释用户能清楚看到自己缺什么。改写节点是唯一大量调用模型的地方。我按段落拆分简历每段单独改写而不是整篇丢进去。原因是整篇改写容易丢失细节而且 token 消耗大。分段改写后每段的 Prompt 更聚焦效果更稳定。校验节点负责检查改写后的内容是否引入了原始简历没有的信息。实现方式是把改写后的段落和原始段落一起丢给模型问它“改写后的内容是否包含原始内容中没有的事实性信息”如果回答是就打回重写。输出节点负责组装最终结果生成修改说明。2.4 边条件什么时候重试什么时候结束LangGraph.js 的边可以是条件边。我定义了两条关键的条件边第一条在改写节点之后如果retryCount小于 3进入校验节点否则直接进入输出节点并标记“未通过校验”。第二条在校验节点之后如果validationResult.passed为 true进入输出节点否则retryCount加一回到改写节点。这里有个坑重试次数一定要设上限。我最初没设上限结果有一次模型陷入死循环改了七遍还是通不过校验token 烧了不少。后来改成最多重试 3 次超过就输出当前结果并提示用户人工检查。注意条件边的判断函数必须是纯函数不能有副作用。我一开始在判断函数里改了 State导致 LangGraph 的状态追踪出问题调试了很久才发现。2.5 检查点让 Agent 可以“断点续跑”LangGraph.js 支持 Checkpointer可以把每一步的状态存下来。我用的是内存 Checkpointer因为简历优化通常几分钟内完成不需要持久化。但如果你要做“用户今天改一半明天继续改”就需要换成数据库 Checkpointer。Checkpointer 的另一个用途是调试。Agent 跑完后我可以把每一步的 State 打出来看它在哪个节点出了问题。这比在代码里到处打 console.log 高效得多。3. 核心环节实操从上传简历到输出优化结果3.1 简历解析别小看 PDF 提取这一步简历解析是整个流程的第一关也是最容易出问题的一关。我试过三种方案方案优点缺点适用场景pdf-parse轻量、快对复杂排版支持差简单单栏简历pdfjs-dist浏览器和 Node 都能用API 较底层需要自己处理文本块需要前端预览大模型直接读 PDF省事贵、慢、可能幻觉不推荐我最终选了 pdfjs-dist因为它在 Next.js 的 Server 和 Client 都能跑而且我能控制文本提取的粒度。具体做法是先用 pdfjs 提取所有文本块然后按 y 坐标排序还原阅读顺序。这一步很关键因为很多简历是双栏布局直接提取会串行。import * as pdfjsLib from pdfjs-dist; async function extractResumeText(buffer: Buffer): Promisestring { const pdf await pdfjsLib.getDocument({ data: buffer }).promise; const pages: string[] []; for (let i 1; i pdf.numPages; i) { const page await pdf.getPage(i); const content await page.getTextContent(); // 按 y 坐标分组还原行 const items content.items as TextItem[]; const lines groupByY(items); pages.push(lines.join(\n)); } return pages.join(\n\n); }groupByY这个函数是我自己写的逻辑是把 y 坐标相近的文本块归为同一行然后按 x 坐标排序。这样双栏简历也能正确还原。解析完之后我用正则提取关键字段姓名、电话、邮箱、教育经历、工作经历、技能列表。正则写起来麻烦但跑一次就存下来后续不用重复解析。3.2 JD 分析关键词提取的 Prompt 怎么写JD 分析的目的是找出“招聘方真正想要什么”。我的 Prompt 经过多次迭代最终版本是这样的你是一位资深招聘专家。请从以下岗位描述中提取信息以 JSON 格式返回 { hardSkills: [硬性技能如 React、Node.js], softSkills: [软性要求如沟通能力、团队协作], experienceYears: 经验年限要求, education: 学历要求, responsibilities: [核心职责列表], keywords: [所有对匹配有用的关键词] } 要求 1. 只提取 JD 中明确提到的内容不要推断 2. 技能名称统一用行业通用写法 3. 如果某项没有提到返回空数组或空字符串 岗位描述 {jdText}这个 Prompt 的关键点是要求返回 JSON并且明确禁止推断。我试过让模型自由发挥结果它把“熟悉 React”推断成“熟悉 React 生态”虽然合理但偏离了 JD 原意。拿到 JSON 后我会做一次后处理把所有关键词转小写去重然后和简历关键词做交集。3.3 差距分析用集合运算代替模型判断差距分析这一步我坚持用代码而不是模型。原因很简单模型会“心软”。你问它“这个候选人匹配吗”它往往倾向于说“基本匹配建议补充 XX”。但实际招聘中硬性技能不匹配就是不行。我的做法是function analyzeGaps( resumeKeywords: string[], jdKeywords: string[] ): GapAnalysis[] { const resumeSet new Set(resumeKeywords.map(k k.toLowerCase())); return jdKeywords.map(keyword { const normalized keyword.toLowerCase(); const matched resumeSet.has(normalized); return { keyword, matched, importance: getImportance(keyword, jdKeywords), suggestion: matched ? : generateSuggestion(keyword) }; }); }getImportance是我定义的一个简单权重函数出现在 JD 标题或职责第一条的关键词权重高出现在“加分项”里的权重低。这样用户能清楚看到哪些差距是致命的哪些是锦上添花。3.4 分段改写为什么我不整篇丢给模型改写是核心环节也是最容易翻车的地方。我踩过的坑包括整篇改写导致模型“偷懒”只改开头结尾中间原封不动整篇改写导致 token 超限长简历直接被截断整篇改写导致风格不统一有的段落正式有的段落口语化后来我改成按段落改写。具体做法是把简历拆成若干“语义段落”每个段落单独调用模型。段落划分规则是工作经历每段经历一个段落项目经历每个项目一个段落技能列表整体一个段落教育经历整体一个段落每个段落的 Prompt 模板你是一位简历优化专家。请优化以下简历段落使其更符合目标岗位要求。 目标岗位关键词{keywords} 该段落原文{sectionText} 优化要求 1. 保留所有事实性信息不得编造经历 2. 突出与目标岗位相关的技能和成果 3. 使用量化数据如果原文有 4. 语言简洁避免空话套话 5. 保持原有时间线和职位名称不变 请直接输出优化后的段落不要添加解释。这个 Prompt 里最重要的是第 1 条和第 5 条。第 1 条防止幻觉第 5 条防止模型“美化”职位名称比如把“实习生”改成“助理工程师”。3.5 事实校验给模型加一道“安检”校验节点的作用是检查改写后的内容有没有引入虚假信息。实现方式是把原文和改写文一起给模型问它一个二选一的问题请判断以下两段文字中改写后的文字是否包含了原文没有的事实性信息。 原文{original} 改写后{rewritten} 事实性信息包括公司名称、职位名称、时间、学历、技能名称、项目名称、具体数字。 不包括形容词、连接词、语序调整。 请只回答 是 或 否。这个 Prompt 的关键是明确界定什么是事实性信息。我最初只写“是否包含新信息”结果模型把“负责”改成“主导”也判定为新增信息导致大量误报。后来把判定标准写细准确率明显提升。如果校验不通过我会把校验结果作为反馈附加到下一次改写的 Prompt 里让模型知道上次哪里出了问题。这样重试的成功率会高很多。3.6 流式输出让用户看到 Agent 在干活Agent 跑一次大概需要 20 到 60 秒如果前端一直转圈用户体验很差。我用 Next.js 的 Streaming Response 把每个节点的输出实时推给前端。实现方式是在 Route Handler 里返回一个ReadableStreamAgent 每完成一个节点就往流里写一条消息export async function POST(req: Request) { const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const send (event: string, data: unknown) { controller.enqueue( encoder.encode(event: ${event}\ndata: ${JSON.stringify(data)}\n\n) ); }; send(status, { stage: parsing, message: 正在解析简历... }); const resume await parseResume(buffer); send(status, { stage: analyzing, message: 正在分析岗位要求... }); const jd await analyzeJD(jdText); // ... 其他节点 send(complete, { result: finalOutput }); controller.close(); } }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive } }); }前端用EventSource接收每收到一条消息就更新 UI。用户能看到“正在解析简历”“正在分析岗位”“正在改写第 2 段”这样的进度等待焦虑会小很多。4. 并发、成本与稳定性Agent 上线的三个现实问题4.1 AI Agent 怎么扛并发这是热词里出现频率很高的问题我结合实际踩坑说。简历工具的特点是单次请求耗时长、token 消耗大如果直接让每个请求都跑完整 Agent并发一上来模型 API 就会限流。我的方案是队列 限流。具体做法第一用 BullMQ 或者简单的内存队列把 Agent 任务排队。用户提交后先返回一个 taskId前端轮询或订阅任务状态。第二限制同时运行的 Agent 数量。我设的是 3 个因为模型 API 的并发限制通常是 5 到 10留一些余量给其他请求。第三给每个任务设超时。超过 90 秒还没跑完直接标记失败释放队列位置。const queue new PQueue({ concurrency: 3 }); async function runAgentTask(taskId: string, input: AgentInput) { return queue.add(async () { const timeout new Promise((_, reject) setTimeout(() reject(new Error(Agent timeout)), 90000) ); return Promise.race([ runResumeAgent(input), timeout ]); }); }这套方案在几十个并发下跑得很稳。如果真要扛几百并发就得上分布式队列和多个模型 API Key 轮询但那是另一个量级的事了。4.2 Token 成本控制哪些地方可以省简历 Agent 的 token 消耗主要在三块JD 分析、分段改写、事实校验。我做了几个优化优化一JD 分析用便宜模型。提取关键词这种任务不需要最强的模型。我用的是 GPT-4o-mini 或者 Claude Haiku效果够用成本只有旗舰模型的十分之一。优化二改写用强模型但只改有差距的段落。如果某段简历和 JD 匹配度很高就不改直接保留。这样能省掉 30% 到 50% 的改写调用。优化三校验用规则 模型混合。先用规则检查有没有新增数字、公司名、职位名如果没有可疑点就跳过模型校验。只有规则检查不通过时才调用模型做精细判断。优化四缓存 JD 分析结果。同一个 JD 可能被多个用户使用我把 JD 的 hash 作为 key分析结果缓存 24 小时。这样热门岗位的 JD 只需要分析一次。实测下来一份中等长度的简历约 1500 字加一份 JD约 500 字完整跑一次的成本大概在 0.05 到 0.15 美元之间。如果不用上面的优化成本会翻三倍。4.3 稳定性模型调用失败怎么办模型 API 不是 100% 可用的超时、限流、返回格式错误都会发生。我的处理策略是分级降级第一级重试。对于超时和限流自动重试 2 次每次间隔 1 秒和 3 秒。第二级切换模型。如果主模型连续失败切换到备用模型。我配置了 OpenAI 和 Anthropic 两家互为备份。第三级降级功能。如果所有模型都不可用就跳过改写环节只输出差距分析结果并提示用户“改写服务暂时不可用”。async function callModelWithFallback(prompt: string) { const providers [ { name: openai, call: callOpenAI }, { name: anthropic, call: callAnthropic } ]; for (const provider of providers) { for (let retry 0; retry 2; retry) { try { return await provider.call(prompt); } catch (err) { if (retry 1) break; await sleep(1000 * (retry 1)); } } } throw new Error(All providers failed); }提示切换模型时要注意 Prompt 的兼容性。不同模型对 JSON 格式的遵循程度不一样我建议在 Prompt 里明确要求“只返回 JSON不要 markdown 代码块”并且在解析时做容错处理。4.4 数据安全简历是敏感信息简历包含姓名、电话、邮箱、工作经历属于个人敏感信息。我在设计时做了几条硬性规定简历文件不落盘解析完立即删除临时文件简历文本在 Agent 跑完后从内存中清除不把简历内容用于任何训练或分析日志里不打印简历原文只打印长度和 hash如果你要做商业化产品还需要考虑用户协议、数据加密存储、访问审计等但个人项目做到上面几条基本够用。5. 常见问题与排查技巧实录5.1 Agent 跑一半卡住不动这是最常见的问题。排查思路按顺序来第一步看是不是模型调用超时。在模型调用外面包一层日志记录每次调用的开始和结束时间。如果某个调用超过 30 秒没返回基本就是模型侧的问题。第二步看是不是条件边死循环。检查retryCount有没有正确递增条件边的判断逻辑有没有写反。我遇到过一次判断函数写成了retryCount 3才退出结果永远退不出。第三步看是不是 State 字段没更新。LangGraph.js 要求节点返回一个 Partial State如果你直接改了 State 对象但没有返回框架不会感知到变化。正确写法是return { rewrittenSections: newSections }。5.2 改写后的简历“假大空”模型改写简历时容易把“参与了 XX 项目”改成“主导了 XX 项目”把“熟悉 React”改成“精通 React”。这是幻觉的一种但比编造经历更隐蔽。我的解法是在 Prompt 里加一条禁止升级动词的规则禁止将以下动词升级 - 参与 → 主导、负责 - 熟悉 → 精通、掌握 - 协助 → 独立完成 - 了解 → 熟练使用 如果原文用的是弱动词保持弱动词只优化表达方式。另外校验节点也要专门检查动词是否被升级。我在校验 Prompt 里加了一条“检查改写后的动词强度是否高于原文。”5.3 双栏简历解析乱序双栏简历是解析的重灾区。pdfjs 提取的文本块顺序是按 PDF 内部顺序来的不一定是阅读顺序。我的解法是按坐标排序function groupByY(items: TextItem[]): string[] { const sorted items.sort((a, b) b.y - a.y || a.x - b.x); const lines: TextItem[][] []; for (const item of sorted) { const lastLine lines[lines.length - 1]; if (lastLine Math.abs(lastLine[0].y - item.y) 5) { lastLine.push(item); } else { lines.push([item]); } } return lines.map(line line.sort((a, b) a.x - b.x).map(i i.text).join( ) ); }这个逻辑是先按 y 坐标从大到小排PDF 坐标原点在左下角y 相近的归为一行行内按 x 排序。实测对大多数双栏简历有效。5.4 常见问题速查表问题现象可能原因排查方法解决方案Agent 卡住不动模型超时看模型调用日志加重试和超时改写内容失真Prompt 约束不够对比原文和改写文加禁止升级动词规则解析乱序双栏布局检查文本块坐标按 y 坐标分组校验误报率高判定标准模糊看校验 Prompt明确事实性信息定义token 消耗过大整篇改写看调用次数改分段改写并发上不去模型限流看 API 返回码加队列和限流前端收不到流响应头不对看 Network 面板检查 Content-Type重试不生效条件边写反看 retryCount修正判断逻辑5.5 几个我踩过的坑坑一LangGraph.js 的版本兼容性。我最初用的 0.0.x 版本API 和文档对不上升级到 0.1.x 后稳定很多。建议锁定版本不要用 latest。坑二Next.js 的 Edge Runtime 不支持 Node API。我一开始把 Agent 放在 Edge Function 里结果 pdfjs 用不了。后来改成 Node.js Runtime问题解决。Route Handler 默认是 Node.js Runtime但如果你显式声明了export const runtime edge就要注意。坑三流式输出在开发环境被缓冲。Next.js 开发模式下Streaming Response 有时候会被缓冲导致前端收不到实时消息。部署到生产环境就正常了。调试时可以用curl -N看原始流。坑四模型返回的 JSON 带 markdown 代码块。即使 Prompt 里说了“不要 markdown”模型有时候还是会返回json ...。解析前先做一次清洗function cleanJSON(text: string): string { return text .replace(/^json\s*/i, ) .replace(/^\s*/i, ) .replace(/\s*$/i, ) .trim(); }6. 后续可以怎么扩展这个项目目前只做了“简历优化”这一个功能但 LangGraph.js 的图结构很容易扩展。我列几个我打算做的方向方向一模拟面试。在 Agent 图里加一个“面试官”节点根据简历和 JD 生成面试问题用户回答后再给反馈。这个节点可以复用已有的简历结构化数据。方向二多版本管理。用户可以针对不同岗位生成不同版本的简历Agent 负责维护版本之间的差异。这需要把 Checkpointer 换成数据库存储。方向三投递追踪。记录用户投了哪些公司、哪些岗位、进展如何Agent 定期提醒跟进。这个偏业务逻辑和 LangGraph 关系不大但能提升工具粘性。方向四批量处理。支持一次上传多份简历批量匹配多个 JD输出匹配度排序。这个需要把队列和并发控制做得更完善。我个人在实际操作中的体会是LangGraph.js 最大的价值不是“让 Agent 更聪明”而是“让 Agent 更可控”。你可以清楚地知道每一步在做什么出了问题能定位想改流程就加节点或改边。对于简历工具这种需要“可解释性”的场景这一点比模型能力更重要。最后再分享一个小技巧如果你也在做类似的 Agent 项目建议先把图跑通再优化 Prompt。我一开始花了很多时间调 Prompt后来发现流程设计有问题Prompt 再好也白搭。先把节点和边画清楚再逐个节点打磨效率会高很多。
返回列表