
做简历优化这种工具最难的地方从来不是“让大模型写一段漂亮话”而是怎么让整个流程可控、可复用、能上线。单靠一个prompt把简历丢给大模型最多算个高级玩具你不知道它哪一步会跑偏不知道它用了多少token更没法让用户中途介入修正方向。这个项目我选择用Next.js做前端和API层用LangGraph.js编排整个Agent流程最终把简历解析、岗位匹配、差距分析、内容重写、格式导出串成一条有状态、可中断、能流式输出的完整链路。这篇文章就把这块的完整落地过程拆开讲清楚包括架构选型、状态图设计、前端对接方式以及线上跑起来之后才暴露的那些坑。文章适合两类人看一类是正在用Next.js做AI应用、想把简单的“调用大模型接口”升级成真正的Agent工作流另一类是已经了解LangChain/LangGraph概念但想知道JS版本真实落地会遇到什么细节问题的人。我会尽量把代码、配置、参数都写明白你可以直接照着改。1. 为什么简历工具最终选了LangGraph.js而不是LangChain1.1 简历优化不是“一次生成”的问题先说清楚业务场景。用户上传一份旧简历再贴一段目标岗位JD系统要完成的事情其实是一连串任务先解析简历里的结构化信息再分析JD里的关键词和硬性要求接着做差距分析然后生成优化后的简历正文最后输出成可下载的Markdown文件。如果把这些塞进一个prompt里让模型一次性输出通常会遇到三个问题。第一不可控模型很容易跳过某个环节比如直接开写但你根本不知道它对JD的分析是否到位。第二不可复用换个岗位、换个用户流程完全一样但代码里全是硬编码的提示词没法沉淀成可组装的能力。第三没法介入用户想在生成前补充一条经历或者想只针对“项目经验”部分重写单次调用完全做不到。所以这个工具的本质是一个多步骤、有状态、需要人工介入的流程而不是一次生成。这种场景正好是Agent编排框架的用武之地LangGraph.js的价值就是把这些步骤定义成“图”让每个节点只干一件事节点和节点之间的流转由状态和控制逻辑决定。1.2 LangGraph.js的核心概念和选型理由LangGraph.js是LangChain生态的JavaScript版本核心抽象是状态图StateGraph。你定义一份全局状态然后声明若干个节点每个节点是一个接收state、返回部分更新的函数。节点之间用边连接边的类型分两种普通边表示走完就切换条件边表示根据当前状态决定下一步走哪条路。这和我们平时写业务代码的思维方式不太一样但它解决了一个关键问题把流程和业务逻辑解耦。你不需要在一个大函数里写一堆if else来编排LLM调用图的每个节点都是独立的测试时可以单独调出问题时可以单独trace。对比LangChainJS版叫LangChain.js的chainLangGraph.js最大的优势是支持循环和条件分支。简历工具里有一个典型场景差距分析完如果发现用户简历严重缺少某类关键词应该先停下来问用户“是否需要补充一段相关经历”用户选择后再继续重写。这种“流程中间停下来等人”的能力在Chain里非常别扭但在LangGraph.js里就是一个条件边加一个中断点的事。另外需要说明LangGraph.js是有状态的图执行过程中的中间结果存在state里天然支持检查点checkpoint。这意味着用户刷新页面后能恢复进度不用从头再来这对简历这种需要反复修改的工场景非常重要。2. 简历Agent的状态图设计与节点拆分2.1 先定义状态一份简历Agent的数据模型设计状态图的第一步不是画节点而是确定状态里放什么。简历Agent的全局状态我用了Annotation.Root来声明大致长这样import { Annotation } from langchain/langgraph; export const ResumeState Annotation.Root({ resumeText: Annotationstring, // 用户上传的原始简历 jobDescription: Annotationstring, // 目标岗位JD parsedProfile: AnnotationProfile, // 解析出的结构化画像 analysis: AnnotationJobAnalysis, // JD分析结果 gaps: AnnotationGapItem[], // 差距清单 rewrittenSections: AnnotationRecordstring, string, // 重写后的各模块 finalResume: Annotationstring, // 最终完整简历 messages: AnnotationMessage[]({ reducer: (left, right) left.concat(right), default: () [], }), });这里有个LangGraph.js容易忽略的细节Annotation里的字段默认是“覆盖式”的也就是说如果两个节点往同一个字段写内容后者会覆盖前者。而像messages这类需要保留完整历史的字段必须显式声明一个reducer用left.concat(right)做数组合并。否则在多次调用大模型时消息历史会被冲掉Agent会失去上下文。我做状态设计时踩过一个教训一开始把gaps和rewrittenSections都放进了普通字段结果重写节点运行完旧的分析结果还在图的下一次流转读到了脏数据。后来统一规则只有“最终产物”类的字段用覆盖式凡是“过程数据”一律用数组加reducer或者用独立命名空间。2.2 节点划分一个节点只干一件能说清的事简历Agent的图我分了五个节点分别对应前面提到的五个子任务节点职责输入输出parseResume从原始简历提取结构化信息resumeTextparsedProfileanalyzeJD解析JD的关键词、硬性要求、偏好jobDescriptionanalysisgapAnalysis对比画像与JD生成差距清单parsedProfile,analysisgapsrewriteResume按差距逐模块重写简历内容parsedProfile,gapsrewrittenSections,finalResumeexportResume把最终内容拼成MarkdownfinalResume返回给前端下载每个节点的函数签名都是一样的接收整个state返回部分状态更新。举个例子parseResume大致长这样async function parseResumeNode(state: typeof ResumeState.State) { const { resumeText } state; const response await resumeModel.invoke([ { role: system, content: PROMPTS.parseResume }, { role: user, content: resumeText }, ]); const parsed JSON.parse(response.text); return { parsedProfile: parsed, messages: [response] }; }节点的设计原则就一条一个节点里只允许有一个“决策点”或一个“外部副作用”。比如rewriteResume节点内部会多次调用大模型按简历模块分多次生成这在本质上是一个子流程。为了主图清晰我建议不要在一个节点里塞多次模型调用而是把“每个模块的重写”也拆成子图或者用循环边反复调用同一个节点。后面我会讲这个循环怎么处理。2.3 条件边与人工反馈让Agent在关键处停下来问人这是LangGraph.js相对普通Chain最有价值的地方也是简历工具能否真正好用的分水岭。gapAnalysis跑完之后系统需要判断一件事如果差距清单里出现了“缺少某项关键经历”“岗位要求中明确提到但简历完全没覆盖的关键词”继续重写只会让大模型强行编造内容这在实际产品里是不可接受的。所以图在这里分叉function routeAfterGap(state: typeof ResumeState.State) { const hasBlockingGap state.gaps.some((gap) gap.level critical); return hasBlockingGap ? askUser : rewriteResume; } graph .addNode(askUser, askUserNode) .addConditionalEdges(gapAnalysis, routeAfterGap, { askUser: askUser, rewriteResume: rewriteResume, });askUser这个节点不会调用大模型它的作用是“挂起”。LangGraph.js在节点里调用interrupt函数图执行到这一步会暂停把当前状态保存到检查点然后返回给前端一个信号等待用户输入import { interrupt } from langchain/langgraph; async function askUserNode(state: typeof ResumeState.State) { const userInput interrupt({ type: critical_gap_confirmation, gaps: state.gaps.filter((g) g.level critical), }); return { userDecision: userInput }; }前端在收到这个中断信号后渲染一个类似“检测到你缺少数据分析相关项目经历是否需要补充一条”的确认卡片。用户点击之后前端把用户的回答提交给图用Command(resume: ...)恢复执行图继续走rewriteResume节点。这个机制我用下来最大的感受是它让AI应用第一次有了“真实业务感”。纯LLM调用是单向的用户只能等一个结果而有中断点的Agent产品可以把主动权交还给用户体验直接从“黑盒生成”变成“协作编辑”。2.4 循环节点模块化重写为什么要用边而不是for循环前面提到rewriteResume需要按简历模块多次生成工作经历、项目经历、技能清单……。第一版我图省事在节点内部写了一个for循环结果发现至少三个问题一是每次模块重写都要把完整上下文塞给模型token消耗直线上升二是某一个模块生成长文本时超时整个节点失败已经生成的部分全部丢弃三是没有办法让用户单独某个模块重试。后来我改成图内循环rewriteResume节点每次只处理一个模块处理完通过条件边判断“是否还有未处理的模块”有就继续走自己没有就跳到exportResume。graph .addNode(rewriteModule, rewriteModuleNode) .addConditionalEdges(rewriteModule, shouldContinueRewrite, { more: rewriteModule, done: exportResume, });shouldContinueRewrite返回more或done状态里用一个pendingSections: string[]记录待处理的模块列表。每次进入节点从数组头部取出一个模块处理完shift掉。这个改动的收益很大。首先是可以流式输出图每次只完成一个模块生成完一个就能先推到前端用户不用干等全部生成完。其次是断点恢复某个模块超时只需要重试这一个模块前序模块的结果都在state里。这在后面部署调优时帮了我大忙。3. Next.js接住Agent流式输出与服务端设计3.1 API Route还是Server ActionsNext.js里接入Agent有两种常见方式一种是App Router的Server Actions一种是Route HandlerAPI Route。我最终选择的是Route Handler理由是Agent执行是长任务前端需要实时拿到流式输出而Server Actions对streaming的支持虽然也在变好但Route Handler可以用标准Web APIReadableStream、SSE做到完全可控。简历工具的接口路径是POST /api/resume-agent请求体是resumeText和jobDescription响应是一个SSE流。这个选择也方便前后端分离——之后如果要做小程序或客户端同一个接口直接复用。3.2 流式输出的核心实现SSE 图执行流LangGraph.js本身支持多种stream模式我主要用streamMode: messages它会把大模型生成的增量token逐步吐出来。Node端收到这些增量后包成SSE格式向前端推送。先看服务端代码import { NextRequest } from next/server; import { buildResumeAgent } from /agent/resumeAgent; export async function POST(req: NextRequest) { const { resumeText, jobDescription, threadId } await req.json(); const encoder new TextEncoder(); const app buildResumeAgent(); const config { configurable: { thread_id: threadId } }; const stream new ReadableStream({ async start(controller) { try { const inputs { resumeText, jobDescription }; const streamResult await app.stream(inputs, { streamMode: messages, config, }); for await (const event of streamResult) { const { chunk } event; const isModelChunk typeof chunk?.text string; if (isModelChunk) { const payload data: ${JSON.stringify({ type: token, text: chunk.text })}\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: (err as Error).message })}\n\n) ); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }这段代码有几个关键点。第一我用ReadableStream而不是直接返回一个Response前就把所有内容都生成完这样客户端不用等全部完成就能开始渲染。第二SSE格式要求用data:前缀加两个换行前端解析时一眼能认出来。第三configurable.thread_id是LangGraph.js用来关联检查点状态的键同一个用户每次进来都用同一个threadId才能实现断点续跑。这里特别提醒一个Node运行时的问题默认的Next.js Route Handler跑在Edge Runtime时ReadableStream虽然可用但LangGraph.js内部的一些依赖比如某些Node专属方法会炸。我在本地开发时遇到Illegal invocation这类奇怪报错最后检查是runtime配置问题。务必在你的route文件顶部加export const runtime nodejs;不加这个你可能本地跑的很好build部署到Vercel或Node服务上才暴雷。3.3 前端流式消费一个通用SSE解析函数前端这边用原生fetch就能消费SSE流不需要额外引库。核心代码是读取response.body用TextDecoder把二进制流转成字符串再按换行切分数据。async function runResumeAgent(resumeText: string, jobDescription: string, threadId: string) { const res await fetch(/api/resume-agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ resumeText, jobDescription, threadId }), }); if (!res.ok || !res.body) { throw new Error(Request failed: ${res.status}); } const reader res.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { 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: )) continue; const data JSON.parse(line.slice(6)); if (data.type token) { // 更新UI里的流式文本 appendToStreamingOutput(data.text); } else if (data.type error) { showError(data.message); } else if (data.type done) { finishStreaming(); } } } }这段代码里最容易写错的地方是buffer处理。SSE数据在网络传输中不保证一次刚好到达一个完整事件可能半个事件、两个事件粘在一起。必须用buffer先缓存每次读完拼上按\n\n切分出完整事件把最后一段残留的放回buffer下次继续拼接。我在前端实时渲染时用的是Next.js的useState存一个流式文本字符串。刚开始直接setText((prev) prev text)每次token都触发一次React re-render简历内容长一点页面就非常卡。优化方案是加一层批量聚合前端每60ms把累积token一次性setState一次体感完全不一样。这是做streaming UI的老经验但很多人第一次写都会掉进去。4. 从能跑到稳定token、重试与部署实战4.1 token消耗估算每个节点花多少钱简历工具这种多节点Agent最容易被忽视的就是token成本。单次调用模型看不到总量但整条链路跑下来一个用户跑一轮可能消耗大量token。我做了一张内部估算表按主流模型4k上下文窗口大约是每百万token几十块钱的水平来算节点输入token量级输出token量级说明parseResume3000~5000300主要消耗在原始简历全文analyzeJD1000~2000300JD文本本身不长gapAnalysis4000~6000500需要同时把画像和JD塞进去rewriteModule × 35000~8000 × 模块数2000 × 模块数大头每个模块都要带上下文exportResume2000100主要是拼接一个典型用户跑完整条链路总token量大概在4万到6万之间。这个数字如果上线前不算清楚等用户量上来再看账单会很痛。省token的思路有三条第一每个模块重写时不要把原始简历全文都塞进去只塞当前模块的原始内容和差距分析结论第二parsedProfile从原始简历中提炼后要压缩字段比如只保留岗位相关关键词而不是保留全文第三流式输出模式不会减少token但可以让用户先看到一部分结果提前终止无效生成。4.2 重试机制哪些调用值得重试哪些不值得LLM接口调用失败是常态问题在于重试策略。我踩过的坑是对graph整条链路做整体重试。一旦某个节点超时重新invoke整个graph结果就是检查点状态被重置用户前面确认过“补充经历”的决策也丢了体验很差。正确做法是分节点设重试。LangGraph.js的节点函数内部我给每个模型调用包了一层withRetryasync function withRetryT(fn: () PromiseT, retries 2): PromiseT { let lastErr: Error; for (let i 0; i retries; i) { try { return await fn(); } catch (err) { lastErr err as Error; // 指数退避500ms, 1500ms await new Promise((resolve) setTimeout(resolve, 500 * Math.pow(3, i))); } } throw lastErr!; }需要注意的是重试只对“请求级”错误有意义——网络超时、5xx、限流。对于业务级错误比如模型返回的JSON解析失败、返回内容格式不对重试大概率也是同样结果这时候要做的是修复提示词或做输出格式校验。还有一个容易被忽略的点流式输出模式下如果客户端中途断开服务端的图执行还会继续跑完白白消耗token。我在Route Handler里用req.signal监听客户端断开事件收到abort时手动调用图执行返回的abortController取消执行。Next.js的请求对象上直接可以拿到req.signal这一点对成本控制非常关键。4.3 常见错误类型速查表开发过程中我汇总了一个错误排查表直接列出来给各位参考现象原因解决办法图执行到一半打日志看到重复调用同一节点状态里缺少“已完成标记”条件边判断永远为真用数组shift或者加processedFlags布尔字段流式输出时页面很久没反应然后一次性出现一大段前端buffer切分逻辑不对事件被粘包按\n\n切行保留末尾buffer参考3.3代码模型返回JSON.parse报错模型输出带了markdown代码围栏提示词里明确要求“直接返回JSON不带任何markdown标识”或先剥掉围栏用户刷新页面后无法恢复进度没有用同一个thread_id检查点找不到为每个用户生成稳定的threadId存在localStorage或服务端会话里生成内容里出现了编造的工作经历差距分析后直接重写没有走人工确认环节对critical级别差距强制用interrupt暂停部署到Vercel后API超时Vercel免费版函数默认最大执行时长限制改为在自建Node服务部署或使用支持长任务的Serverless平台这里面最值得展开的是第一条。LangGraph.js的节点只要执行完就会把返回值写进state如果你用pendingSections数组配合shift()那么重新进入节点之前它已经被修改了不会重复处理。但如果你依赖一个“当前模块索引”数字字段循环边就很容易死循环因为节点返回的索引增量会被覆盖式写入但图重放replay时可能会从检查点恢复旧值。我的结论是图的状态更新最好都是immutable的用数组操作天然生成新数组避免依赖修改旧对象字段。4.4 部署与性能图编译一次执行多次buildResumeAgent()这个函数我最早写的是在每次请求进来时重新compile()一次。后来看了一下执行链路compile()虽然不贵但也存在完全没有必要。因为编译后的图对象是具备完整执行逻辑的模型实例、提示词这些都是在闭包里引用的不需要重新编译。优化方式很简单模块顶层缓存编译结果。let agentApp: CompiledStateGraph | null null; export function buildResumeAgent() { if (!agentApp) { agentApp createResumeGraph().compile(); } return agentApp; }编译一次还有个好处是内存里的node函数引用是同一个不容易出现奇怪的并发问题。但要注意LangGraph.js的图在并发调用时状态是通过config.thread_id隔离的。如果两个请求共享同一个thread_id后一个请求可能会覆盖前者的检查点导致用户A看到用户B的操作结果。我为每个用户单独生成UUID作为thread_id前端在初始化页面时向服务端申请。部署环境方面我在Vercel上跑过然后搬回了自己的Node服务。原因不是Vercel跑不了而是简历生成通常要跑30秒以上Serverless平台的函数执行时长限制和冷启动问题会让体验很不稳定。自建服务的做法是在Node进程里跑一个http-serverNext.js以standalone模式构建再用Nginx反代挂出去/api/resume-agent走SSE的请求不经过额外的缓冲层。这个方案稳定跑了一个多月没有明显的性能问题。5. 后续迭代方向与final心得这版Agent已经完整跑通了但如果还要继续往下做我脑子里有几个明确的扩展点。第一个是支持多轮对话式修改。现在的Agent是单向流程给简历和JD产出新简历。但在实际使用中用户拿到新简历后经常会说“项目描述再突出一下量化结果”或者“第三段经历删掉”。这个需求靠LangGraph.js的状态持久化实现起来会很顺在exportResume之后加一个editResume节点接收用户最新反馈更新对应模块后重新导出。第二个是把模型调用做成可配置的Provider系统不只是换API key而是允许按节点选择不同模型解析用便宜快速的模型重写用能力更强的模型。第三是给每个节点挂上质量评估器自动判断本节点输出是否符合预期比如重写后的内容是否真的融入了JD关键词不符合就走一条retryNode的回路而不是直接往下游传脏数据。最后再分享一个我在实际开发中反复体会到的经验Node.js的Agent应用和Python生态的Agent应用痛点完全不同。Python生态的教程多、资料全但真正做产品时前端衔接、SSE流、Serverless部署、会话恢复这些工程问题反而需要用Node技术栈解决得干净。LangGraph.js从1.0开始API已经相当稳定和Next.js的App Router配合是一个很适合做AI产品原型的组合。如果你正在做类似的项目我真心建议先把状态图画出来再写代码。LangGraph.js会逼着你把Agent拆成有边界、有状态的节点这在短期看是增加了设计成本但一旦产品复杂起来这个成本会十倍返回来。简历工具只是一个例子同样的图结构换一下节点里的提示词和状态字段可以套到很多内容生成、数据分析、信息处理的场景里。做完这一个项目你会对“AI Agent架构”这个词有和之前完全不同的理解——它不是玄学就是一张你现在能画出来、也能跑起来的图。