Next.js + LangGraph.js 实战:从零构建可扛流量的简历 AI Agent
从今年年初开始我一直在做一件事用 Next.js 和 LangGraph.js 从零到一搭一个简历工具 AI Agent不是 Demo是真正能跑起来、能扛流量、能让人日常使用的小产品。前后折腾了两个月走了不少弯路也踩了不少文档里根本不会写的坑。今天把这套东西的完整落地过程整理出来包括选型逻辑、核心代码、并发处理和排查思路希望对正在做 AI Agent 落地的朋友有点帮助。这套简历 Agent 解决的核心问题很具体用户上传一份简历和一段职位描述JDAgent 自动完成简历解析、岗位匹配度评估、差距分析、优化建议生成还能针对用户的追问做多轮对话。它不是一个聊天机器人而是一个有固定流程、有状态、能调用工具、能控制输出的多步工作流。技术栈上选了 Next.js 做应用框架和 API 层LangGraph.js 做 Agent 的编排引擎模型服务用通用的大模型 API。全文涉及的东西不少我尽量把关键代码、设计思路和真实遇到的坑都讲清楚。适合正在做 AI Agent 落地、或者打算用 LangGraph.js 做点实际产品的开发者参考。1. 为什么选 Next.js LangGraph.js而不是 LangChain 直连或纯自建状态机先说结论如果只是做一个一问一答的简历助手直接用 LangChain.js 或者干脆手写几个 fetch 调用大模型 API 就够了。但如果要做的是多步骤、有条件分支、需要维护上下文状态、跑完还能接着聊的简历 Agent就需要一个能显式定义工作流的编排层。LangGraph.js 就是干这个的。1.1 简历工具为什么需要工作流而不是对话最早我做的版本就是典型的对话式实现把简历文本塞进 system prompt让模型回答所有问题。上线测试后问题非常明显每次对话都把整份简历拼到上下文里Token 消耗高得离谱一份 3 页简历加上系统提示词一次请求就烧掉几千 Token模型很容易跑偏明明让它先做匹配度分析它却在回答里自由发挥写起了求职建议无法稳定输出结构化结果解析简历的目的之一是把技能、经历、教育背景拆成 JSON 喂给前端渲染靠大模型自由发挥得到的 JSON 经常缺字段、多字段、格式不对多轮对话没有状态边界用户问帮我改一下项目经历的表述Agent 不知道改是基于哪一段原文容易张冠李戴。所以简历工具天然需要一个流程先解析简历 → 再解析 JD → 然后做匹配计算 → 生成报告 → 最后进入自由问答阶段。每个阶段的结果都要落到一个可查询的状态对象里后续阶段和用户追问都基于这个状态而不是重新读一遍原始文本。1.2 LangGraph.js 对比 LangChain.js 的差异点LangChain.js 的核心抽象是 Chain也就是把多个调用按顺序串起来。问题是Chain 不支持复杂的条件分支和循环也不方便做跨步骤的状态读写。你当然可以用 if/else 拼但拼出来的代码就是一坨难以维护的意大利面。LangGraph.js 的核心抽象是图Graph节点Node就是你要执行的函数边Edge定义了节点之间的流转关系状态State是贯穿整个图的数据对象。相比 LangChain.js它有四个直接的好处能力LangChain.jsLangGraph.js多步流程定义需要手工嵌套声明式定义节点和边条件分支代码里写死用 Condition 函数动态决定下一步状态持久化Chain 不维护状态State 贯穿全流程支持 Checkpointer人工干预 / 中断不支持支持 interrupt可以等人确认再继续Node 的执行顺序由图的拓扑结构决定这和前端开发者熟悉的事件驱动不一样。LangGraph 更适合描述无论过程怎么走最终都要产出某个结果的场景。1.3 Next.js 在整个架构里的角色选 Next.js 不是因为它流行而是因为它能同时承担三层职责API 层App Router 的 Route Handlers 天然支持流式响应处理大模型输出的 SSE 流特别顺手前端交互层简历上传、分析进度展示、报告渲染这些 UI 需要频繁与后端通信同一套代码库维护成本低服务端执行层Agent 的完整工作流跑在服务端不占用客户端资源也不用担心 API 密钥暴露。有一点要提醒如果只做后端 API用 Express 或 Fastify 更轻但如果要兼顾前端交互和部署Next.js 的集成优势就体现出来了。我最后选了 Next.js 14 的 App Router TypeScript。2. LangGraph.js 的状态设计与图结构把简历处理拆成一张有向图LangGraph.js 的入门门槛不在语法而在思维方式的转变。你得先把业务场景拆成节点 边 状态而不是一上来就写代码。2.1 State 定义简历 Agent 的数据库State 是这个 Agent 的核心数据对象它会在每个节点之间传递。我把简历 Agent 的 State 设计了下面几个核心字段// types/agent-state.ts import { BaseMessage } from langchain/core/messages; export interface ResumeState { // 原始输入 resumeRawText: string; jdRawText: string; fileName?: string; // 解析产物 parsedResume?: { basicInfo?: Recordstring, string; skills: string[]; workExperience: WorkExperience[]; education: Education[]; }; parsedJd?: { requiredSkills: string[]; preferredSkills: string[]; responsibilities: string[]; yearsRequirement?: string; }; // 分析结果 matchAnalysis?: { matchScore: number; matchedSkills: string[]; missingSkills: string[]; gaps: GapItem[]; }; // 报告和对话状态 optimizationReport?: string; messages: BaseMessage[]; currentStage: parse | analyze | report | chat; }这里最关键的设计决策是把parsedResume和parsedJd做成结构化对象而不是把原始文本一直带在 State 里。因为后续节点只需要读取结构化字段原始文本在解析完成后就可以从活跃上下文里丢掉别的节点如果想引用原文通过一个getOriginalText(fileName)工具函数按需取。State 的更新遵循不可变原则每个节点返回部分 StateLangGraph 会把返回值合并到整体 State 上。比如解析节点返回的是{ parsedResume: {...} }而不是整个 State 的副本。2.2 节点划分每个节点只干一件事我把整个流程拆成了 5 个节点节点名输入依赖职责输出parseResumeNoderesumeRawText简历文本结构化parsedResumeparseJdNodejdRawTextJD 文本结构化parsedJdmatchAnalysisNodeparsedResume, parsedJd计算匹配分、找差距matchAnalysisgenerateReportNodematchAnalysis生成优化报告optimizationReportchatNodeparsedResume, parsedJd, messages多轮对话messages拆节点的原则很简单能并行就并行能复用就复用。简历解析和 JD 解析互不依赖所以它们是并行节点匹配分析依赖前两者的产出生成报告依赖匹配分析对话节点是最后才进入的收尾态前面所有节点跑完后用户可以在对话节点继续追问。2.3 条件边匹配失败走重试匹配成功进对话LangGraph 里最有用的能力之一就是条件边conditional edges。它允许你根据节点返回值动态决定下一步走哪条路。举个例子parseResumeNode如果发现解析出的技能列表为空说明可能是图片简历或者扫描版 PDF应该走retryWithOcr分支而不是继续往下跑。用代码表达就是// graph/resume-agent.ts import { StateGraph, END } from langchain/langgraph; const graph new StateGraph({ channels: { ... } }); graph.addNode(parseResume, parseResumeNode); graph.addNode(parseJd, parseJdNode); graph.addNode(matchAnalysis, matchAnalysisNode); graph.addNode(generateReport, generateReportNode); graph.addNode(chat, chatNode); graph.addNode(retryOcr, retryOcrNode); // 并行边解析简历和解析 JD 同时开始 graph.addEdge(__start__, parseResume); graph.addEdge(__start__, parseJd); // 两个解析节点都完成后汇合到匹配分析 graph.addEdge(parseResume, matchAnalysis); graph.addEdge(parseJd, matchAnalysis); // 条件边匹配分析完成后判断是否进入报告生成 graph.addConditionalEdges(matchAnalysis, (state) { if (!state.matchAnalysis) return retryOcr; if (state.matchAnalysis.matchScore 0) return retryOcr; return generateReport; }); graph.addEdge(generateReport, chat); graph.addEdge(retryOcr, parseResume); graph.addEdge(chat, END);这个有向图一旦跑起来LangGraph 会自动完成拓扑排序、条件判断和状态流转。我不用再手写一堆 if/else 去控制流程出问题的时候只要看哪条边断了排查效率高很多。2.4 LangGraph.js 版本差异别一上来就照抄老教程LangGraph.js 目前迭代很快网上大量教程还是基于 0.0.x 版本的 API。我在实际开发中遇到了几个版本差异早期版本用addNode(name, fn)传函数即可新版要求函数必须接受state并返回PartialState的PartialState类型StateGraph构造方式的channels参数在不同版本不兼容旧代码直接迁移会报Invalid state key错误早期版本需要手动维护Annotated字段比如用messages 表示消息追加新版对纯对象的处理更智能但如果字段类型切过还是需要显式声明。我的建议是打开项目里实际安装的 node_modules 里的类型定义优先以源码为准。不要照抄网上一年前的代码。3. 核心实现从简历上传到优化报告生成的全链路代码这一节我把每个节点的核心实现贴出来并解释每一步为什么这么写。为了方便复现我简化了部分字段和校验逻辑但核心结构是完整的。3.1 文件上传与文本抽取PDF 和 DOCX 的处理简历上传用的 Next.js Route Handler接收 FormData解析文件后转成纯文本。这里有几个经验PDF 用pdf-parse提取文本中文简历要注意编码问题遇到乱码时用Buffer.from手动转码通常能解决DOCX 用mammoth的extractRawText处理速度快但表格内容会丢失结构。如果是简历里常见的技能表表格mammoth 提取出来会是一行一行纯文本需要后续解析节点做二次加工扫描版 PDF 是硬骨头pdf-parse提取出来是空字符串。排查方法很简单先判断提取文本的长度如果低于 50 个字符直接提示用户上传文字版 PDF 或复制粘贴文本。// app/api/parse-resume/route.ts import { NextRequest, NextResponse } from next/server; export async function POST(req: NextRequest) { const formData await req.formData(); const file formData.get(file) as File; if (!file) { return NextResponse.json({ error: 未找到文件 }, { status: 400 }); } const buffer Buffer.from(await file.arrayBuffer()); const fileName file.name.toLowerCase(); let text ; if (fileName.endsWith(.pdf)) { const pdf await extractPdfText(buffer); text pdf.length 50 ? pdf : ; } else if (fileName.endsWith(.docx)) { const result await extractDocxText(buffer); text result; // mammoth 返回的纯文本 } else { return NextResponse.json({ error: 仅支持 PDF 和 DOCX }, { status: 400 }); } if (!text || text.length 50) { return NextResponse.json({ error: 未能从文件中提取到有效文本可能是扫描件 }, { status: 422 }); } // 返回文本后续节点从文本里解析结构化信息 return NextResponse.json({ text }); }这里还有个容易被忽略的点上传文件一定要做大小限制。简历文件通常不大我限制在 5MB。不限制的话恶意用户传一个 100MB 的文件服务端解析 PDF 时会直接把内存打爆。3.2 简历解析节点用少样本提示让模型输出稳定 JSON解析节点是整条链路里最影响体验的一环。解析不准后面的匹配度、差距分析全部不准。我用的是少样本提示 JSON 模式输出的组合方案。// nodes/parse-resume.ts import { chatModel } from /lib/model; import { ResumeState } from /types/agent-state; const parseResumePrompt 你是一个资深 HR 技术专家负责从简历文本中提取结构化信息。 请严格提取以下字段不要做任何推测未找到的字段使用空字符串或空数组。 输出必须是 JSON 格式结构如下 { basicInfo: { name: , email: , phone: , location: }, skills: [技能1, 技能2], workExperience: [ { company: , title: , period: , highlights: [] } ], education: [ { school: , degree: , major: } ] } 简历文本如下 --- ${state.resumeRawText.slice(0, 16000)} --- 注意如果文本内容无法识别为简历请返回 {error: invalid_resume}。 ; export async function parseResumeNode(state: ResumeState): PromisePartialResumeState { const response await chatModel.invoke([ { role: system, content: parseResumePrompt }, { role: user, content: state.resumeRawText.slice(0, 16000) } ]); const content typeof response.content string ? response.content : JSON.stringify(response.content); try { const parsed await safeJsonParse(content); if (parsed.error invalid_resume) { // 走重试或中断流程 return { parsedResume: undefined }; } return { parsedResume: parsed }; } catch (e) { // JSON 解析失败时用字符串截取兜底 const fallback extractJsonFromText(content); return { parsedResume: fallback }; } }这里有个很实用的细节safeJsonParse。大模型输出 JSON 经常不稳定多一个换行、少一个引号就挂了。我实现了一个容错解析先尝试JSON.parse失败后用正则抽取花括号块再进行 parse还不行就把内容交给一个jsonFix小模型。成本不高但能大幅降低因为 JSON 格式问题导致整个流程崩溃的概率。3.3 匹配分析节点计算匹配分不是简单的字符串包含匹配分析是整个 Agent 里的逻辑担当。早期版本我试过让大模型直接输出一个匹配分数结果发现不同模型稳定性差异很大同一个简历和 JD 跑两次能差 15 分。后来改成嵌入式相似度 规则过滤的组合方案对 JD 的requiredSkills和简历的skills做标准化去空格、转小写、做同义词映射比如React.js和React算同一个计算两个集合的交集、差集得出matchedSkills和missingSkills用 embedding 模型把简历中的项目描述和 JD 中的职责描述各自向量化计算余弦相似度作为经历匹配度参考综合技能匹配率权重 0.6和经历相似度权重 0.4算出一个百分制得分。// nodes/match-analysis.ts import { normalizeSkillName, EMBEDDING_MODEL } from /lib/skills; export async function matchAnalysisNode(state: ResumeState): PromisePartialResumeState { const resumeSkills normalizeSkillList(state.parsedResume?.skills || []); const jdSkills normalizeSkillList(state.parsedJd?.requiredSkills || []); const matchedSkills resumeSkills.filter(s jdSkills.includes(s)); const missingSkills jdSkills.filter(s !resumeSkills.includes(s)); const resumeVector await getEmbedding(JSON.stringify(state.parsedResume?.workExperience)); const jdVector await getEmbedding(JSON.stringify(state.parsedJd?.responsibilities)); const expSimilarity cosineSimilarity(resumeVector, jdVector); const skillMatchRate jdSkills.length 0 ? 0 : matchedSkills.length / jdSkills.length; const matchScore Math.round(skillMatchRate * 0.6 * 100 expSimilarity * 0.4 * 100); return { matchAnalysis: { matchScore, matchedSkills, missingSkills, gaps: buildGapDescriptions(missingSkills, state.parsedResume!.workExperience) } }; }这样算出来的分数可解释性强前端展示时可以明确告诉用户你缺少哪些技能、JD 强调哪些经历。纯靠大模型打分用户追问为什么只有 65 分时你根本答不上来而规则 分数拆分的方式天然支持解释。3.4 报告生成节点串行流式输出而不是一次性生成报告生成节点输出的是优化建议内容较长如果一次性生成用户等待时间会非常久。我把这个节点设计成内部先串行跑三步最后统一流式输出第一步基于gapAnalysis生成 3-5 条针对性修改建议第二步挑选简历中最弱的一项工作经历生成改写示例第三步根据 JD 里的性格/软技能要求生成面试准备提示。三步的结果合到同一个optimizationReport字段里。这里的优化是把大任务拆成 3 个小的独立生成请求可以分别控制超时某一个失败不影响另外两个。// nodes/generate-report.ts export async function generateReportNode(state: ResumeState): PromisePartialResumeState { const [suggestions, rewrite, interviewTips] await Promise.allSettled([ generateSuggestions(state.matchAnalysis!), generateRewriteExample(state.parsedResume!, state.parsedJd!), generateInterviewTips(state.parsedJd!) ]); return { optimizationReport: formatReport(suggestions, rewrite, interviewTips) }; }有坑要注意Promise.allSettled在这里是必须的。如果某一个子生成请求超时失败不能拖垮整个报告生成。失败的那一块可以降级为一段默认文案至少保证报告整体能出来。4. 前端交互与流式输出让用户看到思考过程而不是等死AI Agent 产品最忌讳的就是用户点击开始分析后页面转圈 30 秒没反应。简历分析流程涉及多个节点串行执行总共可能要 20-40 秒。如果不做流式输出用户流失率会非常可怕。4.1 服务端流式事件用 SSE 推送节点状态我选择在 Next.js Route Handler 里直接跑 LangGraph 的图并把每一阶段的结果实时推给前端。前端通过fetchReadableStream接收。// app/api/analyze/route.ts export async function POST(req: NextRequest) { const { resumeText, jdText } await req.json(); const graph buildResumeGraph(); const initialState: ResumeState { resumeRawText: resumeText, jdRawText: jdText, messages: [], currentStage: parse }; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { // 逐节点执行并推送进度 for await (const event of runAgentWithProgress(graph, initialState)) { controller.enqueue(encoder.encode(data: ${JSON.stringify(event)}\n\n)); } controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: done })}\n\n)); } catch (err) { controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: error, message: (err as Error).message })}\n\n)); } finally { controller.close(); } } }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, } }); }runAgentWithProgress是我在 LangGraph 外面包的一个包装函数。LangGraph 本身不直接暴露每个节点运行完毕的回调但通过给每个节点函数内部加一个onStageUpdate钩子可以实现。实际写法是// lib/run-agent.ts export async function* runAgentWithProgress(graph, initialState) { // 简化逻辑逐个调用图节点 let state initialState; yield { type: stage, stage: parse, message: 正在解析简历... }; state { ...state, ...(await parseResumeNode(state)) }; yield { type: stage, stage: parse, message: 简历解析完成, parsedResume: state.parsedResume }; yield { type: stage, stage: parseJd, message: 正在解析职位描述... }; state { ...state, ...(await parseJdNode(state)) }; yield { type: stage, stage: match, message: 正在计算匹配度... }; state { ...state, ...(await matchAnalysisNode(state)) }; yield { type: stage, stage: match, message: 匹配度计算完成, matchScore: state.matchAnalysis?.matchScore }; // ... 后续节点 }4.2 前端接收流式输出的代码前端我用了一个自定义 HookuseAgentStream统一处理连接、接收数据和错误恢复。// hooks/use-agent-stream.ts use client; import { useEffect, useRef, useState } from react; export function useAgentStream() { const [events, setEvents] useStateAgentEvent[]([]); const [status, setStatus] useStateidle | running | done | error(idle); const abortRef useRefAbortController | null(null); const runAnalysis async (resumeText: string, jdText: string) { abortRef.current?.abort(); const controller new AbortController(); abortRef.current controller; setEvents([]); setStatus(running); try { const res await fetch(/api/analyze, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ resumeText, jdText }), signal: controller.signal }); const reader res.body!.getReader(); const decoder new TextDecoder(); while (true) { const { value, done } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); const lines chunk.split(\n); for (const line of lines) { if (line.startsWith(data: )) { const event JSON.parse(line.slice(6)); setEvents(prev [...prev, event]); } } } setStatus(done); } catch (err) { if ((err as Error).name ! AbortError) { setStatus(error); } } }; return { events, status, runAnalysis, abort: () abortRef.current?.abort() }; }这里有个容易被忽略的问题reader.read()拿到的 chunk 不一定刚好是完整的一行 SSE 数据可能是半截 JSON甚至一次包含多条。所以必须用decoder.decode(value, { stream: true }) 按\n拆分并且要处理最后一行不完整的边界情况。我上面的代码简化掉了缓存不完整行的那段逻辑生产环境必须补上否则高并发下会出现随机解析错误。4.3 进度条 UI不要作假要展示真实节点状态前端进度条不能是一个简单的定时动画必须和服务端推送的stage事件对齐。我做了一个五步进度组件上传解析 → JD 解析 → 匹配度计算 → 报告生成 → 对话就绪。每一步对应一个 stage服务端事件一到进度条就跳到对应位置。这么做的好处是用户在等待时能判断 Agent 当前卡在哪一步。如果匹配度计算卡了 20 秒没动静大概率是 embedding 服务超时用户可以取消重试而不是干等。5. 并发与稳定性单实例扛不住加队列和限流热搜词里的AI Agent 怎么扛并发我非常想聊因为这块是 AI Agent 落地和 Demo 之间最本质的区别。简历分析不是一个廉价请求一次完整分析要调 3-5 次大模型 API单次耗时 20-40 秒极端情况下能到 60 秒。如果直接用同步请求处理服务瞬间就会被打爆。5.1 先算清楚你的并发预算假设一台云服务器部署 Next.js单实例 Node 并发能力大约能同时处理 50 个请求取决于 CPU但大模型 API 的响应时间是普通 API 的 10 倍以上。也就是说如果 50 个请求同时进来每个都要等大模型返回实际吞吐量会暴跌。我的做法是把分析请求放进内存队列串行消费而不是直接开并发。队列长度限制 10超过直接返回 429 让用户稍后再试。实际测试数据并发请求数直接同步处理队列 限流5OK总耗时 25sOK总耗时 25s20大量超时服务 CPU 飙升OK排队 15s 后开始处理50服务无响应OK排队 40s但服务稳定100直接 502返回 4295.2 用单例 Queue 管理分析任务我用了一个简单的p-limit库控制并发数配合自定义任务队列。// lib/task-queue.ts import pLimit from p-limit; const limit pLimit(3); // 同时最多 3 个分析任务 const activeTasks new Mapstring, TaskHandle(); export async function enqueueAnalysis(payload: AnalysisPayload): Promisestring { const taskId crypto.randomUUID(); const task limit(async () { return executeAnalysis(taskId, payload); }); activeTasks.set(taskId, { promise: task, status: pending }); return taskId; }核心思想是用户提交分析后立刻拿到一个taskId前端轮询或通过 SSE 订阅这个 taskId 的状态。真正的重活在后台队列里跑前端不直接等待大模型响应。要注意p-limit只是控制并发数如果队列积压太多仍需要上层限流。我在 Route Handler 入口加了个简单的令牌桶每秒钟最多接受 2 个分析请求多余的返回 429。5.3 慢请求的兜底超时和重试大模型 API 偶尔会抽风响应时间从 3 秒飙到 60 秒。我踩过最大的坑是单个节点调用超时整个 LangGraph 流程直接抛出异常用户的请求被挂起前端一直转圈。解决方案是给每个节点加独立的超时控制并且按重试策略降级// lib/with-timeout.ts export async function withTimeoutT(promise: PromiseT, ms: number, fallback: T): PromiseT { try { return await Promise.race([ promise, new PromiseT((_, reject) setTimeout(() reject(new Error(timeout after ${ms}ms)), ms) ) ]); } catch { return fallback; } }具体到每个节点比如parseResumeNode超时返回一个空对象matchAnalysisNode超时则用纯规则逻辑不用 embedding先算出匹配分保证流程不中断。5.4 无状态部署的坑Graph 实例不要全局复用LangGraph 的StateGraph实例如果全局复用在高并发下会出现状态串扰。因为 State 对象在 JS 里是引用传递两个请求同时跑同一个图很容易把一个请求的parsedResume覆盖到另一个请求上。我一开始就是用模块级的buildResumeGraph()缓存结果压测时出现了用户 A 的简历被用户 B 的报告引用的严重 bug。排查后改成每次请求都新建一个 Graph 实例。因为 LangGraph 的编译开销很小创建实例只涉及对象组装真正耗时的是节点里的模型调用不必担心性能损失。// app/api/analyze/route.ts const graph buildResumeGraph(); // 每次请求调用如果后面要把状态持久化到 Redis支持跨会话恢复再考虑用 LangGraph 的Checkpointer配合 Redis Storage。现阶段同机内存已经够用。6. 实测踩坑记录排查一个奇怪的 40 秒超时问题最后分享一次印象最深的排错经历它是AI Agent 落地和写个 Demo这两个状态之间最典型的一道坎。6.1 症状分析请求偶发性超时重试后正常现象是用户上传简历后前端显示正在解析简历...然后没有任何后续事件约 40 秒后整个请求超时。看起来不是每次都发生大概 15% 概率且多发生在下午高峰时段。6.2 排查链路一先排除 LLM API 本身的抖动我的第一反应是查看大模型服务商的控制台统计。结果显示该时段模型 API 平均响应时间确实上升但最高也就 8 秒不该导致 40 秒超时。所以问题不在 API 本身。6.3 排查链路二检查 LangGraph 的节点超时逻辑我怀疑是withTimeout的 fallback 错误。翻代码发现parseResumeNode超时后 fallback 是一个空对象但空对象会导致后续matchAnalysisNode读不到parsedResume直接抛异常。异常虽然被外层 catch 捕获但异常发生时 SSE 连接还挂着前端收不到任何事件直到 Node 默认 30 秒超时断连。这才是 40 秒超时的真正来源模型调用 8 秒 节点异常处理不及时 前端没有兜底逻辑。6.4 修复方案节点失败必须显式上报不能让异常静默修复分三层每个节点函数内部捕获所有异常返回{ error: node_name: 错误描述 }并在 Stream 里推送一条error事件前端收到error事件后立刻显示错误提示并断开连接不依赖 HTTP 层超时根因修复parseResumeNode解析失败时不返回空对象而是返回一个标记stageError的字段让条件边判断走向retryOcr或者生成一个解析失败请手动填写信息的降级界面。修复后压测同样的高峰时段超时率从 15% 降到 0.5% 以下。原因是现在节点失败会立即反馈给用户不再是假死状态。6.5 顺带排查出的另外两个坑这次排错过程中还发现两个隐患StateGraph里如果某个节点内部改了传入的 State 对象可变操作会导致下次执行时初始 State 脏。后来所有节点统一改成返回新对象不修改入参Node 环境默认的keep-alive对 SSE 连接很不友好需要配置Cache-Control: no-cache之外最好加X-Accel-Buffering: no如果走 Nginx否则个别的 Nginx 配置会缓冲流式响应导致前端一次性收到全部数据进度条失效。7. 最后补充一点模型调用的成本控制简历工具这个场景对 Token 敏感度很高因为简历文本和 JD 文本都是 2000-4000 Token 级别多个节点串行下来一次完整分析要消耗 12000-20000 Token。这个消耗比单纯聊天高一个数量级。我的优化手段是模型分级简单任务JSON 解析用小参数模型复杂任务报告生成用大模型单次成本降一半以上缓存解析结果同一份简历计算内容哈希解析结果缓存 24 小时重复上传不重复消费控制上下文对话节点只携带parsedResume和parsedJd的结构化摘要不附带原始文本Token 消耗从 4000 降到 800 左右。做 AI Agent 落地最重要的一点是不要把大模型当成万能计算器能用规则算的地方就用规则能用结构化数据解决的就不靠模型自由发挥。流程编排让 Agent 变成了一个可维护、可观测、可控制的产品而不是一个听天由命的黑盒。个人实际体会这套简历 Agent 从能跑通到能扛住真实用户中间隔着的就是状态设计、流式输出、并发治理和错误兜底这几件事。任何一个环节偷懒用户都会在某个深夜替你发现。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →