最近后台总有人问我:LangGraph4j 到底什么时候该上状态图?我直接用 while 循环调大模型,不也能跑一个 AI Agent 吗?这个问题几乎每个做 Java AI 开发的都会遇到。今天这篇第 14 章,我把选型逻辑一次性讲透,先给判断标准,再讲 LangGraph4j 的 StateGraph 核心模型,最后给一个可以照着改的最小状态图骨架。适合三种人看:准备从 demo 走向生产的 Java 开发者,正在纠结 Spring AI 和 LangGraph4j 怎么选的架构师,以及想把多步骤业务流程固化下来、又不知道从哪下手的人。
1. 先治一个病:不要把 Agent 做成"只能聊天"的玩具
1.1 裸 ReAct 循环的致命自由
很多 Java 开发者第一次接触 LangGraph4j 时,反应出奇一致:我直接写个 while 循环,反复调用大模型 function calling 不就行了?ReAct(Reason + Act)的原理本身不复杂,就是让模型在"思考、调用工具、观察工具输出、继续思考"之间循环,最快几十行代码就能让 Agent 跑起来。我第一版 Agent 也是这么干的,上线第二天就被业务方反馈"机器人乱发邮件"。
问题在于,ReAct 的自由是双刃剑。模型是概率系统,它生成的"下一步动作"是猜出来的,不是被代码约束出来的。你告诉它"查完库存再创建订单",它可能先创建订单再查库存;你告诉它"支付失败就停下来",它可能自己编一个支付成功继续往下走。这些不是模型蠢,而是你没有把控制流锁死。裸循环里所有规则都写在 prompt 里,模型天生会"解释"规则,甚至会绕规则。
单轮问答、翻译、文本分类这类任务,模型自由发挥没问题,因为操作没有副作用。一旦 Agent 开始调用真实业务系统——查库存、扣款、发工单、写数据库,自由就变成了风险。你能容忍模型偶尔漏一步,但很难容忍它多扣一次钱。
1.2 边界、恢复、可观测性:工程化的三个底线
裸循环做 Agent 的三个硬伤,我总结为:没有边界、没有恢复、没有可观测性。
没有边界,是指 Agent 可能无限循环。模型遇到一个不会的工具,会换个说法反复调用同一个工具。有人会在 while 里加 maxIteration 变量,但到不了 maxIteration,重复请求已经发出去了。状态图里,边是显式声明的,回环通常经过条件边判断,你一眼就能看出哪里可能死循环。
没有恢复,是指进程一重启,进行到一半的流程全丢了。比如 Agent 已经给用户发了确认邮件,但创建订单的请求还没发出。裸循环的状态全在内存变量里,没有快照,没有重放能力。真实线上系统需要的是"人关了电脑,半天后回来继续跑前面一半流程"的能力。
没有可观测性,是指日志里全是大模型 prompt 和 response,没有结构化步骤信息。你只知道模型输出了一句话,不知道它现在处于流程的第几步,不知道前一步是哪个节点。出问题只能靠猜。
这三个问题,决定了"什么时候该上状态图"的答案:当你的 Agent 开始触碰真实业务副作用的时候,就已经超出裸循环的舒适区了。反过来说,如果它只是个问答玩具,那裸循环确实够用,后文会展开讲。
2. 什么时候该上状态图:看流程规律,而不是看模型大小
2.1 三条判断标准,命中两条就上
很多人以为状态图是给"复杂的大模型"用的,其实不对。要不要上状态图,跟模型大小无关,跟流程规律有关。我自己用三条标准判断,命中两条就果断上状态图。
第一条:流程有没有稳定骨架。也就是这个 Agent 做的事情,是不是一组固定步骤的组合?比如"查库存→确认订单→创建订单→通知仓库",顺序是固定的,只是每一步的细节内容由模型生成。如果有,就值得用图把骨架固定下来。如果用户问什么、下一步是什么完全不可预测,那图反而是一种束缚。
第二条:有没有多结局分支。比如订单处理的结果可能是"成功"、"需要用户补充材料"、"需要人工审批"、"直接拒绝"。分支一多,靠 prompt 描述很容易漏。状态图的条件边天然支持这种 if/switch,而且每个分支都能单独测试。分支越多,图的收益越明显。
第三条:需不需要控制外部副作用。扣款、发邮件、创建工单、写入数据库,这些操作执行一次和执行三次的后果完全不同。图结构天然可以保证"同一个节点只被它的入边触发",配合条件边还能实现"支付失败重试 N 次"这类语义。
为什么说与模型大小无关?因为再强的模型也还是概率系统。你让更强的模型处理订单,它同样可能在"用户没确认"时主动跳到发货。模型是负责生成内容的,状态图是负责任意控制流正确的。两者分开,各干各的,系统才健康。
2.2 一个订单处理 Agent 的对比实验
假设我们要做一个售前订单 Agent:用户报一个商品,Agent 需要查库存 → 向用户确认 → 创建订单 → 发起支付 → 通知仓库。
裸循环的写法,本质上是让模型在上下文里自己维护一个 todo 列表。模型可能的表现是:先查了库存,然后直接创建订单,完全忘了问用户确认;又或者用户明确说"我再想想",模型仍判断"用户已确认"。你可以在 prompt 里写一百遍"必须等用户确认",但只要有一次上下文太长或工具返回值太复杂,模型就"失忆"了。
换成 LangGraph4j 状态图之后,整个流程变成五个强制节点,模型只需要在每个节点内部做局部工作:查库存节点调库存服务、确认节点负责生成确认话术、创建订单节点负责调订单服务。节点之间的顺序由边固定,用户没确认,流程根本走不到创建订单节点。
| 对比维度 | 裸 ReAct 循环 | LangGraph4j 状态图 |
|---|---|---|
| 控制流来源 | 模型逐轮"猜" | 代码显式定义 |
| 多结局分支 | 靠 prompt 约束,易漏 | 条件边天然支持 |
| 可测试性 | 只能端到端测 | 每个节点独立单测 |
| 可恢复性 | 无状态快照 | 配合 Checkpointer 恢复 |
| 开发初速度 | 快,几小时能跑 | 慢,要先建模 |
| 变更成本 | 改 prompt 糊过去 | 改图和节点,更清晰 |
这张表我用了很多次。简单任务上裸循环胜在快,但只要后面三项变成硬需求,状态图的优势会迅速盖过初始的开发成本。
2.3 真实项目里的三个上图表征信号
除了标准判断,我还会留意三个来自真实项目的表征信号。一旦出现,基本意味着该上图了。
信号一:你在代码里开始写"流程状态机"了。比如用一个 enum(INIT、CHECKING、CONFIRMING、SHIPPING),然后在 service 里 switch(state) 决定下一步。这说明控制流已经复杂到需要显式建模,与其手写 switch,不如把它变成一张图。
信号二:产品开始要"人工介入点"。比如"机器人先自动处理,遇到高风险订单转人工审核"。裸循环里,人工审核很难做,因为流程没有"暂停点"。状态图天然支持这种节点:停在某一步等人触发,收到结果后再继续走。
信号三:你需要对账和回溯。业务方问"昨天那个订单为什么自动发货了?"如果你能从日志里还原每一步状态、每个节点输入输出,处理起来会从容很多。状态图的 Checkpointer 就是干这个的,裸循环做不到。
3. LangGraph4j 的 StateGraph 模型:状态、节点与边如何映射到真实业务
3.1 State、Node、Edge:一张图对应一个业务过程
LangGraph4j 的核心抽象就三个:State、Node、Edge,再加一个条件边 Conditional Edge,就覆盖了绝大多数业务编排需求。
State(状态)是整个工作流共享的数据。可以理解成一个"包裹加运单"的组合:商品、地址、状态标记、消息历史全部装在里面。每个节点从 State 里读数据,处理后返回一份部分更新,框架负责把部分更新合并回 State。在订单场景里,State 至少要有:orderId、库存是否充足、用户是否确认、支付结果、消息列表。
Node(节点)是图里的一个工序。在 Java 里就是一个普通函数或一个类方法,接收当前 State,返回更新后的 State(也可以是部分更新)。我喜欢把节点设计成"幂等"的:同一个节点重复执行两次,结果应该一致。这样即使某个节点因为超时被重放,也不会造成重复扣款。
Edge(边)定义节点的流转顺序。用 addEdge 把上一节点和下一节点连起来,固定流程就完成了。Conditional Edge(条件边)则是一个带判断的传送带:根据当前 State 的内容,决定下一站去哪个节点。这相当于传统代码里的 if/switch,但它是图的一部分,可以可视化,也可以单独测试。
还有一个隐藏概念是START 和 END 节点。一个图必须有入口(entry point)和出口。LangGraph4j 里用 setEntryPoint 指定入口,用 END 常量代表终止。别小看这一步,它决定了你 invoke 之后返回值能否正常回收。
概念太多容易晕,用一张表把它映射到业务:
| LangGraph4j 概念 | 订单业务里的含义 | 类比传统代码 |
|---|---|---|
| State | 订单上下文、消息列表 | 方法参数/会话变量 |
| Node | 查库存、确认、发货 | 一个函数 |
| Edge | 固定流转顺序 | 顺序执行 |
| Conditional Edge | 库存是否充足、用户是否确认 | if / switch |
| END | 流程终止 | return |
3.2 循环、分支、并行:图比你以为的灵活
很多人对"状态图"有个误解,以为只能画一条直线。其实图允许环、允许分支、甚至允许并行节点。比如 Tool Call 循环就是用条件边实现的:Agent 节点调用模型,模型决定"需要调用工具"则进入工具节点;工具节点执行完后,条件边判断"还有没有更多工具要调",有就回到 Agent 节点,没有就走向 END。这不就是 while 循环吗?只不过循环条件从"模型自己觉得"变成了"程序显式判断"。
在 LangGraph4j 里,这个"显式判断"可以非常细。比如工具节点返回一个结构化结果,条件判断函数读取结果里的 needMoreCalls 字段,返回 true 就回环,false 就往下走。这样即便模型这次乱说话,说不出"我可以调另一个工具",流程也不会意外乱跳——因为它根本没有去另一个节点的边。
顺带说一句,有人喜欢先在 PowerDesigner 里画好状态图再翻译成代码。我理解这种习惯,但 LangGraph4j 这类库本身就是"图即代码,代码即图"——图结构是在代码里直接构建的,运行时可以导出可视化。你再维护一份单独的 PowerDesigner 图,很快会跟代码不同步。我现在的做法是:先在纸上画草图,再写代码,图直接留在代码里,后续从代码导出。
3.3 为什么不用手写状态机:图是数据,不是逻辑堆
有人会说:我用 enum + switch 也能实现状态机,为什么非要框架?
手写状态机的问题在于,状态流转逻辑散落在各个方法里,条件与条件之间的关系靠人脑维护。分支一多,一个 switch 里可能嵌套二十个 if,新来的同事根本不敢动。而 LangGraph4j 的图,本质上是一份可以被检查、被遍历、被持久化的数据:你可以在运行时列出所有节点、所有边,可以导出 DOT 文件,可以单独 invoke 任意一个中间状态。
更重要的是,手写状态机很难处理"恢复"。你想让一个跑了一半的流程从某个节点重新开始,手写版本得手动保存一堆局部变量;LangGraph4j 的 Checkpointer 可以把每一步 State 落盘,下次启动直接把上次快照喂回去。这种东西自己从零写,成本极高,用框架是划算的。
4. 从 0 到 1 用 LangGraph4j 搭一个带状态图的 Agent 骨架
4.1 先想清楚:Spring AI 还是 LangGraph4j
每次聊到 AI Agent 选型,总有人问"到底用 Spring AI 还是 LangGraph4j"。我的看法是,这俩不是一个层面的东西。Spring AI 是 Spring 官方提供的大模型集成抽象,擅长管理 ChatModel、EmbeddingModel、结构化输出这类"模型通信";LangGraph4j 是 LangChain4j 社区生态里的图编排库,擅长把多步骤 Agent 流程编排成可恢复的状态图。两者不是二选一,更像是"Spring AI 管模型,LangGraph4j 管流程"。
如果你只是做一个"用户提问→模型回答"的客服助手,Spring AI 足够。但当你需要写"先查库存,再确认,再下单"这种强制流程,或者多个子 Agent 之间互相交接,你就需要图编排。我在 Spring Boot 工程里的做法是:用 Spring AI 的 ChatClient 做模型调用封装,然后在 Service 层引入 LangGraph4j,把 graph 注册成 Spring Bean,需要时用 graph.invoke(...) 触发。这套组合在真实项目里跑得很稳。
4.2 最小可运行骨架:订单处理状态图
先加 Maven 依赖(版本以 Maven Central 上最新稳定版为准):
<dependency> <groupId>org.langgraph4j</groupId> <artifactId>langgraph4j-core</artifactId> <version>替换为当前稳定版本</version> </dependency> <dependency> <groupId>org.langgraph4j</groupId> <artifactId>langgraph4j-langchain4j</artifactId> <version>替换为当前稳定版本</version> </dependency>下面代码是结构示意,重点在建模思路。由于 LangGraph4j 的 API 还在快速演进,方法和常量名你可能需要对照你引入版本的 javadoc 微调,但图的核心概念不会变。
// 1. 定义整个流程共享的 State public record OrderState( String orderId, boolean inventoryOk, boolean userConfirmed, String paymentStatus, List<String> messages ) { public OrderState withMessage(String msg) { List<String> newMessages = new ArrayList<>(messages); newMessages.add(msg); return new OrderState(orderId, inventoryOk, userConfirmed, paymentStatus, newMessages); } } // 2. 用 StateGraph 构建图 StateGraph<OrderState> graph = new StateGraph<>(OrderState.class); graph.addNode("check_inventory", state -> { boolean ok = inventoryService.check(state.orderId()); return new OrderState(state.orderId(), ok, state.userConfirmed(), state.paymentStatus(), appendMsg(state.messages(), "库存检查完成")); }); graph.addNode("confirm_order", state -> { // 真实场景里这里会调用模型生成确认文案,并等待用户反馈 return new OrderState(state.orderId(), state.inventoryOk(), true, state.paymentStatus(), appendMsg(state.messages(), "已发送确认请求")); }); graph.addNode("ship", state -> { String shipNo = shippingService.ship(state.orderId()); return new OrderState(state.orderId(), true, true, "paid", appendMsg(state.messages(), "已发货:" + shipNo)); }); // 3. 设置入口和边 graph.setEntryPoint("check_inventory"); // 条件边:库存不足直接结束,否则去确认 graph.addConditionalEdge("check_inventory", state -> state.inventoryOk() ? "confirm_order" : "end", Map.of("confirm_order", "confirm_order", "end", "end")); // 条件边:用户确认后才发货 graph.addConditionalEdge("confirm_order", state -> state.userConfirmed() ? "ship" : "end", Map.of("ship", "ship", "end", "end")); graph.addEdge("ship", "end"); // 4. 编译并执行 CompiledGraph<OrderState> app = graph.compile(); OrderState result = app.invoke( new OrderState("1001", false, false, null, new ArrayList<>())); System.out.println(result);注意我把库存不足和用户未确认都直接连到了 end。真实项目里,这两个分支应该接上"通知用户库存不足"、"等待用户重新输入"这类的节点,否则用户会被莫名其妙地结束流程。失败分支不要一律连 END,至少要让它有善后机会,这是我踩过的坑。
4.3 条件边与节点实现:Java 侧三个容易翻车的细节
第一个细节:节点返回的是部分 State 还是全量 State?LangGraph4j 通常会把节点返回的结果与旧 State 合并。如果你返回一个新 record,要把不需要改的字段原样传进去,否则字段会被覆盖成 null。我早期写节点时直接 new OrderState(...) 漏传了好几个字段,结果跑到后面突然 NPE。解决办法:给 State 加 with 方法,或者只返回需要变更的字段让框架去合并。
第二个细节:条件边的路由表 key 必须和条件函数返回值精确一致。字符串比对手写很容易出错,少个下划线就报"No edges found for key"。我会把目标节点名抽成常量,比如TO_CONFIRM = "confirm_order",条件函数和 Map.of 都用同一个常量,而不是手写字符串。
第三个细节:编译后的 CompiledGraph 是线程安全的,可以当 Spring 单 Bean 使用。但节点类本身不要持有可变成员状态。我见过有人在节点里加了一个 List 缓存,结果并发请求互相污染。正确做法是节点无状态,所有业务中间数据都放进 State。
生产环境如果追求可恢复,请关注 langgraph4j 的 Checkpointer 模块,它能把每一步状态持久化。这在"人审流程"里几乎是刚需——人工审批等了一天,Agent 不能失忆。
4.4 在 Spring Boot 里把图暴露成接口
图本身只是编排引擎,对外还是得走 HTTP。我一般会在 Service 层封装一个AgentService,注入编译后的图,然后提供一个 start 和 resume 两个接口。
@Service public class OrderAgentService { private final CompiledGraph<OrderState> app; public OrderAgentService() { StateGraph<OrderState> graph = buildOrderGraph(); this.app = graph.compile(); } public OrderState random(String orderId) { return app.invoke(new OrderState(orderId, false, false, null, new ArrayList<>())); } }Controller 里就很薄了,直接调 service。注意每次 HTTP 请求要不要新建初始 State:如果是新业务流程,就 new 一个;如果是恢复流程,先通过 Checkpointer 把之前快照取出来,再 invoke。
5. 什么时候不上状态图:轻量任务与快速迭代的取舍
5.1 简单场景,裸循环更务实
不是所有 Agent 都要上状态图。如果你只是做一个"翻译"Agent:用户丢一段话,模型翻译后返回,中间没有多步、没有分支、没有外部副作用,那 while 循环甚至不需要,调一次 ChatModel 就结束了。这种场景硬上状态图,等于把 if 全部换成图节点,阅读成本和维护成本反而更高。
还有一类"工具调用型"Agent:用户说"帮我查一下天气",模型调一个天气 API 返回结果。一个工具调用,不需要图。即使有两个工具,模型 function calling 自己就能决定用哪个,返回结果后 AI 总结一下完事。这类模式的规模如果在三五个工具以内,裸写 ReAct 反而好扩展,因为你可以把工具列表动态塞进 prompt,改动成本低。
判断依据就是回到 2.1 的三条标准:没有稳定骨架、没有多结局分支、没有强副作用,就不上图。很多人不敢放下框架,总觉得"用状态图才专业",其实专业是"选择与问题匹配的复杂度"。
5.2 从裸循环到状态图的自然演进路线
我见过太多项目一上来就铺一个巨大的状态图,结果需求还在剧烈变化,改一个流程要动五六条边,苦不堪言。反过来,我也见过第二周就出事故的裸循环 Agent。更实际的路线是分四步走:
- 裸调用阶段:单工具,单轮问答,直接调模型。
- ReAct 循环阶段:多工具调用,用 while + function calling,配 maxIteration 防跑飞。
- 手工状态机阶段:当流程开始出现"先 A 后 B,但 B 之前要确认",用 enum + switch 手写状态流转,先拿到正确的流程语义。
- 状态图框架阶段:当分支越来越多、需要恢复、需要并发、需要人工介入,把手写状态机翻译成 LangGraph4j。
每次升级的触发信号我都记得很清楚:手写状态机里开始出现"我这个状态流转到底有几个出口"的 if 堆叠,那就该换成图了。图优于手写状态机的地方在于:图是数据,不是代码,可以动态组装、可以导出、可以从任意节点恢复。到了这一步,你用 LangGraph4j 不是赶时髦,而是给流程上了一套工程化底座。
我实际操作中的体会是,状态图的价值不在于告诉你下一步去哪,而在于清清楚楚地告诉你哪些路不能走。边界即自由,这句话在 AI Agent 工程里尤其成立。先把图的边界画好,模型才能在边界内全力输出。下一章我会专门拆解 Checkpointer 和人工介入节点的实现,欢迎继续跟进。