ARTICLE DETAIL

资讯详情

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

LangGraph.js实战:用状态图搭建可循环的简历优化Agent

LangGraph.js实战:用状态图搭建可循环的简历优化Agent 1. 为什么最终选了“状态图”而不是再来一个巨型 Prompt先交代一下项目背景。前阵子接到一个在线简历优化工具的需求用户把现有简历内容贴进来再填一个目标岗位系统自动生成一份针对这个岗位优化过的新简历。听起来很简单但真正落地时我才发现自己差点走进一个最典型的坑——把所有逻辑塞进一个超长 Prompt 里。第一版确实这么干了一个SYSTEM_PROMPT里写满“你是资深HR”“提取你的技能”“优化经历描述”“调整关键词匹配”“注意格式”……当时以为 LLM 能一把梭全搞定。实际跑起来问题很明显长上下文下模型经常漏掉一部分要求要么只提取不优化要么优化了但丢失了原有事实细节更麻烦的是后续想增加“SWOT 分析”“关键词覆盖度打分”这类模块Prompt 会越堆越不可维护。这时候我想到了 LangGraph.js。它的核心价值不是“多了一个库”而是把 AI 应用从“单次文本进文本出”变成了一张可控、可循环、可中断的状态图。简历处理天然适合做成图解析 - 建结构化画像 - 对齐岗位需求 - 生成摘要 - 润色 - 质量检查每个环节独立成节点节点之间共享一个状态对象。好处是逻辑看着清楚调试时能精准定位出错节点而且能真正实现“检查不合格就回去重新生成”这种循环控制。于是项目栈定为Next.js负责前后端托管和 API RouteLangGraph.js负责 Agent 流程编排LLM 走 OpenAI 兼容接口后面可以随意换模型。现在的实现里用户得到的不是一次性生成的临时文本而是一个有“过程”的 Agent 处理结果。如果你也是第一次在这种工具类项目里引入 Agent建议先放弃“一套 Prompt 走天下”的想法。哪怕做最轻量的多步流程状态图带来的可控性也远超单次调用。接下来我把整个落地方案拆开讲从环境搭建到核心状态设计再到实际踩坑全程可以照着抄。2. 环境准备Once MoreNext.js 项目骨架和依赖安装2.1 初始化 Next.js 项目我用的是 App Router 模式版本要求 Node 18 以上建议 20。终端里直接跑npx create-next-applatest resume-agent-app --typescript --eslint --app --src-dir --turbopack cd resume-agent-app这里选 TypeScript 是因为状态图里要定义 Schema 和类型用 TS 能让节点函数的输入输出在编译期就暴露问题。装依赖npm install langchain/langgraph langchain/openai zod如果你计划用流式输出还需要ai这个包配合useChat或者自己用ReadableStream实现我后面会讲手动流式方案所以暂时不引入额外依赖。2.2 项目目录设计我的思路是把 Graph 定义和 Next.js 路由解耦Graph 本身不依赖框架。目录结构如下src/ ├── app/ │ ├── api/ │ │ └── agent/route.ts # API 路由触发 Agent │ ├── page.tsx # 简单的表单页 │ └── layout.tsx └── lib/ ├── graph.ts # 状态图定义、节点连接、条件边 ├── nodes.ts # 各个节点的实现调用 LLM └── state.ts # 状态 Schema 定义state.ts单独拆出来很有必要因为在 LangGraph.js 中状态是所有节点的“钱包”你改一个字段可能影响四个节点。2.3 LangGraph.js 的版本与包名提醒注意安装的是langchain/langgraph别装成langgraph或者旧的langchain/langchain。现在最新版本已经支持Annotation.Root这种更简洁的状态定义方式我在下面直接用它如果你的版本较旧代码会有不少差异。装完顺手npm ls langchain/langgraph确认版本。2.4 设置环境变量在.env.local里写上模型接口信息# 使用 OpenAI 的包但接口可以指向任意兼容服务 OPENAI_API_KEYsk-xxx OPENAI_BASE_URLhttps://api.example.com/v1如果你不想用 OpenAI 官方完全可以换成langchain/anthropic或langchain/ollama但前提是 LangGraph 的节点函数内部调用什么是不受限的它只负责编排。这算 LangGraph 一个特别好的点节点不绑定模型同一个图里可以“解析用便宜模型生成用贵模型”。3. 状态机建模简历 Agent 的四个关键节点和一个质量循环3.1 State Schema 的定义状态是整个 Agent 的大脑中枢。我的简历工具里状态大致长这样// src/lib/state.ts import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ // 输入 resumeText: Annotationstring, jobDescription: Annotationstring, // 解析结果用户简历里的结构化信息 parsedProfile: AnnotationRecordstring, any, // 针对岗位生成的个性化内容 optimizedSummary: Annotationstring, optimizedResume: Annotationstring, feedback: Annotationstring, iterationCount: Annotationnumber, });每个字段的Annotation默认行为是“覆盖写入”但对于数组类数据比如关键词列表我建议用Annotation({ reducer: (a, b) [...(a ?? []), ...(b ?? [])] })否则每次节点返回都会把之前累积的数据冲掉。3.2 四个节点的职责划分第一个节点parseResume输入resumeText输出填充parsedProfile技能、工作经历、教育背景、项目经验等模型选便宜且快的小模型因为这一步不需要太多创造性关键是稳定抽取。代码示意const parseResume async (state: typeof ResumeState.State) { const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0 }); const response await model.invoke([ [system, 你是简历解析器只输出 JSON不要任何解释。], [human, 请从以下简历中抽取技能、工作经历、学历、亮点项目输出 JSON\n${state.resumeText}] ]); const parsed JSON.parse(response.content as string); return { parsedProfile: parsed }; };第二个节点matchJobRequirements输入parsedProfilejobDescription输出一个匹配分析结果包括岗位关键词、缺失能力点、建议调整的策略。不要在这一步直接生成终稿只生成“策略”因为后续的润色节点会更专注于表达。第三个节点generateResume输入parsedProfilematchStrategy输出optimizedResume这是完整的新简历文本。模型这里用强模型我实际用gpt-4o因为在长期保持事实准确的前提下重写履历表达弱模型容易编造经历。第四个节点qualityCheck输入optimizedResumejobDescriptionparsedProfile输出feedback通过 or 具体修改建议和iterationCount 1。3.3 条件边还没达标就再加工一轮LangGraph 的杀手锏是条件边。我在graph.ts中这样跑const builder new StateGraph(ResumeState) .addNode(parse, parseResume) .addNode(match, matchJobRequirements) .addNode(generate, generateResume) .addNode(check, qualityCheck) .addEdge(START, parse) .addEdge(parse, match) .addEdge(match, generate) .addEdge(generate, check) .addConditionalEdges(check, (state) { if (state.feedback.includes(PASS) || state.iterationCount 3) { return END; } return generate; // 回到 generate 重新加工 }) .compile();这里“回到 generate”不是简单重跑因为generate节点可以读到上一次的feedback和optimizedResume相当于第二次生成时会带着修改意见去迭代。为什么我要单独加一个质量检查节点直接让generate一次到位不行吗我实际测试下来如果让模型“生成完了顺手自检”它永远会觉得自己生成得很完美把“生成”和“评估”拆成两个节点后自检幻觉明显减少因为检查节点用的是另一个 temperature 和另一段系统提示词视角不同能挑出来的问题也更多。3.4 为什么状态图比“链式调用”更合适你可能会想这不就是一段串行代码吗用 Promise 链也能做。区别在于Promise 链做不到“有条件地跳回前面某个流程”除非你自己写一堆 if-else。状态图让你为每个节点单独打日志、单独重试、单独追踪 token 消耗。当后续增加新功能比如“比较三版简历相似度”时只需再加节点和边不动已有代码。我甚至把每个节点的输入输出都做了一层 cache放在状态里的cacheKey字段避免同一个 resume 反复调用解析节点。4. 和 Next.js 集成把 Graph 放进 API Route再让结果流式输出4.1 API Route 里调用 GraphNext.js 的 App Router 下最简单的方式是写一个 route handler// src/app/api/agent/route.ts import { NextRequest, NextResponse } from next/server; import { graph } from /lib/graph; export const runtime nodejs; // 关键避免 Edge Runtime export async function POST(req: NextRequest) { const body await req.json(); const jobDescription body.jobDescription || ; const resumeText body.resumeText || ; // 确保必填字段存在 if (!resumeText) { return NextResponse.json({ error: 缺少简历文本 }, { status: 400 }); } // 调用状态图等价于开启一个 Agent const result await graph.invoke({ resumeText, jobDescription, parsedProfile: {}, optimizedSummary: , optimizedResume: , feedback: , iterationCount: 0, }); return NextResponse.json({ optimizedResume: result.optimizedResume, iterationCount: result.iterationCount, feedback: result.feedback, }); }有个小地方要注意graph.invoke是一次性把整条链路跑完返回的结果是最终状态。这意味着用户界面需要等待整个 Agent 流程结束才能拿到数据。如果生成耗时超过 10 秒浏览器本身就等得起但用户体验会很差。所以我才另外实现了“非流式版 流式版”两种模式下面说流式。4.2 手动实现可读流不使用第三方 SDKLangGraph.js 支持await graph.stream()返回每次节点执行后的状态更新。我们可以把这些更新推给前端// 在 route.ts 中添加流式处理逻辑 import { ReadableStream } from node:stream; // Node 20 以后可直接使用 Web API export async function POST(req: NextRequest) { ... const stream await graph.stream( { resumeText, jobDescription, parsedProfile: {}, optimizedSummary: , optimizedResume: , feedback: , iterationCount: 0, }, { recursionLimit: 10 } // 防止无限循环 ); const encoder new TextEncoder(); const streamResult new ReadableStream({ async start(controller) { for await (const step of stream) { // step 包含每个节点执行完后的状态快照 controller.enqueue(encoder.encode(JSON.stringify(step) \n)); } controller.close(); }, }); return new Response(streamResult, { headers: { Content-Type: application/json, Cache-Control: no-cache, }, }); }前端我直接用fetch去读流再逐行解析。这会比集成ai包的useChat更轻量核心逻辑是const res await fetch(/api/agent, { method: POST, body: JSON.stringify(payload) }); const reader res.body.getReader(); const decoder new TextDecoder(); let bufferText ; while (true) { const { done, value } await reader.read(); if (done) break; bufferText decoder.decode(value, { stream: true }); const lines bufferText.split(\n); bufferText lines.pop() || ; for (const line of lines) { if (!line.trim()) continue; const step JSON.parse(line); // 这里依据 step 里是哪个节点在 UI 上显示进度解析中 / 匹配中 / 生成中... } }流式输出的意义不只是 UI 更炫而是让用户感知到 Agent 确实“干了几件事”而不是干等一个转圈。尤其是简历工具解析-匹配-生成-质检这四个进度提示能显著降低用户焦虑。4.3 模型选择冷热分离我的做法是parseResume用gpt-4o-minigenerateResume用gpt-4oqualityCheck用gpt-4o-mini。在项目早期这么设计后总成本下降得很明显。列表简单画一下节点推荐模型原因parseResumegpt-4o-mini抽取信息是确定性任务小模型够用matchJobRequirementsgpt-4o-mini关键词比对逻辑简单generateResumegpt-4o需要重写履历对逻辑和表达能力要求高qualityCheckgpt-4o-mini判断格式与覆盖度不需要太多创作能力这种方式在 LangGraph 里实现起来几乎零成本因为节点函数内部只是调用不同的ChatOpenAI实例LangGraph 本身不关心你用的是哪个模型。5. 三个绕不开的坑以及我是怎么填平的5.1 坑一节点返回值必须是一个对象片段LangGraph 的节点函数可以return一个PartialState但这个返回值的 key 必须和 State 里的字段对得上。我曾在一个节点里直接return something结果运行时直接报错“Expected a partial state object, got [object String]”。如果你需要返回多个字段把它们放在一个对象里return { parsedProfile: parsed, iterationCount: state.iterationCount 1, };不要以为 return 一个字符串是“自定义消息”这是纯业务习惯导致的错误社区里新人常见。5.2 坑二Edge Runtime 的隐性问题Next.js 默认在生产环境中会尝试把 API Route 编译为边缘函数。langchain/langgraph的某些内部依赖依赖 Node.js 的crypto和stream在 Edge Runtime 下会拉胯。最直接的解决方案是显式声明export const runtime nodejs;加在 route.ts 顶部。如果你忘了写可能会遇到这样的摸不着头脑的错误API resolved without sending a response、No body、甚至socket hang up。排查到半夜才想起来是 runtime 设置。5.3 坑三无限循环和超时条件边虽然强大但也容易造成死循环。比如qualityCheck永远觉得“不满意”就永远跳回generate直到 API 超时。我在条件判断里硬顶了iterationCount 3同时初始化时传了recursionLimit: 10双保险。而在路由层我又用Promise.race包了一层超时控制const result await Promise.race([ graph.invoke(input), new Promise((_, reject) setTimeout(() reject(new Error(Agent timeout)), 50000)), ]);50 秒是因为要照顾generateResume使用强模型时最长输出时间。实测 50 秒完全够用正常流程通常在 15 秒内结束。6. 让 Agent 更可靠实验记录、迭代日志与缓存复用6.1 关键把每次运行的关键字段都打印出来开发阶段我在每个节点入口处统一打印import { getLogger } from ../utils/logger; const logger getLogger(generateResume); logger.log(input: , { summary: state.optimizedSummary.slice(0, 100), hasJd: Boolean(state.jobDescription), iterationCount: state.iterationCount, }); logger.log(output: , { resumeLength: result.optimizedResume.length, });尤其是matchJobRequirements和generateResume两个节点的中间状态能直接看出模型是不是“读到了 JD”。我第一次试用时发现用户填了 JD 之后生成结果仍和原简历一模一样日志一看jobDescription字段在进入节点前就已经被截断了原因是前端表单没把长文本传全。6.2 缓存解析结果省时又省钱对同一个用户而言原始简历在一周内不太可能频繁变化。我给parseResume加了一层缓存用resumeText的哈希值作为 keyRedis 存储解析结果。但如果你不想引入 Redis也可以暂时用项目内存 Map单机部署没问题const parseCache new Mapstring, Recordstring, any(); function cachedParse(resumeText: string) { const key hash(resumeText); if (parseCache.has(key)) { return parseCache.get(key)!; } const parsed await parseResumeLLM(resumeText); parseCache.set(key, parsed); return parsed; }实际落地中这个缓存帮我把日常测试成本降了差不多一半尤其是反复调 prompt 时不需要每次重新解析。6.3 记录每次迭代的 token 用量在节点函数内部我调用的model.invoke返回值包含response_metadata.token_usage。汇总到状态里tokenUsage: Annotationnumber({ value: (x, y) (x ?? 0) (y ?? 0), })这样就能在最终响应里返回totalTokens。简历工具如果后面做成付费产品这是一项必须的计量指标。6.4 用标签体系提高返回质量我在generateResume的系统提示词里加入请保持简历中每一段经历的真实性不要添加原文不存在的公司、职位或时间。你可以用行业惯用的强动词改写但要确保基本信息不丢。原因很简单简历工具最大的抗风险点就是“幻觉”。如果 agent 编造一份经历被用户直接拿去求职后果非常严重。加这条约束后模型会更谨慎。另外我还会把parsedProfile里的字段名明确告诉模型让它只能改写表达不能改写事实。7. 从落地到上线稳定性、权限与前端的联动设计7.1 API 鉴权与用户隔离Agent 逻辑本身是无状态的但生产上你一定不希望任意请求都能无限调用你的模型。我在route.ts里先做了一层简单鉴权通过请求头里的 token 映射到用户 ID。const userId await authenticate(req.headers.get(authorization)); if (!userId) { return NextResponse.json({ error: Unauthorized }, { status: 401 }); }有了 userId 之后还可以在状态中加入userId字段以便后续把生成记录存入数据库方便用户查看“历史版本”。这件事我建议在第一天就做而不是等量上来以后再做因为加字段会影响所有节点吗不会状态图的扩展性足够你只需要在初始化时多传一个字段节点里暂时不用它即可。7.2 前端轮询 vs 流式对于简历这种中等耗时任务我更推荐流式理由前面说过。但如果你的部署环境是 Serverless 且 API 网关不支持流式响应那最好改成“先提交任务再轮询结果”的模式第一次 POST 生成任务 ID存入内存或数据库后端进程在后台运行graph.invoke客户端每 3 秒 GET 一次任务状态直到状态为 completed。我用过这种模式在 Vercel 上搭小 demo但注意 Vercel Serverless 函数不能常驻后台跑任务你只能把任务调度交给一个常驻服务或队列比如 Upstash QStash / 自己的 Node 服务。项目如果打算长期托管在 Vercel 上我更建议用流式方案它和 Serverless 的模型更搭。7.3 浏览器端表单的细节处理前端有个容易被忽略的坑用户在textarea里粘贴简历时可能带出大量空行和特殊字符。我前后端都会做一次normalizeResume函数function normalizeResume(text: string): string { return text .replace(/\r\n/g, \n) .replace(/\t/g, ) .replace(/[ \t]\n/g, \n) .replace(/\n{3,}/g, \n\n) .trim(); }这样做的好处是减少 token 浪费也减少解析节点对空白区域的误解。别小看这几步很多用户从 PDF 复制出来的文本包含大量多余换行直接丢给 agent 会影响输出排版。8. 还能怎么玩从“单份简历”到“简历工厂”的扩展思路当核心的“解析-匹配-生成-质检”链路稳定之后我发现它能复用到很多衍生场景而不是只能做单一优化。批量生成多个版本的简历同一个parsedProfile配上不同的jobDescription可以并行跑多个图实例。由于图的定义是纯函数式的每秒生成 20 份都不成问题只要模型接口扛得住。增加第三方“ATS 评分节点”在qualityCheck之后接一个节点模拟 ATS 扫描结果反馈关键词覆盖度。原本需要手工对比的环节现在完全自动化。制作“内部版本对比器”用parsedProfile生成一个标准化 JSON再用标准化 JSON 和用户原始简历做 diff能让用户追踪哪些经历被改写了减少对模型出错的恐惧。我在项目里已经实现了前两个第三个正在做。每次只要在状态图里加一个节点、连一条边原有节点完全复用。这种扩展成本是传统硬编码流程不具备的。最后分享一个个人体验把 Agent 逻辑和相关模型调用分离之后前端和后端实际上只关心状态流这句话在项目中期给我省了非常多事。哪怕你暂时不打算引入复杂的 GenAI 功能只要涉及多步骤 AI 流程LangGraph.js 这套图模式都值得提前试一把。
返回列表