
先说结论这个项目做下来我的最大感受是——用 Next.js 做 AI Agent 应用的外壳其实不难难的是把 LangGraph.js 的状态流转和真实业务场景揉在一起。简历工具这个选题非常合适它既有明确的输入输出边界简历文本 目标岗位又有足够复杂的任务链路解析、诊断、建议、生成面试题不会像纯聊天机器人那样空泛也不会像单轮问答那样只是套壳。这篇文章我会把整个落地方案拆开讲从技术选型、Agent 工作流设计、前后端联调、成本控制到部署监控所有踩过的坑和实测有效的方法都会写出来。1. 项目全貌一个简历工具被拆成了哪几层1.1 为什么选 Next.js 做 AI 应用的外壳现在的 AI Agent 应用前端框架基本就三个选择Next.js、Nuxt、或者直接用纯 API 静态页。我最终选了 Next.js 的 App Router主要原因是它把“AI 应用需要的所有基础设施”都内置了服务端组件天然适合管理 API 密钥、Server Actions 可以直接调用后台逻辑、Route Handler 方便做 SSE 流式响应。尤其是流式输出AI Agent 的响应往往要几十秒如果用户盯着空白页面等体验是很差的Next.js 的 ReadableStream 支持可以很好地解决这个问题。一个比较隐蔽但很重要的点是部署模型。Vercel 默认环境是 Serverless 函数虽然冷启动有优化但 Chat 类应用需要长连接流式传输这会导致 Serverless 函数的执行时间限制和最大 duration 限制变成瓶颈。我实测下来如果 Agent 链路包含 3 次以上的 LLM 调用整体耗时会超过 30 秒这时候就需要考虑两个方向一是把 Agent 放到独立的 Node.js 服务里Next.js 只做展示层二是直接用 Vercel 的 Fluid compute 或者独立服务器跑 Next.js standalone 模式。这个我在后面部署章节详细说。1.2 为什么要用 LangGraph.js而不是直接 fetch 硬调很多人一开始图省事直接在前端写一个 fetch 调 LLM API加一个 while 循环判断要不要调工具这就是最原始的 ReAct Loop。我也这么干过问题在于——你无法控制 AI 的行为边界。LangGraph.js 的核心价值是它把 Agent 从“一大坨循环”变成了“一张可执行的状态图”。你可以显式定义哪些节点可以调用工具、哪些节点只做判断、节点之间是顺序执行还是条件跳转、某一步失败后是重试还是终止。对于简历工具这种业务逻辑清晰、步骤确定的场景状态图比自由 ReAct Loop 可控得多。你可以把 LangGraph.js 的 StateGraph 理解为“带红绿灯的导航地图”。纯 LLM Loop 是让司机自己选路状态图是把路线、岔路口、禁行路段全部规划好LLM 只能在指定路口做选择。这听起来好像更死板但在真实产品里客户要的不是 AI 的自由发挥而是稳定的结果。1.3 这个 Agent 到底能做什么我做的这个简历工具最终形态是一个 Web 应用用户粘贴简历文本和目标岗位 JDAgent 自动执行四个任务——解析简历结构、分析岗位匹配度、提出针对性修改建议、生成模拟面试问题。同时支持多轮对话追问比如“我的项目描述怎么写更有说服力”“如果转行前端我的后端经验有什么可迁移的点”。这个工具和普通 LLM 聊天最大的区别是它产出的不是一段泛泛而谈的文本而是一个可结构化的任务结果。简历解析结果是 JSON匹配度分析是评分 条目级差距列表修改建议是带原文引用的具体改动。这就要求 Agent 内部不是“用户问一句答一句”而是有任务队列、有中间状态、有结果校验的完整流程。LangGraph.js 在这套流程里正好充当了状态管理器的角色。2. Agent 工作流设计从套壳对话到可控的自动化流程2.1 状态图拆解五个节点和一个条件分支我先画出整个 Agent 的流程图不是文字描述是真实在 LangGraph.js 里定义的状态图。完整状态定义// graph-state.ts export const ResumeAgentState { resumeText: { value: string, reducer: (a, b) b ?? a }, jobDescription: { value: string, reducer: (a, b) b ?? a }, resumeData: { value: ResumeData | null, reducer: (a, b) b ?? a }, parsedRequirements: { value: Requirement[] | null, reducer: (a, b) b ?? a }, matchAnalysis: { value: MatchAnalysis | null, reducer: (a, b) b ?? a }, suggestions: { value: Suggestion[] | null, reducer: (a, b) b ?? a }, interviewQuestions: { value: InterviewQuestion[] | null, reducer: (a, b) b ?? a }, currentStep: { value: string, reducer: (a, b) b ?? a }, messages: { value: ChatMessage[], reducer: (a, b) [...a, ...b] } }五个节点分别是extractResume从原始文本里抽取结构化简历数据。这是整个链路的地基后面所有分析都依赖这里。parseRequirements把 JD 拆解成硬性条件、软性技能、加分项三类。analyzeMatch基于简历数据和 JD 要求做差距分析输出匹配评分和逐项结论。generateSuggestions根据差距分析生成修改建议每条建议都要有“原文定位”。generateInterviewPrep基于简历 差距生成模拟面试题难度分层。还有一个条件分支analyzeMatch 之后会判断——如果简历数据缺失严重比如用户只贴了 200 字干瘪的个人介绍就退回 extractResume 节点重新解析并且追加一条“请补充更多简历信息”的提示如果数据齐备才走 generateSuggestions。这个条件分支是 LangGraph 和普通链式调用最大的区别所在。链式调用是写死的 A → B → C如果 B 的结果不对C 只能硬着头皮算。状态图可以在每个节点之间加判断结果的置信度不够就回到之前的节点重跑。这相当于给 Agent 装了一个纠错回路。2.2 工具调用让 Agent 自己选择要不要解析LangGraph.js 的节点函数可以声明 toolsLLM 在运行到这个节点时会自行判断是否需要调用工具。简历解析这个节点里我注册了三个工具parseResumeText纯函数用正则把简历文本切成“基本信息、工作经历、教育背景、技能标签”等原始块。extractContactInfo从文本里提取电话、邮箱、GitHub 链接等联系方式。normalizeWorkExperience把乱序的工作经历按时间倒序重新排列输出 JSON。这里有一个非常关键的设计思路让 LLM 做语义理解让代码做确定性计算两者分工不要混用。简历里经常有奇怪的排版——全角符号、换行缺失、中英文混排、扫描件 OCR 出来的乱码。如果直接让 LLM 从零开始解析它会把乱码也“硬编”进 JSON如果全用正则解析职位名称、技能归类这类语义信息又完全识别不了。我的做法是先用正则做粗筛把结构化程度高的信息联系方式、时间、公司名确定性地提取出来再把剩余的非结构化文本交给 LLM 做二次整理。工具函数的长这样// tools/resume-tools.ts export async function parseResumeText(text: string) { const educationBlocks text.match(/教育经历|Education[\s\S]*?(?工作经历|Work Experience|$)/i) ?? []; const workBlocks text.match(/工作经历|Work Experience[\s\S]*?(?项目经历|Projects|$)/i) ?? []; return { educationBlocks: educationBlocks.map(block block.trim()), workBlocks: workBlocks.map(block block.trim()), skillsSection: text.match(/技能|Skills[\s\S]*$/i)?.[0] ?? }; }注意这里我没有让 LLM 自己去决定“要不要调用 parseResumeText”——它在 extractResume 这个节点里是强制调用的。原因是简历解析是整个 Agent 的地基这一步容不得 LLM 偷懒。LangGraph 允许你在节点里直接调用工具不一定要走 tool_calling 协议。这个细节很重要很多人会误以为 LangGraph 一定要靠 LLM 主动发起 tool call其实完全可以在节点代码里强制执行工具再把结果塞进 state。2.3 流式输出与中间态持久化LangGraph.js 的 checkpoint 机制是这个框架里最容易被低估的功能。默认情况下Agent 所有中间状态都保存在内存里进程一重启就没了。但简历工具是个 Web 应用用户可能会中途刷新页面、断开连接、隔半小时再回来继续。如果没有持久化用户每次刷新都要从头开始解析简历这个产品基本没法用。我用 Redis 做 checkpoint 存储LangGraph.js 提供了现成的 RedisCheckpointSaver。关键配置// agent/checkpoint.ts import { RedisCheckpointSaver } from langchain/langgraph-checkpoint-redis; const saver RedisCheckpointSaver.create({ client: redisClient, config: { cluster: false } }); export const agent createAgent().compile({ checkpointer: saver });用户每次请求都带一个threadId相当于给每次简历分析开了一条独立的“会话轨道”。如果用户刷新页面next 走同一个 threadIdAgent 会直接从上一个 checkpoint 恢复而不是重新跑。这个体验非常关键——我见过有团队做 AI 工具用户在等待分析结果时不小心点了一下刷新结果整个流程从头开始那个页面跳出率简直惨不忍睹。流式输出我用的是 LangGraph 的streamMode: messages它会把每个节点的中间输出实时 push 给前端。前端拿到中间态后可以渲染一个“任务进度步骤条”显示“正在解析简历 → 正在分析岗位要求 → 正在生成修改建议 → 正在准备面试题”而不是一条干巴巴的 loading 转圈。用户看到进度条会更容易等待这个微小的交互设计对转化率的帮助很直接。2.4 多轮对话的上下文组装策略简历工具不只是跑一遍流程就结束它还需要支持用户追问。比如用户看到修改建议后问“第二条建议能展开讲讲吗”“这条建议改完会不会影响我的关键词匹配得分”。这类追问如果用传统 Chat 模式会把整段简历 整段 JD 前面所有分析结果全部塞进 contextToken 消耗直接暴增。我的方案是LangGraph 的 state 里始终保留结构化结果matchAnalysis、suggestions 等对话节点构建 prompt 时不把原始 resumeText 全文塞进去而是只放 resumeData结构化摘要 用户最近一轮问题。这样做有三个好处一是 Token 成本大幅下降二是回答会基于之前的分析结论保持一致不会跑偏三是上下文窗口的占用率稳定后续轮次不会因为历史太长而截断。这里可以回答一个关于 token 的常见疑问AI Agent 里的 token 到底怎么算它不只是“输入 输出的字数”还包括 system prompt、历史消息、工具定义、每次调用的中间结果。简历工具这种场景一次完整分析的 token 消耗通常在 8000-15000 之间如果用的 Claude 或 GPT-4o 级别模型。如果不加结构化摘要而是每次都塞全文5 轮追问下来直接翻 5 倍。控制 token 就是控制成本这个问题在设计 graph state 的时候就该想清楚而不是等账单出来了再优化。3. 核心实现细节从 LangGraph 到 Next.js 的完整链路3.1 Server Actions 还是 Route HandlerNext.js 里接 Agent 后台有两种主流方式Server Actions 和 Route HandlerAPI 路由。这个二选一我之前纠结了很久最后两者都用了分别承担不同职责。Server Actions 用于页面初始化和表单提交类操作。比如用户第一次提交简历文本和 JD这个动作不需要流式返回Server Action 直接在服务端启动 Agent 流程把 threadId 写入 cookie页面就跳转到“分析中”状态。好处是代码量少、类型安全前后端共用 TS 类型定义而且不需要额外暴露 HTTP 接口。Route Handler 用于流式输出和长轮询。POST /api/agent/stream这个接口接收 threadId用 ReadableStream 把 Agent 产生的中间事件实时推给浏览器。两个通道分工明确写操作走 Server Action读操作走 Stream API。如果只用一个 Server Action 做流式返回会碰到 Next.js 对异步组件的限制非常难受。3.2 通过 SSE 推送中间状态给前端核心代码在 Route Handler 里我需要把 LangGraph 的事件流转换成浏览器可消费的 SSE 格式。LangGraph.js 的 stream 支持不同类型我用messages模式拿到每轮 LLM 调用的增量输出再包装成自定义事件// app/api/agent/stream/route.ts import { NextRequest } from next/server; import { resumeAgent } from /agent; import { RedisCheckpointSaver } from langchain/langgraph-checkpoint-redis; export async function POST(req: NextRequest) { const { threadId, userMessage } await req.json(); const checkpointer RedisCheckpointSaver.create({ client: redis }); const agent resumeAgent.compile({ checkpointer }); const encoder new TextEncoder(); const stream new ReadableStream({ async start(controller) { const config { configurable: { thread_id: threadId }, streamMode: messages }; const eventStream await agent.stream( { messages: [{ role: user, content: userMessage }] }, config ); let step ; for await (const event of eventStream) { if (event.event on_chat_model_stream) { const chunk event.data.chunk; const text chunk.content ?? ; controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: token, content: text })}\n\n)); } else if (event.event on_node_start) { step event.data.node; controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: step, step })}\n\n)); } } const finalState await agent.getState({ configurable: { thread_id: threadId } }); controller.enqueue(encoder.encode(data: ${JSON.stringify({ type: done, data: finalState.values })}\n\n)); controller.close(); } }); return new Response(stream, { headers: { Content-Type: text/event-stream, Cache-Control: no-cache } }); }前端在浏览器里用EventSource监听这个接口如果走 POST 就得用 fetch ReadableStream 解析 SSEEventSource 只支持 GET。我在前端封装了一个自定义 hookuseAgentStream它内部处理连接管理、重连、消息队列UI 层只关心step和token两种事件。实现时踩过的坑SSE 复杂的不是前端解析而是连接断开后的恢复。Next.js 自托管模式下如果 Nginx 或负载均衡器的 keep-alive 超时设置太短流式响应会被强制截断表现为前端只收到一半内容。这个问题排查了很久最后在 Nginx 配置里加了proxy_buffering off;和足够长的proxy_read_timeout才解决。如果你用 Vercel 托管基本没这个问题但自托管必须提前处理。3.3 解析 LLM 结构化输出的通用方案整个 Agent 链路里最容易出 bug 的不是流程编排而是LLM 输出不符合 JSON Schema。LangGraph 本身不帮你校验这一点它只是把 LLM 的字符串输出塞进 state。我的解决方式是写了一个统一的parseStructuredLLMOutput工具函数// utils/structured-output.ts export async function parseStructuredOutputT(raw: string, schema: z.ZodSchemaT): PromiseT { // 第一次尝试直接 JSON.parse try { return schema.parse(JSON.parse(raw)); } catch {} // 第二次尝试提取 markdown 代码块 const codeBlockMatch raw.match(/(?:json)?\s*([\s\S]*?)/); if (codeBlockMatch) { try { return schema.parse(JSON.parse(codeBlockMatch[1])); } catch {} } // 第三次尝试找第一个 { 到最后一个 } 之间的内容 const firstBrace raw.indexOf({); const lastBrace raw.lastIndexOf(}); if (firstBrace ! -1 lastBrace ! -1) { try { return schema.parse(JSON.parse(raw.slice(firstBrace, lastBrace 1))); } catch {} } throw new Error(LLM 输出无法解析为有效 JSON); }刚开始我只做了一次 JSON.parse结果生产环境报错率超过 20%全是因为 LLM 喜欢在 JSON 前后加解释性文字。后来加上 Zod schema 校验和三次兜底解析报错率降到了 2% 以下。在最终版本里我还给每个节点加了一个“重试一次”的机制如果解析失败把错误信息反馈给 LLM让它修正。实测确实能救回不少偶发错误。这一点对于任何做 AI Agent 的人来说都很有用——市面上大多数教程都会假设 LLM 一定会输出合法的 JSON但实际上LLM 输出结构化内容的稳定性远没有想象中好尤其当输出内容特别长比如 10 条修改建议或者中英文混排时很容易在末尾截断或插入多余字符。永远要假定 LLM 输出是不合法的然后做防护。3.4 手动中断与人工介入有些节点执行完结果不能直接进下一个节点需要用户确认。我们做的修改建议节点就是这样Agent 生成 5-6 条建议用户可以先看不清漂漂就要直接注入简历这个判断交给用户。LangGraph.js 的interrupt机制可以实现这个需求——节点执行完把控制权交回给用户agent 挂起等待用户输入而不是继续往下执行。// agent/nodes/generate-suggestions.ts import { interrupt } from langchain/langgraph; export const generateSuggestions async (state: ResumeAgentState) { const rawSuggestions await callLLM(generateSuggestionPrompt(state)); const suggestions await parseStructuredOutput(rawSuggestions, SuggestionSchema); return interrupt({ type: pending_user_review, data: suggestions }); };前端接收interrupt事件后弹出一个可编辑的列表让用户勾选/修改建议确认后把选择结果作为新 input 继续传入 agent。这在产品层面是一个很好的增强用户不再是被动接收 AI 结果而是可以控制最终输出的内容。这也是 AI Agent 和自动化脚本的本质区别——自动化是固定流程Agent 是有人参与决策的流程。使用 interrupt 之后要注意一个问题节点一旦被 interruptagent 的状态会停留在那个节点上用户的后继输入会被当作“对中断的响应”处理而不是新的一轮对话。所以在前端要区分“正常对话消息”和“中断响应消息”LangGraph 的Command(resume...)就是干这个的await agent.stream( new Command({ resume: userConfirmedSuggestions }), config );如果前端没做好这个区分用户中断后发一句“帮我解释一下第二条建议”Agent 会尝试把它当作用户确认传入 resume导致类型不匹配——这个问题我实际踩到过排错花了一个下午。4. 实操过程中的几个大坑与优化4.1 并发场景下的 Redis 连接管理在一开始写 Agent 的 checkpoint 配置时我在每个 request handler 里都创建了一个新的RedisCheckpointSaver结果上线后 Redis 连接数以肉眼可见的速度飙升。原因是 serverless 环境每次请求都会新建连接函数执行结束后连接没释放。后来我改成在模块加载时创建 Redis client 单例所有 checkpoint 共用// lib/redis-singleton.ts const globalForRedis globalThis as unknown as { redisClient?: RedisClientType }; export const redisClient globalForRedis.redisClient ?? createClient({ url: process.env.REDIS_URL }); if (process.env.NODE_ENV ! production) globalForRedis.redisClient redisClient; export const getCheckpointer () RedisCheckpointSaver.create({ client: redisClient, config: { cluster: false } });还有个坑是 Redis 超时时间的设置。LangGraph 的 checkpoint 默认可能会保留很久但对于简历工具这种场景用户一次完整分析流程通常在 10 分钟内结束中断后的会话超过 30 分钟基本也不会回来了。所以我把 Redis key 的 TTL 设为 30 分钟防止 Redis 里积攒大量无用的线程状态内存增长失控。4.2 长文本处理的 Token 控制简历文本通常 1000-3000 字JD 文本 500-1500 字单轮总输入原始文本可能就 4000 字。如果每个节点都把这些文本完整塞给 LLM一次完整分析会消耗大量 token。我的优化思路是分层处理解析阶段简历原始全文 JD 全文一次性塞给模型这是必须的无法避免。分析阶段只塞结构化后的 resumeData压缩掉原文中的空行、无关信息 JD 的要求列表。建议阶段只塞 resumeData 和 matchAnalysis 的差距条目不再重复 JD。对话阶段只塞结构化结果 用户最近一条消息。实际跑下来完整流程的 token 消耗从最初版本的 2 万左右降到 1.1 万左右少了接近一半。而且由于每个阶段的输入更聚焦输出质量反而更高——模型没有被冗余的原始文本干扰更容易聚焦在关键信息上。4.3 本地开发与生产环境的一致性问题LangGraph.js 这个库目前版本迭代比较快开发本地和生产环境的 Node 版本对不上会出怪问题。我在部署时遇到过langgraph和langchain/core版本冲突导致的运行时错误排查了半天才发现是 package.json 里 lock 文件的策略问题。这里建议一个简单有效的方法CI/CD 里固定 Node 版本为“18.17.0”且安装依赖时用npm ci而不是npm install确保 lock 文件的一致性。另外不太建议在生产环境直接跑 Worker 形态的 Next.js。简历 Agent 这种重计算场景建议用standalone输出模式部署next build然后直接跑node server.js。这个模式下可以方便地用自己的进程管理工具PM2 或 systemd做守护也更容易控制流式连接的并发数。如果你用 Vercel那更省心但自托管时 standalone 模式几乎是必须的。4.4 模型选择大模型还是本地模型做 AI Agent除了流程编排模型本身的选择也很影响最终效果。我用过几类模型做对比测试GPT-4o、Claude Sonnet、以及几个开源模型。最终的生产环境我是混用的简历解析和 JD 分析用 Claude/GPT 这类闭源模型因为语义理解更可靠。修改建议和面试题生成这类生成式任务可以用性价比更高的模型。小规模关键词匹配、格式校验这种任务可以用纯代码实现根本不需要 LLM。如果你考虑私有化部署那本地模型比如 Qwen、Llama 系列量化的在简历解析这种任务上也能做到可用的程度但需要做好 Prompt 的适配。我在本地实验时用 Rust 重新写了工具函数部分主要是文本切片和正则匹配速度确实比 JS 快很多而且没有 GC 卡顿问题——如果你对性能有极致要求可以考虑这个方向。但如果是小团队快速验证产品用 Node.js 实现完全够了不必为了性能过早引入 Rust 的多语言复杂链路。5. 从 demo 到可用的产品还需要做这些事5.1 数据脱敏与安全合规简历是高度隐私的个人数据处理时必须非常谨慎。我的做法是Agent 解析完成后立即从日志中过滤联系方式、身份证号等敏感信息前端展示时邮箱、手机号默认打码显示用户手动点击才能查看。LLM 的 API 请求日志中不记录 resumeText 原文只记录结构化后的 resumeData。不过注意调用第三方大模型 API 时你的数据会传到模型服务商的服务器如果有合规风险需要接入私有化部署的方案或签署数据协议。5.2 评测与回归Agent 的回放调试AI Agent 项目最容易忽略的就是“评测”。传统 Web 开发可以写单元测试但 Agent 是非确定性的同一个输入可能产出不同结果。我建了一个“回归样本库”收集了 20 份真实脱敏简历和对应的 JD每次改动 Prompt 或升级模型版本时自动跑一遍全部样本对比结构化输出的完整率字段缺失率、建议的有效率是否有人工打分、以及响应耗时的变化。这个回放调试非常实用。有一次我改了一个 Prompt结果所有样本的“技能标签”提取准确率下降了 15%如果没有回归测试这种退化在个别试例上根本看不出来。LangGraph.js 的 checkpoint 机制让回放变得很方便——你可以用同一 threadId 重新走一遍完整的 agent 执行记录检查每个节点的输入输出调试时非常有价值。5.3 日志、监控与成本Agent 的每个节点执行我都会打一条结构化日志包含节点名、耗时、token 数、是否重试。这些日志统一发到日志平台或直接在系统里做表格汇总。成本优化也依赖这些数据如果发现某个节点重试率特别高比如 parseJSON 连续失败 3 次就该考虑降低该节点的输出长度要求或改用更强模型如果某节点耗时过长就该优化输入或换更小的模型。监控指标我给两个必须看的一个是 agent 完成的成功率即走到最后一个节点且输出可用的比例一个是平均每用户的消费成本。前者决定产品体验后者决定商业模式能不能跑通。初期可以用一个简单的对象存储日志文件后面量大了再考虑接入外部监控。5.4 用这个项目作为一条 AI Agent 学习路线如果你正在摸索 AI Agent 该怎么入门这个项目的选型和链路是一个非常标准的学习样本。整理下路线大概是先理解 ReAct 循环和 LLM Tool Calling 的基础概念再看状态图StateGraph能解决哪些 ReAct 解决不好的问题包括可控性、条件分支、必要性接着实现一个最简单的单节点 Agent 跑通 Next.js 的流式输出再逐步增加节点、增加工具调用、增加中断和人工介入最后用 checkpoint 持久化把它变成一个“真正想长期用”的落地产品。架构演进到这个阶段经典的“AI Agent 主流架构”其实你已经踩到过一遍了从单轮 LLM 调用 → 工具增强 → 循环自主决策 → 有状态的工作流。当前业界讨论比较多的也是这个方向——Agent 不一定是完全自主的更多是在一个受控的状态机里做智能决策。再分享一点我个人的切身体会:做这类工具最容易低估的工作量不是 Agent 本身而是“让用户看得懂 Agent 在干什么”的过程。状态图给你的可控性不只是技术层面的好处——当你把“正在解析、正在分析、正在生成建议”这些步骤展示给用户时用户对产品的信任感会明显提升因为他们能感觉到这不是一个黑盒,而是一个在认真干活的系统。如果你正在做一个新的 AI 应用我建议从最开始的版本就保留中间状态的可视化这个投入的回报比后面所有锦上添花的功能都高。