ARTICLE DETAIL

资讯详情

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

LangGraph状态图打造简历优化AI Agent的实战复盘

LangGraph状态图打造简历优化AI Agent的实战复盘 最近三个周末我把之前那个只会“拿着提示词硬怼 GPT”的简历优化工具彻底推倒重写了。新版基于Next.js LangGraph.js做成了一个完整的AI Agent上传简历、追问需求、逐段诊断、按岗位定制生成、导出 Markdown/PDF不再是一次性聊天而是一条像工厂流水线一样可控可追踪的处理链路。这篇文章就是这个项目从 0 到 1 的完整复盘包含选型理由、核心代码、部署细节和我在真实落地中踩过的坑。如果你正在做 Agent 类 Web 应用或者想搞清楚 LangGraph.js 到底能不能用在业务里这篇文章应该能帮你省下不少时间。1. 为什么我会选择这套组合项目背景与需求拆解1.1 简历工具到底在解决什么问题先说我最初的需求用户上传一份现有简历输入目标岗位的 Job Description工具自动把简历改成更匹配这份工作的版本。这个需求听起来简单但拆开之后会发现它根本不是“AI 帮我写一段文字”的问题而是一个结构化信息处理问题。求职者最常见的痛点有三个简历写得像职位说明书只有职责没有成果大量经历没有量化数据没有针对目标岗位调整关键词。而 AI 最擅长的是生成通顺的文字它天然会把“做了什么事”润色成“负责什么什么”听起来很专业但千篇一律。真正有价值的简历优化是在不编造事实的前提下把用户已有的经历重新组织成 HR 最想看到的表达方式。所以这个项目最重要的一条产品约束是只改表述不造事实。所有优化结果必须能从原始简历里找到证据。这条约束直接决定了后面 Agent 的节点设计和提示词策略而不是简单地让大模型“自由发挥”。1.2 技术选型为什么是 Next.js LangGraph.js我一开始也想过用 Python 后端毕竟 LangChain 和 LangGraph 的 Python 生态最成熟。但这个产品的前端需要一个实时交互界面需要文件上传、逐段编辑、进度展示、文档预览如果前后端分开用两套语言类型不一致、联调成本高、一个小改动要动两边对于一个周末项目来说太重了。Next.js的优势在于App Router 可以直接写 API Route前端和后端共享一套 TypeScript 类型Agent 的服务端调用放在 Route Handler 里模型 API Key 不会暴露到浏览器将来要加静态页面、SEO 也很方便。所以“前端交互 服务端接口 简单数据存储”这三件事一个 Next.js 项目全包了。LangGraph.js是我后来换上去的。之前我用的是 LangChain.js 的链式调用把“解析简历”、“提取 JD 要点”、“逐段优化”、“生成结果”串成一个顺序链。最开始跑得通但很快就发现几个问题链式调用像一条单向管道中间想加一个“用户补充信息”的分支非常别扭每步之间的上下文全靠一个大的聊天历史对象传递越往后越难追踪更麻烦的是一旦某个环节失败了整个链要重跑。LangGraph.js 的核心思想是把 Agent 定义成一张图节点是处理逻辑边是跳转关系全局状态 State 是唯一数据源。这让流程里的判断、循环、分支都变得明确。比如用户没填 JD就先进“提问节点”解析简历置信度低就回到“上传节点”。这种能力正是简历工具需要的。顺带一提现在社区里也有不少用 Rust 写 Agent runtime 的声音性能确实强但对于一个需要快速迭代、还要和前端深度绑定的 Web 产品来说TypeScript 全栈的交付效率要高得多。Rust 更适合做底层基础设施而不是业务产品第一版。1.3 我对“AI Agent 主流架构”的理解做这个项目之前我把市面上的 Agent 架构大致归成了三类纯 Chat 工具调用模型根据用户问题决定是否调用某个工具适合开放域助手。ReAct 循环模型反复“思考—行动—观察”适合需要多步推理和工具调用的场景。StateGraph 工作流预先定义好状态和节点按条件边流动适合流程固定、边界清晰的业务。简历工具属于第三类。它不是一个需要模型自主决定“下一步干什么”的开放场景而是一个有明确交付物的流程上传简历、收集需求、解析、优化、输出。与其让模型在一个大循环里自由发挥不如把流程固定成图把“需要模型判断”的地方只留一个条件边。这也是 LangGraph.js 最合适的用法。很多人问“AI Agent token 是什么意思”。在我实际项目里token 就是计费单位同时也是上下文窗口的占用单位。它直接决定了成本和延迟Agent 每多带一轮历史、每多传一段全文消耗的 token 就会线性增长。控制 token本质上就是控制成本和控制响应时间。这个认知贯穿了整个架构设计。2. 整体设计思路把“闲聊式对话”改成“可控制的流水线”2.1 需求拆解从上传到交付一共分几步我把整个流程拆成了五个阶段上传并提取简历文本提取目标岗位需求判断信息是否足够将简历结构化逐段分析匹配度按岗位 JD 逐段生成优化建议和改写文本输出完整简历文件并允许用户逐段确认修改。这里有一个关键决定Agent 不是一个“聊天机器人”而是一个多节点的流水线只是在某些节点需要和用户交互。比如第二阶段如果缺少 JD会先向用户提问用户在界面上补充后流程继续。聊天只是这个流水线的交互外壳核心是状态流转。2.2 状态图与节点设计LangGraph.js 的核心优势我用 LangGraph.js 设计了一张非常简单的状态图节点如下节点输入依赖产出是否调用模型collect原始简历、JD、用户回答missingInfo 列表、是否可继续是parse原始简历structuredResume是optimizestructuredResume、JD逐段优化建议与改写是render优化结果最终简历文本否节点之间用条件边和普通边连接。collect之后判断信息是否足够不够就返回collect继续追问够了就进入parse。parse之后如果结构化失败可以回到collect让用户补充成功则进入optimize。最后render负责格式化输出。这个设计的最大好处是每个节点只关心自己的状态字段不需要把几十轮聊天记录全部传给模型。比如optimize节点只需要读取structuredResume和jobDesc它根本不关心用户之前问了几个无关问题。这让上下文变得非常干净也让 token 消耗大幅下降。2.3 上下文与 Token 成本怎么在真实项目里算这笔账我做了一个粗略的成本模型。一份简历大约 1000~2000 字加上 JD 和系统提示词每轮调用大约 1500~2500 token。如果采用“把全文丢给模型让它一次性优化”的方案一次生成可能消耗 4000~6000 token看起来也不贵。真正的问题在于多轮交互。用户第一次上传简历、补充回答、要求修改某一段、再要求继续改如果每一轮都把全部历史塞进上下文状态会像滚雪球一样膨胀。我实测过一个重上下文方案用户在第五轮交互时单次请求消耗已经超过 15000 token而且响应速度明显变慢。所以我做了三个压缩措施简历先结构化把原文拆成“教育经历、工作经历、项目经历、技能列表”等块后续节点按需取用不再传原文全文JD 单独缓存不塞进聊天历史需要时通过状态字段读取用户追问只保留结构化答案不保留原始对话轮次。这套方案跑下来单用户从上传到生成一份完整简历平均消耗大约 6000~8000 token成本从原来的 0.2 美元左右降到了 0.04 美元左右。对个人项目来说这个数字意味着可以放心给朋友试用不至于被账单吓到。3. 核心代码落地从零搭一个能跑的 Agent3.1 初始化工程装好依赖我用 create-next-app 初始化项目然后安装 Agent 相关依赖npx create-next-applatest resume-agent --ts --app cd resume-agent npm i langchain/langgraph langchain/openai zod如果你还在使用 LangChain.js 老版本建议保持版本锁定。LangGraph.js 的 API 现在还在快速迭代不同版本之间接口差异不小我第一次用的时候就被Annotation.Root的写法变化坑过一次。生产项目记得把版本号固定下来。3.2 定义 State 和主图让流程先跑起来LangGraph.js 的 State 用 Annotation 定义。我把所有流程共享的数据放在里面import { Annotation, StateGraph, START } from langchain/langgraph; export const AgentState Annotation.Root({ resumeRaw: Annotationstring, jobDesc: Annotationstring, structuredResume: AnnotationRecordstring, unknown, missingInfo: Annotationstring[]({ reducer: (current, update) update ?? [], }), optimizeResult: Annotationunknown, finalResume: Annotationstring, }); const graph new StateGraph(AgentState) .addNode(collect, collectNode) .addNode(parse, parseNode) .addNode(optimize, optimizeNode) .addNode(render, renderNode); graph.addEdge(START, collect); graph.addConditionalEdges(collect, routeAfterCollect); graph.addEdge(parse, optimize); graph.addEdge(optimize, render); export const app graph.compile();这里的重点不是 API 细节而是状态定义决定了能做什么。missingInfo使用 reducer 来覆盖更新保证每次只保留最新值不会把历史数组越撑越大。这就是我在前文说的 token 压缩在代码层面的体现。3.3 节点内部的 Prompt 与结构化输出每个节点内部其实还是调用大模型但对 Prompt 和输出格式的要求完全不同。我拿collect节点举例import { ChatOpenAI } from langchain/openai; import { z } from zod; const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0.2, }); const askSchema z.object({ questions: z.array(z.string()).max(3), enough: z.boolean(), }); export async function collectNode(state: typeof AgentState.State) { // 如果已经拿到足够长的 JD直接跳过提问 if (state.jobDesc state.jobDesc.length 20) { return { missingInfo: [] }; } const prompt 你是一个简历优化服务的需求分析师。 用户已经上传简历但还没有提供目标岗位的 JD。 请根据简历内容列出最需要补充的 2~3 个问题。 例如目标岗位名称、期望城市、最想突出的项目。 ; const res await model .withStructuredOutput(askSchema) .invoke([ { role: system, content: prompt }, { role: user, content: 简历${state.resumeRaw} }, ]); return { missingInfo: res.questions }; }我强烈建议在需要“后续程序处理结果”的节点使用结构化输出而不是让模型返回一段自由文本。zod在这里起到了双重作用既能定义 schema 让模型输出 JSON又能在返回时做运行时校验。简历解析和优化建议也都用了类似方式每个优化段都带evidence字段用来给前端展示“这段依据是什么”。3.4 用 Next.js API Route 把 Agent 暴露成接口Agent 编译完成后需要暴露给前端调用。我在 Next.js 的 App Router 下新增了一个 Route Handler// app/api/agent/route.ts import { NextResponse } from next/server; import { app } from /lib/graph; export async function POST(req: Request) { const { resumeText, jobDesc, sessionId } await req.json(); const config { configurable: { thread_id: sessionId }, streamMode: values as const, }; const stream await app.stream( { resumeRaw: resumeText, jobDesc }, config ); const encoder new TextEncoder(); const readable new ReadableStream({ async start(controller) { try { for await (const state of stream) { const payload { node: state.lastNode, missingInfo: state.missingInfo, progress: state.progress, }; controller.enqueue(encoder.encode(data: ${JSON.stringify(payload)}\n\n)); } controller.close(); } catch (err) { controller.error(err); } }, }); return new Response(readable, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, }, }); }这里的streamMode: values会返回每一步执行后的 State 快照前端可以据此显示“正在解析简历”、“正在优化工作经历”等阶段状态。注意SSE 响应在 Agent 全部节点执行完之前不会自动结束如果某个节点卡住前端会一直转圈后面我会讲超时处理。4. 前端交互与生产部署不是能跑就算完4.1 上传、进度与预览前端体验的细节前端最容易被低估的是文件上传。我一开始把 PDF/Word 直接传到服务端解析结果服务端要额外引入 PDF 解析库、处理各种编码问题还很慢。后来改成在浏览器端解析文本PDF 用 pdf.jsWord 用 mammoth解析完只把纯文本传给后端。这样不仅快而且服务端不落原始文件隐私压力也小。Word 文件有个坑直接File.text()读出来是乱码必须用mammoth.extractRawText()这类专门的库处理。PDF 也一样扫描版 PDF 其实就是图片任何纯文本提取都不会有结果遇到这种情况我直接提示用户手动粘贴文本不硬撑。进度展示方面我用了 SSE 推送节点状态前端根据lastNode切换提示文案。这一步对用户体感提升非常明显从一个“漫长的聊天框”变成一个“看得见流程的工具”信任度完全不一样。预览部分我做了两个视图左右分栏左边是原文右边是优化后的版本对于有evidence的优化建议用高亮标记出来用户点一下就能看到“这句话是根据原文哪一段改写的”。这个设计也间接解决了“模型编造经历”的用户信任问题。4.2 Serverless 部署有哪些隐藏坑我把项目部署在 Vercel 上最开始踩到的最大坑是函数执行时间限制。简历 Agent 完整流程包含多个模型调用一次请求可能要跑 10~20 秒而 Vercel 的函数默认超时可能比这短得多不同套餐限制不一样Hobby 套餐尤其紧张。SSE 解决了“客户端等待体验”的问题但并不能改变服务端执行时长。我的解决办法是把流程拆成两个接口/api/agent/collect-parse负责收集需求和解析简历返回结构化结果/api/agent/optimize负责后续逐段优化和渲染。这样一来单次函数调用只包含 1~2 个模型节点时长可控。另外如果你有长期使用的计划建议直接部署到自己的 Node 服务器或容器上用 PM2 守护进程彻底摆脱函数超时限制。还有一个配置细节模型 API Key 必须放在服务端环境变量里。Next.js 只有以NEXT_PUBLIC_开头的变量才会被打到浏览器端其他变量只存在于服务端。我在项目里把 Key 放在.env.local的非NEXT_PUBLIC_变量中确保任何客户端代码都拿不到。4.3 敏感数据处理与多用户隔离简历是高度敏感的数据姓名、电话、邮箱、工作经历都在里面。我的处理原则是后端日志里禁止打印用户简历内容只记录 sessionId 和耗时Agent 状态不落本地磁盘默认使用内存存储服务重启即清空如果后续要多用户持久化建议用 Redis 或 Postgres 存储 LangGraph 的 checkpoint并给每个 thread_id 设置过期时间前端拿到生成结果后不要把完整简历写进 localStorage最多保留最近一次版本方便还原编辑状态。我在 Vercel 上部署时也专门确认过浏览器的网络请求不会经过日志系统暴露输入内容但如果接入了第三方日志采集一定要加脱敏过滤。这不是锦上添花而是做工具类产品的基本底线。5. 真实项目里踩过的坑问题排查与避坑手册5.1 防“编简历”让模型只改不造我在第一版遇到的最严重问题就是模型“编简历”。用户只写了“负责用户增长”模型直接润色成“负责用户增长策略通过 A/B 测试将转化率提升 32%”。这个数据完全是幻觉。后来我同时做了两件事第一在系统提示词里明确写“只允许改写不允许新增任何经历、数字、项目、技能”第二给所有优化结果增加evidence字段要求模型指出这处改写对应原文的哪一句话前端在界面上强制展示对应关系。即便如此模型偶尔还是会犯迷糊。我在服务端加了一层事实校验器简单对比优化结果里的数字和原文中的数字凡是原文没出现过的数字自动把这句话从结果中剔除。这个逻辑放在 LangGraph 的optimize节点的后处理函数里虽然粗暴但非常有效。5.2 JSON、流式输出和结构化输出打架如果你想用withStructuredOutput让模型输出结构化 JSON同时又想拿到边生成边显示的流式文本这两个需求本质上是冲突的。结构化输出要等模型调用结束后才能解析出完整 JSON不存在“边输出 JSON 边渲染 Markdown”这种美好体验。我最终的取舍是解析阶段用结构化输出结果之前可以等优化阶段用流式文本输出前端逐字展示“AI 正在重写这段经历”让用户觉得响应快。优化结果通过 Markdown 的约定段落来切分前端解析 Markdown 标题后展示到对应模块。这个方案绕开了“既要又要”的矛盾而且用户对优化阶段的耐心明显更高因为他们能看到内容在生长。5.3 状态越长越贵内存、超时与成本LangGraph.js 的默认 MemorySaver 把状态存在内存里适合开发调试。但在 Serverless 环境里函数实例随时可能被回收内存状态也会丢失。用户回访时如果带着同一个thread_id继续请求会发现 Agent 完全不记得之前聊过什么。后来我把 checkpoint 存储换成了可持久化方案并在写 Redis 时做了 TTL。同时为了让旧流程不被无限重放我在collect节点里判断jobDesc是否已存在一旦满足条件就跳过提问、直接进入下一步。这个判断逻辑等于在流程层面做了一次“短路”既省时间又省 token。5.4 一张表看懂我遇到的所有典型问题问题现象原因解决办法模型编造经历优化后出现原文没有的数据提示词约束不足加 evidence 字段 数字事实校验JSON 解析失败前端白屏或报错模型返回了 JSON 外的内容用 zod 结构化输出 重试接口超时用户转圈后失败多个模型节点串行耗时过长拆分接口、减少单次节点数状态丢失用户刷新后 Agent 失忆MemorySaver 内存态换 Redis/Postgres checkpoint TTLtoken 飙升多轮交互后账单暴涨上下文无限增长结构化字段覆盖、按需取用API Key 泄露浏览器能读到 Key环境变量前缀错误使用非 NEXT_PUBLIC_ 前缀Word 文件乱码上传后文本不可读没有正确解析二进制前端用 mammoth 解析这张表基本覆盖了我在这个项目里踩过的 80% 的坑。如果你也要做一个类似的 Agent 产品建议提前对照检查一遍。折腾完这个项目我最深的体会是Agent 落地难的不是模型而是流程边界、状态管理和成本控制。LangGraph.js 给我的不是一个“能跑的聊天机器人”而是一套可以拆分、可以测试、可以观察的工程结构。简历这个场景足够窄反而适合拿来做状态图 Agent 的第一次练手。如果你正在做类似的东西不妨把流程画出来、把状态字段列清楚再写代码你会省下很多重写的时间。
返回列表