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。在项目早期这么设计后,总成本下降得很明显。
列表简单画一下:
| 节点 | 推荐模型 | 原因 |
|---|---|---|
| parseResume | gpt-4o-mini | 抽取信息是确定性任务,小模型够用 |
| matchJobRequirements | gpt-4o-mini | 关键词比对逻辑简单 |
| generateResume | gpt-4o | 需要重写履历,对逻辑和表达能力要求高 |
| qualityCheck | gpt-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 网关不支持流式响应,那最好改成“先提交任务,再轮询结果”的模式:
- 第一次 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 这套图模式都值得提前试一把。