ARTICLE DETAIL

资讯详情

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

用LangGraph.js构建AI Agent:简历优化工具的状态图落地与并发实践

用LangGraph.js构建AI Agent:简历优化工具的状态图落地与并发实践 用了Agent重写简历工具这件事我前后折腾了一个多月。最开始就是拿Next.js的普通接口套个Prompt让大模型干改写的活儿看起来像模像样但用户一用就露馅要么丢失项目细节要么建议千篇一律根本不像一个懂行的HR在看简历。后来狠下心把LangGraph.js拉进来把整个流程改成AI Agent状态机体验完全不一样了。这篇就把我的完整落地过程写出来从架构设计到工作流拆解再到代码实现和并发优化都是实际能用的东西不是PPT。这篇不是新手教程也不会讲Agent那些虚头巴脑的概念。面向的是已经写过几个接口、想把自己业务真正Agent化的开发者。如果你正在纠结“AI Agent怎么扛并发”“Agent状态怎么编排”“流式输出怎么接Next.js”这篇应该能帮你省下不少时间。我先从为什么要用Agent架构重构这个工具说起。1. 项目全貌为什么简历提效非得用Agent而不是普通接口先说清楚一件事简历工具这种场景本质上是一个“多步骤、多输入、结果可迭代”的任务。普通的单次Prompt调用根本搞不定你把JD、原始简历、岗位要求、用户期望一口气塞给模型模型给你吐一段看起来专业的优化建议但仔细一读就会发现它没有真正理解“这个岗位到底要什么人”“这段经历在这个岗位视角下有哪些关键词没用上”。用Agent架构的核心理念是把一个大任务拆成若干小节点每个节点只做一件事然后通过状态图把节点串起来节点之间可以循环、有条件跳转、有中间产物。这才是简历工具真正需要的形态。1.1 用户要的不是“改写”而是一个懂JD的陪练我在设计产品形态之前先盘了一遍用户真实需求发现“AI优化简历”这个诉求其实分三个层次最低层次把语言改通顺、措辞改专业。这个普通Prompt完全够用。中间层次根据岗位JD把简历里的经历重新排序、提炼关键词、补充量化描述。这里已经需要模型理解两份文本之间的映射关系。最高层次模拟HR/面试官视角做差异诊断告诉用户“你目前简历最大的三个问题是什么”“针对这个岗位你缺哪些关键素材”“某段经历应该往哪个方向补充”。这已经不是文本改写而是基于岗位画像的推理任务。所以产品就按这三个层次拆成了一条流水线JD理解、简历解析、差异评估、优化建议、精修生成。这五个环节天然适合用LangGraph.js这种图编排引擎来做。每个节点有明确的输入输出节点与节点之间传输的都是结构化数据而不是一段含糊的聊天记录。1.2 技术选型背后的取舍Next.js全栈加LangGraph.js状态编排选Next.js做全栈框架原因很实际简历工具需要页面交互、需要服务端接口、需要流式输出Next.js的App Router加API Routes能一套代码全包。而且部署到Vercel也好自托管到Node容器也好生态都成熟。让我下决心用LangGraph.js而不是直接手写一个状态机的关键点在于LangGraph.js原生支持带记忆的、可循环的图结构执行而且内置了流式事件机制。如果自己写我既要维护节点状态、又要处理重入、又要做流式事件的派发工作量远超想象。具体说LangGraph.js给你的是“状态图加可重入执行器”这套模型。它跟LangChain.js那种“链式调用思维”不一样LangChain是直线序列很难优雅地处理循环和条件分支。简历这个场景需要“评估不通过就退回重写”这样的循环逻辑用链式写起来非常别扭用图就顺理成章。1.3 边界原则给Agent划定工作区很多Agent项目翻车不是因为模型不行而是因为Agent边界太宽。Agent一拿到简历就开始自由发挥一会儿想联网查行业报告一会儿想自己生成一整套技能树结果用户等半天产出的东西根本不贴合原文。我踩过一次这个坑之后给简历Agent定了三条边界这也是整个项目最底层的设计原则只能使用工作区内明确定义的几个工具不允许自由“思考”去调用未注册的外部能力。“改写简历”节点只能基于“差异评估”节点输出的结构化建议来执行不允许模型脱离评估结果自行发挥。整个图必须在用户点击“开始优化”时一次性完成不允许多轮自由对话除非用户主动进入“面试模拟”模式。这三条边界让整个系统可解释、可调试。用户能清楚地看到每一步发生了什么比如“系统正在理解JD”“系统正在解析简历”“系统正在生成优化建议”而不是一个漫长的转圈等待。2. Agent工作流简历从原始信息到定稿文件的完整链路这一节把LangGraph.js状态图的设计过程完整讲一遍。我参考的是LangGraph.js官方文档里那种Annotation.Root定义State的写法但实际项目里做了不少改造因为简历这个场景的状态字段特别多而且很多字段不是字符串是结构化对象。2.1 岗位画像构建JD理解节点JD理解节点是整个Agent的信息源头如果这里理解偏了后面所有节点都会跟着偏。我的做法是让模型输出一个带固定Schema的JSON对象而不是自由发挥的一段话。const JD_PROFILE_SCHEMA z.object({ roleName: z.string(), industry: z.string(), seniority: z.enum([junior, mid, senior, staff]), requiredSkills: z.array(z.string()), preferredSkills: z.array(z.string()), keyResponsibilities: z.array(z.string()), cultureKeywords: z.array(z.string()), });节点内部是结构化输出加JSON校验的流程。模型返回的原始结果先用Zod校验校验失败就触发一个简单的重试机制最多重试两次两次都失败就直接走降级路径用原始文本塞给后续节点。这个降级路径特别重要因为Agent项目里最怕的不是模型答错而是模型返回一个不合法结构导致整个链崩掉。这里补充一个实操心得JD理解阶段的模型参数temperature不要超过0.2。这不是玄学是通过测试验证的。JD解析本质上是信息抽取任务temperature高会让模型去“补全”JD里不存在的细节比如自己脑补出“快速学习能力”“团队协作精神”这种万金油要求反而干扰后续差异分析的准确性。2.2 简历解析与结构化简历这块是另一个坑。用户上传的简历五花八门有的是PDF有的是Word有的是网页粘贴出来的纯文本甚至还有照片转文字的结果。我的做法是先统一做文本抽取再交给模型做结构化解析。export const parseResumeNode: NodeFunctiontypeof AgentState async (state) { const rawText await extractTextFromUpload(state.uploadedFile); const structured await model.withStructuredOutput(RESUME_SCHEMA).invoke([ { role: system, content: RESUME_PARSE_SYSTEM_PROMPT }, { role: user, content: rawText }, ]); return { structuredResume: structured, rawResume: rawText }; };Resume结构化解析的Schema比JD的更复杂因为简历里包含教育经历、工作经历、项目经历、技能清单这些嵌套结构。这里有一个关键技巧不要试图把简历里的每一条细枝末节都结构化只要把“工作经历”和“项目经历”里的关键信息抽取出来就够用了。项目经历尤其重要因为后续的优化建议主要围绕项目经历展开JD再泛泛最终还是要落到“你做过什么、做出过什么结果”上。文本抽取阶段有个隐藏问题从PDF里抽出来的文本经常丢换行、丢缩进、把两栏简历的内容搅在一起。我的处理方式是在抽取之后加一个“文本清洗”步骤把连续空白字符压缩成单个空格再把超过3000字符的段落按句号分片。清洗这一步直接决定了解析质量实测清洗前后模型解析的准确率能差出将近十个百分点。2.3 差异评估与优化建议差异评估节点是整个Agent里最重要的一个节点也是我重构整个项目的原因。普通Prompt把JD和简历一起丢给模型让它“优化”模型只会机械地替换词汇做不到真正的诊断。差异评估节点的设计思路是先让模型产出一份“差异矩阵”再基于矩阵给出优化建议。差异矩阵的结构大概是type GapMatrix { matchedSkills: string[]; missingSkills: string[]; weakAreas: Array{ resumeSection: string; jdRequirement: string; gapDescription: string; severity: high | medium | low; }; strengthHighlights: string[]; };为什么需要中间产物因为“诊断”和“开药方”是两种不同的认知任务混在一起会让模型偷懒。先产出矩阵再基于矩阵产出建议每一步都有据可查而且我可以把矩阵展示给用户看增加信任感。用户看到Agent不是胡乱改他的简历而是清楚地指出了“你的简历里缺少XX技能的关键词”“某段项目经历描述跟JD要求的XX职责高度相关但没展开”对产品的接受度是完全不同的。优化建议产出的格式是三块必须修改项、建议补充项、可以保留项。这里必须对模型做很强的约束因为大模型天然有“过度修改”的倾向把用户原本写得朴实但真实的经历改成夸大其词的假大空话。我的Prompt里明确写了一句“不允许虚构任何量化数据不允许把原始经历改写为用户没有声称过的内容”这句约束在实测中非常有效。2.4 精修生成与条件路由有环图的真实威力最后一步是生成精修简历。这一步不是简单地把原始简历丢给模型让它重写而是基于上一节点产出的优化建议逐段重建简历。我把精修生成拆成了四个并行子任务工作经历重写、项目经历重写、技能关键词补充、整体结构调整。并行子任务用LangGraph.js的Send API实现这个API允许你从当前节点动态分发多个并行分支。简历工具场景下四段内容之间互相独立并行生成能明显缩短总耗时。实测串行跑四段大概要30秒左右并行跑能压缩到15秒以内对用户体验来说是质的差别。条件路由在这里也派上了大用场。精修生成之后我加了一个“质量检查”节点用另一个模型实例对生成结果做一致性校验。校验内容包括是否保留了原始简历里的关键事实、是否包含虚构数据、是否使用了原简历里没有的关键词来强行蹭JD。校验未通过的简历会路由回精修节点最多重写两次。这个循环用LangGraph.js表达起来非常优雅。const builder new StateGraph(AgentState) .addNode(parseJD, parseJDNode) .addNode(parseResume, parseResumeNode) .addNode(analyzeGap, analyzeGapNode) .addNode(generateOptimized, generateOptimizedNode) .addNode(rewrite, rewriteNode) .addNode(qualityCheck, qualityCheckNode) .addEdge(START, parseJD) .addEdge(parseJD, parseResume) .addEdge(parseResume, analyzeGap) .addEdge(analyzeGap, generateOptimized) .addEdge(generateOptimized, qualityCheck) .addConditionalEdges(qualityCheck, (state) state.rewriteCount 2 || state.qualityPassed ? generateOptimized : rewrite ) .addEdge(rewrite, qualityCheck) .addEdge(generateOptimized, END);有一个小细节值得注意条件边里我同时判断了“重写次数”和“是否通过质量检查”。这是经验之谈如果不限制最大重写次数碰到模型连续两次生成结果都不达标的情况Agent会无限循环把用户的请求卡死在超时边缘。有界循环是所有Agent项目都该遵守的底线。3. 核心代码落地Next.js API层与LangGraph.js整合图纸画完接下来是打地基。这一节只讲代码讲Next.js的API层怎么和LangGraph.js的执行引擎衔接。目录结构我先贴出来方便你建立整体印象。3.1 工程目录结构src/ app/ api/ agent/ route.ts # 简历Agent主入口 page.tsx # 前端简历上传与结果展示页 lib/ agent/ graph.ts # 状态图定义与编译 nodes/ parseJD.ts parseResume.ts analyzeGap.ts generateOptimized.ts qualityCheck.ts llm/ client.ts # 模型Client封装 fallback.ts # 降级路径 tools/ resumeParser.ts # PDF/Word/纯文本解析 resumeStorage.ts # 结果存储 types/ resume.ts agent.ts核心是lib/agent/graph.ts和app/api/agent/route.ts两个文件。graph.ts负责定义状态图和编译route.ts负责接收HTTP请求、创建图实例、执行图并流式返回结果。3.2 状态图定义与节点实现状态定义用的是Annotation.Root。这里有个使用要点not所有字段都需要reducer只有那些会被多个节点追加写入的字段才需要。messages就是用reducer做concat追加而像structuredResume这种字段每次都是新节点整体覆盖写入不需要reducer。const AgentState Annotation.Root({ jobDescription: Annotationstring, jdProfile: AnnotationJDProfile, uploadedFileMeta: AnnotationFileMeta, rawResume: Annotationstring, structuredResume: AnnotationStructuredResume, gapMatrix: AnnotationGapMatrix, optimizationPlan: AnnotationOptimizationPlan, optimizedResume: Annotationstring, rewriteCount: Annotationnumber({ reducer: (a, b) a b, default: () 0, }), streamEvents: AnnotationStreamEvent[], });图实例的管理有一个性能优化点。LangGraph.js的编译结果可以复用不需要每次请求都重新编译。我初始化了一个模块级变量在服务启动时编译一次后续请求直接复用graph实例。不过这里要小心多用户并发安全的问题同一个graph实例可以被多次invoke状态是独立的但要注意不要把用户A的输入在代码里不小心塞进用户B的调用。这部分在下一节讲并发的时候详细说。节点实现的核心模式是每个节点从state读取需要的数据调用模型或工具返回一个部分状态的更新。LangGraph.js会自动合并这部分更新到全局状态里。我习惯在每个节点的返回里附上一些调试字段这样调试流式事件时能看到每个节点的触发顺序和耗时。3.3 Node.js工具解析PDF与Word写结果存储简历解析我用的组合是pdf-parse加mammoth。pdf-parse处理PDF文本抽取mammoth处理Word文档转HTML后再转纯文本。两个库都有反直觉的坑pdf-parse对扫描版PDF图片型抽出来的是一片空白必须在解析前先判断PDF是否包含文本层没有文本层就直接提示用户重新上传纯文本版本。mammoth转换出的HTML里有大量无意义的样式标签转纯文本时要用cheerio做一次标签剥离不然模型会看到一堆div和span的噪声。resumeStorage工具更简单就是把优化结果和原始输入存到PostgreSQL里方便用户历史记录查询。这里不直接用向量数据库因为简历工具的核心是“单次优化”不是“按简历语义检索”向量检索在这个场景里没有特别大的存在感。等后续做“简历库批量优化”功能时再考虑引入pgvector。3.4 API Route流式SSE输出简历Agent的处理过程通常要跑20到40秒如果让用户干等一个HTTP响应大概率会等出焦虑感然后关掉页面。所以流式输出是必须的。我的做法是用SSEServer-Sent Events协议在Next.js的Route Handler里自定义一个ReadableStream。export async function POST(req: NextRequest) { const body await req.json(); const { jobDescription, fileMeta } body; const graph getCompiledGraph(); const config { streamMode: updates as const, recursionLimit: 25, }; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { const inputs { jobDescription, uploadedFileMeta: fileMeta, rewriteCount: 0, }; for await (const update of await graph.stream(inputs, config)) { const payload data: ${JSON.stringify({ type: node_update, update })}\n\n; controller.enqueue(encoder.encode(payload)); } controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: done })}\n\n)); } catch (err) { controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: error, message: String(err) })}\n\n)); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }, }); }流式输出的粒度上有一个取舍。一开始我用的是token级别的streamMode前端能看到一个字一个字蹦出来效果很炫。但实际问题是在20多秒的流程里用户根本看不懂token级别的事件在说什么只会觉得在狂刷屏。后来我换成了updates模式只在“节点完成”时推送一个事件前端基于事件里的nodeName字段展示“正在解析简历”“正在评估差距”这类状态提示体验反而更好而且后端和前端的数据量都大幅下降。4. AI Agent怎么扛住并发性能、成本与稳定性热搜词里“AI Agent怎么扛并发”这个搜索量很高说明这是大家共同的痛点。简历Agent跟普通接口比并发处理的复杂度高了一个量级。原因有几个普通接口一次请求最多调一次模型Agent一次请求要调五到十次模型普通接口可以随时横向扩容Agent的图执行状态是长生命周期的对时序要求更敏感。4.1 并发问题到底卡在哪先拆解一下简历Agent单次请求的资源消耗。一次完整的简历优化大约要调用模型五次JD解析一次、简历解析一次、差异评估一次、优化建议一次、精修生成一次再加质量检查一次。按每次调用2到4秒算单请求占用模型API的时间大概在15到25秒。这个数字在并发场景下非常要命因为OpenAI类API的限流是按每分钟token数和每分钟请求数两方面限制的。如果你在30秒内涌进来20个请求瞬间就会打爆每分钟请求数上限然后就是成片的429。更隐蔽的一个坑是Agent内部的多次模型调用之间存在依赖关系。例如精修生成节点必须等差异评估节点完成后才能启动。这意味着你是无法通过“无脑横向扩容”来解决问题的因为瓶颈不在CPU而在模型API的速率限制。扩容到多少台机器都一样撞到API限流墙上。4.2 作业队列与QPS控制我最终采用的方案是自建作业队列加固定并发上限。具体做法是在Node进程里维护一个全局的异步队列单实例最大并发Agent执行数控制在5到8个。超过这个数的请求先排队前端通过SSE的队列事件告诉用户“系统繁忙正在排队预计等待X秒”。class AgentTaskQueue { private queue: Array{ run: () Promisevoid } []; private activeCount 0; private readonly maxConcurrent: number; constructor(maxConcurrent 6) { this.maxConcurrent maxConcurrent; } async addT(task: () PromiseT): PromiseT { return new Promise((resolve, reject) { this.queue.push({ run: async () { try { resolve(await task()); } catch (err) { reject(err); } }, }); this.pump(); }); } private pump(): void { while (this.activeCount this.maxConcurrent this.queue.length 0) { const next this.queue.shift(); if (!next) break; this.activeCount; next.run().finally(() { this.activeCount--; this.pump(); }); } } }这个方案很朴素但配合限流测试后效果很稳。关键点在于队列一定要做成进程级的单例。在Next.js里要注意开发模式下模块热更新会导致队列实例被反复创建所以要把队列挂在global这个不会被热更新重置的对象上或者干脆在生产环境使用独立的Node Worker进程跑Agent队列。如果你要扛非常大的并发比如一个面向C端用户的公开站点建议直接用BullMQ加Redis来做分布式队列把Agent任务分发给多个Node Worker。我前半程用的是自建队列后来用户量上来后切到了BullMQ架构原理没变只是把“进程内队列”换成了“Redis队列”这样多实例也能共享排队状态。这个切换成本其实很低因为Agent执行逻辑完全没变改动只是把task包装成了BullMQ的job。4.3 重试与退避策略Agent请求里你会碰到各种瞬时错误429限流、5xx网关错误、连接超时。我的做法是给所有模型调用统一包一层带指数退避和抖动jitter的重试函数。async function callWithRetryT(fn: () PromiseT, maxAttempts 4): PromiseT { let lastError: unknown; for (let attempt 0; attempt maxAttempts; attempt) { try { return await fn(); } catch (err) { lastError err; const isRetryable err?.status 429 || err?.status 500 || err?.code ECONNRESET; if (!isRetryable) break; const baseWait 500 * 2 ** attempt; const waitTime baseWait Math.random() * 200; await sleep(waitTime); } } throw lastError; }退避必须是带抖动的指数退避。原因是如果20个请求同时失败然后同时重试不带抖动的退避会把所有重试请求完美地集中在同一个时间点再次撞上限流。加一个±200ms的随机抖动能把重试请求的到达时间打散这是实测非常有效的手段。重试还有一个很容易被忽视的问题超时设置。模型API调用必须设置超时时间不能无限等。简历Agent的每一个节点单次LLM调用我设的是30秒超时超过就按失败处理。这能避免某个模型API卡住导致整个Agent卡死的极端情况。给SSE连接也加了一个全局超时最长60秒没收到任何事件就主动断开并提示用户重新提交。这样做虽然损失了少量正常慢请求但换来了整个系统的稳定性。4.4 缓存与成本控制Agent的token消耗是普通接口的好几倍单次简历优化最多能烧掉将近两万token。成本控制不做好用户一多你直接亏本。我的策略是分层缓存。第一层JD画像缓存。同一个岗位名称、同一个行业的JD模板在很多公司的招聘里高度相似。我对JD文本做了一次语义哈希哈希一致就直接复用此前的解析结果不再调用模型。第二层差异评估结果缓存。用户上传的简历如果跟历史某份简历的文本相似度超过90%直接复用此前的优化建议只做一次精修生成。第三层精修结果的数据库存储。用户重复点击“重新生成”时如果原始输入没变就直接返回上次结果。另外在模型调用层面给每次Agent执行设置一个token消耗的上限超过上限就触发中止逻辑。这里还要盯住一个隐形成本黑洞——Agent内部的重试和循环。我在每个节点都打点记录了token消耗最后把整条链路的总token消耗合并成一份执行报告。排查成本问题的时候先看这份报告基本一眼就能定位哪个节点在烧钱。4.5 部署环境选择与超时限制Next.js部署到Vercel是最省心的但Vercel Serverless函数的默认执行时长限制是10到60秒取决于你的套餐而简历Agent单次执行经常跑到30到40秒高峰期排队后更是远超限制。所以你别无选择只能跑在自己控制的Node环境上或者用Vercel的fluid compute长时间运行模式。我的经验是这类Agent重活千万别塞进Serverless函数里一定放到常驻Node服务上用队列来削峰填谷。如果你还有GPU类需求另说简历Agent这种纯文本任务4核8G的单机就能扛住每天几千次的量瓶颈一直在模型API那边的限流和成本不在服务器资源。5. 踩坑实录这些问题让我改了三天代码这节是个人项目里最值得记录的部分。我按真实踩坑顺序写因为这些问题很多都没有现成的中文资料基本都是靠翻源码和在调试器里一点点挖出来的。5.1 流式输出被缓冲前端像“假死”第一个版本上线测试时前端页面在用户点击“开始优化”后20秒内没有任何消息。我一开始以为是后端执行慢后来排查发现SSE数据确实在返回但被中间代理或Next.js的响应缓冲给攒住了不是一次性吐给前端。Nginx默认会缓冲HTTP响应直到攒够一定大小才发给客户端。解决方式是在响应头加“X-Accel-Buffering: no”。这个头是Nginx的开关告诉代理不要对这份响应做缓冲。同时后端在SSE开始前先发一个心跳事件确保连接被及时唤醒。5.2 “Only plain objects”与状态序列化问题这个坑非常隐蔽。LangGraph.js内部的状态里包含了一些非纯JSON对象比如Date、RegExp、甚至可能是模型返回的特殊对象。当Next.js把流式响应通过Response对象返回时如果内部有任何不能序列化的字段就会抛出“Only plain objects can be passed to Client Components”类似的报错。排查思路是把LangGraph的状态对象做一个“净化”步骤在把node_update事件塞进SSE之前用JSON.parse(JSON.stringify())把深层字段强制转一遍丢掉所有非标准对象。同时注意不要直接修改LangGraph内部状态对象只对事件负载做克隆处理。5.3 长简历把上下文撑爆有个用户上传了一份十页带完整论文发表的简历整个文本接近两万token加上从JD里抽取的信息和Prompt模板直接把模型上下文窗口打到接近上限。后果是后续节点的生成质量肉眼可见地下降因为模型在超长上下文里会“迷失”尤其是精修生成时它会漏掉简历前半段的经历。后来我在简历解析节点后面加了一个“分段打包”逻辑把结构化简历按前五段工作经历截取额外的经历只保留公司和职位名称详细描述全部截断。这听起来像是在丢信息但实际测试下来模型生成质量反而更好因为模型的注意力集中在前几段最重要的经历上。这里要特意提醒不要试图让模型处理“全部信息”让模型专注处理“关键信息”才是Agent工程里更实用的原则。5.4 Agent幻觉优化建议怎么治最早期版本里模型会一本正经地给用户虚构出来一段“在XX公司担任XX职务”的经历然后推荐用户写进简历。这是绝对不能接受的。我在诊断了十几条badcase之后发现根源在于差异评估节点的Prompt里暗示了“如果缺少某些技能请建议用户补充相应经历”。模型为了讨好指令就直接编了。处理方式有两个方向同时堵Prompt层面明确加一条“不得生成用户没有经历过的描述如果某项技能用户完全没接触过建议放到技能补充模块而不是工作经历”架构层面加了一层“事实一致性检查”节点把精修前后两份简历进行比对把新增内容里无法溯源到原始简历的句子全部标记为疑似虚构并剔除。这一层检查很重但它把系统的可信度拉高了一个级别。5.5 并发429与执行超时自建队列上线之前我经历过一次小型流量高峰同时涌进来十几个请求模型API直接429打满队列里积压的任务一个接一个超时失败用户端看到的是全员报错。后来加上队列和重试机制之后这个问题基本消除。这里追加一个小提醒队列积压时要主动向前端发出排队事件前端基于这个事件展示排队进度用户虽然等待了但没有恐慌感流失率反而比直接报错低很多。5.6 常见问题速查表现象根因解决方案SSE前端长时间无数据代理缓冲加X-Accel-Buffering: no头发送心跳事件Response序列化报错状态里有非Plain Object事件负载做JSON净化克隆后再发送生成结果漏掉简历前半段上下文超长解析后按经历分段截断只保留关键部分优化建议包含虚构经历模型幻觉Prompt硬约束一致性检查节点并发一高全是429API限流自建队列加并发上限指数退避加抖动重试Agent超时卡死单节点LLM无超时所有模型调用设30秒超时SSE设60秒总超时扫码PDF解析空白扫描件无文本层检测文本层无文本层直接提示用户换上传方式说到实测中的体会我最想强调的一点是Agent工程的复杂度不是来自“调大模型”而是来自“让大模型在可控的流程里干活”。简历工具这个场景从用户上传文件到拿到精修简历每一个环节都要有明确的责任边界。模型只负责理解和生成流程控制交给LangGraph.js并发和稳定性交给队列和重试机制最终交付的质量靠一致性检查来兜底。这种拆分思路比堆Prompt技巧重要得多。如果你也想把某个业务改造成Agent形态我的建议是先用流程图把业务所有分支穷举出来再去翻LangGraph.js的文档而不是反着来。工具永远服务于业务流程。希望这篇落地记录能让你少踩几个坑。
返回列表