多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Next.js + LangGraph.js:简历AI Agent全栈落地实践

Next.js + LangGraph.js:简历AI Agent全栈落地实践 先交代个背景我接手这个简历工具项目的时候团队已经用 FastAPI LangChain 跑通了一版原型能根据用户输入生成简历片段。但真要拿出去给用户用问题全冒出来了——前端要频繁改交互、流式输出总是断、每个用户的简历版本状态没人管、模型一换就要改一堆胶水代码。最后我们推倒重来换成了 Next.js LangGraph.js 这套组合重新实现了整个简历工具 AI Agent从工作流设计到并发处理到数据安全完整落了一次地。这篇文章就把整个过程中的选型逻辑、代码结构、踩坑记录和并发经验一次性写透适合正在做 AI Agent 落地、尤其是想用 TypeScript 全栈方案替代 Python 原型的朋友参考。1. 为什么是 Next.js LangGraph.js一次技术选型的排雷记录1.1 FastAPI 方案先跑通却在产品化时卡了壳原型阶段用 FastAPI 确实舒服Python 生态里 LangChain 的文档最全写个链式调用几十分钟就能出活。但产品化不是写 Demo简历工具有几个需求是 FastAPI 方案很难绕过去的交互密度太高。用户上传简历后需要边生成边看效果、随时打断修改、针对某一段单独重新生成。这些交互如果走 REST 接口轮询体验非常割裂。而 Next.js 的 Route Handler 天然支持流式响应前端组件可以直接消费 SSE 流状态更新能做到逐字级别的实时反馈这对简历编辑场景几乎是刚需。部署链路太长。FastAPI 后端 React 前端是两套代码、两套部署环境变量、跨域配置、鉴权逻辑要重复写。简历工具这种中小型产品用一个 Next.js 应用同时承担页面渲染和 API 层能省掉一大半运维心智。团队协作成本高。前端同学改接口要等后端后端同学调 prompt 要等前端给参数。全栈 TypeScript 之后类型定义前后端共享Agent 的状态结构、节点的输入输出类型全部集中在一个仓库里维护改起来清爽得多。1.2 LangGraph.js 相比 LangChain.js关键在“状态机”而非“链”如果你只了解 LangChain.js可能会觉得 LangGraph.js 只是换个写法。实际上两者解决的问题完全不同。LangChain.js 的 Chain 适合“一条道走到底”的固定流程比如“取用户输入 - 拼 prompt - 调模型 - 返回结果”。但简历 Agent 的核心逻辑天然是分叉的用户可能是想全面评测也可能是想针对某个项目经历重写还可能只是要一份格式统一的纯文本版本。如果评测结果不达标需要走“重写”分支如果达标直接输出建议即可。这种带条件分支、带循环、带人工确认节点的流程用 Chain 硬写会变成一团乱麻你要么写一堆 if/else 去手动维护对话状态要么把状态塞进全局变量里等着并发请求互相踩。LangGraph.js 的核心抽象是图——节点是函数边是路由逻辑整个 Agent 的执行过程就是在一个显式的状态机上流转。每个节点都能读写共享状态每个条件边都能决定下一步走向整个流程画出来就是一张清晰的图。我见过很多团队在选型时纠结“用 LangGraph 还是自己写状态机”我的观点是如果你的流程超过三个节点、存在条件分支直接用 LangGraph.js。它能省掉的就是你最不该花时间去写的那部分——状态流转和流程编排。1.3 Next.js 作为宿主前后端一体但不是所有东西都该放在 Route Handler 里强调一点Next.js 在这个架构里的角色是“宿主应用”不只是“前端框架”。我们用 App Router 的时候把 LangGraph 的执行逻辑放在 Route Handler 里前端页面通过 fetch 发起请求。但这里有个非常容易踩的坑Route Handler 默认是运行在 Node.js 运行时里的而 LangGraph.js 的状态检查点机制checkpointing依赖可持久化的存储。如果你用 Vercel 部署注意要把相关路由配置为runtime nodejs否则会因为无服务器环境缺少长驻内存而出现状态丢失。另一个选择是“到底哪些逻辑放 Next.js哪些放独立服务”。对于简历工具我的建议是凡是单次交互内能完成的任务一次评测、一次改写、一轮对话放 Route Handler凡是需要长时间跑的后台任务批量生成、多版本对比放到独立任务队列里别让 Route Handler 傻等。这两条通道我在后文写并发处理的时候会详细展开。2. 简历 Agent 的工作流设计从一段自然语言到一份可用简历2.1 先拆用户真实意图简历工具不是“改写机”很多简历工具的失败在于产品经理把它定义成了“输入一段文字AI 帮你润色”。真实用户的诉求复杂得多我梳理下来至少三类用户有一份旧简历希望按某个 JD职位描述定向优化用户没有简历但有工作经历和项目材料希望从零生成一份用户想知道自己的简历哪里不行希望先得到评测报告再决定改哪里。如果 Agent 只能处理“润色”这一种操作前两类用户进来就会流失。所以工作流的第一步不是碰简历内容而是做意图识别。我们设计了一个入口节点用来分析用户输入和上传文件输出一个结构化的intent对象是evaluate、rewrite还是generate以及对应的目标职位、目标关键词。这个节点看起来简单但它是整个图的“总闸门”。意图识别错了后面所有节点都在白费 Token。我们在这里用了一次工具调用让模型返回 JSON 结构再用 zod 做严格校验校验不过就重新生成一次。2.2 这张有向图上有六个节点为什么必须用条件边完整的工作流里我们设计了六个核心节点节点职责输入依赖可能的下一步意图识别解析用户输入确定任务类型用户消息 上传文件简历解析简历解析从 PDF/Word/纯文本中提取结构化内容意图识别结果评测节点评测节点按 JD 维度给简历逐项打分结构化简历 目标 JD条件边判断改进建议生成输出可执行的分点建议评测报告人工确认简历重写按建议定向改写简历原简历 建议 用户确认格式清洗格式统一输出为标准 Markdown/HTML重写结果结束重点在于“评测节点”之后的边不是普通的直线边而是条件边。评测报告里会有一个总分比如满分 100低于 80 分走“改进建议 重写”分支高于 80 分直接走“仅输出建议”分支。这个分叉逻辑如果写在业务代码里就得在每次 Agent 执行完节点后手动判断返回值再手动调用下一个节点而用 LangGraph 的addConditionalEdges逻辑就非常直观图的结构本身就能表达业务决策。2.3 状态协议是 Agent 的地基先定义 Schema再写节点函数LangGraph.js 里状态State是所有节点共享的内存节点函数接收整个状态返回状态的部分更新。这块设计的好坏决定了 Agent 后期好不好维护。我们的状态 Schema 大概长这样type ResumeAgentState { messages: ChatMessage[]; intent?: ResumeIntent; parsedResume?: StructuredResume; jd?: JobDescription; evaluationReport?: EvaluationReport; rewriteTask?: RewriteTask; humanConfirmation?: HumanConfirmation; outputDocument?: string; errors?: string[]; };这里有个经验每个节点只修改自己负责的字段别让一个节点同时改五个字段。比如评测节点只写evaluationReport不碰rewriteTask重写节点只读parsedResume和evaluationReport只写outputDocument。这样每个节点都是可单独测试的纯函数调试的时候出了错也能立刻定位到是哪个环节污染了状态。另一个关键是 messages 数组的处理。LLM 对话需要历史上下文但简历数据本身不是对话不能全塞进 messages 里。我们约定messages 只保存用户和 Agent 之间的交互记录简历内容和评测结果全部放在独立字段里。这样有两个好处一是不会因为简历太长把上下文窗口撑爆二是评测、重写节点可以直接读结构化字段不用反复从对话里抽取。3. 关键节点实现评测、改写与格式清洗是怎么落地成代码的3.1 评测节点量化维度 结构化输出是 Agent 质量的地基评测节点是整张图里技术含量最高的节点。它不只是“让模型打分”而是要输出一份可量化的、能被下游节点直接消费的报告。我们把简历评测拆成了五个维度基本信息完整性、工作经历与 JD 匹配度、项目亮点呈现、量化成果密度、格式规范性。每个维度一个分数和一段改进建议。为了让模型输出稳定的结构不能用普通的 prompt 让模型“总结一下”而是要走 tool calling 路线const evaluateResumeTool { type: function as const, function: { name: submit_evaluation_report, description: 提交简历评测报告, parameters: { type: object, properties: { overallScore: { type: number }, dimensions: { type: array, items: { type: object, properties: { name: { type: string }, score: { type: number }, reason: { type: string }, suggestion: { type: string }, }, required: [name, score, reason], }, }, }, required: [overallScore, dimensions], }, }, };调用之后我们会用 zod 解析模型返回的工具调用参数解析失败就走model.withStructuredOutput()的重试机制。实际测试下来结构化输出的成功率比裸 prompt 高 30% 以上而且报告直接进状态下游节点不需要再做文本解析。3.2 改写节点温度调参不是玄学是吞吐量权衡改写节点负责把评测建议落到简历正文里。这里的第一个决策是“改多少”。我们限制模型每次只改一个 section要么只改“工作经历”要么只改“项目经验”。一次全改不仅 Token 消耗大而且容易出现“模型把用户真实经历改没了”的灾难。第二个决策是模型参数。改写场景的温度我们固定在 0.4 左右。很多人觉得温度越高越有创造性但简历不是文案创作它是在用户提供的真实经历基础上做优化表达。温度拉到 0.9模型会开始给用户编造“负责了千万级用户平台的技术架构”这种原简历里根本没有的内容。温度 0.4 能在保留事实的前提下做词汇和句式层面的优化实测幻觉率降低了约 70%。另外为了控制输出格式改写节点的 system prompt 里必须明确所有经历条目要保留原始时间线和公司名只允许调整 STAR 法则中的 action 和 result 描述。我们还在改写之后接了一个“事实一致性校验”的小节点用另一个轻量模型检查输出里是否存在原简历没有的实体。代价是每次改写多花一轮请求但比起让用户发现简历里多出一段假经历这点开销完全值得。3.3 格式清洗节点LLM 输出的 HTML 让你知道什么叫“不可信计算”模型输出的文本通常带 Markdown 标记或者 HTML 片段直接渲染到页面上有 XSS 风险更麻烦的是简历还要能复制到 Word 里保持格式。我们的格式清洗节点做三件事第一把模型输出解析成 AST抽象语法树过滤掉所有非白名单标签只保留p、ul、li、strong、h4这类基础标签。第二把清洗后的结构化内容重新映射成三种格式Markdown用于页面预览、受限 HTML用于复制到 Word、纯文本用于 ATS 系统投递。第三对联系方式的 risk 检查。这个节点会扫描输出文本里的手机号、邮箱格式如果发现和原简历不一致直接标记 warning 并拦截输出。因为 LLM 很可能在改写过程中“顺便”把用户的邮箱改成了一个看似合理但根本不存在的新邮箱。这里送给新手一句话永远把 LLM 输出当不可信输入处理。它可以不脏但清洗节点必须有。4. 让 Agent 在 Next.js 里跑得稳流式、人工确认与超时处理的真实取舍4.1 流式输出不是 SSE 一接就完事要处理中断和重连简历工具的体验核心是“边生成边看到效果”所以我们从评测节点开始就跑了流式输出。LangGraph.js 的streamMode: updates可以拿到每个节点更新的增量我们把这些更新通过一个自定义的 ReadableStream 推给前端export async function POST(req: Request) { const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const graph buildResumeGraph(); const config { streamMode: updates as const }; const inputs { messages: [{ role: user, content: payload }] }; for await (const event of graph.stream(inputs, config)) { controller.enqueue(encoder.encode(data: ${JSON.stringify(event)}\n\n)); } controller.close(); }, }); return new Response(stream, { headers: { Content-Type: text/event-stream } }); }前端拿到事件后根据事件里包含的节点名更新对应的 UI 区块。比如收到evaluateResult事件就渲染评分卡片收到rewriteDelta事件就逐字追加正文内容。这里最容易忽略的是中断恢复。用户在流式输出过程中点击“停止”浏览器会 abort 请求但后端如果没做 AbortSignal 的监听LangGraph 的循环不会停下来Token 继续烧。我们在实现里把req.signal传给了graph.stream()的 config并在每个节点内部响应 abort 信号确保中断后立即停止当前节点的模型调用。4.2 人工确认节点Human-in-the-loop 不是可选项简历改写是一件“改错了用户要骂人”的事。所以我们坚决在“改进建议生成”和“简历重写”之间加了一个人工确认节点。流程是评测节点跑完生成建议报告Agent 先不继续执行重写而是把建议推给用户等用户勾选“接受这些修改建议”再触发重写分支。LangGraph.js 对这种情况有原生支持用interrupt()函数挂起图执行等待外部输入再继续。在 Next.js 里的实现方式是第一次请求跑完评测节点后把图的 checkpoint 存到数据库里返回给前端一个thread_id用户点击确认后前端拿着这个thread_id再发一次请求图从断点继续执行。实际效果非常明显加了人工确认之后用户对最终简历的满意度大幅提升而且因为用户参与了决策后面即使生成结果不理想用户也不会觉得是“AI 乱改”而是自己选择的结果。这个心理层面的价值被很多人低估了。4.3 超时、重试与幂等LLM 调用比数据库操作更容易“半途而废”简历 Agent 的每个节点都可能调用大模型而大模型接口最讨厌的地方在于超时是常态失败是常态部分成功更是常态。我们在 Node.js 层面给每次模型调用统一封装了三层策略第一层是超时控制。所有模型调用设置 30 秒硬超时流式模式下每 15 秒检查一次是否有新 token 到达没有就判定卡死。第二层是重试。对可重试的错误429、5xx、网络抖动做指数退避重试最多 3 次。这里有个关键细节重试必须带幂等键防止因为网络超时其实服务端已经完成生成重试时重复计费。第三层是全局熔断。连续失败超过 5 次就暂停该节点的调用直接返回一个错误节点事件给前端同时把状态里的errors字段写满便于排查。这三层策略最核心的价值不是提升成功率而是防止简历 Agent 在用户面前表现得“卡住不动”。宁可快速失败并提示用户重试也比让用户盯着一个转圈图标等三分钟强。5. 简历 Agent 怎么扛并发我在生产环境做的四件事5.1 分离“流式对话”和“后台任务”两套执行通道“AI Agent 怎么扛并发”这个问题的第一答案不是优化代码而是分流负载。简历 Agent 的请求分两种一种是交互式的用户在线等结果必须快另一种是离线的比如批量生成多个版本的简历用户不需要实时盯着。我们在生产环境里把这两类请求完全拆开了。交互式请求走 Next.js Route Handler直接调用 LangGraph 的流式接口单次请求的时长控制在 30 秒以内这样 Route Handler 的长连接压力可控。离线任务走独立的后台队列我们用的是 BullMQ Redis任务进来后立刻返回task_id前端轮询任务状态完成后展示结果。这样拆完之后并发压力最大的流式通道其实只承受“人在用鼠标点”的请求量单机扛几百 QPS 很轻松真正吃并发的是队列消费者而这部分可以横向扩容。5.2 模型供应商上做了两层缓存第一层是语义缓存很多团队忽略一个问题同一个用户的同一份简历反复点击生成结果其实差不多。我们观察到的数据是大约 40% 的请求是重复的评测请求用户改了又改 prompt但简历内容没变这 40% 在烧钱。我们加了语义缓存把用户输入 简历内容的嵌入向量存入 Redis新请求进来先算一次相似度如果相似度超过 0.95 且缓存未过期直接返回上一次的评估报告不再调用模型。具体实现用的是langchain的CacheBackedEmbeddings在 LangGraph 节点内部包一层缓存读取逻辑。实测缓存命中后评测节点的响应时间从 6 秒降到 200 毫秒成本下降非常明显。第二层是短时缓存同一个thread_id下 5 分钟内的重复节点输出直接复用防止前端重试导致的重复消费。5.3 背压与排队别让你的 LangGraph 节点同时打爆供应商模型供应商的限流是真实存在的而且你不一定知道账号的配额上限。我们遇到过一次线上的事故活动期间流量翻倍所有节点同时打模型 API瞬间触发 429导致整个 Agent 大面积失败。解决办法是全局限流器。我们在应用里内置了一个令牌桶以模型供应商的配额为上限每秒钟允许通过的模型调用数固定。超出额度的请求进入等待队列。这样即使前端流量突增打到供应商那里的请求量是恒定且可控的。令牌桶参数要根据供应商配额设置比如供应商允许每分钟 600 次调用我们就设定每秒 8 个令牌突发容量 20。宁可让用户多等 1 秒也不让供应商把请求拒了导致用户看错误提示。5.4 并发测试结果比较改造前后我们做了一次压测对比场景是 200 并发用户同时提交评测请求方案平均响应时间成功率模型调用次数纯 Route Handler 直连模型11.2s68%200 分流后台任务8.5s84%200 语义缓存3.1s92%118 全局限流 背压队列4.2s99.5%120从数据可以明显看到副作用最小的优化其实是缓存层它直接砍掉了 40% 的重复调用。限流器表面上是“限制”但因为降低了供应商拒绝率整体的成功率和平均响应时间反而变好了让用户少看一会儿转圈图标本身就是巨大的体验提升。6. 简历数据是敏感资产这套流程里的隐私与安全设计6.1 数据最小化只把本段需要的字段送给模型简历是一个人最完整的职业档案包含手机号、邮箱、公司名、项目细节甚至还有期望薪资。如果一个 Agent 为了重写“工作经历”把整份简历所有字段都塞给模型那不仅烧 Token更是在扩大数据暴露面。我们做了一个字段级过滤层根据当前节点的用途动态生成传给模型的 prompt 片段。比如重写“项目经验”节点只提取原简历里的项目列表 目标 JD 的关键词手机号、邮箱、住址这些和改写无关的字段一律不出现在 prompt 里。这个设计的额外好处是 Token 大幅下降。一个包含大量冗余字段的简历可能有 3000 token过滤后只需要 1000 token生成速度也更快。6.2 脱敏与白名单机制即使做了字段过滤仍有一些字段不可避免地要出现在模型调用中比如公司名称。对这些字段我们分成两档完全脱敏手机号、邮箱替换成占位符和明文白名单仅当节点明确需要时引用。替换后的简历内容进模型模型返回的结果再通过反向替换恢复真实联系方式。这个环节我们投入了专门的测试用例——故意构造包含各种格式的简历文本确保脱敏正则不会漏。因为一旦漏掉用户的真实手机号就会进入模型供应商的日志系统负面影响很难挽回。6.3 结果暂存与定时清理上传的简历文件和 Agent 生成的结果我们都不做永久存储。文件上传后存入临时目录设置 30 分钟过期生成的简历文档在用户下载后立刻从存储中删除只保留一份加密的归档用于审计。有人可能会说“用户下次还要编辑怎么办”我们的方案是用户主动点击“保存到我的简历库”才落库而且明确告知存储策略。这个确认动作不仅合规还能让用户对平台产生信任感。简历工具做的是一锤子买卖还是长期生意就看数据这一点处理得干不干净。7. 踩过的坑与收尾经验7.1 Token 消耗失控一次评测跑掉 100 万 Token项目早期我们犯过一个低级错误评测节点把整份简历 全部 JD 历史对话记录一股脑塞给模型而且要求模型“逐步思考”。一次评测下来输入输出加起来消耗了超过 10 万 token。10 个用户测试就直接烧到配额上限。后来我们做了三重约束一是所有节点输入都走字段过滤层二是移除“逐步思考”这类追求过程展示的指令改为直接输出结构化结果三是加了预算护栏每次请求前估算输入 token超过阈值直接拒绝并要求用户精简输入。改造后单次评测成本下降了 85%。7.2 模型幻觉导致的简历伪造这是简历 Agent 最致命的问题。测试阶段模型在改写“项目经验”时给用户凭空加了一段“主导了公司内部数据中台建设日处理数据量达到 TB 级”。用户的原简历根本没有这段经历。这种幻觉一旦流入最终简历轻则面试被戳穿重则影响用户职业诚信。除了前面提到的温控和事实一致性校验节点我们还在改写节点的 system prompt 里加了硬性规定原文写的是“你只能对用户提供的经历进行表述优化严禁添加新经历、新公司、新成果” 。事后统计这条约束让幻觉率从 12% 降到 2% 左右剩余的 2% 靠校验节点拦截。7.3 最后分享三个小技巧第一个技巧把评测报告作为重写节点的输入而不是原始简历。重写质量的提升主要来自目标明确评测报告里的“问题点 建议”恰好给了模型一个清晰的改写方向比直接丢简历让模型自由发挥稳定得多。第二个技巧多轮对话时合并压缩消息。用户和 Agent 对话超过 10 轮后历史消息会占用大量上下文窗口。我们在状态里维护一个“摘要字段”每 5 轮对话自动调用一次轻量模型压缩历史保证后续节点始终有足够的上下文空间。第三个技巧上线前准备一份 100 份脱敏简历的回归测试集。每次更新模型版本或修改 prompt 后全量跑一遍回归集对比每一份的评测分数和改写内容。这个习惯帮我们避免了好几次“改了一个 prompt 导致另外 10 个用户简历生成质量下降”的事故。把这些细节都做完之后整个简历工具的 AI Agent 才算真正从“能跑”变成了“能扛事”。按照我的经验Agent 项目的复杂度从来不在模型也不在花哨的框架而在于你有没有把边界条件、状态流转、异常恢复都处理干净。Next.js 和 LangGraph.js 提供的是一副好骨架但血肉还是得靠一层层把业务细节填进去这一点无论换什么技术栈都不会变。
返回列表