上个月我把一个内部简历工具从“表单 + 模板渲染”重写成了“AI Agent 多轮对话式生成”,技术栈选的 Next.js + LangGraph.js。改完之后我最大的感受是:用户在使用这类工具时,根本不按表单逻辑出牌——有人一上来就甩一大段零散经历,有人半天憋不出一句完整描述,还有人中途突然想起“哎我还有个开源项目忘了写”。
这种碎片化、多轮、带条件分支的交互,用传统表单根本兜不住,但交给一个状态图驱动的 AI Agent 来做,反而变得很自然。这篇文章不绕弯子,直接讲我们是怎么用 Next.js 做服务端、用 LangGraph.js 编排 Agent 状态图,把一个简历生成工具完整落地的。内容会覆盖场景分析、状态图设计、SSE 流式接入、并发压力下的无状态化改造,以及真机跑起来之后踩到的几个坑。适合已经写过一些 LLM 调用、想往 Agent 架构上走的开发者,也适合正在纠结“我这个需求到底要不要上 Agent”的人。
1. 简历生成这件事,为什么非得用 Agent
先说结论:不是所有 CRUD 场景都值得上 Agent,但简历生成这个场景,恰好踩中了 Agent 的舒适区。拆开看就三件事——信息收集、结构化输出、格式渲染,每一件单独拎出来都不复杂,可一旦用户真实参与进来,复杂度就上来了。
1.1 传统表单交互的痛:信息总是碎片化的
绝大多数简历工具的交互方式是一张长长的表单:姓名、电话、教育背景、工作经历、项目经历、技能标签、自我评价……每项一个输入框。听起来没毛病,但用户的真实行为是:先写公司名和职位,然后卡在“项目描述怎么写”上想半天;或者先贴一段过去简历的纯文本,再一点点拆。更常见的是,用户根本不想一次性填完,他今天想到什么补什么。
表单本身无法处理这种“半成品状态”。你要么做分步表单,把流程固定死;要么做一个巨大的自由文本输入框,让用户自己整理。前者把用户当机器人,后者把整理负担全甩给用户。
AI Agent 的多轮对话天然适合这种场景。用户说“我在字节干了三年前端”,Agent 可以继续追问“主要做什么业务方向?有没有带过团队?”。用户突然贴一段 GitHub 项目描述,Agent 能自动识别出这是项目经历,问一句“这个项目你负责的是哪部分”然后收进对应字段。整个交互是收敛的,而不是让用户面对一张 20 个空格的表单发愁。
1.2 Agent 和普通 LLM 直连的差别在哪
有人会说:那不就是要一个聊天机器人吗?我直接用 ChatOpenAI 的 API,把历史消息往 system prompt 里一塞,让 AI 回答问题不就完了?
能跑,但你会发现纯 LLM 直连有几个问题很难处理。第一,它没有“确定的出口”,用户信息收集齐了,模型可能还在闲聊,不往下走。第二,它无法稳定地执行“渲染成模板”这种动作——让 LLM 直接输出 HTML 不是不行,但输出结果不可控,模板样式稍微复杂一点,它就会给你整出各种奇怪的 CSS。第三,多轮对话的状态全靠消息数组硬扛,如果中途要插入一个“信息完整度校验”这种确定性的逻辑,就只能靠提示词碰运气。
Agent 解决的是这个分层问题:LLM 只负责“理解和生成文本”这种不精确的部分;而“信息是否齐全”“要不要进入渲染环节”“调用哪个模板”这类确定性的规则,交给代码节点和条件边来管。这整套编排能力,正是 LangGraph.js 提供的东西。
1.3 为什么不选 Python 版 LangGraph 而选 LangGraph.js
如果团队里没有 Python 服务,完全没必要为了一个简历工具再造一个 Python 后端。LangGraph.js 是 LangGraph 的 TypeScript/JavaScript 版本,API 设计和 Python 版基本对齐,核心概念就是 StateGraph、节点、边、条件边。更重要的是,我们整个项目就是 Next.js,前后端同一门语言,Agent 的状态定义、工具函数、LLM 调用逻辑可以全部放在一个包里维护,类型还能共享,这比拆成 Python 服务和 Node 前端两个项目省心太多。
顺带说一句,LangGraph.js 对 TypeScript 的类型推导做得不错,State 的字段类型、节点返回值的结构、条件边的返回值都会有比较明确的类型提示,对于习惯了前端工程化的团队来说,上手成本其实比 Python 版更低。
2. 把简历生产画成状态图:LangGraph.js 的节点与边
LangGraph 的核心思想是把 Agent 的每一次运行定义成一个“图”:节点是具体的处理步骤,边是步骤之间的流转,条件边负责根据当前状态决定下一步往哪走。简历工具这个场景,图结构不复杂,但设计得好不好,直接决定了后面迭代顺不顺手。
2.1 先定义 Agent 的记忆与状态:ResumeAgentState
在写任何节点之前,先把状态结构定义清楚。这相当于给 Agent 定了一套“工作记忆”,所有节点读写都基于这份状态。我们设计的状态长这样:
import { Annotation } from "@langchain/langgraph"; import { BaseMessage } from "@langchain/core/messages"; export interface UserProfile { name?: string; yearsOfExperience?: number; skills?: string[]; projects?: Array<{ name: string; role: string; description: string; highlights?: string[] }>; education?: Array<{ school: string; major: string; degree: string; period: string }>; expectation?: { position: string; salaryRange?: string; location?: string }; } export const ResumeAgentState = Annotation.Root({ messages: Annotation<BaseMessage[]>({ reducer: (current, incoming) => current.concat(incoming), }), profile: Annotation<Partial<UserProfile> | undefined>(), collectedFields: Annotation<string[]>({ reducer: (current, incoming) => Array.from(new Set([...(current ?? []), ...(incoming ?? [])])), }), resumeDraft: Annotation<string | undefined>(), renderError: Annotation<string | undefined>(), }); export type ResumeAgentStateType = typeof ResumeAgentState.State;这里有个细节值得说:messages 字段用的是数组 reducer,这样每个节点返回的新消息会自动追加到历史里,不会覆盖旧消息。collectedFields 记录当前已经收集到的简历字段名,用什么字段就让节点返回什么,不用管顺序。
Profile 字段没有写 reducer,默认行为是新值覆盖旧值。这在我们的场景里是合理的——如果中间某个节点更新了 profile,我们希望拿到的是最新版本,而不是历史合并结果。
2.2 拆分节点:收集、生成、渲染三条主线
整个简历生成流程,我们拆成了三个业务节点,加一个 START/END 系统节点。从工程角度看,节点拆得越细,单个节点的逻辑就越容易测试,后续在旁边加新能力(比如“把简历翻译成英文”“针对 JD 优化关键词”)时,只需要插入新节点和条件边,不需要动原有节点的内部实现。
第一个节点是 collectInfo。它的职责是:看当前 profile 缺哪些必要字段,然后调用 LLM 生成一句(或一段)提问。注意这里的 LLM 调用只负责生成提问文本,不负责判断信息是否齐全——“判断”这个动作从 LLM 手里拿出来,交给代码做,这是 Agent 编排里非常重要的一条原则。
第二个节点是 generateResume。信息齐全后进入这个节点,它会把对话历史里散落的信息聚合成一份结构化 profile,然后调用一个工具生成最终简历内容。这个节点的输出不一定是一段纯文本,更多时候是一个“工具调用请求”。
第三个节点是 renderResume。它负责执行渲染:把 structured profile 和选中的模板名传给一个渲染函数,拿到 HTML 或 PDF 字符串,存进 resumeDraft。渲染动作是确定性的代码,不经过 LLM。
2.3 条件边与工具注册:把“信息够不够”变成程序逻辑
条件边是这套图的灵魂。collectInfo 节点跑完之后不能无条件进入 generateResume,它要先检查 profile。检查逻辑写成一个纯函数:
function isProfileComplete(profile: Partial<UserProfile>): boolean { if (!profile?.name || !profile.yearsOfExperience) return false; if (!profile.skills || profile.skills.length < 3) return false; if (!profile.projects || profile.projects.length === 0) return false; return true; }这个函数放在节点外面,方便单独单测。然后注册条件边:
.collection("collectInfo") .addConditionalEdges("collectInfo", (state) => { return isProfileComplete(state.profile) ? "generateResume" : "collectInfo"; }, ["generateResume", "collectInfo"])如果信息不全,条件边让图重新回到 collectInfo 节点,继续下一轮提问;如果齐全,就走到 generateResume。整个过程 LLM 不需要自己做“到底完没完”的判断,这个决策完全由确定性的代码掌控。这对用户体验是决定性的:聊到第六轮模型还在问“请告诉我你的姓名”,这种事在纯提示词方案里经常发生,在状态图方案里不可能。
工具注册用 LangChain 的 tool 函数,参数校验交给 zod:
import { tool } from "@langchain/core/tools"; import { z } from "zod"; const renderResumeTool = tool( async ({ template, profileJson }) => { const profile = JSON.parse(profileJson) as UserProfile; const html = renderResumeHtml(template, profile); return html; }, { name: "render_resume", description: "将结构化的简历信息渲染成最终 HTML 简历,模板可选现代、经典、紧凑三种风格。", schema: z.object({ template: z.enum(["modern", "classic", "compact"]).describe("简历模板风格"), profileJson: z.string().describe("简历信息的 JSON 字符串,必须包含姓名、技能、项目等完整信息"), }), } );工具描述和参数描述一定要写清楚,因为 LLM 是读这些 description 来决定什么时候调用工具、传什么参数的。工具描述写得含糊,模型就会在奇怪的时候触发工具调用。
2.4 构建 StateGraph 的代码骨架
最后把这些拼起来编译成图:
import { StateGraph } from "@langchain/langgraph"; const workflow = new StateGraph(ResumeAgentState) .addNode("collectInfo", collectInfoNode) .addNode("generateResume", generateResumeNode) .addNode("renderResume", renderResumeNode) .addEdge("__start__", "collectInfo") .addConditionalEdges( "collectInfo", (state) => (isProfileComplete(state.profile) ? "generateResume" : "collectInfo"), ["generateResume", "collectInfo"] ) .addEdge("generateResume", "renderResume") .addEdge("renderResume", "__end__"); export const resumeGraph = workflow.compile();编译出来的 resumeGraph 就是一个可被反复调用的对象。每次调用可以传入不同的消息状态,图会自己决定走哪条路径。这意味着,我们把“简历生产流程”从一个写死的 if-else 逻辑,变成了一张可观测、可调试、可扩展的图。后续加一个“根据 JD 优化措辞”的节点,只需要在 renderResume 后面加一条边,非常轻量。
3. 挂到 Next.js 上用:API Route 与 SSE 流式输出的完整串联
图本身跑在纯 Node 环境里,没有任何 Next.js 依赖。真正要接的是服务端入口。我们用的 Next.js App Router,Agent 的执行体放在 API Route 里,前端通过 fetch 拿流式响应。
3.1 为什么服务端编排,而不是页面里直接跑
有人在浏览器端直接 new ChatOpenAI 然后在前端跑 Agent,我强烈不建议这么干。原因很简单:API Key 一旦打进前端包,就是公开的。而且 Agent 的状态图在服务端跑,才能统一控制并发、做日志、做权限校验。Next.js 的 API Route 天然适合做这个中间层——它就是一个普通的 Node 函数,你可以在这里做认证、限流、调用 Agent、把结果流式吐给浏览器。
3.2 API 路由设计:对话接口和渲染接口分开
我们实际开了两个路由:
- POST /api/agent/chat:接收用户新消息 + 当前状态快照,返回 Agent 的流式增量。
- POST /api/resume/render:接收结构化 profile 和模板名,返回最终 HTML。这个接口是纯粹的确定性渲染,供前端在“编辑模式”下单独调模板用,不经过 LLM。
为什么渲染要单独拆一个接口?因为 Agent 图里工具调用的渲染结果和前端直接选模板的预览,本质是同一件事。如果只把渲染能力锁死在 Agent 内部,后续前端做个“换模板实时预览”就没法直接复用了。接口拆分遵循一条原则:LLM 参与的路径和纯代码路径分开,能不走 LLM 就绝不多花钱。
3.3 SSE 流式下发的代码实现
LangGraph.js 编译后的图对象支持 stream 方法,可以按节点产出增量。我们用的是 streamMode: "updates"——每产生一个节点的输出,就往 SSE 管道里推一个事件,前端可以边收边渲染。
import { NextRequest } from "next/server"; import { resumeGraph } from "@/lib/agent/graph"; import { HumanMessage } from "@langchain/core/messages"; export const runtime = "nodejs"; export const maxDuration = 60; export async function POST(req: NextRequest) { const { messages, state } = await req.json(); const input = { messages: [ ...(state?.messages ?? []).map((m: any) => m), new HumanMessage(messages.at(-1).content), ], profile: state?.profile ?? undefined, collectedFields: state?.collectedFields ?? [], }; const stream = await resumeGraph.stream(input, { streamMode: "updates", recursionLimit: 30, }); const encoder = new TextEncoder(); const readable = new ReadableStream({ async start(controller) { try { for await (const update of stream) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(update)}\n\n`)); } controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); controller.close(); } catch (error) { controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: (error as Error).message })}\n\n`)); controller.close(); } }, }); return new Response(readable, { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", }, }); }几个细节值得注意。runtime 必须设成 nodejs,不能用 edge runtime——LangGraph.js 内部依赖一些 Node 内置模块,edge 下会直接报错。maxDuration 在流式响应场景下同样重要,因为一个多轮 Agent 的完整执行时间可能远超普通接口。
3.4 前端怎么消费这个流
前端侧我们用 fetch + ReadableStream 手动解析 SSE,没有引入额外的 SSE 库,因为事件格式简单,自己解析反而更可控。
async function chatWithAgent(userMessage: string, state: any) { const response = await fetch("/api/agent/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [...state.messages, { role: "user", content: userMessage }], state }), }); const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (line.startsWith("data: ")) { const payload = line.slice(6); if (payload === "[DONE]") continue; const event = JSON.parse(payload); handleAgentEvent(event); // 根据节点名更新 UI } } } }前端拿到的事件结构是按节点区分的,比如 collectInfo 节点的事件里是一段回复文本,renderResume 节点的事件里是最后的 HTML。因为事件里带了节点名,前端可以据此做不同的 UI 展示——聊天界面只展示 collectInfo 的文本,渲染结果则在侧边栏预览。
4. 并发压力下怎么扛:无状态化设计与调用治理
很多人关心“AI Agent 怎么扛并发”,这其实是个复杂话题,因为 Agent 的耗时和普通接口完全不同。一个普通接口 100ms 返回,Agent 可能要 10 到 30 秒。这中间任何一个环节卡住,都可能拖垮整个服务。
4.1 实话实说:Agent 的并发瓶颈不在框架,在 LLM 调用
先客观说一句:LangGraph.js 本身是个很薄的状态机编排层,它的 CPU 开销几乎可以忽略。真正的瓶颈在 LLM 供应商的响应时间,以及你对每个请求持有的连接资源。Node.js 的事件循环天然适合这种 IO 密集型等待场景——你在等 LLM 返回时,事件循环并没有被阻塞,其他请求照样在跑。
但有一个隐藏问题:Agent 里可能调用了多个 LLM 串行执行(一个节点一次调用,多节点串起来就多次)。如果每个请求内部串行调了 3 次 LLM,每次耗时 4 秒,那么这个请求的总耗时就是 12 秒。如果一个进程同时来 100 个请求,虽然事件循环没卡死,但 LLM 供应商那边会先扛不住,返回 429 限流错误。
4.2 方案 A:用 Redis checkpointer 把会话状态摘出来
第一种做法是用 LangGraph.js 的 Checkpointer 机制,把会话状态持久化到 Redis。这样图可以暂停、恢复,状态不放在内存里,多个 Node 实例都能读到同一个会话的状态,真正支持水平扩展。
LangGraph.js 的 BaseCheckpointSaver 接口在做自定义实现时要小心,不同小版本的抽象方法签名略有差异。核心思路是实现四个方法:getTuple、put、putWrites、deleteTuple。一个最简的 Redis 实现长这样:
import { BaseCheckpointSaver } from "@langchain/langgraph"; import { createClient } from "redis"; class RedisCheckpointSaver extends BaseCheckpointSaver { constructor(private client: ReturnType<typeof createClient>) { super(); } async getTuple(config: any) { const threadId = config.configurable?.thread_id; if (!threadId) return undefined; const raw = await this.client.get(`resume:agent:${threadId}`); return raw ? JSON.parse(raw) : undefined; } async put(config: any, checkpoint: any, metadata: any, newVersion: any) { const threadId = config.configurable?.thread_id; const key = `resume:agent:${threadId}`; await this.client.set(key, JSON.stringify({ checkpoint, metadata, newVersion }), { EX: 3600 }); } async putWrites(config: any, writes: any, taskId: string) { // 这个接口要按版本看实现,有些版本可以留空 } async deleteTuple(config: any) { const threadId = config.configurable?.thread_id; await this.client.del(`resume:agent:${threadId}`); } }然后把 saver 传给 graph:
export const resumeGraph = workflow.compile({ checkpointer: redisSaver });之后调用时,config 里带上 thread_id,图就能从对应会话的历史节点恢复。这个方案适合多实例部署、需要严格会话管理的线上环境。
4.3 方案 B:纯无状态调用,客户端回传消息上下文
如果只是个小工具、不需要严格的暂停恢复,我建议先用更轻的无状态方案:状态快照直接由前端带回,服务端每次从完整状态重建。反正我们的 ResumeAgentState 本身是普通 JSON 结构,完全可以序列化返回给前端。
前端在每次请求时,把上次拿到的 state 原封不动地放进 body 传回来。服务端拿到 state 后,即使图每次从start开始执行,条件边会立刻判断“信息已齐全”,直接跳过 collectInfo 走到 generateResume,不需要一个真的“恢复”机制。
这个设计妙在:我们不是靠框架的断点续跑,而是靠状态图本身的可重入性。状态是完整的,图每次从入口跑一遍,也只会做“该做的事”。它代价是多做一次节点名检查,换来的是服务端完全不用存内存 session,彻底无状态,每个实例都能服务任意请求。
4.4 超时、重试与信号量:防止 LLM 拖垮 Node 进程
不管用哪种会话方案,并发治理必须做。我们实践下来有三板斧。
超时必须设置。给每个 LLM 调用设置 30 秒超时,超了就返回错误提示,不要无限等。Next.js 的 fetch 可以传 AbortSignal,LangChain 的模型也都支持 timeout 参数。
const model = new ChatOpenAI({ model: process.env.LLM_MODEL ?? "gpt-4o-mini", temperature: 0.3, timeout: 30_000, maxRetries: 2, });重试对 429 限流和临时网络错误很有用,但要注意指数退避。不要把所有请求的重试都挤在同一时刻,那会造成惊群效应。
信号量是保护 LLM 供应商配额和本机连接池的关键。我们内部做了个简单的异步信号量,控制同一时间同时进行的 Agent 执行数:
class Semaphore { private queue: (() => void)[] = []; private active = 0; constructor(private limit: number) {} async acquire(): Promise<void> { if (this.active < this.limit) { this.active++; return; } return new Promise((resolve) => this.queue.push(() => { this.active++; resolve(); })); } release(): void { this.active--; const next = this.queue.shift(); if (next) next(); } } const agentSemaphore = new Semaphore(20); export async function runWithConcurrencyLimit<T>(fn: () => Promise<T>): Promise<T> { await agentSemaphore.acquire(); try { return await fn(); } finally { agentSemaphore.release(); } }每个 API Route 的入口套上 runWithConcurrencyLimit,超出并发上限的请求会排到队列里等待,而不是一股脑打到 LLM 供应商那里。
4.5 用 Next.js 部署时要注意的配额与限制
如果服务部署在 Vercel,平台自带的限制要提前摸清:Hobby 和 Pro 的 function duration 上限不一样,Node runtime 的冷却时间和内存也有约束。如果 Agent 的平均执行时长超过 30 秒,Vercel Serverless 可能不是最优选择,更稳的是自托管一个 Node 服务,或者用容器平台。
我们实测下来的经验:2 核 4G 的 Node 实例,跑这个三节点图,压到 50 个并发客户端,服务端 CPU 占用不超过 30%,瓶颈完全在 LLM 供应商的响应时长上,同等条件如果不用信号量保护直接在代码里限制 LLM 调用,供应商 10 分钟内必返 429。这个对比很能说明问题。
5. 真机跑起来的坑:版本、工具参数与流式丢消息
最后这部分是实际操作中踩过的坑。代码能一次跑通是运气,跑不通才是常态。我把几个印象最深的列出来,希望能帮你少走弯路。
5.1 LangGraph.js 的版本坑
LangGraph.js 迭代速度很快,API 变动比 Python 版更频繁。我们项目从 @langchain/langgraph 0.2.x 一路升到 0.5.x,期间踩到了两个明显的破坏性变化。
第一个是 Annotation 的用法。0.2 时代用的是Annotation<Type>({ reducer })这种接近 zustand 的写法,后来推荐改成Annotation.Root({...})一次定义整棵状态树。老的写法在 0.5.x 里还能用,但类型提示会变得很怪。
第二个是 tool 函数。早期版本是DynamicStructuredTool,后来变成tool(fn, { schema, description })。如果你在网上看到一篇老教程,复制下来的代码在本机直接报 TS 类型错误,不用怀疑,去项目 changelog 里查一下对应版本的 API 变化。
建议:package.json 里锁死版本号,不要用 ^ 范围,团队里统一升级时间。
5.2 zod 工具参数校验坑
工具调用失败的一大来源是参数校验。我们第一次跑 render_resume 时,LLM 有时候会把 template 传成用户在聊天的原文,比如“现代化一点的”,而 schema 里定义的是 enum,zod 直接抛错。LLM 收到抛错后,会有一定概率自我纠错重新调用,但纠错过程会多消耗一轮 token,而且耗时翻倍。
解决办法有两个。第一,在工具描述里写清楚可选项的字面常量:“template 参数只接受以下三种值:modern、classic、compact,分别对应现代、经典、紧凑风格。”这比单纯写“简历模板风格”有效得多。第二,系统提示词里给一个示例映射:“如果用户说想要大气一点的,选择 modern。”
另外,schema 里的 describe 字段非常重要,这相当于是给 LLM 的“字段说明文档”,写得好不好直接决定工具调用准确率,不要偷懒。
5.3 流式输出丢消息的处理
我们早期用 streamMode 默认值(messages)来推流,结果前端收到的不是“每轮新增的消息”,而是“全量消息数组的每个版本”。每推一次,前端如果无脑 setState,就会出现消息重复或覆盖的问题。
后来统一改用 streamMode: "updates",它推的是每个节点的增量输出,按节点结构返回,比如{ collectInfo: { messages: [...] } }。前端按节点名处理,就不会出现“新消息顶掉旧消息”的情况。还有个细节:最后一个节点的输出里,end事件会带出最终状态,这里可以直接取到完整渲染结果,前端可以靠它做“完成”状态判断。
5.4 最后:记得把内存缓存清掉
如果早期图实现里在模块级别缓存了 session 状态(比如Map<threadId, state>),跑一段时间后内存会悄悄上涨,这是 Agent 服务最典型的隐形 OOM 原因。改用 Redis 或纯无状态方案之后,这个问题才会根治。
我们的最终选择是方案 B:客户端携带状态快照。理由很务实——简历工具没有复杂的长周期会话,用户通常几分钟内聊完,无状态化让整个服务变得极其简单,不需要额外部署 Redis 依赖,也方便后续在边缘节点直接部署。如果哪天产品需要一个持续数小时的多轮 Agent 流程,再加 Redis checkpointer 也不迟。
收尾之前的一点心得
整个项目落地下来,我最深的体会是:LangGraph.js 真正解决的不是“让 LLM 更聪明”,而是“让流程可控”。简历工具这个场景里,用户目标是明确的,Agent 要做的是像老编辑一样,该问的时候问,该停的时候停,最后稳定地产出一份格式正确的文档。这套“LLM 负责理解与生成、代码负责决策与控制”的模式,比纯聊天式封装或者纯规则表单都要舒服得多。
如果你也想在 Next.js 项目里做类似的东西,我会建议先画一个非常小的图,哪怕只有一个节点加一个条件边,把 SSE 流式打通,再逐步加节点。图结构的好处是天然可演进,你不需要在项目第一天就把所有流程设计完。等你跑起来后,大概率会和我一样,开始琢磨能不能把图再拆细一点,把更多确定性逻辑掌握在自己手里。