做这个简历工具的起因很实际:年前帮学弟改了一轮简历,发现大部分人的问题根本不是措辞,而是结构、匹配度和可量化结果。当时手头正好在调研 AI Agent 的落地场景,就想着干脆用 Next.js 加上 LangGraph.js 撸一个完整的简历 AI Agent 出来。从简历解析、诊断评分、定向优化到模拟面试,一整条链路全部跑通。这篇文章不写宣传稿,就是把从 0 到 1 落地这个 Agent 全过程的选型思考、状态图设计、核心代码实现和踩过的坑如实记录下来。想用 LangGraph.js 做实际业务 Agent、或者正考虑把 AI 能力嵌进现有 Web 应用的开发者,看完应该能少走不少弯路。
这个项目本身不算大,但它的价值在于完整覆盖了一个 Agent 从"有想法"到"能上线"的所有环节:前端交互、后端流式响应、LLM 调用、状态管理、结构化输出、错误恢复,全都碰了一遍。而且简历这个场景非常典型——输入是非结构化的 PDF/文本,经过多个子任务处理,最终生成多份结果,正好适合用图状态编排来做。如果你也在纠结"AI Agent 到底怎么落地到实际产品",这篇可以当成一份最小可行参考。
1. 为什么用 Next.js + LangGraph.js 做简历 Agent,而不是一把梭
1.1 AI Agent 主流架构里,为什么选 LangGraph.js 当编排层
先说结论:AI Agent 的底层模型很成熟,真正难的是"流程可控"。你让大模型自由发挥,它能帮你写简历,但也能在第三个环节突然跑偏,把一个改简历的任务变成写情书。像 Coze、Dify 这类平台也能搭 Agent,但对开发者来说最大的问题是流程不透明、扩展困难、代码不能进 Git。自己写编排,我又不想跟复杂的回调地狱纠缠。
LangGraph.js 解决的核心问题,是把 Agent 的推理和执行过程建模成一张有向状态图。每个节点是一个函数,比如"解析简历"、"诊断问题"、"生成优化建议",节点之间通过边连接,并定义好状态如何流转。和 LangChain 里那种chain = prompt1.pipe(model).pipe(parser)的线性串联相比,LangGraph 的主角变成了"图"——有分支、有循环、有条件的跳转,这才是 Agent 该有的样子。比如简历诊断完发现某个板块信息缺失,Agent 可以回到解析节点要求补充,而不是像线性链一样直接死磕到生成端。
你可能会问,LangChain 不也能做 Agent 吗?LangChain 里已经内置了AgentExecutor等高层封装,但它把大量决策细节隐藏了,出了问题你不太清楚状态在哪一步挂掉的。LangGraph 则把每一个节点、每一条边摊开给你看,调试体验更好,也更容易精确控制 token 消耗和分支逻辑。对我这个项目来说,我需要做的简历诊断、优化、模拟面试,恰好就是一条带条件分支的流水线,用 LangGraph 写出来的代码,读起来和画在纸上的流程图一样直观,这对后期维护是致命的友好。
1.2 技术选型对比:为什么不用纯 Next.js API 硬写 Agent
很多人会问,你直接用 Next.js 的 API Routes 写几个接口轮询调用 OpenAI,不也能实现吗?确实能,但区别在于"有状态"和"无状态"。
如果你只是调一次POST /api/resume/optimize,把简历全文丢给模型,返回一段优化文本,那完全不用 LangGraph。这种模式本质是"单次问答"。但真实业务里的简历工具,一定是多轮、多步骤、有记忆的:第一步解析产生的"结果摘要"要传给第三步做"匹配度分析";用户在第二步说"我的经历偏运营",后续的优化方向就要跟着变。这种跨步骤的上下文状态,你用无状态 API 写,只能靠数据库字段硬存,然后到处传递参数,写到最后满屏幕都是"if (step === 3 && mode === 'interview' && flag === 'a')"。
LangGraph 把状态管理做进了框架里。它的StateGraph自带一个全局的State对象,每个节点函数都能读取和更新它,节点之间天然共享上下文。我只需要定义好状态类型,节点之间通过边传递信息,状态的读写集中在同一份数据上,不用再操心参数传递顺序。还有一点:LangGraph.js 原生支持流式输出节点结果,这正好解决了我想要的前端打字机效果。
1.3 这个项目用 Next.js 而不是纯后端框架的原因
我见过不少团队做 AI 项目,前端用 React,后端用 FastAPI,中间跨两个仓库、两套部署,联调一次要半天。这个简历工具本身不大,我一个人全干,没必要搞成分布式。Next.js 的强项在于全栈:App Router 里可以直接写 Route Handler 做流式接口,前端组件可以放在同一代码库,部署到 Vercel 或自己的 Node 服务器都方便。
还有一个实际的好处:Next.js 的流式渲染和服务端组件,对 LLM 输出有天然的配合。比如简历优化结果可以一边流式生成、一边渐进式渲染到页面组件里,用户感知到的延迟从等待 10 秒变成首字 1 秒内出现。这种体验在没有 Next.js 的纯前端架构里很难做顺。更关键的是,Next.js 生态里有成熟的ai库(Vercel AI SDK),它和 LangGraph.js 可以直接打通,前端用useChat或者自定义 streaming handler 接后端流,一套流程非常顺。选 Next.js 的核心逻辑不是跟风,而是在"全栈能力、流式体验、部署成本"这三个维度上,它是我这个规模项目的最优解。
2. 基于 LangGraph.js 的简历 Agent 核心状态图设计
2.1 把简历 Agent 拆成一张流水线状态图
整个简历 Agent 的流程,我在落地前画了一张纸面图,然后直接照着翻译成 LangGraph 的StateGraph。既有线性流程,也有条件分支,正好把 LangGraph 的看家本领都用上。
先看整体的图状态设计(用文字描述,你可以照着画出同样的图):
Start → parseResume(解析简历) → checkMissingInfo(判断关键信息是否缺失,条件分支) ├─ 缺失 → requestMissingInfo(向用户询问补充) ├─ 完整 → diagnoseResume(简历诊断,输出评分) → generateOptimizedResume(生成优化后简历) → matchJobDescription(对JD匹配度分析) → simulateInterview(模拟面试) → End这张图看起来简单,但落地时每个节点内部都有完整的 prompt 工程和输出解析逻辑。checkMissingInfo本身不是一个简单的条件,它需要从解析结果里判断:教育经历、工作经历、项目经历、技能清单、联系方式这五类信息是否齐备。如果哪一类缺失或者内容过短,State 里会挂起一个waitingUserInput的标记,Agent 会先向用户提问,拿到补充后再继续往下走。
设计的关键在于"节点即函数、状态即全局变量"这一思路。每个节点函数长这样:输入整个 State,输出 State 的一部分更新。节点之间不直接通信,只依赖 State。这让每个节点都能独立测试——我可以在 REPL 里单独喂给diagnoseResume一段简历文本,看它的诊断结果是否合理,而不用把整条流程跑通。
2.2 状态对象设计和各节点职责拆分
State 是整个 Agent 的数据中枢。我定义的状态结构大概是这样的(TypeScript 类型,后续代码会展开):
interface ResumeAgentState { rawText: string; // 用户上传的原始简历文本 parsedResume: ParsedResume | null; // 结构化解析后的简历对象 missingInfo: string[]; // 缺失的关键信息清单 diagnosis: DiagnosisResult | null; // 诊断结果,含各项评分和改进点 optimizedMarkdown: string; // 优化后的简历(按板块拆分的Markdown) jdSummary: string; // 岗位JD的关键要素提炼 matchScore: number; // 简历与JD的匹配度评分(0-100) interviewQuestions: InterviewQuestion[]; // 模拟面试问题及参考思路 userInputs: Record<string, string>; // 用户在对话中补充的信息 }State 里每个字段的职责尽量单一,避免大杂烩。比如parsedResume是纯数据对象,missingInfo是判断条件的分支依据,userInputs是对用户反馈的临时存储。节点函数拿到 State 后只读取自己需要的字段,更新也只更新自己要负责的那几个字段,互不干扰。这样做的最大好处是:某个字段出了问题,五分钟内就能定位到是哪个节点改坏了。
节点职责的拆分我遵循了"一个节点做一件事"的原则。解析节点只负责把纯文本变成结构化 JSON;诊断节点只负责评分和改进建议,不做改写;优化节点只负责生成优化后的简历内容;匹配度分析节点只负责算分数和给出匹配点分析。这样拆完后,每个节点的 prompt 都很短,逻辑清晰,token 消耗也更可控。如果你把多个职责塞进一个节点,prompt 一长,输出的稳定性就会断崖式下跌,模型很容易把不该丢的信息丢掉。
2.3 条件分支的设计:如何让 Agent 决定下一步走向
LangGraph 比线性链强的地方就是条件边。在checkMissingInfo节点之后,我加了一个路由函数,LangGraph 会根据这个函数的返回值决定走哪条边:
function routeAfterCheck(state: ResumeAgentState): "requestInfo" | "diagnose" { if (state.missingInfo.length > 0) { return "requestInfo"; } return "diagnose"; }这个函数本身不复杂,关键在于missingInfo是怎么生成的。我没有直接让 LLM 返回一个"是/否"的布尔值,因为 LLM 的自由文本结果不稳定。我让解析节点输出结构化的ParsedResume,然后用正则硬校验必填字段:
- 如果
parsedResume.education为空数组 → 判定缺教育经历 - 如果
parsedResume.workExperience为空数组 → 判定缺工作经历 - 如果
parsedResume.contact.phoneOrEmail为空 → 判定缺联系方式 - 如果
parsedResume.skills.length < 3→ 判定技能太单薄
用正则和规则判断悬着的分支条件,而不是依赖模型二次判断,是我在这个项目里悟到的关键一点。Agent 的"智能"应该体现在大模型擅长的内容生成和理解上,规则分支该硬的时候就该硬,否则 Agent 就变成了一个失控的随机数生成器。后面我在实操中还发现,让 Agent 主动提问补充信息时,也要限制它只能针对识别出的缺失项提问,避免它发散问一些和简历无关的隐私问题。
3. 简历 AI Agent 完整落地:从后端流程到前端交互的实战实现
3.1 环境准备和项目初始化
我用的实验环境是 Node.js 20 LTS + npm。项目基于 Next.js 14 App Router,TypeScript。初始化命令直接用:
npx create-next-app@latest resume-agent --typescript --tailwind --app cd resume-agent npm install @langchain/langgraph @langchain/openai ai zod依赖里zod很关键,它是定义结构化输出的 schema 校验工具。LangGraph 本身不强制你用什么 schema 库,但你做结构化输出的时候,没有 zod 你就要自己手写一堆类型守卫,写起来很痛苦。后面解析节点的代码里,我会展示怎么用zod定义输出结构然后让 LLM 严格遵循格式。
还需要注意.env里配置模型 API Key。如果你用的是国内可直连的模型,LangGraph.js 也支持通过ChatOpenAI的兼容接口接进来,只要 model 名称和 baseURL 对应即可。我自己本地调试用的是标准 OpenAI 接口,线上换成了国内模型厂家的 OpenAI 兼容端点,代码层面只改了一行 baseURL,LangGraph 和这个兼容性处理得挺好。
3.2 核心代码实操:定义 StateGraph 和四个核心节点
先把最核心的编排代码写出来。基于@langchain/langgraph,定义一张状态图,串起全部节点。
import { StateGraph, END } from "@langchain/langgraph"; import { ChatOpenAI } from "@langchain/openai"; import { z } from "zod"; const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.3, }); // 定义状态 const graphState = { rawText: { value: (prev?: string) => prev ?? "" }, parsedResume: { value: (prev?: any) => prev ?? null }, missingInfo: { value: (prev?: string[]) => prev ?? [] }, diagnosis: { value: (prev?: any) => prev ?? null }, optimizedMarkdown: { value: (prev?: string) => prev ?? "" }, matchScore: { value: (prev?: number) => prev ?? 0 }, jdSummary: { value: (prev?: string) => prev ?? "" }, interviewQuestions: { value: (prev?: any[]) => prev ?? [] }, }; const workflow = new StateGraph(graphState) .addNode("parseResume", parseResumeNode) .addNode("checkMissingInfo", checkMissingInfoNode) .addNode("requestInfo", requestInfoNode) .addNode("diagnoseResume", diagnoseResumeNode) .addNode("generateOptimizedResume", optimizeResumeNode) .addNode("matchJob", matchJobNode) .addNode("simulateInterview", interviewNode) .addEdge("__start__", "parseResume") .addEdge("parseResume", "checkMissingInfo") .addConditionalEdges("checkMissingInfo", routeAfterCheck, ["requestInfo", "diagnoseResume"]) .addEdge("requestInfo", "checkMissingInfo") .addEdge("diagnoseResume", "generateOptimizedResume") .addEdge("generateOptimizedResume", "matchJob") .addEdge("matchJob", "simulateInterview") .addEdge("simulateInterview", END); export const resumeAgent = workflow.compile();这段代码读起来就是一张图。routeAfterCheck是条件边,缺信息时回到requestInfo节点,这个节点会用一个专门的交互 prompt 向用户提问。请求到信息后回到checkMissingInfo重新检查,直到信息齐了才继续往下走。这一步是 Agent 和普通一次性 API 的本质区别——它能根据自身状态发起多轮对话,而不是把用户输入丢进模型返回结果就完事。
有些刚接触 LangGraph 的朋友疑惑为什么value字段要用这样一个函数形式,这是 LangGraph 的状态更新机制。你可以传入一个 reducer,它会决定当节点返回部分状态更新时,如何把新值和旧值合并。我第一次用默认值只写了value: null,结果节点返回数据后状态没变,查了半天文档才发现要定义{ value: (prev) => prev ?? "" }这种带默认值的 reducer,让它接受首次写入。这个坑后面第 4 节会继续聊。
3.3 解析节点:用结构化输出让 Agent 学会"读简历"
parseResume节点是整个流水线的入口。它的任务是把用户上传的 PDF/文本转成结构化的简历对象。我在代码里嵌入了一个 zod schema,交给模型做结构化输出,然后把结果写回状态。
import { z } from "zod"; const ResumeSchema = z.object({ personal: z.object({ name: z.string().describe("姓名"), phone: z.string().nullable().describe("手机号"), email: z.string().nullable().describe("邮箱"), location: z.string().nullable().describe("所在城市"), }), education: z.array(z.object({ school: z.string(), major: z.string(), degree: z.string(), startDate: z.string(), endDate: z.string().nullable(), })), workExperience: z.array(z.object({ company: z.string(), title: z.string(), startDate: z.string(), endDate: z.string().nullable(), achievements: z.array(z.string()), })), projects: z.array(z.object({ name: z.string(), description: z.string(), highlights: z.array(z.string()), })), skills: z.array(z.string()), });解析节点的完整流程是:把 rawText 塞进 prompt,告诉模型"你是简历解析引擎,只负责提取结构化信息,不要补全或编造简历里不存在的内容",然后调用model.withStructuredOutput(ResumeSchema)直接拿到符合 schema 的 JSON。这个withStructuredOutput的用法是 LangChain.js 提供的,它会用函数调用的方式强制模型按 JSON Schema 输出,配合 zod 做了运行时校验。对 LLM 输出有了解的人都知道,这一步要紧,不然模型输出的 JSON 十个里有三四个是坏的。
实际测试中,解析节点用温度 0.1 的低温,因为我希望它只做提取,不做创作。如果温度太高,模型可能会自作主张给简历里添加他"觉得应该有"的内容,而不是用户真的写过的。这件事在简历场景里很敏感——添加不存在的经历属于严重的信任危机,所以解析模型必须极端保守。我甚至在 prompt 里刻意强调:"没有提取到的字段使用 null 或空数组,不要猜测。"最终解析出来的数据直接铺在页面上,用户修改后存为可信数据,后续诊断和优化都基于这份数据,而不是再回去读原文。
3.4 诊断、优化、匹配、面试四个节点的 prompt 设计思路
诊断节点是给用户"获得感"最直接的一步。它接收解析后的结构化简历,输出一份诊断报告,包括总体评分(百分制)、分维度评分(结构、内容、措辞、量化、匹配度)、以及每项需要改进的具体建议。评分我用严格的 JSON 结构输出,让前端可以直接渲染成雷达图。prompt 的核心设计原则是"必须逐条指向原文证据、给出具体的修改建议,不要输出大而化之的鸡汤"。
优化节点是诊断节点的输出再加工。它把diagnosis和parsedResume一起传给模型,在保留基本信息不变的前提下,逐条应用改进建议,输出一版标记了修改点的 Markdown 简历。这里我做了个细节:要求优化结果用自定义格式标注每一处修改的原因,比如// [改动] 将"负责XX系统"改为"主导XX系统,覆盖用户量提升40%"。这样用户看到的不是一版黑箱重写内容,而是能清楚知道模型动了哪些地方、为什么改。对简历这种牵一发动全身的文档,黑箱式整体重写是最不可取的。
匹配度分析节点是要附加一个岗位 JD 输入。它的输出包含一个matchScore(0-100)和若干条"匹配点"与"缺口点"。我要求它基于 JD 里高频关键词和硬性要求做对比,而不是泛泛而谈。这里的分数计算是让模型按关键词出现情况和技能覆盖率来给的,还会输出一个missingKeywords数组,方便前端高亮展示。
模拟面试节点是整条链路里唯一会多轮交互的节点。它的任务不是生成一份面试题列表,而是根据用户正在应聘的岗位,生成 5 道与简历内容强相关的面试问题,然后逐题和用户对话。这里我把每个问题设计成带questionType的枚举,比如"行为面试"、"项目深挖"、"技能考察"、"情景题"等,并附上"参考回答思路"和"追问列表"。如果用户回答完,Agent 会基于回答内容生成追问,实现真的对话感而不是一锤子买卖。这个节点也是 LangGraph 循环边发挥作用最明显的地方:用户答完一题,状态会回到面试节点生成下一题内容,同时保留历史问答记录。
3.5 前端流式交互:Next.js Route Handler 对接 LangGraph
后端图编译好后,前端需要通过 HTTP 调用并流式拿到结果。这里走的是 Next.js App Router 的 Route Handler 方案,在app/api/agent/route.ts里实现一个 POST 接口,接收用户的简历文本和当前回复消息,调用resumeAgent.stream()并逐块转发给前端。
import { resumeAgent } from "@/lib/agent"; import { NextRequest } from "next/server"; export async function POST(req: NextRequest) { const body = await req.json(); const { rawText, jd, userInputs } = body; const config = { configurable: { thread_id: body.threadId || "default" } }; const stream = await resumeAgent.stream( { rawText, jd, userInputs }, config ); const encoder = new TextEncoder(); const readable = new ReadableStream({ async start(controller) { for await (const chunk of stream) { const payload = `data: ${JSON.stringify(chunk)}\n\n`; controller.enqueue(encoder.encode(payload)); } controller.close(); }, }); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, }); }前端我写了一个自定义 hook,用fetch调用这个接口,再从ReadableStream里逐步解析 SSE 数据块,把最新的输出追加到 UI 上。这里没有用useChat库,原因是 LangGraph 返回的 chunk 结构不是简单的对话文本,而是{ nodeName: { ...partialState } }这种结构。我需要根据 node 名来分区块展示,如果直接用现成的useChat,反而要绕一层适配。封装一个useAgentStreamhook 反而更灵活,它可以按节点名分别存储输出,在界面上一边跑流程图一边实时显示节点进度。这个逐步点亮流程图的效果也成了整个产品的"面子担当"——用户看着 Agent 从"解析简历"走到"模拟面试",对 AI 的信任感比纯黑盒等待强得多。
流式读取的细节有个容易踩的坑:SSE 的分包可能会把一个事件拆成两次传输。前端的解析逻辑不能只看\n\n就一次性切割,需要用TextDecoder配合一个 buffer 把残留数据累积起来。这里我贴一下关键代码,后面踩坑清单里还会细讲:
const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (line.startsWith("data: ")) { const json = JSON.parse(line.slice(6)); handleChunk(json); } } }3.6 部署到服务器时需要处理的两个核心配置
本地跑通后,部署到服务器时有两个配置必须提前处理好,否则上线了就是一堆乱码报错。第一个是 Node.js 版本。我的服务器环境是 Node 20,但如果你用 Node 18,有些流式 API 的表现会有细微差异。LangGraph 依赖的比较新的 Web Streams API 需要 Node 18+,但平滑体验建议直接用 20 LTS。
第二个是函数超时设置。Next.js 单独部署成 Node 服务时,我遇到了一个很坑的情况:默认的路由处理器没有做超时限制,但如果部署到某些 Serverless 平台,默认函数超时只有 10 秒或 30 秒,而一个完整的简历 Agent 流程跑完可能要 40 到 60 秒(要调多个模型节点)。这个问题导致线上会看到用户跑了一半,接口直接 504。解决的方法有两个:自托管 Node 服务器覆盖超时限制,或者把长任务队列化处理。我选择的是自托管,毕竟这个场景的核心价值就是实时流式,等队列再回来取结果会让体验大打折扣。但如果你用的是 Serverless 平台,最好把流程图拆成多个小接口,每一步单独部署成一个短函数。
4. 实测踩坑记录:流式返回、JSON 解析、上下文管理
4.1 流的 eta 信息到底怎么解析:别把每个 chunk 当完整 JSON
流式返回是用户体验的关键,但也是我前期调试最费时间的一块。LangGraph 的stream()返回的 chunk 结构比一般 LLM 流式响应复杂得多。我第一次接的时候,想当然地认为每个 chunk 都是类似{ text: "你好" }的增量文本,直接往前端拼。结果界面上出现了大量[object Object],控制台也是各种 JSON 解析错误。
实际打印后发现,stream()返回的每个 chunk 通常是{ nodeName: { ...updates } },比如:
{ "parseResume": { "parsedResume": { ... } } } { "diagnoseResume": { "diagnosis": { "score": 75, ... } } }所以在前端解析事件时,必须做二次处理:先判断当前事件的nodeName是哪个节点,再提取该节点返回的部分状态数据,更新对应的 UI 区域。同时由于流式传输是持续不断的,界面接收到的数据可能不是按节点完整到达的——比如一个节点的输出很长,会被拆成若干块。我用一个agentProgress的状态对象维护每个节点的完成度和输出,前端再根据完成度渲染进度条和内容区。这里有个建议:先用浏览器的 Network 面板直接看接口返回的原始流,看清数据结构再写解析逻辑,比凭空猜要高效得多。
4.2 JSON 结构化输出不稳定:用 zod 和 withStructuredOutput 兜底
简历 Agent 的每一个节点都要返回结构化 JSON,这看起来是小事,实际上却是最容易翻车的环节。最开始我图省事,直接在 prompt 里写"以 JSON 格式返回",结果模型要么在 JSON 前后加说明文字,要么把注释写进 JSON 里,要么返回的是 Markdown 代码块包裹的 JSON。解析起来手忙脚乱。
现在的做法是统一走model.withStructuredOutput(schema)。这个函数内部会用 tool calling 方式让模型严格按 schema 生成 JSON,并且自带校验。如果校验失败,LangChain 会尝试重试。这里我还加了一层保险:即使有了withStructuredOutput,我仍会在节点函数里用 zod 的safeParse做一次手动校验。因为就算模型生成的 JSON 符合 schema,也可能存在"语义上错了但结构上对"的情况。比如解析简历时把公司名填到了学校名里,结构合法但内容错误,这种只能靠 prompt 和模型能力控制,无法靠 parser 解决。所以我在解析 prompt 里会特别强调"公司名和学校名不能混淆"并给出几个常见反例。
还有一个隐藏问题:深层嵌套的 schema 有时会超出某些模型的函数调用能力限制。我一开始定义了非常复杂的嵌套 schema,结果小模型输出质量明显下降。解决方法是把大 schema 拆成几个小的子 schema,分多次调用模型完成。比如先解析基本信息,再分别调用解析教育经历、工作经历和项目经历。虽然多调了几次模型,但稳定性提升显著。这个经验也验证了一个通用原则:对 Agent 来说,给模型的每一个任务都尽量小、边界清晰,输出质量远比一个巨型 prompt 更可控。
4.3 LangGraph 状态 reducer 的坑:默认值不是你以为的那样
LangGraph 的 StateGraph 类型定义里,每个字段往往是{ value: (prev?: T) => T }这样的 reducer 函数。这个设计容易让人困惑,我就是在这里浪费了不少时间。一开始我写状态定义时给字段做了简单的直接赋值:
const graphState = { rawText: { value: (prev?: string) => prev ?? "" }, parsedResume: { value: (prev?: any) => prev ?? null }, };结果节点函数的返回值总是覆盖不了旧值,或者只生效一次。后来弄明白 LangGraph 的节点返回有两种模式:如果节点返回的是一个Partial<State>,它会和现有状态做 merge;如果返回的是{ state: ..., update: ... }这种对象,则需要你显式声明如何 merge。我在这里建议直接用{ value: (prev) => prev ?? "" }这样的 reducer 方式,让每个字段自己实现"首次赋值、后续跳过"的更新逻辑。如果某个字段需要累积追加(比如interviewQuestions数组要不断 push 新题),就写一个数组 concat 的 reducer。这段逻辑看起来不起眼,但不理清楚会导致状态更新行为玄学化。
4.4 token 消耗不设限,一个用户能把你的预算打到爆
Agent 是 token 消耗大户,这点不亲自上线你不会意识到。简历 Agent 一个完整流程会调用多次大模型:解析一次、诊断一次、优化一次、匹配一次、模拟面试每轮问答一次。如果用户上传一份很长的简历,再上传一段很长的 JD,每次上下文里都塞着全量的 rawText、parsedResume 和对话历史,平均单次完整流程可能消耗 8k 到 12k tokens。这还只是良性使用。没人盯着的话,模拟面试这个环节可以让用户无限轮询,钱直接打水漂。
我的处理方案分三层:第一,用更小更快的模型跑解析和诊断这类子任务(temperature 低、输出结构固定,不需要顶级模型);第二,给每个环节设置上下文窗口上限,解析结果和诊断结果同步做内容截断,不会把十万字简历全文带进后续所有节点;第三,也是最重要的,给模拟面试设置最大轮数和最大 token 上限,达到后自动终止循环并输出总结。具体实现上,我用recursionLimit参数限制整张图的执行步数,同时在后端接口层做了每用户每小时调用次数限制。这种"能省则省、能限制就限制"的思路,是所有 AI 应用上生产环境的必修课。
4.5 常见问题排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
流式输出变成[object Object] | 前端把 chunk 当文本直接拼接,没解析 JSON | 按nodeName提取数据并在前端逐步渲染 |
withStructuredOutput偶尔抛错 | 模型输出与 zod schema 不匹配 | 用safeParse捕获错误,并让节点重试一次 |
| 状态更新只生效一次 | LangGraph 状态字段的 reducer 写错,直接覆盖而非合并 | 检查value的逻辑,数组字段用 concat 风格 reducer |
| 整图运行超时 | 某个节点模型等待过长,或节点死循环 | 设置 recursionLimit + 单步 API 超时 |
| 简历文本和 JD 都很长时响应极慢 | 上下文里塞了过多原始文本 | 解析后用摘要字段替代全文,后续节点只读摘要 |
| 模型在模拟面试中忘记之前的问答 | 没有把历史对话写入 state | 在 State 中新增conversationHistory字段,每轮结束后追加上一条 |
排查问题最笨但最有效的方法,是给每个节点打日志,打印进入节点时的 state 摘要和节点输出。LangGraph 本身支持在stream时预留 verbose 参数,能把每一步的调用链打出来。把日志打出来,基本一眼就能定位到是解析坏了、prompt 坏了还是 reducer 坏了,比我对着错误消息瞎猜要快得多。
5. 后续还能怎么扩展
这个简历 Agent 目前的链路已经完整覆盖了从解析到模拟面试的全程,但以 LangGraph 的底子,扩展方向还有不少。比如接入简历附件上传时做 PDF 文本抽取,或者对接招聘网站 JD 自动抓取,再比如把不同角色的面试官观点做成多个子 Agent,然后编排进一张更大的图里,让"技术面试官 Agent"、"HR 面 Agent"并行产出。甚至可以把多个用户的简历数据聚合到一个独立的手动触发节点,跑批量分析生成岗位适配度报告。
我自己最想做的下一步是引入"用户反馈闭环"。现在优化节点的输出是单向的,如果用户手动改了某个板块,Agent 不会感知到。加入一个userEdits字段,把用户手动修改的内容同步回状态,让诊断节点的后续回合理解用户偏好,这样才真正形成了一个有记忆、能进化的 Agent 循环。做这个扩展其实不需要改流程结构,只需改 state 类型定义和对应节点的 prompt,LangGraph 这种图的架构在这个场景下就显得很舒服。
最后分享一个我个人的体会:AI Agent 的开发,核心不在模型,不在炫技,而在把流程拆得足够细、把状态管得足够稳、把成本控得足够死。LangGraph.js 最大的价值不是替代 LangChain,而是逼着你用图的方式思考 Agent——每个节点干一件事,状态统一流转,一切可观测、可回放。如果你打算做类似的项目,先在纸上把流程图画明白,再把手伸向代码,可能比我当初直接动手写要顺利得多。