生产级 AI Agent 落地:用 Next.js + LangGraph.js 打造简历工具
我第一次真正把 AI Agent 用到业务系统里不是在公司的大项目上而是自己折腾的一个简历工具。用 Next.js 和 LangGraph.js 把它从想法做到生产环境前后花了两周多。过程中最大的体会是Agent 项目的难度从来不在调用一个大模型本身而在怎么把流程拆成可控的图、状态怎么在节点间安全流转以及怎么扛住真实用户的并发。这篇文章就是这次简历工具 AI Agent 的完整落地记录从工作流设计、核心代码、踩坑排错到部署选型都写清楚给准备用 AI Agent 做真实产品的全栈和前端同学一个参考。1. 为什么简历工具需要 LangGraph而不是几行 Prompt 搞定1.1 单次 Prompt 在简历场景的三个死穴先说结论如果你只是想在 Demo 里生成一份优化后的简历单次 Prompt 完全可以。但只要你打算给真实用户用很快就会撞墙。我最早的做法非常简单把简历原文和岗位描述拼到一段系统提示词里让模型输出一份改好的简历顺带在末尾附上匹配分数。Demo 演示时效果惊艳老板和用户都满意一上线问题全冒出来了。第一个问题是一锤子买卖。解析、匹配、改写全部挤在一次模型生成里用户想看先评估、满意再改的流程你根本没法做。你只能靠提示词让模型顺手评估一下可评估结果和改写结果混在一起用户无法单独对某个环节做反馈。产品上想设计成免费评估付费改写的漏斗单次 Prompt 直接把这个漏斗堵死了。第二个问题是无法追问。真实用户的简历千奇百怪有人没写项目经历有人没有教育经历有人把工作经历和技能混在一段话里。单次 Prompt 面对信息缺失时只有一个办法——让模型瞎猜。对工具类产品这是致命的因为它会一本正经地生成用户根本没做过的项目。我见过模型给一位刚毕业的同学补了一段三年大厂经历如果用户没检查就直接发出去了后果很严重。第三个问题是输入一变就翻车。不同简历格式差异太大你为了让单次 Prompt 覆盖所有情况只能不断堆 case今天是没有项目经历怎么办明天是技能写了二十项怎么办提示词越来越长效果却越来越不稳定。我后来意识到这根本不是提示词工程能解决的问题而是流程设计的问题。1.2 图编排把简历流程变成一条可解释的流水线单次 Prompt 就像让一个大厨从买菜、洗菜、切菜到掌勺全程一个人包办菜品好不好全看大厨当时的状态。而 LangGraph 的图编排就像一套后厨分工明确的流水线每个工位只干一件事原材料通过传菜口State在工位间流转哪个环节出问题就修哪个环节。具体到简历场景好处是立刻能感受到的每个节点的职责单一模型压力小提示词可以写得非常聚焦输出质量比一个大模型干所有事稳定得多。节点之间通过显式的状态对象传递数据某一步的结果可检查、可替换。比如解析结果不对我只需要换解析节点不会影响改写节点。条件边能根据分数走不同的路径匹配分高就走微调抛光匹配分低就走深度改写加建议而不是让模型在一条路径里同时完成所有事。支持中断和人工介入。信息缺失时就停下来问用户而不是替用户编造。我见过很多人把 Agent 理解成会调用工具的聊天机器人但在简历工具这个场景里Agent 真正的价值是把一个复杂任务编排成可控流程。LangGraph 给的不是一个魔法模型而是一整套状态机、任务图和检查点机制让你能用工程手段管理 AI 行为。1.3 为什么前端栈选了 Next.js LangGraph.js选型时我确实纠结过。AI 生态里 Python 的 LangGraph 资料最多社区也最活跃但我们的团队是 JS 全栈为了一个工具再拆出一个 Python 服务维护成本翻倍得不偿失。最后定下来 Next.js LangGraph.js有几个直接原因第一全栈统一。同一个 TypeScript 代码库里前端页面、API 路由、Agent 编排在一起共享类型定义。简历的结构化数据模型在前后端是同一套 TypeScript interface不用像以前那样维护两份 contract。第二Next.js 的 Route Handler 对流式响应支持得很顺手。AI Agent 运行时如果等全部结果返回再给前端看用户体验就是转圈 40 秒必须做流式输出。Next.js 的 Response 对象原生支持 ReadableStream跟前端 fetch 的流式读取天然配合比用 Server Action 处理流舒服得多。第三LangGraph.js 是官方 JavaScript 版本API 设计和 Python 版几乎对齐。这意味着我在 Python 社区查到的设计思路、术语、调优方案基本都能直接翻译成 TS 代码。团队没有 Python 背景也能快速上手。这里也顺便说一句如果你已经在生产环境跑 Python 服务那选择 LangGraph Python 版完全合理。但对我们这种前端起家、不想维护两套语言栈的团队LangGraph.js Next.js 就是当前综合成本最低的方案。技术选型没有绝对最优匹配团队现状才是关键。2. 先把工作流画清楚节点、状态与分支设计2.1 五个节点的职责与模型选型写代码之前我花了大半天时间把工作流画在纸上而不是直接上手写。这一步是我认为整个项目里最值得做的投入。最后定下来的 Agent 工作流包含五个核心节点节点输入输出推荐模型温度parseResume简历纯文本结构化简历对象技能、经历、教育等快模型如 gpt-4o-mini0analyzeJd岗位描述文本岗位要求要点列表快模型0matchScore结构化简历 岗位要求匹配分数、差距原因快模型0generateSuggestions差距原因优化建议列表强模型如 gpt-4o0.3rewriteResume原简历 建议 岗位信息改写后的 Markdown 简历强模型0.4模型分层的原则是解析、分析、打分类任务要稳定提取所以用快模型、温度设为 0并且用结构化输出约束格式生成改写类任务需要有洞察且有文采所以用强模型、温度可以稍微拉高一点。有人可能会问为什么把解析和分析 JD拆成两个节点不能合并吗理论上能合并但拆开以后每个提示词都短、聚焦、可单独调优。JD 分析结果还可以缓存——同一个岗位描述反复被用户提交时不用每次重新分析。这种为将来留余地的拆分在项目扩展期会很值。2.2 状态对象与 Reducer 设计让数据在节点间有序流动LangGraph 的核心抽象是 State。节点函数读 State、返回部分更新框架负责把返回值合并回 State。这个合并规则在 LangGraph.js 里通过 reducer 定义。我的状态定义长这样// lib/state.ts import { Annotation } from langchain/langgraph; export const ResumeAgentState Annotation.Root({ // 用户原始输入 resumeText: Annotationstring({ reducer: (_prev, next) next, }), jdText: Annotationstring({ reducer: (_prev, next) next, }), // 中间产物 parsedResume: AnnotationResumeData | null({ reducer: (_prev, next) next ?? _prev, }), jdAnalysis: AnnotationJdAnalysis | null({ reducer: (_prev, next) next ?? _prev, }), matchResult: AnnotationMatchResult | null({ reducer: (_prev, next) next ?? _prev, }), suggestions: AnnotationSuggestion[]({ reducer: (_prev, next) next ?? [], }), rewrittenResume: Annotationstring | null({ reducer: (_prev, next) next ?? _prev, }), // 对话与流程信息 messages: AnnotationBaseMessage[]({ reducer: (prev, next) [...(prev ?? []), ...(next ?? [])], }), needsUserInput: Annotationboolean({ reducer: (_prev, next) next ?? false, }), });这里的 reducer 语义很关键。resumeText和jdText是覆盖语义新值直接取代旧值parsedResume用的是非空才覆盖messages是拼接语义每次新增的消息追加到数组末尾。为什么要区分这些语义因为 LangGraph 的 reducer 定义了当多个节点并发写同一个字段时状态如何合并。虽然我们这个工作流目前是单向串行的但未来如果要做并行子图比如同时跑多个分析节点reducer 的语义就决定了数据不会互相覆盖。另一个经验是状态里所有字段最好都是可序列化的 JSON 或字符串数组。因为 LangGraph 的 Checkpointer 会把状态持久化到 Redis 或 Postgres如果你塞入一个包含循环引用的对象保存时会直接报错。我一开始在状态里放了一个 LLM 实例结果每次存检查点都失败排查了半天才发现这个问题。2.3 条件边什么情况下该问用户什么情况下直接改条件边是 LangGraph 真正体现智能的地方。我的工作流里有一条核心分支根据匹配分数走不同路径。// lib/graph.ts builder.addConditionalEdges(matchScore, (state) { const score state.matchResult?.score ?? 0; if (score 75) return rewriteResume; if (score 50) return generateSuggestions; return requestMissingInfo; });这个分支解决了一个非常实际的产品问题匹配度高和匹配度低的用户需要的帮助完全不同。匹配度高的人只需要润色措辞、突出亮点匹配度中等的人需要针对性补强建议匹配度低的人往往问题不在措辞而在经历本身与岗位不匹配这时候最好的做法是告诉用户差距在哪里并提供补齐路径而不是硬着头皮帮他编一段不存在的经历。requestMissingInfo节点我用了中断机制也就是 Human-in-the-LoopAgent 停下来向用户提问等待用户补充数据后继续奔跑。这个能力在第五节展开先记住一个结论一个真正可用的简历 Agent必须学会不知道就停下来问这是它与提示词套壳产品的分水岭。3. 代码落地从空项目到跑通流式 Agent3.1 环境准备与依赖清单创建项目我用的是 Next.js 官方脚手架App Router 模式npx create-next-applatest resume-agent --ts --app --tailwind --eslint然后安装需要的依赖npm install langchain/langgraph langchain/openai zod我用的版本是LangGraph.js0.2.x、LangChain OpenAI0.3.x、Next.js15.x。这三个版本的 API 在本文写作时是兼容的如果你后续安装的版本更新个别 API 可能有变化但核心概念不变。环境变量配置在.env.localOPENAI_API_KEYsk-xxxx # 可选的 LangSmith 追踪配置后面会讲 LANGSMITH_TRACINGtrue LANGSMITH_API_KEYlsv2-xxxx一个容易踩的坑Next.js 会尝试在构建时读取环境变量如果你的.env.local没有正确加载API Route 运行时拿到的process.env.OPENAI_API_KEY会是undefined。建议在next.config.js里不要做任何特殊处理直接让运行时读取系统环境变量即可本地开发用.env.local就够了。3.2 核心实现节点函数与图编译每个节点就是一个读 State、返回更新的异步函数。先看解析节点// lib/nodes.ts import { ChatOpenAI } from langchain/openai; import { z } from zod; const ResumeSchema z.object({ name: z.string().nullable(), yearsOfExperience: z.number(), skills: z.array(z.string()), projects: z.array(z.object({ title: z.string(), description: z.string(), achievements: z.array(z.string()).optional(), })), education: z.array(z.object({ school: z.string(), major: z.string(), degree: z.string(), })), missingFields: z.array(z.string()), }); export async function parseResumeNode(state: typeof ResumeAgentState.State) { const model new ChatOpenAI({ model: gpt-4o-mini, temperature: 0, }).withStructuredOutput(ResumeSchema); const parsed await model.invoke( 请从以下简历文本中提取结构化信息。如果某个字段无法确认请标记为 null。如果整体缺失某类信息请添加到 missingFields。\n\n简历文本\n${state.resumeText} ); return { parsedResume: parsed }; }这里有两个关键点。第一withStructuredOutput配合 zod schema可以让模型严格按 schema 输出避免解析结果字段格式每次都不一样的经典问题。第二我在 schema 里故意加了一个missingFields字段让模型主动标记缺失信息这个设计直接支撑了后面的 Human-in-the-Loop 分支。改写节点的提示词是最讲究的export async function rewriteResumeNode(state: typeof ResumeAgentState.State) { const model new ChatOpenAI({ model: gpt-4o, temperature: 0.4, maxTokens: 2000, }); const response await model.invoke([ { role: system, content: 你是一位资深的简历优化专家。你的任务是在保留候选人真实经历的前提下优化简历表达突出与目标岗位的匹配度。禁止虚构任何经历和数据。输出格式为 Markdown。, }, { role: user, content: 目标岗位描述\n${state.jdText}\n\n原始简历\n${state.resumeText}\n\n匹配评估\n${JSON.stringify(state.matchResult)}\n\n优化建议\n${JSON.stringify(state.suggestions)}\n\n请基于以上信息生成一份优化后的简历。, }, ]); return { rewrittenResume: String(response.content) }; }然后把这些节点组装成图并编译// lib/graph.ts import { StateGraph, START, END, MemorySaver } from langchain/langgraph; import { ResumeAgentState } from ./state; import { parseResumeNode, analyzeJdNode, matchScoreNode, generateSuggestionsNode, rewriteResumeNode, requestMissingInfoNode } from ./nodes; const builder new StateGraph(ResumeAgentState) .addNode(parseResume, parseResumeNode) .addNode(analyzeJd, analyzeJdNode) .addNode(matchScore, matchScoreNode) .addNode(generateSuggestions, generateSuggestionsNode) .addNode(rewriteResume, rewriteResumeNode) .addNode(requestMissingInfo, requestMissingInfoNode); // 主线解析 - 分析 JD - 打分 builder.addEdge(START, parseResume); builder.addEdge(parseResume, analyzeJd); builder.addEdge(analyzeJd, matchScore); // 条件分支根据得分走不同路径 builder.addConditionalEdges(matchScore, (state) { const score state.matchResult?.score ?? 0; if (score 75) return rewriteResume; if (score 50) return generateSuggestions; return requestMissingInfo; }); // 结束路径 builder.addEdge(generateSuggestions, rewriteResume); builder.addEdge(rewriteResume, END); builder.addEdge(requestMissingInfo, END); // 注意这一步先用了 MemorySaver生产环境需要换 Redis/Postgres export const resumeGraph builder.compile({ checkpointer: new MemorySaver(), });编译出来的resumeGraph就是最终可执行对象。第一次跑通时我直接在 Node 脚本里用invoke做测试确认整条链路工作正常后才接入 Next.js 接口层。3.3 在 Next.js API Route 里做流式输出接入 Next.js 时我面临一个选择Server Action 还是 Route Handler。结论很明确流式输出场景用 Route Handler。Server Action 虽然能处理表单提交但对 SSEServer-Sent Events这种逐段推送的支持不够直接而且我不想让前端页面和 Agent 接口强耦合。我的 API Route 实现如下// app/api/resume-agent/route.ts import { NextRequest } from next/server; import { resumeGraph } from /lib/graph; import { randomUUID } from crypto; export async function POST(req: NextRequest) { const { resumeText, jdText, threadId } await req.json(); // 如果没有传入 threadId就生成一个新的 const requestThreadId threadId ?? randomUUID(); const config { configurable: { thread_id: requestThreadId }, }; const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { try { // 用 streamEvents 监听 LLM token 级事件 const events resumeGraph.streamEvents( { resumeText, jdText }, { version: v2, ...config } ); for await (const event of events) { // 只把 LLM 文本 token 推给前端 if (event.event on_chat_model_stream event.data?.chunk) { const chunkText event.data.chunk.text; if (chunkText) { controller.enqueue( encoder.encode( data: ${JSON.stringify({ type: token, content: chunkText })}\n\n ) ); } } // 节点结束事件方便前端判断流程进度 if (event.event on_chain_end event.name) { controller.enqueue( encoder.encode( data: ${JSON.stringify({ type: node_end, node: event.name })}\n\n ) ); } } controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: done, threadId: requestThreadId })}\n\n)); } catch (error) { controller.enqueue( encoder.encode( data: ${JSON.stringify({ type: error, message: String(error) })}\n\n ) ); } finally { controller.close(); } }, }); return new Response(stream, { headers: { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, }, }); }前端读取这段 SSE 流的代码也不复杂// app/agent-runner.ts const res await fetch(/api/resume-agent, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ resumeText, jdText, threadId: currentThreadId }), }); const reader res.body?.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); // 按行解析 SSE 数据更新页面对话流 const lines chunk.split(\n).filter((line) line.startsWith(data: )); for (const line of lines) { const payload JSON.parse(line.replace(data: , )); if (payload.type token) { appendTokenToUI(payload.content); } else if (payload.type node_end) { updateAgentProgress(payload.node); } else if (payload.type error) { showError(payload.message); } } }这里有一个细节streamEvents是 LangGraph.js 里非常实用的调试和流式工具。它有两种事件粒度v1是老版本兼容方式v2是当前推荐方式事件结构更清晰。在v2下LLM 模型生成 token 会触发on_chat_model_stream每个节点开始/结束会触发on_chain_start/on_chain_end。利用这些事件前端不仅能看到打字机效果还能显示正在解析简历正在匹配岗位这类进度提示体验上比黑盒等待好太多。关于threadId我的规范是前端首次进入页面生成一个 UUID 并存在 sessionStorage 里后续请求带同一个 ID。这样同一个用户的同一次会话可以复用状态不同用户、不同任务则各自隔离。注意thread_id是 LangGraph Checkpointer 恢复历史状态的钥匙如果所有用户共用一个 ID状态就全乱了这个问题后面在状态污染那一节会展开讲。3.4 前端交互设计要点前端页面本身不复杂就是一个自定义的双栏界面左侧粘贴简历和岗位描述右侧实时展示 Agent 工作流进度和结果。交互设计上我有三个心得第一整个 Agent 流程可能持续 30 到 60 秒如果只给一个 loading 转圈用户早就关页面了。我用node_end事件驱动一个步骤指示器用户能看到解析中 → 分析岗位 → 匹配打分 → 生成建议 → 改写简历这五个阶段的推进过程。这个设计对用户耐心帮助极大。第二结果分块展示。匹配分用一个大数字卡片突出显示优化建议用可勾选的清单呈现用户可以选择采纳哪几条改写后的简历放在最底部用 Markdown 渲染。分块展示的灵感来自 Debugger 的分步观察——把 Agent 的中间输出暴露给用户用户才会信任这个工具。第三用户中断处理。如果用户在流式输出过程中点了停止前端会 abort fetch但后端 Agent 可能还在继续跑。这种情况下我需要根据threadId查询当前状态决定是放弃还是续跑。落地版我做了简单处理前端 abort 后 30 秒内若用户再次提交同一次会话后端会基于已有状态继续而不是从头开始。这个功能完全依赖 Checkpointer也是我在这个项目里决定无论如何都要用状态持久化的根本原因。4. 实测遇到的四个坑并发、超时、Token 与状态污染4.1 Agent 怎么扛并发先想清楚状态存哪AI Agent 怎么扛并发是我在热搜里见到的最高频问题也是我实测中踩得最深的一个坑。第一版上线时我把resumeGraph做成了模块级单例checkpointer 用的MemorySaver然后直接部署到 Vercel。测试只有两三个用户时一切正常放到线上后从第三个并发请求开始出现各种诡异问题用户的简历内容串了、有人收到别人的匹配报告、还有人报错state not found。排查链路是这样的先看日志发现报错集中在MemorySaver的 checkpoint 读取再检查代码确认graph是单例、thread_id是前端传的最后才意识到问题根源MemorySaver是实例内存存储而 serverless 环境下每个请求可能由不同实例处理MemorySaver里的状态根本不在同一个进程里共享。这只是第一层。第二层问题是即使所有请求落在同一个实例上如果两个用户恰好被分配了同一个thread_id比如前端把thread_id写死成demo他们的状态就会被 LangGraph 的 Checkpointer 合并到同一个线程里A 用户的中间结果覆盖 B 用户的这就是内容串了的直接原因。正确的并发模型是三层第一Agent 本身在无状态情况下是天然支持并发的——每个 HTTP 请求调用resumeGraph.invoke就是一次独立执行。真正有状态的是 Checkpointer它必须按thread_id隔离且存储必须外置。第二在生产环境把MemorySaver换成 Redis 或 Postgres 实现。我用的是 Redis// lib/checkpointer.ts import { RedisSaver } from langchain/langgraph/checkpoint/redis; import { createClient } from redis; const client createClient({ url: process.env.REDIS_URL }); await client.connect(); export const checkpointer new RedisSaver(client);然后把graph.ts里的MemorySaver替换成这个 RedisSaver。这样一来无论请求落在哪个 serverless 实例都能读到同一份状态。第三真正的并发瓶颈不在 LangGraph 本身而在底层模型 API 的速率限制。我压测时发现当并发请求到 10 个以上时OpenAI 接口开始报 429。我的处理方案是引入p-limit做并发控制把同时打到模型 API 的请求数限制在 5 个以内超出的排队等待import pLimit from p-limit; const limit pLimit(5); export async function runResumeAgent(input, config) { return limit(() resumeGraph.invoke(input, config)); }实测数据调整前 40 个用户同时触发时大约 70% 的请求超时或失败调整后95% 的请求在 15 秒内返回第一个 token基本达到可用标准。提示如果你的 Agent 流程是全异步后台任务可以用消息队列如 BullMQ Redis做削峰比我这里直接限流更稳妥。但简历工具需要实时交互所以选择了限流 流式返回这个折中方案。4.2 流式超时不要让用户在 20 秒里盯着空白简历 Agent 最影响体验的指标不是总响应时长而是首 token 时间——用户点击提交后到屏幕上出现第一个字的时间。第一版实测首 token 平均要 8 到 10 秒如果是 gpt-4o 生成改写内容受排队影响甚至到 20 秒以上。用户盯着一片空白几乎都会关掉页面。问题出在流程设计上所有节点是串行的parseResume、analyzeJd、matchScore三个节点加起来耗时约 5 秒这期间没有任何 LLM 输出被推送到前端。用户的感觉就是死了一样。我做了两个优化。第一个优化是先给结论再给细节。我在streamEvents里对节点结束事件也做了推送前端拿到on_chain_end后立即更新步骤指示器。这样用户在 2 秒内就能看到正在解析简历的反馈而不是空白。体验立刻改善。第二个优化是解析和初始化用快模型。parseResume和analyzeJd用的gpt-4o-mini响应很快1 到 2 秒真正拖时间的是rewriteResume的gpt-4o生成。所以我把前端流程调整成先推送解析完成提示紧接着就用流式输出开始展示匹配结果和改写内容——用户注意力被逐步分流等待的痛苦感就小了很多。还有一个技术细节Vercel serverless 函数默认执行时长有限制免费版只有 10 秒Pro 版最长 300 秒。我的完整 Agent 流程更接近 30 到 60 秒直接跑 API Route 很容易超时。最终我放弃了纯 Serverless 部署把 Agent 这块拆成了独立的长驻 Node 服务Next.js 只负责页面部分。这部分在第五节部署选型里详细对比。4.3 Token 失控简历太长、输出太长怎么办简历场景有个天然麻烦输入长、输出也长。我实测的典型数据是内容Token 数约原始简历文本3000 - 4000岗位描述800 - 1200系统提示词600 - 1000解析结果800 - 1200优化建议800 - 1500改写后简历1500 - 2500单次完整流程大概消耗 7000 到 10000 token。问题不是超出上下文窗口gpt-4o 有 128k而是成本失控和响应变慢。我的解决思路是压缩输入、限制输出。压缩输入方面我在parseResume节点得到结构化结果后后续节点匹配、改写都不再传原始简历全文而是传结构化的精简字段。比如改写节点拿到的不是 4000 token 的原文而是几百 token 的结构化要点加原文的分段索引。这个设计让后续节点输入量减少 60% 以上。限制输出方面改写节点设置了maxTokens: 2000确保单次生成不会无限扩散结构化输出用 zod schema 限制字段和长度避免模型在suggestions字段里生成一篇 2000 字的小作文。成本实测用gpt-4o-mini做解析和匹配单次成本约 0.0005 美元用gpt-4o做改写单次约 0.04 美元整个流程加流式重试单用户单次任务成本约 0.06 到 0.1 美元。这在工具类产品里可以接受但如果用户频繁重试成本会快速上涨。我加了两个兜底同一用户同一岗位 24 小时内改写结果缓存对每用户设置小时级调用频率限制。4.4 状态污染两个用户看到了彼此的简历这是我在压测中遇到的另一个诡异问题也是排查最久的一次。现象用户在 A 会话里提交的简历出现在 B 会话的结果中。第一反应是 Redis key 冲突但检查后发现 key 都带了 UUID并没有冲突。接着怀疑thread_id生成逻辑打印出来也没有重复。最终定位到两个原因。第一个原因是前端在重新开始按钮上复用了同一个threadId。用户在完成一次任务后点击再来一次前端没有重置threadId新任务的所有状态都写进了旧线程。LangGraph 的 Checkpointer 会根据已有状态继续累积旧线程里的parsedResume不是被覆盖而是因为 reducer 用了next ?? prev语义当新任务尚未生成解析结果时读到的还是旧任务的解析结果。于是新旧简历内容混在一起。修复方式每次新任务必须生成新的threadId只有继续当前任务时才复用threadId。我在前端加了一个明确的taskId生命周期由任务列表管理与应用会话解耦。第二个原因是thread_id没有绑定用户身份。如果两个用户被分配了同一个threadId概率虽低但在压测时由于randomUUID的种子问题出现过A 用户的输入就会覆盖 B 用户。修复方案是在后端强制用用户身份 任务 ID复合线程 IDconst threadId ${userId}:${taskId};同时在后端入口处校验请求的userId与登录态一致防止前端伪造。提示Agent 项目的状态隔离是数据安全的基础。凡是有 Checkpointer 的场景线程 ID 必须满足可枚举、可归属、不可跨用户猜测三个条件。最简单的做法就是userId:taskId复合格式。这次排查也让我理解了 Reducer 的语义在实际运行中的重要性。如果你发现第二次跑任务结果包含第一次的数据先检查是不是thread_id没变再检查状态字段的 reducer 是不是写成了保留旧值。前者是外部原因后者是内部原因但症状很容易混淆。5. 进阶Human-in-the-Loop、部署与可观测性5.1 让 Agent 学会卡住问人简历缺信息时的追问简历 Agent 能不能像人关键看它会不会在信息不足时停下来问而不是硬编。LangGraph 的interrupt机制让这件事做得很干净。我的requestMissingInfo节点这样实现// lib/nodes.ts import { interrupt } from langchain/langgraph; export async function requestMissingInfoNode(state: typeof ResumeAgentState.State) { const missingFields state.parsedResume?.missingFields ?? []; const response interrupt({ question: 简历信息不完整需要你补充以下内容, missingFields, }); // 用户补充后把新信息写入状态 return { resumeText: ${state.resumeText}\n\n【用户补充信息】\n${JSON.stringify(response)}, needsUserInput: false, }; }运行逻辑是这样的当matchScore节点判断得分低于 50 且missingFields不为空时流程走到requestMissingInfo。此时interrupt会让图暂停执行并把预设问题返回给调用方。前端收到这个带__interrupt__标记的事件后弹出表单让用户补充缺失信息用户提交后后端带着用户输入再次调用invoke图从requestMissingInfo节点继续往下走。这一段逻辑我单独拿出来讲是因为它彻底改变了这个工具的产品形态。之前信息不全就硬编的问题靠这个机制从根上解决了。对工具类 Agent 来说Human-in-the-Loop 不是可选项而是刚需——它决定用户对工具是信任还是提心吊胆。有一个实现细节容易踩坑interrupt之后图的状态已经通过 Checkpointer 持久化了但你再次invoke时不能重新传resumeText和jdText否则状态会被重置。正确的做法是只传需要更新的字段比如messages或补充信息让 Checkpointer 恢复完整状态后继续执行。我在这里卡了半小时给后来者提个醒。5.2 部署选型Serverless 还是长驻服务这个项目的部署选型我前前后后换了两轮最后的结论很明确有状态 Agent 尽量不要纯 Serverless。方案首 Token 延迟长任务支持状态持久化成本运维复杂度Vercel Serverless RedisSaver有冷启动波动大受函数时长限制依赖外部 Redis低低长驻 Node 服务 Postgres/RedisSaver稳定秒级无限制本库/外部存储中中消息队列 Worker不适用实时场景完全异步强中高高我的最终方案是Next.js 前端部署在 VercelAgent 运行时拆出来部署在长驻 Node 服务上通过NEXT_PUBLIC_AGENT_API_URL环境变量指过去。两个服务共享同一个 TypeScript 代码库只是入口不同。这样既保住了前端部署的便利性又解决了 serverless 函数时长限制和后端状态不可控的问题。实际压测对比同一 Redis60 秒内 20 个请求指标Vercel Serverless长驻 Node冷启动首请求延迟5 - 12 秒无冷启动平均首 Token 时间约 6 秒约 2.5 秒完整流程平均耗时45 秒偶发超时32 秒P95 完整流程耗时70 秒45 秒这组数据足以说明问题。如果你只是做一个搁在 Hacker News 上的 DemoServerless 够用如果准备给真实用户用我建议把 Agent 部分独立部署长驻服务别让平台限制成为交互瓶颈。5.3 可观测性与成本控制Agent 项目比普通 API 项目更需要可观测性因为一次请求会打多个模型、跑多个节点、消耗几万 token出了问题如果看不到中间过程排错会非常痛苦。我接入了 LangSmith只用两步LANGSMITH_TRACINGtrue LANGSMITH_API_KEYlsv2-xxx LANGCHAIN_PROJECTresume-agent接入后LangSmith 会自动记录每次运行的所有节点、每次 LLM 调用的输入输出、token 消耗和延迟。我印象最深的一次排错是在 LangSmith 里看到一个节点调用了 18 次 LLM点进去发现是重试逻辑没有设置最大次数接口偶发 500 导致无限循环。这种问题如果没有追踪靠日志很难定位。除了 LangSmith我还在每个节点函数里加了轻量级统计// lib/telemetry.ts let totalTokens 0; export function recordUsage(usage: { inputTokens?: number; outputTokens?: number }) { totalTokens (usage.inputTokens ?? 0) (usage.outputTokens ?? 0); }每次任务结束后把用量和耗时写入 Redis 的统计 key定时跑一个聚合任务就能看到每天的成本和平均耗时的趋势。当单日成本超过某个阈值时我会收到告警。成本控制还有一个重要策略模型分层。解析、分析、匹配这类重理解、轻生成的任务我一律用gpt-4o-mini只有最终改写才用gpt-4o。单次流程成本从最初全用gpt-4o的约 0.15 美元降到了约 0.06 美元首 Token 时间也缩短了一半。这个优化思路对所有 Agent 项目都适用——把便宜模型用在信息提取和判断上把贵模型留在最终内容生成上。做完整套落地我自己最大的收获其实是状态设计。Agent 的核心从来不是某个聪明的模型而是数据如何在节点间安全、有序、可恢复地流动。LangGraph 的 Checkpointer、Reducer、interrupt这些原语把可控变成了工程上可实现的指标。这个项目做完以后我的体感是AI Agent 落地难的从来不是模型能力而是工程化能力——状态隔离、并发控制、异常恢复、成本规划每一样都比提示词更考验人。如果你也准备做类似工具建议先把状态设计和工作流图画清楚再碰代码。