news 2026/10/8 4:29:02

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangGraph.js实战:用状态图搭建可循环的简历优化Agent

1. 为什么最终选了“状态图”而不是再来一个巨型 Prompt

先交代一下项目背景。前阵子接到一个在线简历优化工具的需求:用户把现有简历内容贴进来,再填一个目标岗位,系统自动生成一份针对这个岗位优化过的新简历。听起来很简单,但真正落地时我才发现自己差点走进一个最典型的坑——把所有逻辑塞进一个超长 Prompt 里。

第一版确实这么干了:一个SYSTEM_PROMPT里写满“你是资深HR”“提取你的技能”“优化经历描述”“调整关键词匹配”“注意格式”……当时以为 LLM 能一把梭全搞定。实际跑起来问题很明显:长上下文下模型经常漏掉一部分要求,要么只提取不优化,要么优化了但丢失了原有事实细节,更麻烦的是后续想增加“SWOT 分析”“关键词覆盖度打分”这类模块,Prompt 会越堆越不可维护。

这时候我想到了 LangGraph.js。它的核心价值不是“多了一个库”,而是把 AI 应用从“单次文本进文本出”变成了一张可控、可循环、可中断的状态图。简历处理天然适合做成图:解析 -> 建结构化画像 -> 对齐岗位需求 -> 生成摘要 -> 润色 -> 质量检查,每个环节独立成节点,节点之间共享一个状态对象。好处是逻辑看着清楚,调试时能精准定位出错节点,而且能真正实现“检查不合格就回去重新生成”这种循环控制。

于是项目栈定为:Next.js负责前后端托管和 API Route,LangGraph.js负责 Agent 流程编排,LLM 走 OpenAI 兼容接口(后面可以随意换模型)。现在的实现里,用户得到的不是一次性生成的临时文本,而是一个有“过程”的 Agent 处理结果。

如果你也是第一次在这种工具类项目里引入 Agent,建议先放弃“一套 Prompt 走天下”的想法。哪怕做最轻量的多步流程,状态图带来的可控性也远超单次调用。接下来我把整个落地方案拆开讲,从环境搭建到核心状态设计再到实际踩坑,全程可以照着抄。

2. 环境准备:Once More,Next.js 项目骨架和依赖安装

2.1 初始化 Next.js 项目

我用的是 App Router 模式,版本要求 Node 18 以上(建议 20+)。终端里直接跑:

npx create-next-app@latest 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_KEY=sk-xxx OPENAI_BASE_URL=https://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: Annotation<string>, jobDescription: Annotation<string>, // 解析结果:用户简历里的结构化信息 parsedProfile: Annotation<Record<string, any>>, // 针对岗位生成的个性化内容 optimizedSummary: Annotation<string>, optimizedResume: Annotation<string>, feedback: Annotation<string>, iterationCount: Annotation<number>, });

每个字段的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:

  • 输入:parsedProfile+jobDescription
  • 输出:一个匹配分析结果,包括岗位关键词、缺失能力点、建议调整的策略。
  • 不要在这一步直接生成终稿,只生成“策略”,因为后续的润色节点会更专注于表达。

第三个节点generateResume:

  • 输入:parsedProfile+matchStrategy
  • 输出:optimizedResume,这是完整的新简历文本。
  • 模型:这里用强模型,我实际用gpt-4o,因为在长期保持事实准确的前提下重写履历表达,弱模型容易编造经历。

第四个节点qualityCheck:

  • 输入:optimizedResume+jobDescription+parsedProfile
  • 输出: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 里调用 Graph

Next.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 手动实现可读流(不使用第三方 SDK)

LangGraph.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-mini,generateResume用gpt-4o,qualityCheck用gpt-4o-mini。在项目早期这么设计后,总成本下降得很明显。

列表简单画一下:

节点推荐模型原因
parseResumegpt-4o-mini抽取信息是确定性任务,小模型够用
matchJobRequirementsgpt-4o-mini关键词比对逻辑简单
generateResumegpt-4o需要重写履历,对逻辑和表达能力要求高
qualityCheckgpt-4o-mini判断格式与覆盖度,不需要太多创作能力

这种方式在 LangGraph 里实现起来几乎零成本,因为节点函数内部只是调用不同的ChatOpenAI实例,LangGraph 本身不关心你用的是哪个模型。

5. 三个绕不开的坑,以及我是怎么填平的

5.1 坑一:节点返回值必须是一个对象片段

LangGraph 的节点函数可以return一个Partial<State>,但这个返回值的 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的哈希值作为 key,Redis 存储解析结果。但如果你不想引入 Redis,也可以暂时用项目内存 Map,单机部署没问题:

const parseCache = new Map<string, Record<string, 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: Annotation<number>({ 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 网关不支持流式响应,那最好改成“先提交任务,再轮询结果”的模式:

  1. 第一次 POST 生成任务 ID,存入内存或数据库;
  2. 后端进程在后台运行graph.invoke;
  3. 客户端每 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 这套图模式都值得提前试一把。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 4:28:56

用Next.js和LangGraph.js构建简历AI Agent的实战指南

做这个简历工具的起因很实际&#xff1a;年前帮学弟改了一轮简历&#xff0c;发现大部分人的问题根本不是措辞&#xff0c;而是结构、匹配度和可量化结果。当时手头正好在调研 AI Agent 的落地场景&#xff0c;就想着干脆用 Next.js 加上 LangGraph.js 撸一个完整的简历 AI Age…

作者头像 李华
网站建设 2026/10/8 4:28:37

Java Web图书馆管理系统课设:从源码部署到答辩加分实战指南

简介&#xff1a;这是一套基于Java Web的图书馆管理系统课程设计完整项目&#xff0c;围绕图书借阅、归还、查询、读者管理等核心业务&#xff0c;采用ServletJSPMySQL技术栈&#xff0c;在Eclipse环境中开发&#xff0c;面向高校计算机相关专业学生以及希望入门Java Web开发的…

作者头像 李华
网站建设 2026/10/8 4:28:31

Agent工程实战:从七要素到七个决策点的完整落地指南

这两年聊 AI Agent 的文章&#xff0c;基本都在解释“Agent 是什么”&#xff1a;能拆任务、能调工具、能自己决策。可真到自己上手做工程实现&#xff0c;你会发现概念层面的热闹撑不住代码层面的冷清——Demo 里那个会自己刷网页、写周报的 Agent&#xff0c;挪到生产环境之后…

作者头像 李华
网站建设 2026/10/8 4:28:30

JSP+Struts+Hibernate+Oracle在线考试系统:部署、改造与踩坑指南

简介&#xff1a;这是一套基于 Java Web 技术栈实现的通用在线考试系统源码&#xff0c;采用 JSPStrutsHibernateOracle 组合开发&#xff0c;适合正在学习 SSH 框架整合与 MVC 分层架构的中级 Java 学习者。压缩包共 342 个文件、约 3.1MB&#xff0c;包含 71 个 Java 源码、3…

作者头像 李华
网站建设 2026/10/8 4:28:11

C#无人值守地磅称重系统设计:串口、状态机与防作弊实现

简介&#xff1a;一套基于C#技术的无人值守地磅称重系统设计源码&#xff0c;面向仓储物流、矿业、化工、港口等行业的软件开发与系统集成人员&#xff0c;用于实现称重流程自动化、数据自动采集与记录&#xff0c;降低人工干预和操作误差。压缩包共243个文件&#xff0c;大小约…

作者头像 李华
网站建设 2026/10/8 4:27:19

从代码生成模型到AI编程助手:上下文、提示词与工程落地全复盘

去年有一段时间&#xff0c;我对“AI 代码生成模型”的预期发生了明显变化。最初我只是把补全当成高配版自动完成&#xff0c;生成一段能跑的代码就满足&#xff1b;直到真把一个半成品模块交给它“帮忙完善”&#xff0c;结果它非常礼貌地把函数补齐了&#xff0c;同时也非常均…

作者头像 李华