去年年底我接了一个小活儿:要做一个能分析简历、打分、给修改建议、还能按岗位要求生成优化版简历的工具。需求看起来不复杂,但真正动手才发现,单纯接个大模型聊天窗口根本糊弄不过去——简历处理是一个多步骤、有分支、还要人机协作的完整流程。最后我把整套东西落在 Next.js + LangGraph.js 上,做了一个真正意义上的 AI Agent,而不是一个只会“吐字”的聊天接口。这篇文章就把完整的落地过程、踩坑记录、核心代码和一些经验判断写出来,给正在用或者打算用 LangGraph.js 做同类“工具类 Agent”的朋友作参考。
整体来说,这套方案解决的核心问题是:把“简历解析 → 能力评估 → 岗位匹配 → 修改建议 → 生成优化稿 → 导出下载”这种多节点、可回退、需要状态共享的工作流,交给一个有状态的 Agent 来编排,而不是靠一堆散落的 prompt 和回调硬拼。适合的人群是:已经熟悉 Next.js 基础、想在业务里真正落地 AI Agent、但对 LangGraph.js 还比较陌生的开发者。
1. 项目整体设计与思路拆解
1.1 为什么不用“对话框 + 流式输出”搞定一切
简历工具看起来是个简单的问答场景,用户上传一份简历,随便聊几句就能得到结果。但真去对照需求会发现,它天然是一个“工作流”:
- 简历可能是 PDF、DOCX、图片,有的排版杂乱,需要先抽取文本。
- 评估不能只问“这份简历怎么样”,而要根据目标岗位、工作要求做多维度评分。
- 修改建议往往是几类:内容增减、措辞优化、格式调整、技能补全。
- 用户可能只改某一段,而不是全盘重写。
- 最后要导出成 PDF 或 Markdown,还要保留用户手工修改过的内容。
这些步骤之间不是简单的“一问一答”,而是有顺序、有依赖、有循环、甚至需要人工介入的流程。如果继续用“前端调 API 拿 completion”的方式实现,你会发现状态管理和分支逻辑全被堆到了前端代码里,乱到没法维护。
LangGraph.js 给了一个很自然的解法:把每一步定义成图里的“节点”,节点之间用“边”连接,所有的共享数据放在一个可持久化的 State 对象里。这样整个业务流程长什么样,代码结构就长什么样,逻辑清晰得多,也方便后续加人审、加循环、加回退。
1.2 Agent 工作流的整体结构
我最后设计出来的流程图逻辑是这样的(不用画图工具,用文字描述):
Upload Resume -> Parse Document -> Normalize to JSON -> Score against Job Description -> Suggest improvements -> User Review/Edit -> Generate optimized Resume -> Export这里有几个关键分支:
- 解析失败或格式不支持:回退到让用户手动粘贴文本。
- 分数偏低:直接进入“改进建议”节点,建议里附带具体修改文案。
- 用户对某些建议不满意:支持单独驳回某条建议,Agent 需要重新思考替代方案。
- 生成优化稿之后:用户可以选择只导出,也可以继续提意见生成第二个版本。
这些分支在 LangGraph.js 里面实现起来很顺手,因为它原生支持条件边(Conditional Edges)和循环。比如用户说“这个技能描述不准确”,Agent 可以重新走一遍“改进节点”,而不是从头把整个简历重新分析一遍。
1.3 状态(State)设计是成败关键
LangGraph.js 核心思想是“状态驱动流程”。每个节点接收当前 State,返回状态的部分更新,图引擎负责在节点之间传递状态。状态设计得不好,后面所有节点都会越写越痛苦。
我的 State 大体长这样:
export interface AgentState { resumeRawText: string; resumeParsed: ResumeData | null; jobDescription: string; analysisResult: AnalysisResult | null; suggestions: Suggestion[]; userFeedback: string[]; optimizedResume: OptimizedResume | null; rejectedSuggestionIds: string[]; messages: ChatMessage[]; error?: string; }这里有个容易忽略的点:不要把所有中间结果都塞进 State。比如 PDF 文件的二进制内容、调试日志、中间 prompt 提示词,都不应该放进 State,否则每次状态拷贝、序列化、传给子节点的成本都会很高。我最后只保留了“结果型数据”,原始文件数据放在临时文件或者内存对象里,节点内部单独处理。调试日志单独用 Logging 走,不进 State。
2. 核心技术选型与关键依赖
2.1 LangGraph.js 还是 LangChain.js 还是手写编排
现在已经有很多同学在基于 LangChain 封装自己的 Agent,但 LangChain.js 偏向提供“组件库”,并没有把“多步流程 + 状态共享 + 条件跳转”真正的固定下来。LangGraph.js 更像是一个轻量的流程引擎,它允许你定义节点和边,然后编译成一个可执行的图(StateGraph)。两者不是替代关系,而是互补关系:LangChain 提供模型封装、工具链、输出解析器;LangGraph 提供状态机和流程控制。
为什么我没手写编排?手写编排在只有两三个步骤的时候很爽,但一旦出现以下任一需求,手写就会爆炸:
- 某一步需要根据输出结果决定走哪条分支。
- 需要支持用户中途打断并修改输入。
- 需要支持多轮工具调用并保存中间状态。
- 需要在错误后重试某一步而不是重开整个流程。
这些正是 LangGraph 最擅长的事情。所以哪怕初期项目规模不大,我认为也值得引入这种有状态图编排抽象。
2.2 关键依赖清单和版本注意点
我实际使用的核心依赖大致如下:
next: ^14.2.x react: ^18.x @langchain/langgraph: latest @langchain/openai: latest langchain: ^0.2.x pdf-parse: ^1.1.4 mammoth: ^1.7.x openai: latest zod: ^3.22.x其中zod用来定义结构化输出 schema,这个后面详聊。pdf-parse和mammoth一个是 PDF 文本抽取,一个是 DOCX 解析,都有坑,后面单独说。
关于版本选择有一句忠告:LangGraph.js 的 API 在 0.x 阶段变化非常快,一切以当前 npm 包实际的导出名为准。我写本文时一些 API 和早期版本已经不同,比如StateGraph、END的导入方式都有了新的写法。建议你安装后先在项目里打印一下Object.keys(require('@langchain/langgraph')),确认下实际导出的 API。
2.3 模型选型与参数选择
简历分析属于“结构化输出 + 长文本理解”的任务,不能光用对话模型瞎白话。我最后选择了支持函数/工具调用的模型(比如 gpt-4o-mini 和 gpt-4o),并且严重依赖“工具调用返回 JSON”的能力。这个选择的原因很简单:简历评分、技能提取、建议列表,都必须以严格结构返回,不能靠 prompt 里写“请以 JSON 格式返回”,那样会有大量概率性格式错误。
具体参数参考:
temperature: 0.2,分析任务不能太放飞。max_tokens: 但要根据文件大小适当调高,一般 4000 起步。- 调用模型时用
response_format: { type: "json_object" }(如果模型支持),或者用.bindTools()+ zod 输出解析器,两者都行。 - 为了控制成本,简历文本过长时先做预处理截断,比如只保留前 20000 个字符,再分段分析。
3. 实操落地:从零搭一个简历分析 Agent
3.1 初始化 Next.js 项目与目录结构
我按“路由分隔业务”的方式组织代码,不把 Agent 逻辑塞进组件里:
app/ api/ analyze/ route.ts // 启动 Agent 工作流 feedback/ route.ts // 接收用户对建议的反馈 page.tsx // 上传页 components/ ResumeUpload.tsx ScoreRadar.tsx SuggestionList.tsx lib/ agent/ state.ts // Agent State graph.ts // 构图与节点编排 nodes/ parseResume.ts analyze.ts suggest.ts optimize.ts exportDoc.ts tools/ atsScore.ts skillExtract.ts resume/ pdfParser.ts docxParser.ts textCleaner.ts初始化和安装依赖就不赘述了,核心是StateGraph的使用。
3.2 定义节点:解析简历
解析节点是所有后续流程的入口。它要处理 PDF 文本抽取、DOCX 文本抽取、纯文本清洗,然后交给 LLM 结构化抽取。
一个简化的节点实现:
// nodes/parseResume.ts export const parseResumeNode = async (state: AgentState) => { const { resumeRawText } = state; const parser = zodToJsonSchema(resumeSchema); const extractionTool = new Tool({ name: "extract_resume_data", description: "提取简历中的结构化字段", schema: resumeSchema, }); // 用模型绑定工具并强制调用 const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0, }).bindTools([extractionTool]); const res = await model.invoke([ new SystemMessage( "你负责从纯文本简历中提取结构化数据,字段以工具定义为准。不要遗漏个人技能、工作经历和教育背景。" ), new HumanMessage(resumeRawText), ]); const toolCall = res.tool_calls?.[0]; if (!toolCall) { throw new Error("模型没有返回结构化结果"); } const parsed = wellParseJSON(toolCall.args); return { resumeParsed: parsed }; };这里最大坑点就是“模型返回的 JSON 不一定是合法 JSON”,就算用了工具调用,也偶尔有转义异常、字段缺漏的情况。所以我写了一个wellParseJSON方法,内部先JSON.parse,失败后用“提取{...}片段再解析”,再失败就用“找一个修复 JSON 的模型调用”,三级兜底。
解析节点的输出会直接影响后续的评分和优化质量,所以这个兜底值得写。
3.3 定义节点:岗位匹配与评分
评分节点用resumeParsed和用户输入的jobDescription,让模型按几个维度打分并给出依据:
const scoreSchema = z.object({ dimensions: z.array(z.object({ name: z.string(), score: z.number().min(0).max(100), reason: z.string(), })), overallScore: z.number().min(0).max(100), summary: z.string(), });这个 schema 直接绑定为工具的schema,模型返回的结果天然就是一个完整 JSON 对象。整体评分 + 维度评分有个好处:后面前端渲染雷达图特别方便。你可以把“匹配度、经验深度、技能匹配、项目亮点、格式规范”这五个维度固定下来,让模型按这个维度打分。
提示词里必须明确要求模型遵循“岗位描述优先”原则,否则模型会纯粹按通用优秀简历标准打分,导致匹配度失真。例如后端岗位的简历里如果没写项目压测和性能优化经验,那“技术深度”维度分数要合理下调,而不是按模板化技能列表打分。
3.4 构图与条件分支
这部分是 LangGraph.js 的核心体现。我用一个简化图来说明:
import { StateGraph, END, START } from "@langchain/langgraph";定义节点和边:
const workflow = new StateGraph<AgentState>({ channels: ["resumeRawText", "resumeParsed", "jobDescription"], }) .addNode("parse", parseResumeNode) .addNode("analyze", analyzeNode) .addNode("suggest", suggestNode) .addNode("optimize", optimizeNode) .addNode("should_optimize", shouldOptimizeNode) .addEdge(START, "parse") .addEdge("parse", "analyze") .addEdge("analyze", "suggest") .addEdge("suggest", "should_optimize") .addConditionalEdges("should_optimize", (state) => { if (state.optimizeRequested || state.userFeedback.length > 0) return "optimize"; return END; }) .addEdge("optimize", END); export const graph = workflow.compile();这里有一个我认为最实用的设计:把“should_optimize”也做成了一个节点。它本身不做重活,只判断当前状态里是否有新的用户反馈、是否要求生成优化稿。有了这个节点,整个流程在“分析出建议后停住 → 用户反馈 → 再次进入优化 → 输出结果”的闭环里运转得非常自然。这就是典型的 Agent 循环,而不是一次性调完所有步骤。
3.5 如何在 Next.js API Route 中调用图
在 Next.js App Router 下我用一个 POST 接口接收用户上传和岗位要求,然后调用graph.invoke()或graph.stream()。区别在于:
invoke():一次性等待整张图跑完。适合内部调试或不需要实时反馈的场景。stream():可以逐步发射每个节点的更新。适合前端展示当前进度,比如“正在解析中”“正在评分中”。
我在产品里用的是stream模式,前端通过 SSE 获取进度。API Route 里关键代码如下:
export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get("resume") as File; const jobDescription = formData.get("jobDescription") as string; const rawText = await extractTextFromUpload(file); const initial_state = { resumeRawText: rawText, jobDescription, messages: [], }; const stream = await graph.stream(initial_state, { streamMode: "updates" }); const encoder = new TextEncoder(); return new Response( new ReadableStream({ async start(controller) { for await (const update of stream) { controller.enqueue(encoder.encode(`event: ${JSON.stringify(update)}\n\n`)); } controller.close(); }, }), { headers: { "Content-Type": "text/event-stream" } } ); }注意streamMode: "updates"只返回每个节点更新后的状态块,比values模式省流量,前端也很好解析。如果需要对每个 token 做打字机效果,那更适合用模型层的 LLM stream,而不是整个 Graph 的 stream。两者不要混用,否则很容易出现前端状态错乱。
3.6 前端交互和状态渲染
前端上传页的核心交互是:
- 拖拽上传简历文件。
- 填写目标岗位描述。
- 点击“分析”,通过 EventSource 或者 fetch + ReadableStream 接收 SSE 事件。
- 根据事件类型更新 UI:节点名称、进度条、解析后的基本信息卡片。
- 评分结果用雷达图展示。
- 建议列表支持单选“采纳”或“驳回”。
- 点击“生成优化稿”后,把用户反馈作为新 State 传入并再次调用 graph。
这些交互并不复杂,但有一点值得提醒:前端不要直接依赖 AI 返回的 Markdown 字符串做 DOM 渲染。简历优化稿和评分理由都应该是结构化数据,最好用 JSON 传给前端渲染。原因是模型输出的 Markdown 有概率包含非法标签、危险协议链接,提前做白名单过滤或者改用结构化字段更安全。
4. 踩坑实录:那些文档里没写明白的事儿
4.1 PDF 解析是最大的“刺客”
简历里最常见的格式是 PDF,但它也是最恶心的。pdf-parse这个库对于扫描版 PDF 完全无能为力,对于部分软件生成的 PDF 会出现文本顺序错乱、中文字符乱码、表格内容粘连。
我的处理策略是分三层:
- 先尝试
pdf-parse提取文本,如果文本长度太短(比如不足 200 字符)就认为提取失败。 - 提取失败时,如果有配置 OCR 服务,就调用一次 OCR(比如本地用 PaddleOCR 或对标云 API)。
- 如果 OCR 也没有结果,就明确返回错误,让用户手动粘贴简历文本。
另外要注意pdf-parse在 Next.js API Route 中运行时,默认不包含 Node 的核心模块 polyfill,可能会报buffer相关错误。解决办法是在 Next.js 配置里加上:
config: { runtime: 'nodejs', }不要用 Edge Runtime 去解析 PDF,边缘运行时对文件 API 兼容性太差。
4.2 DOCX 的编码问题
mammoth能提取 DOCX 文本,但提取后经常伴随大量的空白字符、重复段落。我写了一个textCleaner,连续多个换行合并成两个换行,去掉非打印字符,把全角标点转半角(除了中文场景)。清理这一步极大提升了后面 LLM 抽取的准确率。
还有 DOCX 文件里的图片信息、表格拆分问题。mammoth默认不提取表格内容里的复杂嵌套结构,所以我在解析文档时额外用includeEmbeddedStyleMap配置保留表格结构。但即便如此,模型结构化抽取时还是会漏掉合并单元格信息。如果你面向的简历模板很复杂,建议直接告诉用户“不支持过于复杂的图文混排”并在前端提示,比后台反复优化解析效果更实际。
4.3 LangGraph.js 的 State 更新机制
一开始我以为每个节点返回的对象会整体覆盖 State,但 LangGraph 的 State 更新是merge 而不是 replace:默认状态更新会把返回对象的字段合并到现有 State 中,如果你返回{ resumeParsed: null },也会把原来非 null 的值覆盖成 null。这在某些场景下需要主动清空字段时没问题,但如果你只是想“返回空对象”表示不更新,一定要明确返回{},否则可能会误清空。
我还踩过一个坑:当 State 里包含嵌套对象时,直接返回一个深拷贝的新对象没问题,但如果引用同一个对象再叠加字段变化,有时会出现深层对象字段不更新的情况。我的建议是:在每个节点返回前对修改字段做一次结构化克隆,不要直接改传入的 state。
return { resumeParsed: { ...state.resumeParsed, lastUpdatedAt: Date.now() }, };而不是在state.resumeParsed对象里 push 字段再返回。
另外,如果你用了messages这样的通道字段,要注意 LangGraph 对消息序列的处理有专门的消息合并策略(messageschannel)。默认情况下,节点返回的messages数组会追加到现有消息列表,而不是替换。如果不需要这个行为,可以把通道类型改为普通channel。这块官方文档有一篇专门讲durable execution和channels的内容,值得通读。
4.4 模型返回结构不稳定时的三层兜底
结构化输出虽然用了工具绑定,但模型偶尔还是会出现:
- 返回空
tool_calls。 - 返回多个
tool_calls(我不希望它一次调用多个工具)。 tool_calls[0].args不是合法的 JSON 字符串,而是有截断迹象。
我的解决方案是写了一个「强制单次工具调用」的封装函数:
async function callWithSingleTool(model, messages, schema) { const response = await model.invoke(messages); const calls = response.tool_calls ?? []; if (calls.length === 0) { throw new Error("NO_TOOL_CALL"); } if (calls.length > 1) { // 重新调用一次并显式在 system message 里强制只调用一个工具 return retrySingleToolCall(model, messages, schema); } const argsText = typeof calls[0].args === "string" ? calls[0].args : JSON.stringify(calls[0].args); return wellParseJSON(argsText); }同时配合OpenAI的parallel_tool_calls参数设为false:
const model = new ChatOpenAI({ model: "gpt-4o-mini", parallel_tool_calls: false, });这一步能少踩很多坑。parallel_tool_calls=false对工具型 Agent 尤其重要,因为大多数业务场景一次只需要执行一个决策,而不是让模型疯狂并发调用多个工具。
4.5 Serverless 超时和内存限制
Next.js API Route 在 Vercel 等平台默认 serverless function 有执行时间限制(免费版通常是 10 秒,付费也一般是 60 秒)。但我的简历 Agent 一旦上传大文件、跑多轮模型调用,很容易超过 30 秒。
我的应对方案:
- 把重型工作放到“异步任务 + 轮询/Webhook”模式,而不是在 HTTP 请求内同步等完整流程。API Route 收到请求后,先把任务状态写进数据库或内存队列,然后立即返回一个
taskId,前端异步轮询或者用 SSE 订阅结果。 - 如果坚持同步返回,则务必使用 Node.js Runtime,并考虑把
maxDuration配置调整到你的平台支持上限。 - 不要把大文件全文存进
agentState再传给模型,先把文件处理好了存到云存储,给节点传一个fileUrl引用。
这个架构调整可能让代码“没有断点调试那么直接”,但对生产环境来说是必须做的。
5. 常见问题排查技巧与性能优化
5.1 用调试模式快速定位“卡在哪个节点”
LangGraph 提供了编译后图的debug或interrupt机制,虽然 API 不断变化,但核心思想是:在节点执行前后打印状态。
我养成了一个习惯:写一个 wrap 函数把每个节点包一层日志:
const logWrapper = (nodeName, nodeFn) => async (state) => { console.log(`[进入节点] ${nodeName}`, { preview: JSON.stringify(state).slice(0, 200), }); const next = await nodeFn(state); console.log(`[离开节点] ${nodeName}`, { preview: JSON.stringify(next).slice(0, 200), }); return next; };这样不用改每个节点实现,就能在本地看到完整流转路径。排查问题最快的方法是看“进入节点但没离开节点”的地方,通常就是模型调用超时或者解析抛错。
LangSmith也是一个可视化追踪工具,强烈建议在开发环境配上,它会把每个节点的输入输出、token 消耗、耗时展示得很清楚。配置方式就是给 ChatOpenAI 传tracing相关环境变量,具体以 LangSmith 文档为准。
5.2 常见错误速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 上传 PDF 后解析到乱码 | 扫描版 PDF、字体嵌入异常 | 走 OCR 或手动粘贴文本 |
| 节点返回后 State 不更新 | 没有返回新的对象、嵌套引用问题 | 每个节点返回结构化克隆后的快照 |
| 模型返回空 tool_calls | 上下文过长、模型误判 | 降低输入长度、强制单工具调用、重试一次 |
| 前端 SSE 收不到完整数据 | Serverless 超时、代理缓冲 | 改用异步任务模式或调大 maxDuration |
| 生成的建议和简历内容对不上 | prompt 里没有给足原文上下文 | 把关键段落摘要或原文片段注入 prompt |
| 费用暴涨 | 长文本反复重试、模型选得太贵 | 截断输入、用 mini 模型做解析、结果缓存 |
5.3 提示词模板经验
简历 Agent 的提示词可以拆成两部分:
系统提示词只定义角色和工作原则,比如:
你是一名资深HR和技术面试官。你根据岗位描述和简历内容进行客观评估。 你的评分必须参考岗位要求,不得凭印象打分。 分数必须有依据,依据必须引用简历原文或岗位原文的关键词。用户提示词则把“要处理的具体内容”塞进去,包括简历文本、岗位描述、限定输出的维度。不要把所有历史聊天记录都塞进系统提示词里,那样既浪费 token 又会让模型忘记当前任务。
对于“建议生成”环节,我要求模型必须按以下结构输出:
修改项 | 原文 | 建议修改为 | 理由 | 优先级这样前端渲染时每一个建议都能单独生成“采纳”“忽略”按钮,而且用户点击“不采纳”后,Agent 可以直接把这条建议的 ID 放进rejectedSuggestionIds,在下一轮优化时跳过它。
5.4 人机协作 + 记忆扩展
简历工具最理想的使用方式不是全自动出结果,而是“AI 生成建议,人做裁决”。LangGraph 里可以用interruptBefore或interruptAfter预设断点,也可以像我这样,把流程跑到“建议列表”后自然停住,等前端把用户的反馈拼到下一次graph.invoke里。
如果你希望 Agent 记住用户对不同风格的历史偏好,比如“这个人不喜欢动作化动词开头”,可以把这些偏好写成结构化userPreferences存进 State,并在后续优化节点中注入。注意这个偏好字段应该是在每次用户反馈时增量更新,而不是每次重写,否则一次误操作会把前面所有偏好清空。
5.5 成本和性能优化
我实测一个完整简历分析流程的 token 消耗:
- 解析抽取:约 3000 - 5000 token。
- 评分分析:约 2000 - 4000 token。
- 生成建议:约 2000 - 4000 token。
- 生成优化稿:约 3000 - 6000 token。
整套下来一次调用消耗接近 1.5 万 token。所以性能优化重点不是单次速度,而是“减少不必要的重跑”。
我的优化手段:
- 缓存解析结果:同一份文件即使改了几次岗位要求,也不需要重新做解析,只重新做分析和优化。
- 复用评分维度:评分维度固定后,前端可以先展示雷达图,用户明确只改某个维度时,Agent 针对该维度做局部重算,而不是全量重算。
- 使用更便宜的模型做预处理:解析和评分用
gpt-4o-mini,只有最终生成修改建议和优化稿时用更强的模型。 - 限制上下文长度:简历文本超过一定字数就分段截取重点段落,而不是全文塞给模型。
最后补充一点个人体会
整个项目做下来,我最强烈的感受是:LangGraph.js 的价值不在“让模型更聪明”,而在“让流程不会乱”。AI Agent 落地最怕的不是模型答错,而是流程失控——某个分支没考虑、状态覆盖错误、用户反馈没有生效。这些靠提示词解决不了,靠状态机和图的编排才能兜住。
如果你正准备做类似的“工具型 Agent”(不止是简历,也可以是合同审查、资料整理、内容生成工作台),我的建议是从一开始就按 LangGraph 的“节点 + 条件边 + 状态”来思考业务流程,哪怕前两三个节点显得多余,也千万别为了省事把所有逻辑塞进一个巨型 prompt。等你真的跑通了第一个带分支的 Agent,再回头看那些“对话框式”实现,你会感谢当初多写的这几十行图配置。