尧图精选

Next.js+LangGraph.js实战:构建简历优化AI Agent全过程

🕒 发布时间:2026/10/2 10:58:39 📁 来源:尧图网络
这两年聊AI Agent的人很多但真正能从立项一路推到线上、还能扛住几个人同时用的项目其实并不多。我自己最近完整落地了一个实战项目核心组合是Next.js LangGraph.js应用场景选了简历优化用户上传一份PDF简历再贴一段目标岗位JDAgent自动完成解析、匹配分析、逐条优化建议最后直接产出一版可下载的对比简历。从最初只有十几个用户的内部工具到后来对外开放压测中间踩过的坑基本都能写成一本书。这篇就把整个落地的设计思路、技术选型、代码实现和排错经验一次讲透适合想做Agent练手项目或正在把智能体推向生产的团队。1. 项目定位与整体设计拆解1.1 为什么拿“简历优化”当Agent落地场景我见过很多人练手Agent一上来就想做“全能助手”结果对话没边界、输出没格式、评估没标准项目多半烂尾。选简历工具当切入口是因为它天然满足Agent练手的四个条件任务边界清晰输入是简历和JD输出是结构化分析和优化建议不需要Agent去探索互联网。用户痛点强写简历这件事几乎人人需要而且大多数人的简历确实存在“没量化、没重点、表述平淡”的问题。多步骤有复杂度识别→分析→生成建议→产出文档这个链路足够体现LangGraph的状态流价值而不只是单轮Prompt。产出可评估优化前后的简历可以直接对比好不好用一眼看得出来。这个定位决定了产品的交互形态不是打开一个空白的对话框让用户自由聊而是提供一个带引导的“上传简历 贴JD 出报告”工作台。对话作为补充核心是结果交付。1.2 为什么是Next.js LangGraph.js这套组合技术选型我纠结过一阵。最早想过用Python FastAPI LangChain后来还是定了TypeScript全栈。原因很实际Next.js把前端页面、API路由、Serverless部署一把梭Hobby计划就能跑个人项目和早期团队用成本极低。简历工具这种页面不算复杂但需要文件上传、流式返回、Markdown渲染Next.js的App Router都能干净承接。LangGraph.js是我对比LangChain后最终选的。LangChain适合“链式调用”但Agent一旦带分支、带人工确认、带回放调错链式表达就啰嗦了。LangGraph是用状态图StateGraph组织Agent节点和边都是显式的整个工作流画得出来、看得见、能单步调试。TypeScript统一栈两套代码都用TS前后端共享类型定义比如Agent返回的Analysis结构可以直接被前端复用省掉一整套DTO转换。我更看重的其实是LangGraph的可控性。很多Agent项目死在不稳定上——模型自由发挥输出时好时坏。状态图逼着你把“流程”和“模型能力”分开流程是确定的代码逻辑模型只负责局部的文本生成。这样系统整体是可控的出问题也容易定位在哪一步。1.3 工作流设计从聊天到简历产出简历Agent的完整工作流我按LangGraph的节点和边拆成下面几条关键路线start→collectRequirements先询问用户目标岗位、工作年限、想突出什么收集必要上下文。collectRequirements→parseResume上传简历后解析PDF/文本抽取基本信息、工作经历、项目经历、技能标签。parseResume→analyzeMatch把解析结果和JD做匹配计算关键词覆盖度、技能缺口、经历相关度。analyzeMatch→generateSuggestions逐条生成优化建议包括表述改写、STAR补充、量化结果。generateSuggestions→generateResume按目标JD重排内容优先级生成一版优化后简历Markdown/PDF。过程中如果简历信息不足走askClarification节点向用户提问补齐再回到analyzeMatch。任意节点允许用户“回到上一步修改答案”通过interrupt实现提前中止和人工介入。设计成有向图而不是一段Prompt到底最大的收益是可观测。我在开发期给每个节点打了日志能精确看到用户卡在哪一步、哪个节点耗时长、哪一步解析结果为空。如果用户反馈“生成结果不准确”我能直接回放当时的State而不是盲猜模型抽风。2. 核心细节解析与实操要点2.1 LangGraph.js 核心概念State、Node、Edge、CheckpointerLangGraph.js的四个核心概念用流水线来类比最好懂State整条流水线上所有传送带的汇总状态面板。所有节点只能读写这个State节点之间不直接传参。Node流水线上的工位。每个工位接收当前State完成自己的工作后把改动写回State。Edge流水线轨道。有普通边走完必到下一个工位和条件边根据State内容决定走哪条轨道。Checkpointer流水线在每天收工时的存档。有了存档对话中断/重启后可以回到原来的进度继续。State定义在LangGraph.js里用的是Annotation.Root其中每个字段可以配置reducer。比如对话消息数组用合并式reducer简历文本字段用覆盖式reducerimport { Annotation, MessagesAnnotation } from langchain/langgraph; export const AgentState Annotation.Root({ // 消息历史用内置的MessagesAnnotation负责累积 messages: MessagesAnnotation.spec, // 原始简历文本新值覆盖旧值 resumeText: Annotationstring({ reducer: (_, next) next, }), // 目标JD文本 jdText: Annotationstring({ reducer: (_, next) next, }), // 解析出的简历结构化数据 parsedResume: AnnotationResumeData | null({ reducer: (_, next) next, }), // 匹配分析结果 matchAnalysis: AnnotationMatchAnalysis | null({ reducer: (_, next) next, }), });节点函数接收State并返回部分更新async function parseResume(state: typeof AgentState.State) { const text state.resumeText; const parser new ResumeParser(); const data await parser.parse(text); return { parsedResume: data }; }这里有一个新手容易犯的错节点里不要依赖上次节点产生的局部变量一切输入输出都走State。因为LangGraph的节点在并发或回放时可能被重新执行多次只有State是可靠的数据来源。2.2 简历解析与JD匹配的实现要点简历解析是最容易翻车的一步。我用了两层方案第一层文本抽取。上传PDF后先用pdf-parse把内容抽成纯文本再对扫描件尝试OCR做兜底。纯文本丢失了版式信息但简历的语义原本就是线性的标题、经历、技能一般都能按顺序抽出来。第二层结构化输出。我不用一长串Prompt引导模型“总结一下简历”而是用JSON Schema约束输出格式。LangGraph节点里调用模型时把结构化输出的schema传给模型接口请求模型严格返回指定结构async function parseResume(state: typeof AgentState.State) { const model getModel().withStructuredOutput(ResumeSchema); const prompt 请从以下简历文本中抽取结构化信息按给定JSON Schema返回。 文本 ${state.resumeText} ; const res await model.invoke(prompt); return { parsedResume: res }; }JD匹配分析也同理模型返回一个结构化的差距清单字段包括matchedKeywords、missingKeywords、experienceGap、suggestions。字段固定之后前端渲染就简单了直接用表格展示“岗位要求/当前简历情况/优化建议”不需要从自然语言里再猜一遍。2.3 流式返回与并发背后的现实问题聊天式Agent一定得流式返回否则用户等那个转圈会疯掉。我做流式时一开始直接用graph.stream()拿到的是异步生成器把它桥接到Next.js API Route的ReadableStream上返回前端。前端用fetch读取并逐段解析跟常见流式输出体验一致。但流式也带来了两个坑拦截器问题Node在流式过程中还会产生中间的“thinking”输出如果直接全量发给前端用户会看到一堆乱七八糟的JSON。需要在前端按事件类型过滤只渲染messages和最终结果事件。内存问题默认的InMemoryCheckpointSaver把对话历史存在Node进程内存里自己本地玩没问题一上生产、一多个实例就会遇到“用户上一轮对话找不到了”。简历工具涉及隐私和数据安全更不能把数据丢在进程内存里不管。我后来把Checkpointer换成外置存储MySQL/Redis并且给每个会话加TTL过期自动清理。3. 完整落地实操3.1 初始化项目与目录结构我用的Next.js版本是App Router TypeScript Tailwind创建命令如下npx create-next-applatest resume-agent --typescript --tailwind --app --src-dir核心目录结构长这样src/ app/ page.tsx // 主页面拖拽上传 对话面板 api/ agent/route.ts // Agent流式接口 upload/route.ts // 简历上传接口 agent/ state.ts // LangGraph State定义 nodes/ // 各节点实现 graph.ts // 组装StateGraph checkpointer.ts // Checkpointer封装 components/ chat-panel.tsx // 聊天气泡区域 resume-upload.tsx // 上传组件 report-view.tsx // 分析报告渲染先把目录结构固定下来很重要。Agent代码和页面代码分开后续换模型、换存储、加节点都不需要动前端。3.2 定义Agent状态与组装工作流状态定义在2.1节已经给过了这里直接看工作流组装。LangGraph.js的StateGraph用起来很像画流程图添加节点、声明边、编译import { StateGraph } from langchain/langgraph; import { AgentState } from ./state; import { collectRequirements, parseResume, analyzeMatch, generateSuggestions, generateResume, askClarification, } from ./nodes; export function buildAgent(checkpointer) { const graph new StateGraph(AgentState) .addNode(collectRequirements, collectRequirements) .addNode(parseResume, parseResume) .addNode(analyzeMatch, analyzeMatch) .addNode(generateSuggestions, generateSuggestions) .addNode(generateResume, generateResume) .addNode(askClarification, askClarification) .addEdge(start, collectRequirements); // 收集信息后有简历就去解析没有就进入澄清循环 graph.addConditionalEdges(collectRequirements, (state) { return state.resumeText ? parseResume : askClarification; }); graph.addEdge(parseResume, analyzeMatch); graph.addEdge(analyzeMatch, generateSuggestions); graph.addEdge(generateSuggestions, generateResume); // 澄清后回到匹配分析 graph.addEdge(askClarification, analyzeMatch); return graph.compile({ checkpointer }); }组装时有个关键参数compile({ checkpointer })。没有checkpointerLangGraph只能单轮执行多轮对话的历史不会自动带上。简历工具需要用户在不同轮次补充信息所以我必须给整个图加记忆。3.3 在Next.js里暴露流式接口Next.js API Route接收前端请求把输入写入初始State然后用graph.stream()驱动Agent执行并把事件流转成浏览器可读的流。代码如下import { NextRequest } from next/server; import { buildAgent } from /agent/graph; import { createCheckpointer } from /agent/checkpointer; export const runtime nodejs; // 不要用edgeLLM SDK在edge下兼容性差 export async function POST(req: NextRequest) { const { threadId, resumeText, jdText, message } await req.json(); const agent buildAgent(createCheckpointer()); const input { messages: [{ role: user, content: message || 开始优化我的简历 }], resumeText, jdText, }; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { for await (const event of await agent.stream(input, { configurable: { thread_id: threadId }, streamMode: updates, })) { // event的结构类似 { nodeName: { 字段更新 } } const payload data: ${JSON.stringify(event)}\n\n; controller.enqueue(encoder.encode(payload)); } } catch (err) { controller.enqueue(encoder.encode(data: ${JSON.stringify({ error: String(err) })}\n\n)); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, }, }); }前端就用原生fetch消费这个SSE流。事件流转到浏览器后按照nodeName决定渲染逻辑parseResume完成后展示解析摘要analyzeMatch完成后展示差距表格generateResume完成后提供一个下载按钮。如果直接用AI SDK的useChat我需要自己实现一套LangGraphAdapter把event翻译成AI SDK消息格式工作量差不多所以我直接手写了。3.4 前端交互与产出物体验前端我做了三个关键组件上传区支持拖拽PDF文件走/api/upload上传到对象存储返回一个临时URL同时触发解析节点。加了一行“简历仅用于本次分析系统会在24小时后自动删除”的提示这个对简历类隐私产品是必备的。聊天面板普通消息区 结构化报告区。结构化报告用report-view.tsx渲染内容包括“岗位匹配度”“技能缺口TOP5”“逐条优化建议”。Chat模型的滚动流写在顶部报告区固定在底部避免用户被流式推送打乱阅读节奏。结果下载优化后的简历用Markdown排版前端预览后导出PDF调浏览器的打印接口按A4样式注入CSS。这一步我没有让Agent直接生成PDF因为模型生成的PDF排版质量太不可控前端模板渲染反而稳定得多。前端还有一个细节中止按钮。Agent跑错方向的时候用户能立刻停止生成前端AbortController取消fetch请求后端在ReadableStream的cancel里触发graph的中断逻辑。这个交互平时不太起眼真上线后我发现很多用户会点它因为优化大段简历时生成时间可能要20秒没人能傻等。4. 常见问题与排查技巧实录4.1 我印象最深的五个坑第一个坑模型结构化输出不稳定。我用withStructuredOutput约束答案时偶尔还是拿到缺字段的JSON。后来排查发现是Prompt里给的示例不够模型喜欢偷懒省略数组元素。解决方法是把strict: true打开并在校验不通过时让节点自动重试一次。重试不是简单“再问一遍”而是要把缺什么字段明确写进失败反馈里。第二个坑LangGraph Checkpointer报错。本来用内存Checkpointer上线后用户反馈“聊到一半再发消息Agent失忆了”。查了半天发现Next.js Serverless冷启动后实例被销毁进程内存里的存档全没了。换成持久化的检查点存储后问题消失。这类问题在本地开发永远遇不到因为本地进程一直活着。第三个坑扫描版PDF乱码。有个用户传的简历其实是图片PDFpdf-parse抽出来的文本几乎为空解析节点直接吐了垃圾结果。我加了一个前置判断如果文本长度小于100主动抛给OCR管线。OCR同样不够稳定所以我在界面上加了“手动粘贴简历文本”的兜底入口。这个兜底上线后使用率比预期高说明简历来源五花八门技术方案必须留手动通道。第四个坑流式事件顺序乱。LangGraph的stream()返回的事件顺序不一定按节点完成顺序排尤其是多个分支并行时前端如果直接按收到顺序渲染报告会跳来跳去。我处理的办法是给每个事件带上nodeName前端维护一个固定节点顺序表等关键节点完成后再渲染对应区块中间事件先缓存。第五个坑Edge Runtime与LLM SDK冲突。我最初想用Edge函数省成本结果openai等SDK在Edge环境下部分能力受限还有Node专属模块比如PDF解析根本跑不了。排查了一晚上最终把API路由runtime锁定为nodejs上传、解析、Agent流式全部走Node环境页面静态部分继续走Edge缓存。混跑架构看起来没那么“纯”但实测稳定很多。4.2 常见问题速查表下面这个表是我在项目维护期整理的速查表遇到问题先对号入座现象可能原因排查/解决Agent记不住多轮对话Checkpointer缺失或用了内存存储检查compile()是否传checkpointer生产换外置存储生成内容全是废话解析阶段文本为空看parseResume日志里的文本长度加OCR或手动粘贴兜底前端收到乱JSON流式事件类型没过滤按nodeName过滤只渲染必要事件请求超时模型推理太慢或重试次数过多缩短Prompt、限制输出token、给节点加重试上限并发一高就无响应内存Checkpointer或上游API限流外置存储 模型请求限流 队列削峰优化后的简历格式错乱模型直接生成PDF/HTML改为模型生成Markdown/JSON前端模板渲染这张表不一定适应所有项目但它说明一个道理排查Agent问题先看日志定位到具体节点不要一上来怀疑模型。大部分问题其实出在流程边界而不是模型能力。5. AI Agent 压测与并发优化实录5.1 先搞清楚瓶颈在哪“Agent怎么扛并发”这个问题我压测前以为是模型API限流测完发现还有另外两个瓶颈。第一次压测用k6模拟20个并发用户同时跑“简历解析生成建议”完整流程结果惨不忍睹平均响应时间36秒接口大量503。逐段排查后发现耗时大头确实在LLM生成环节但真正让服务雪崩的是两件事内存Checkpointer把所有对话状态堆在进程里20个用户同时写入时频繁触发GC。每个用户的完整Agent流程要调用3-4次LLM模型而免费/低配额模型API限得很死串行排队等超时。如果只盯着LLM延迟优化方向就带偏了。真实瓶颈是协作流程效率低下以及无状态服务的水平扩展问题。5.2 我采用的优化手段针对这三个瓶颈我做了四件事第一把Session存储外置化。Checkpointer从内存版换成Redis适配版每次读写状态都在同一份外部存储中完成。这样即使后面挂多个实例用户在哪个实例上续聊都不会失忆。代价是每次节点间切换多了一次存储读写但换来的是水平扩展能力。第二无状态化节点。所有节点函数除了State之外不持有任何局部状态LLM客户端连接复用。上了这一步后单实例能同时处理的对话数明显提高因为不再受事件循环里的长任务拖累。第三加了一层简单的队列削峰。在API层加了个内存级令牌桶每个用户每5秒最多发1次请求。简历优化是长耗时的非实时任务用户根本不需要高频调用。超出的请求直接返回“上一个结果还在生成中”前端显示等待状态。这层保护对个人项目尤其重要能防止一个人刷接口刷爆整个服务的配额。第四缓存相同输入。JD文本和简历文本做哈希只要两者相同就直接返回缓存的报告不再重复调用模型。这个命中率其实不高因为我做的是一对一优化几乎每次输入都不同。但我把“标准JD模板”单独拎出来做了缓存用户粘贴同一套岗位模板时能秒出结果。5.3 隐私与安全边界简历属于个人信息这块不能含糊。我的做法是默认不把原始PDF落盘到业务数据库上传后先转存到临时目录处理完立即删除。数据库只存脱敏后的结构化字段比如“5年Java后端经验”可以存姓名、手机号这类敏感字段不落库。如果一定要做用户系统至少对敏感字段加密存储。会话数据设置7天TTL到期自动清理并在隐私页面写清楚数据保留策略。在展示“优化建议”时我还会把涉及真实姓名、联系方式等敏感信息打码只展示工作内容相关部分。这一步虽然不是技术难题却是判断一个Agent工具是否“能上生产”的分水岭。6. 一些实话与后续方向这个项目从第一行代码到稳定服务几百个真实用户请求最大的经验不是某个框架多牛而是“Agent应用要先画清楚状态图再写代码”。我最初直接上手写节点写到第三个节点发现流程乱了才老老实实回头用Graph的方式把整个链路画出来。状态图一旦画清楚LangGraph.js的代码几乎是照着图誊一遍开发速度和排错效率都会上一个大台阶。另外一点体会生产级Agent必须为“不可靠的模型”留后路。结构化输出校验、重试机制、人工确认节点、手动兜底输入这些都是和模型能力无关的工程保险。宁可代码里多一些防御逻辑也不要裸奔依赖模型的随机成功。接下来我打算继续做的两个方向一是给Agent加一条“Fetch实际招聘信息”的工具节点让它能搜索目标公司在招岗位并自动对比这会真正形成从“优化简历”到“投递指导”的闭环二是把流式事件链路做得更细让用户能直观看到Agent当前在“读简历”“分析JD”“改写经历”哪个阶段。这些都会在现有LangGraph状态图上扩展节点和条件边而不需要推翻架构。对一个AI Agent项目来说架构的扩展性就是它的生命力。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →