先说结论:这个项目做下来,我的最大感受是——用 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 应用:用户粘贴简历文本和目标岗位 JD,Agent 自动执行四个任务——解析简历结构、分析岗位匹配度、提出针对性修改建议、生成模拟面试问题。同时支持多轮对话追问,比如“我的项目描述怎么写更有说服力”“如果转行前端,我的后端经验有什么可迁移的点”。
这个工具和普通 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 的节点函数可以声明 tools,LLM 在运行到这个节点时会自行判断是否需要调用工具。简历解析这个节点里我注册了三个工具:
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 走同一个 threadId,Agent 会直接从上一个 checkpoint 恢复,而不是重新跑。这个体验非常关键——我见过有团队做 AI 工具,用户在等待分析结果时不小心点了一下刷新,结果整个流程从头开始,那个页面跳出率简直惨不忍睹。
流式输出我用的是 LangGraph 的streamMode: "messages",它会把每个节点的中间输出实时 push 给前端。前端拿到中间态后,可以渲染一个“任务进度步骤条”:显示“正在解析简历 → 正在分析岗位要求 → 正在生成修改建议 → 正在准备面试题”,而不是一条干巴巴的 loading 转圈。用户看到进度条会更容易等待,这个微小的交互设计对转化率的帮助很直接。
2.4 多轮对话的上下文组装策略
简历工具不只是跑一遍流程就结束,它还需要支持用户追问。比如用户看到修改建议后问“第二条建议能展开讲讲吗”“这条建议改完会不会影响我的关键词匹配得分”。这类追问如果用传统 Chat 模式,会把整段简历 + 整段 JD + 前面所有分析结果全部塞进 context,Token 消耗直接暴增。
我的方案是: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 Handler?
Next.js 里接 Agent 后台有两种主流方式:Server Actions 和 Route Handler(API 路由)。这个二选一我之前纠结了很久,最后两者都用了,分别承担不同职责。
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 解析 SSE,EventSource 只支持 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 parseStructuredOutput<T>(raw: string, schema: z.ZodSchema<T>): Promise<T> { // 第一次尝试:直接 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 之后要注意一个问题:节点一旦被 interrupt,agent 的状态会停留在那个节点上,用户的后继输入会被当作“对中断的响应”处理,而不是新的一轮对话。所以在前端要区分“正常对话消息”和“中断响应消息”,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 应用,我建议从最开始的版本就保留中间状态的可视化,这个投入的回报比后面所有锦上添花的功能都高。