做简历工具AI Agent这个项目,前前后后折腾了接近一个月。起因很简单,身边不少朋友找工作,改简历改到崩溃,就想着能不能做一个能聊天、能分析职位描述、能直接改简历内容的智能体。技术栈选了Next.js和LangGraph.js,原因也很直接:Next.js负责前后端接入和流式体验,LangGraph.js负责把Agent的决策流程变成一张可控制的图。这篇文章不聊虚的,只讲我这个项目从设计、开发到部署的完整过程,包括踩过的坑和性能优化心得,希望给正在搞AI Agent落地的朋友一点参考。
1. 项目背景与技术选型
1.1 需求分析:简历工具到底要做什么
市面上简历工具不少,但大多数只是模板编辑器,顶多带一个“AI润色”按钮。真正常见的场景是:用户手里有份写得不怎么样的简历,看到一份心仪职位JD,想知道自己哪里匹配、哪里缺,希望AI基于JD给出一份能直接投递的版本。这其实是一个完整的决策流程,不是一次prompt调用就能解决的。
我的目标是做一个“对话式简历助手”。用户上传简历文本或PDF,然后粘贴JD,AI Agent会自主完成几个动作:拆解简历内容、提取关键画像、分析JD要求、比对匹配度、生成修改建议、重写简历关键模块,并且在过程中随时可以反问用户,比如“你最近的项目有没有量化数据”“你期望的岗位方向是偏业务还是偏技术”。这一个闭环如果只用简单的if-else和字符串拼接,逻辑会非常脆弱,于是自然想到了用Agent框架来落地。
从实际体验出发,整个产品可以拆成三个核心模块:
- 输入模块:支持文本粘贴、PDF上传/解析、JD链接复制。
- 分析模块:解析简历,抽取技能、年限、项目经历、教育背景;解析JD,提取岗位关键词、硬性要求、偏好。
- 输出模块:生成匹配度评分、问题清单、修改建议、重写后的简历段落,支持按模块调整和重新生成。
这个项目的核心价值在于“把AI从被动问答变成主动干活”。用户进来不是来聊天,而是让AI完成一次“简历诊断+定制优化”的任务。所以我从一开始就要求自己:Agent的每个步骤要可见、可中断、可回退,不能把所有逻辑塞进一个黑盒。
1.2 为什么选Next.js而不是“前端+后端”两个服务
先对比一下常规方案。如果用React/Vue写前端,再用FastAPI或Spring Boot写后端,会有几个绕不开的问题:前后端各自部署、跨域配置、联调成本、流式推送需要额外搭WebSocket或SSE服务。对于一个小型AI工具项目,这些成本过于冗余。
Next.js的核心优势是“一个应用,全栈搞定”。App Router下的路由处理器可以把API和前端页面放在同一个项目里,数据流不需要跨服务通信。更关键的是它天然支持流式渲染和流式API响应。AI生成内容往往要几十秒,前端需要逐字展示Agent的思考结果,这时候SSE(Server-Sent Events)是最实用的方案,Next.js的Route Handler可以直接返回ReadableStream,配合前端fetch实现流畅的逐字输出。
此外,Next.js的Server Components和Server Actions让我可以把敏感逻辑放在服务端,模型API Key永远不会暴露到浏览器。对于简历这种涉及个人隐私的数据,这点很重要。部署也省心,直接用Vercel或Node服务器跑起来,不需要额外配置Nginx和反向代理。所以说,选Next.js不是因为它花哨,而是因为项目需要。
1.3 为什么选LangGraph.js而不是LangChain.js
LangChain的JS版本我一开始也试过,简单场景很顺手,比如把几个prompt串成Chain。但简历Agent不是线性链条:解析完简历可能发现字段缺失,需要回去问用户;分析完JD可能要调整建议策略;重写简历时可能要把某个项目经验扩写,再回到比对步骤。这些“分叉、循环、状态回溯”用Chain表达起来特别拧巴。
LangGraph.js的价值在于把Agent工作流建模成一张有向图。每一个功能单元是一个节点,节点与节点之间通过共享的State传递数据,边可以是条件路由,这样我可以像画流程图一样设计Agent行为。举个直观例子:解析节点发现JD里要求“熟悉Kubernetes”,但简历里没有提到相关经验,Agent不会直接给出“不匹配”结论,而是通过条件边走到“询问节点”,问用户是否在过往项目里用过K8s只是没写。这种控制流如果用传统LangChain,要么强行用if拼接,要么让LLM自行决定一切,可控性很差。
LangGraph.js还有两个我很喜欢的能力:第一,支持节点级事件流式输出,前端可以实时看到“正在解析简历”“正在分析JD”“正在生成建议”;第二,支持人类介入暂停,Agent问完用户之后继续往下走。这个机制和高交互的简历场景完美契合。所以我最终选择LangGraph.js作为整个Agent的大脑框架。
2. 简历AI Agent的核心功能与状态流设计
2.1 Agent工作流的状态定义
LangGraph.js的核心是状态机,每个节点的输入输出都来自同一个状态对象。设计好这个对象,基本等于设计好了整个Agent的“内存”。
我定义了这样的AgentState:
import { Annotation } from "@langchain/langgraph"; export const AgentStateAnnotation = Annotation.Root({ // 原始输入 resumeText: Annotation<string>, // 用户粘贴或解析后的简历文本 jdText: Annotation<string>, // 用户粘贴的职位描述 // 解析结果 parsedProfile: Annotation<Record<string, any>>, // 从简历提取的画像 jobRequirements: Annotation<Record<string, any>>, // 从JD提取的要求画像 // 分析结果 matchAnalysis: Annotation<Record<string, any>>, // 匹配度分析结果 suggestions: Annotation<string[]>, // 修改建议列表 rewrittenResume: Annotation<Record<string, any>>, // 重写后的简历内容 // 对话相关 messages: Annotation<any[]>({ reducer: (left, right) => left.concat(right), }), clarifications: Annotation<string[]>, // 需要问用户的问题 });几个字段设计的细节:
parsedProfile和jobRequirements用Record<string, any>是因为结构化数据字段很多,善用Zod校验后用对象存储更灵活。messages必须用reducer,否则每次节点写入都会覆盖之前的消息。LangGraph.js里,带上reducer的字段会做合并,而不是赋值重置,这是多轮对话能持续下去的关键。clarifications用于暂存“当前还缺哪些信息”,Agent可以循环询问,直到信息补齐再继续。
状态定义是整个Agent的地基。如果状态设计得不好,后面每加一个节点都要改全局,非常痛苦。
2.2 节点设计与条件路由
有了状态,就可以设计图了。我最初的图包含以下节点:
parseResume:解析简历文本,抽取结构化画像。parseJD:解析职位描述,抽取岗位要求。checkInfoCompleteness:检查信息是否足够生成建议,不够就走到询问节点。askClarification:生成需要用户补充的问题,由前端展示给用户。analyzeMatch:基于简历画像和职位要求做匹配分析。generateSuggestions:生成具体可执行的修改建议。rewriteResume:基于建议重写简历的各个模块。end:结束节点,返回最终结果。
LangGraph.js构建图的方式是把节点连起来,并指定条件边。代码结构大致如下:
import { StateGraph, START, END } from "@langchain/langgraph"; const graph = new StateGraph(AgentStateAnnotation) .addNode("parseResume", parseResume) .addNode("parseJD", parseJD) .addNode("checkInfoCompleteness", checkInfoCompleteness) .addNode("askClarification", askClarification) .addNode("analyzeMatch", analyzeMatch) .addNode("generateSuggestions", generateSuggestions) .addNode("rewriteResume", rewriteResume) .addEdge(START, "parseResume") .addEdge("parseResume", "parseJD") .addEdge("parseJD", "checkInfoCompleteness") .addConditionalEdges("checkInfoCompleteness", (state) => { if (state.clarifications.length > 0) return "askClarification"; return "analyzeMatch"; }) .addEdge("askClarification", "parseJD") // 用户补充信息后回到解析,更新需求 .addEdge("analyzeMatch", "generateSuggestions") .addEdge("generateSuggestions", "rewriteResume") .addEdge("rewriteResume", END) .compile();条件路由是LangGraph.js最爽的地方。checkInfoCompleteness节点输出可能的补充问题,然后边上的函数决定走询问还是直接分析。这种写法把Agent的决策逻辑从prompt里解放出来,变成程序可控的显式逻辑,排错非常方便。
2.3 与LLM模型的交互方式
Agent节点里的核心动作就是调用LLM。我统一封装了一个callModel函数,支持传入system prompt和用户消息,返回结构化JSON。关键点是不要直接用自然字符串让模型“自由发挥”,而是用Zod定义输出结构,再让模型按照JSON模式输出。
以解析简历节点为例:
import { ChatOpenAI } from "@langchain/openai"; import { z } from "zod"; const profileSchema = z.object({ name: z.string().optional(), yearsOfExperience: z.number(), skills: z.array(z.string()), projects: z.array(z.object({ name: z.string(), description: z.string(), highlights: z.array(z.string()), })), education: z.array(z.string()), missingInfo: z.array(z.string()), }); const model = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.1, }).withStructuredOutput(profileSchema);这里我用withStructuredOutput绑定Zod schema,模型返回的结果就一定会是一个符合schema的JSON对象。之后无论后面节点怎么消费,字段都不需要再猜测。对于简历这种高信息密度文本,结构化抽取远比“让模型读一遍然后自由发挥”可靠。
需要注意,结构化输出不等于业务逻辑正确。模型可能漏掉一些细节,所以我在每个关键节点后面都加了一层简单的规则校验,例如sills数组为空就强制走askClarification。AI Agent不能只靠模型自觉,工程兜底才是稳定性的关键。
3. 实操搭建:从零到一落地你的Agent
3.1 项目初始化与依赖安装
我现在过一遍完整的搭建步骤,方便你照着操作。
初始化Next.js项目:
npx create-next-app@latest resume-agent选择TypeScript、App Router,其他选项按需。接着安装AI Agent相关依赖:
npm install @langchain/langgraph @langchain/core @langchain/openai langchain npm install zod pdf-parse react-markdown npm install @types/pdf-parse --save-dev这里有两个容易踩的坑:第一,LangGraph.js目前要求Node.js 18以上,本地环境最好用Node 20,否则会有兼容性报错;第二,pdf-parse的类型定义需要手动安装,不然TS会报错。如果你部署到Vercel,记得Node version要选20。
项目结构上,我大概是这样组织的:
src/ app/ api/agent/route.ts # Agent API路由 page.tsx # 前端主页面 components/ ChatPanel.tsx ResumeViewer.tsx lib/ agentGraph.ts # LangGraph图定义 model.ts # LLM封装 pdf.ts # PDF解析工具3.2 编写LangGraph Agent核心逻辑
现在写agentGraph.ts。前面已经给出了状态和节点的轮廓,这里补一份更完整的示例框架。注意每个节点的函数签名都是(state: typeof AgentStateAnnotation.State) => Promise<Partial<typeof AgentStateAnnotation.State>>,返回部分状态即可,LangGraph.js会与当前状态合并。
一个节点内部通常是“调模型→处理结果→写回状态”。比如parseResume:
import { AgentStateAnnotation } from "./state"; import { profileModel } from "./model"; import { z } from "zod"; async function parseResume(state) { const result = await profileModel.invoke({ resumeText: state.resumeText, }); return { parsedProfile: result, messages: state.messages.concat({ role: "assistant", content: `已解析简历,识别到${result.skills.length}项技能。`, }), }; }实际项目里,我通常在节点开头加日志,方便在开发时追踪每一步的状态。LangGraph.js内置了getGraph调试工具,也可以在compile()之后调用graph.invoke()传入初始状态,直接看返回结果。前期一定要多写测试,把每个节点单独测一遍,再合并成完整图测全流程,否则出了问题很难定位是模型问题还是Graph路由问题。
3.3 实现流式API路由
这是整个项目里最有价值的一部分。Agent跑起来可能要花几秒到几十秒,如果用传统HTTP接口,前端只能干等。我用了SSE的方式,让Agent执行过程的每一个节点、每一条消息都实时推给前端。
Next.js App Router的写法如下:
import { NextRequest } from "next/server"; import { getAgentGraph } from "@/lib/agentGraph"; export const runtime = "nodejs"; export async function POST(request: NextRequest) { const body = await request.json(); const { resumeText, jdText } = body; const graph = getAgentGraph(); const stream = await graph.stream( { resumeText, jdText, messages: [{ role: "user", content: jdText }], }, { streamMode: "updates" } ); const encoder = new TextEncoder(); const readableStream = new ReadableStream({ async start(controller) { try { for await (const update of stream) { const chunk = JSON.stringify(update); controller.enqueue(encoder.encode(`data: ${chunk}\n\n`)); } controller.enqueue(encoder.encode("data: [DONE]\n\n")); } catch (err) { console.error(err); controller.error(err); } finally { controller.close(); } }, }); return new Response(readableStream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }, }); }关键点:
streamMode: "updates"只返回每个节点更新后的部分状态,比"values"少了重复数据,流量更小。- 一定设置
runtime = "nodejs"。Edge Runtime下@langchain/openai的部分依赖可能不兼容,我在部署时踩过这个坑。 - SSE报文以
data:开头,以\n\n结束,[DONE]作为结束标记。
前端在page.tsx里用fetch就可以流式读取:
const response = await fetch("/api/agent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ resumeText, jdText }), }); const reader = response.body?.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (reader) { 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 data = line.replace("data: ", ""); if (data === "[DONE]") continue; const update = JSON.parse(data); // 根据update的节点名更新UI状态 } } }前端拿到节点名后,可以渲染不同的提示条,比如“正在解析JD”“正在生成建议”,让用户看到Agent并不是黑盒,而是在一步步完成工作。体验上的提升非常明显。
3.4 前端实时交互界面
界面我做得比较克制,核心是一个左侧聊天/步骤面板,右侧是简历结果展示。用户输入简历和JD后,点击“开始分析”,中间区域会不断冒出Agent的“动作气泡”,每条都把节点名和关键输出折叠展示,最终结果用Markdown渲染在右侧。
为了支持用户中途回答问题,前端需要暂停Agent并展示问题列表。我在状态图里用了interrupt机制,让askClarification节点返回时向前端发送一个特定事件,前端弹出输入框,用户补充完信息后再继续invoke。LangGraph.js里可以用graph.invoke(State, { recursionLimit: 10 })控制最大步数,防止Agent无限循环。这个限制在UI里要有兜底提示。
整体来说,Next.js的App Router让这套交互写起来很顺手。Api路由负责Agent执行,页面组件负责状态渲染,我唯一额外引入的UI组件库是react-markdown,用来呈现建议和重写后的简历。如果你后续要做多人协作,再考虑引入状态管理库也不迟,初期Next.js的Server State就够用。
4. 并发与性能优化:让AI Agent扛住流量
4.1 流式响应与用户感知
AI Agent最直观的性能感受是“会不会卡”。模型推理本身需要时间,但我们不能让用户干等。流式响应是必须做的基础能力,它能将首次可见内容的时间压缩到1~2秒。我的实现方式是所有节点状态都走SSE推送,用户看到第一步“正在解析简历”的提示后,心理耐心会高很多。
除此之外,节点内调用模型时也要开流式。LangGraph.js支持streamMode: "messages",把LLM内部的token追加流发送出来。但这会增加实现复杂度,我一开始只做节点级流式,等用户反馈“还是有点慢”之后才加上token级流式。建议你先做节点级,再迭代token级,不要一上来搞最复杂的。
4.2 模型调用成本控制与并发上限
并发瓶颈通常不在服务器,而在模型API的速率限制和token成本。我做了三个措施:
- 模型分级:解析简历和JD用
gpt-4o-mini,速度和价格都友好;最终简历重写才用更强的模型。不同节点用不同的model实例,成本能减少一半以上。 - 输入裁剪:调用模型前先对文本做长度截断,简历正文最多保留1.5万字符,JD最多保留6000字符。超出部分用摘要节点先压缩。
- 频率限制:在API路由前面加了一层简单的内存限流,同一个IP一分钟最多调用5次完整流程。生产环境建议换Redis实现,内存限流在重启后会失效。
其实“AI Agent怎么扛并发”这个问题,最直接的答案是:让Agent本身不要都是重任务。拆成轻量子任务,配合缓存,并发能力自然就上来了。
4.3 缓存与异步任务队列
简历工具里有很多可复用的计算结果。同一个JD短时间内可能被多个用户粘贴,JD分析结果几乎可以共享;同一个用户的同一份简历,解析结果可以缓存,避免每次重新调用模型。我用Redis做了两层缓存:
- 简历解析缓存:key为
md5(resumeText),value为解析后的parsedProfile,TTL设为24小时。 - JD分析缓存:key为
md5(jdText),value为jobRequirements,TTL设为6小时。
对于耗时的批量任务,比如一次处理10份简历,我会用BullMQ把任务丢到队列里,让工人进程一个个跑,而不是占住同步HTTP请求。前端轮询或通过WebSocket接收完成事件,用户体验依然顺滑。这个方案尤其适合后续做“海报生成”“简历评分”这种耗时任务。
4.4 部署层面注意事项
部署到Vercel时,Serverless Function的执行时长有硬上限(Hobby计划为10秒,Pro计划为60秒,具体以官方为准)。Agent完整运行可能会超过这个时限,所以我不建议把整张图放在一个Vercel Function里。稳妥的做法是拆两步:
- 第一步:解析简历和JD,直接同步返回中间结果。
- 第二步:把重写任务投递到后台队列,由独立Node进程执行,再通过回调或轮询拿最终结果。
如果不想引入任务队列,更简单的方式是用一台小型的Node服务器,直接把Next.js的start跑起来,不走Serverless。我最后选择在云服务器上用Docker部署,搭配PM2守护进程,彻底绕开了Serverless的限制。部署时记得给Node进程设置合理的--max-old-space-size,防止高并发下内存溢出。
5. 常见问题与排查技巧实录
5.1 LangGraph.js状态图运行时状态不更新的坑
我第一个项目里,parsedProfile字段总是被下一个节点清空。排查后发现是Annotation定义时忘记理解“以返回值合并”的机制。LangGraph.js里节点返回的Partial会与当前状态做浅合并,但如果我把parsedProfile定义为普通Annotation且某个节点没有返回该字段,就不会被清空。真正的问题是我在一个节点里错误地给parsedProfile赋了undefined,直接覆盖了原有对象。
解决方案很简单:节点里不要传多余的字段,也不要把不明确的值塞到状态里。我还在每个节点入口打印了当前state的关键字段,用console.log追踪数据流转。LangGraph.js没有内置可视化调试面板,但可以通过graph.getGraph()导出Mermaid或JSON格式,用文本梳理图结构,排错足够用。
5.2 流式响应乱序、中断的处理
SSE推送在本地开发很正常,部署后却出现消息乱序。原因是多个节点几乎同时完成,返回顺序不确定。我调整了设计,让所有更新都进入一个队列,按节点执行顺序编号后统一发送。更简单的方式是只在关键节点推送,不要每个子步骤都推,减少乱序概率。
中断问题多半出在代理服务器上。Next.js返回的SSE经过Nginx时,如果没有关闭缓冲,客户端会在请求完成才收到全部数据。我用的办法是在Nginx配置里加proxy_buffering off;。如果你用Vercel,官方网关一般没问题,但也要在Response Headers里显式设置Cache-Control: no-cache,防止CDN缓存SSE流。
5.3 简历PDF解析乱码问题
PDF解析是简历工具的老大难。pdf-parse对文字型PDF有效,但扫描版PDF(本质是图片)解析出来是空字符串,中文PDF还可能出现乱码。我踩过这个坑之后,放弃了“全自动PDF解析”的幻想,改为双通道:上传PDF后先尝试解析,然后提示用户核对;如果解析文本少于200字符,就自动弹窗让用户粘贴简历内容。
项目里还引入了pdf-parse的render属性来处理部分加密PDF,但效果有限。如果真要处理扫描件,需要接入OCR服务,成本会高不少。小工具项目的原则是“能空格绝不做复杂”,让用户手动粘贴文本,体验反而更直接。
5.4 Token开销与上下文失控
多轮对话开始后,messages数组不断膨胀,每次调用模型都会把全部历史发出去,token飞快上涨。我做了两层措施:
- 轮次裁剪:只保留最近两轮用户消息和一轮Agent回复,更早的消息压缩成摘要放入
state.summary。 - 任务隔离:简历解析、JD分析、重写这些节点内部调用模型时,不把
messages传给模型,只传相关字段。对话消息只存在于对话节点。
这个经验用一句话总结:状态里的数据是为业务服务的,不是全部都要塞给模型。控制好输入长度,既能省钱,也能减少模型受无关信息干扰。
最后一个老生常谈但必须强调的点:不要把任何API Key放在前端,所谓NEXT_PUBLIC_前缀的变量会被浏览器直接获取,模型调用必须在Next.js服务端Route Handler里完成。我见过有人直接把OpenAI Key写死在前端代码里,上线后被人刷光了额度,这个学费交得太没必要。
这个项目做到底,最深的体会是:AI Agent落地最大的难点不是模型能力,而是流程可控性和工程化。LangGraph.js把Agent的每一步决策都放在明面上,对排错和优化帮助很大。如果让我重来一次,我会一开始就设计好状态管理和缓存分层,省得后期返工。最后一个小建议:先别想着上太多功能,把“简历解析→职位分析→生成修改建议”这条主链路跑通,再逐步加多轮对话和定制重写,稳很多。