恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Next.js+LangGraph.js实战:AI Agent简历工具从架构到并发落地
首页
资讯中心
/
Next.js+LangGraph.js实战:AI Agent简历工具从架构到并发落地
Next.js+LangGraph.js实战:AI Agent简历工具从架构到并发落地
发布时间:2026/10/7 23:10:48
“AI Agent能不能真正落地”这话我听了太多次。很多项目Demo阶段风生水起一上生产就露馅。但这次用Next.js加LangGraph.js搭的简历工具AI Agent从架构设计到上线扛并发全程踩坑也全程填坑总算把“能用”变成了“好用”。这篇文章就把完整落地过程拆开揉碎包括选型逻辑、核心代码、并发方案、成本控制以及我踩过的那些坑。先交代背景这个项目是做一个在线简历优化工具。用户上传简历AI Agent自动解析内容、诊断问题、给出修改建议甚至直接生成优化后的简历。整个过程不是一次Prompt能搞定的需要分步骤、带状态、还要调用多个工具解析PDF、提取结构化信息、调模型分析、生成文档。这种场景天然适合“Agent化”而不是“单轮问答化”。技术栈选型上前端交互和API路由给了Next.jsAgent编排给了LangGraph.js。当时对比过几套方案最后敲定这套组合核心原因有三Next.js同时承担前端界面和API Route部署简单一个服务搞定不用额外拆前端工程。LangGraph.js在JS生态里把“有状态、可编排、可断点续跑”的Agent流程做得最顺手Python版LangGraph很成熟但团队是JS栈LangGraph.js是天然选择。简历解析、诊断、重写这些节点天然适合有向图编排而不是一条链走到黑——比如某些简历缺少技能模块Agent要临时插入一个“技能提取”节点再继续往下走。1. 项目整体设计与技术选型1.1 核心需求解析简历工具看起来简单实际拆开有这些硬需求支持PDF和Word上传准确解析出教育背景、工作经历、技能标签、项目经验等结构化字段对简历质量进行多维诊断完整度、量化程度、关键词匹配、格式规范能给出逐项修改建议最终生成一份排版整洁的优化后简历。整个过程要做到可追踪、可中断、可恢复。这就不是“传一个文件返回一段话”的接口能覆盖的。单轮Prompt做不到精确提取和分步诊断普通函数调用链又没法处理分支和条件跳转。AI Agent的引入让这个流程变成了可编排的状态机上传文件后进入解析节点解析结果写入状态诊断节点读取状态产出报告如果简历缺少某个模块Agent会动态插入一个修复节点。整个流程的状态是显式的每一步都能审计出了问题还能从断点恢复。1.2 为什么用Next.js做载体选Next.js不仅仅是前端框架顺手它在这个项目里的价值有三层第一统一开发模型。前端页面、API路由、SSR逻辑都在同一个工程里简历上传页、解析进度页面、结果展示页共享TypeScript类型定义不需要前后端联调接口文档。第二API Route天然适合做Agent的HTTP入口。Next.js的Route Handlers支持流式响应后面细讲SSE实现而Agent执行过程中的每个节点状态、每条日志都可以通过Server-Sent Events推给前端。对比WebSocketSSE实现简单、自动重连、天然适配单向事件流而Agent执行过程恰好是服务器向客户端单向推送进度。第三部署友好。打成一个Docker镜像直接跑天然支持无服务器部署配合Redis做状态存储横向扩容几乎不需要改代码。当然也有代价长时间运行的Agent任务会占满Serverless函数的执行时长配额。所以架构上我把“短请求”和“长任务”做了分离——API Route只负责接收请求、创建任务、查询状态真正跑Agent的是独立的工作进程或队列Worker。这个设计后面细说。1.3 为什么选LangGraph.js做Agent编排看了不少Agent框架最后还是选了LangGraph.js。原因是它在“有状态图执行”上做得最贴近生产需求。你可以把LangGraph.js理解成一个专门为Agent设计的状态机引擎定义状态类型定义多个节点每个节点可以是LLM调用、工具调用、普通函数定义节点之间的边包括条件边然后编译成一张图。运行时每个节点接收输入状态执行后返回新的状态图引擎根据当前状态和条件边自动决定下一步走向。与LangChain的链式调用Chain相比LangGraph有几个关键优势显式状态共享所有节点共享一个State对象读取和写入都用类型约束调试验证都简单。条件分支根据LLM输出或工具结果不同情况下走完全不同的路径这是真实业务场景的刚需。可断点续跑支持中断和恢复可以暂停在某个节点人工介入或等待外部事件后再继续。内建检查点机制保存每一步的状态快照配合Redis可以直接做持久化。简历工具里最典型的分支场景就是解析节点如果发现“工作经历”字段缺失或为空会直接走一条“缺失字段补充”路径而不是硬着头皮继续诊断。这种动态决定性传统Chain很难做到。1.4 整体数据流设计整个系统跑起来后的数据流是这样的用户在Next.js前端上传简历文件。API Route接收文件存入对象存储生成任务ID把任务推入队列。队列Worker取出任务调用LangGraph.js编译好的Agent图开始执行。Agent图中解析节点调用文本抽取工具 → 结构化节点调用LLM做字段提取 → 诊断节点调用LLM分析打分 → 生成节点调用LLM重写简历 → 文档生成节点调用排版工具产出最终文件。每个节点执行完毕状态快照写入Redis同时通过Redis Pub/Sub把进度事件推送给API层API层通过SSE推送给浏览器。前端页面实时展示“正在解析…正在诊断…正在生成…”全部完成后展示报告和下载链接。这套设计的精髓在于流程图是预定义的LangGraph编译期确定但执行路径是动态的运行期根据状态分支。既保证了可控性又保留了Agent的灵活性。2. 简历工具Agent的核心实现细节2.1 定义Agent状态与图结构状态是LangGraph的基石。简历工具的状态我定义为interface ResumeState { filePath: string rawText: string | null structuredData: { basics: BasicInfo | null education: EducationItem[] experience: ExperienceItem[] skills: string[] projects: ProjectItem[] } | null diagnosis: DiagnosisResult | null rewriteResult: string | null outputPath: string | null currentNode: string error?: string }每个字段对应Agent流程中的某个阶段的产物。定义好状态后用LangGraph.js构建图import { StateGraph, END } from langchain/langgraph const graph new StateGraphResumeState({ channels: { filePath: { value: (a, b) b ?? a }, rawText: { value: (a, b) b ?? a }, structuredData: { value: (a, b) b ?? a }, // ... 其他通道定义 }, }) .addNode(extractText, extractTextNode) .addNode(structureParsing, structureParsingNode) .addNode(diagnose, diagnoseNode) .addNode(rewrite, rewriteNode) .addNode(generateDoc, generateDocNode) .addEdge(__START__, extractText) .addEdge(extractText, structureParsing) .addEdge(structureParsing, diagnose) .addConditionalEdges(diagnose, (state) { if (state.diagnosis?.missingSections?.length 0) { return rewrite // 有缺失模块时优先补全再重写 } return rewrite }) .addEdge(rewrite, generateDoc) .addEdge(generateDoc, END)这里有个坑channels里的value函数如果不写合并逻辑默认用新值覆盖旧值但structuredData这种嵌套对象容易丢字段。建议用显式的合并函数或直接使用LangGraph.js内置的Annotation化的状态类。2.2 工具节点的设计与JSON Schema化LangGraph.js里的节点可以是普通函数也可以调用工具。简历工具里最核心的节点是“结构化解析节点”把纯文本简历解析成结构化JSON。这个节点不能只靠Prompt裸调必须用JSON Schema约束输出格式。const structureParsingNode async (state: ResumeState) { const llm getLLM() const extractionChain llm.withStructuredOutput({ type: object, properties: { basics: { type: object }, education: { type: array }, experience: { type: array }, skills: { type: array }, projects: { type: array } }, required: [basics, education, experience, skills] }) const result await extractionChain.invoke({ input: state.rawText }) return { structuredData: result } }使用withStructuredOutput的最大好处是避免LLM返回乱七八糟的Markdown或多余解释。实测下来结构化输出的解析成功率从裸Prompt的80%出头提高到99%以上这个差异在批量处理场景下是致命的。工具节点内部还可以嵌套调用其他函数。比如技能标签提取如果发现某一项缺失就调用一个专用的“技能补充”小工具直接从岗位JD里提取技能。这样就把“工具调用”嵌进了Agent节点内部形成Tool-in-Node的模式灵活性比纯链式高很多。2.3 流式输出与SSE实现Agent执行耗时动辄十几秒甚至几十秒如果让用户盯着空白页面干等体验再好在生产环境也是废的。我的方案是Agent状态变化时实时推送给前端。LangGraph.js原生支持stream执行模式。通过graph.stream(state, { streamMode: updates })可以拿到每一步的节点执行结果。我需要把这些事件实时转发给浏览器。Next.js API Route实现SSE的关键代码export async function POST(req: Request) { const { taskId } await req.json() const encoder new TextEncoder() const stream new ReadableStream({ async start(controller) { const subscriber redis.duplicate() await subscriber.subscribe(task:${taskId}:events) subscriber.on(message, (channel, message) { controller.enqueue(encoder.encode(data: ${message}\n\n)) }) req.signal.addEventListener(abort, () { subscriber.unsubscribe() subscriber.quit() controller.close() }) } }) return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive } }) }前端用EventSource或fetch的流式读取方式来接收事件。这里有一个重要经验SSE连接必须处理客户端断连否则Redis订阅会一直挂着造成连接泄漏。上面的代码里用req.signal的abort事件做了清理这个一定不能省。另一个容易被忽略的点代理服务器和浏览器的SSE缓冲。Next.js默认可能会有缓冲需要在next.config.js里关闭API路由的压缩或者确认部署平台不缓存SSE响应。否则前端拿到的是一坨一次性到达的缓冲数据流式的体验就完全没了。2.4 状态持久化与断点续跑Agent如果跑了一半崩溃整个流程重来对用户和成本都是灾难。LangGraph.js的检查点机制Checkpointer就是为这个设计的每执行完一个节点把完整状态存到持久化存储里。import { MemorySaver } from langchain/langgraph-checkpoint // 生产环境用Redis实现而不是MemorySaver const checkpointer new RedisSaver({ redisClient }) const app graph.compile({ checkpointer })使用Redis做检查点时要注意Redis里存的是整个State对象包括rawText这种可能很大的字段。一定要给每个任务设置TTL比如2小时。简历文本一般不大但如果将来接入其他文件类型状态体积会膨胀得很厉害。断点续跑的执行方式是传入同一个threadIdconst result await app.invoke( { filePath: /tmp/xxx.pdf }, { configurable: { thread_id: taskId } } )如果某次执行中途失败只需要用同一个threadId再次调用invokeLangGraph.js会自动从最后一个成功的检查点继续执行而不是从头开始。3. AI Agent怎么扛住并发一套完整的生产级方案这是全网都在问的问题。Agent执行任务耗时远高于普通API调用如果不做架构优化一个涉及多轮LLM调用的Agent任务能把服务器线程池瞬间打满。3.1 问题拆解Agent的并发瓶颈在哪Agent任务的耗时构成和普通请求完全不同。普通接口几百毫秒返回Agent任务动辄10到30秒期间包含多次LLM调用、工具执行、节点编排。一个Agent任务占用的资源是一个普通请求的几十倍。这意味着同样的QPS下Agent服务需要的资源量完全不是一个量级。还有上下游依赖问题LLM服务有速率限制RPM/TPMRedis有连接数上限对象存储有并发限制。Agent编排层即使扛住了下游任何一环被限流整个任务也会失败或重试。所以“扛并发”远不止加机器那么简单必须做全链路治理。3.2 全链路异步化API层和Worker层分离我的核心设计原则就一句话HTTP请求不直接执行Agent只创建任务。用户在Next.js前端上传的请求进来API Route做三件事保存文件、生成任务ID、把任务消息推入队列。然后立即返回“任务已受理”。真正的Agent执行放到Worker进程里。// API Route只入队不执行 export async function POST(req: Request) { const formData await req.formData() const file formData.get(file) as File const taskId crypto.randomUUID() await storage.put(${taskId}.pdf, file) await queue.add(resume-task, { taskId, filePath: ${taskId}.pdf }) return Response.json({ taskId, status: pending }) }Worker侧用一套独立的进程拉取队列消息// Worker消费队列执行Agent queue.process(resume-task, async (job) { const { taskId, filePath } job.data const app compileAgentGraph() await app.invoke( { filePath }, { configurable: { thread_id: taskId } } ) })这个模式最大的好处是API层的Pod和Worker层的Pod可以独立扩缩容。上传量突增就多开API PodAgent任务积压就多开Worker Pod。互不拖累。队列系统我用的是Redis Stream或者BullMQ。注意要设置任务的超时时间、重试次数、死信队列。Agent任务重试要尤其小心LLM调用重试可能导致重复计费。我设置了“幂等键”LLM请求带上taskId-nodeName作为幂等标识对于超时的请求宁可报错也不要盲目重试。3.3 并发控制与实例隔离核心中的核心有了队列之后并发控制变得极其可控。关键的参数是Worker进程的并发数。// Worker并发数控制 const worker new Worker(resume-task, processor, { concurrency: process.env.WORKER_CONCURRENCY || 2, limiter: { max: 10, // 每分钟最多处理任务数 duration: 60000 } })这里我踩过一个很深的坑一开始为了追求吞吐把Worker并发数调到10结果LLM服务直接返回429限流错误大批任务失败重试。后来把并发数降到2到4每个任务内部的LLM调用再做一次本地信号量控制整体吞吐反而更稳更高。每个Worker进程内部Agent执行是独立的。Node.js单线程事件循环模型下并发数本质上是任务争抢事件循环的调度。Agent任务里有大量IO等待LLM、Redis、存储所以并发数大于CPU核心数是有意义的但不宜过大。实测下来4GB内存的容器跑2到4并发是最稳的状态。除了进程内并发控制还有一层是实例级隔离。API层和Worker层分开部署之外我为Worker配置了独立的Redis实例和独立的LLM API Key池。这样API层即使被刷爆也不会影响正在执行中的Agent任务。这就是分层隔离的价值。3.4 Token成本控制与性能优化Agent任务和单次LLM调用的成本结构完全不同。多次调用意味着输入token被反复发送语境累积成本呈指数上涨。简历工具的控制方案第一最小化Context。原始简历全文提取后是一次读取后续节点不要再带全文。每个诊断节点只读structuredData里的相关字段比如诊断“技能匹配度”的节点只接收skills字段和JD文本不要接收整个structuredData。这样既能降低成本也能减少无关上下文对LLM判断的干扰。第二结果缓存。同一份简历如果只是调整了诊断标准不需要重新跑解析节点。我做了节点级别的缓存以“输入内容的哈希节点名称模型版本”作为缓存键命中直接返回历史结果。实测简历解析节点缓存命中率在30%以上对于老用户重试场景非常划算。第三用小模型做粗筛大模型做精修。技能标签提取用轻量模型就能做好但是简历重写的质量必须用更强的主力模型。LangGraph.js的节点是可以自由选择LLM实例的所以在图定义里就给每个节点指定了不同的模型。这个灵活度是链式调用很难替代的。还有一个隐藏成本结构化输出解析失败会触发重试。每次重试都等于重新跑一遍LLM。所以前面强调withStructuredOutput的必要性——它是省钱的工具不只是省心的工具。4. 常见问题与排查技巧实录4.1 高频问题速查表问题表现直接原因排查方法解决方案SSE连接频繁断开代理缓冲或心跳缺失查看浏览器网络面板看是不是一次性返回关闭Next.js路由压缩服务端每15秒发送注释心跳包Agent任务在“解析”阶段卡死LLM超时未处理检查LLM调用是否设置了超时所有LLM调用加timeout和maxRetries结构化输出偶尔缺失字段输出Schema约束力不足打印原始LLM输出给withStructuredOutput增加formatInstructions或在节点内做二次解析兜底Worker堆积大量任务并发数设置过高导致限流查看LLM服务RPM剩余量调低Worker并发加任务队列积压告警Redis内存暴涨检查点TTL未设置查看Redis内存碎片占比设置TTL并限制状态字段大小前端进度条停留在90%文档生成节点超时查看日志确认执行到哪个节点对文档生成节点单独设置超时和重试次数4.2 几个让我印象深刻的现场排查第一次压测时QPS一到5就整片超时。排查后发现既不是Worker不够也不是LLM限流而是API层和Worker共用了一个Redis实例。Agent频繁读写检查点把Redis的CPU冲到100%API层的队列写入和状态查询全部被拖慢。解决方案是一拆二API用Redis A实例Worker用Redis B实例问题当场消失。又一次SSE推送总是延迟十几秒。排查发现是部署平台默认开启了响应缓冲SSE的数据攒到一定量才刷给浏览器。流式效果彻底失效。解决方案是在路由的响应头里显式加X-Accel-Buffering: no同时关闭API路由的compress: true配置。还有一次是Worker内存泄漏。排查发现是LangGraph.js的图对象每次执行任务时都重新编译并且有大量的闭包引用没有释放。解决方案是在进程启动时编译一次图之后所有任务复用同一个编译结果。内存曲线瞬间平稳。4.3 避坑技巧和设计建议Agent图不要设计得太深。节点之间层级过深排查问题时看日志能把人绕晕。图结构控制在6到8个节点以内超过就要考虑拆分多个子图。给每个节点设置独立的超时时间。解析节点和生成节点的耗时基准完全不同统一超时会导致某项任务频繁失败。善用检查点做A/B测试。同一个状态快照可以尝试不同的诊断Prompt或不同的模型对比输出质量。这个能力让我在优化Prompt时效率翻倍。所有LLM调用都要监控token消耗。我之前只关注成功率没有按节点维度统计token成本结果月底账单翻了三倍才知道出了大问题。现在每个节点执行完都记录token用量到日志成本一目了然。任务要有“取消”机制。用户中途取消操作如果没有及时终止Agent任务Worker还在白白烧钱跑。我在Redis里维护任务状态Worker每执行完一个节点前检查一次该任务是否被标记为“已取消”如果是立即终止并释放资源。4.4 关于“Agent落地的最后一步”的思考有过这次实战后我最大的体会是Agent框架只是骨架真正决定成败的是状态设计、成本控制和并发治理这三件事。很多人把Agent项目做成“高级聊天机器人”就是因为忽视了状态的可管理性和系统的可运维性。LangGraph.js让人能把握前两点但并发治理的功夫全在框架之外——消息队列的选择、并发数的调优、Redis实例的拆分、LLM调用链路的限流每一项都是实打实的工程经验。如果从零开始再做一个Agent项目我会先画三张图状态流转图有哪些状态、谁能改状态、成本时间图每个节点的平均耗时和token费用、故障恢复图谁来重试、怎么重试、怎么止损。这三张图画清楚了选什么框架、怎么扛并发都是水到渠成的事。这个简历工具的Agent架构目前已经平稳运行了几个版本。后续我打算把简历诊断的规则引擎再往LangGraph的节点里下沉一层让静态规则比如“技能缺失”“时间线断档”和动态LLM判断做强结合。毕竟AI Agent落地这件事永远是架构先行、细节为王。