先别急着把状态图塞进业务里。我最近在公司帮 Java 组搭 Agent 框架,发现一个特别典型的现象:不少人一上来就奔着 LangGraph4j 这种状态图框架去,结果业务逻辑总共三步,非要用图画出个迷宫来;另一部分人正好相反,业务里明明有循环、有分支、有人工介入,却硬用 while 加 if 拼出来,代码写到后面连自己都看不懂。
这篇文章我站在 Java 开发者这边,聊聊 LangGraph4j 入门,以及更关键的一个问题:AI Agent 什么时候该上状态图。文章里不会有那种“从入门到放弃”的空话,所有代码都来自我简化过的真实项目。适合正在用 Java 做 Agent 开发,或者被业务流程里的分支循环搞到头大的朋友。版本相关的地方我会特别标注,你照着思路改到自己业务里就行。
1. 先想清楚:你的 Agent 到底要不要状态图
1.1 没有状态图也行的 Agent
先别急着引入框架。我见过太多团队,把最简单的对话流程硬改成状态图,最后图比代码还复杂。一个典型的反例:用户发消息 -> 调用大模型 -> 返回回答。总共三步,普通 Java 方法就能写得很干净。
String answer = chatClient.call(userMessage); return answer;哪怕要加点工具调用,无非就是先让模型决定调哪个工具,再执行工具,再把结果带回模型。这种线性流程,用一个 for 循环就能控制,最多包一层函数。
什么时候开始别扭呢?当你发现自己需要记录“当前走到哪一步了”,需要支持用户中途撤回,或者模型在某一步反复调用同一个工具不退出。这时候你的线性代码里已经开始塞入各种状态标记位、嵌套 if 和 break。代码表面还在跑,但你已经很难向同事讲清楚整个流程。
我自己的经验是:如果流程是“顺序执行,偶尔回退”,而且状态字段少于五个,先不要上状态图。你需要的只是一个更规范的工具调用封装,而不是一张完整的图。
1.2 出现三个信号再考虑状态图
信号一:分支和循环本身就是业务流程的一部分。比如 Agent 要先判断问题类型,再选择不同的工具链;工具调用失败后,要换一种策略重试,而不是直接把错误抛给用户。这种逻辑用 if/else 写出来会越来越乱。
信号二:需要审计和断点续跑。用户会取消任务,会暂停任务,需要人工介入,系统得能保存当前状态,并在未来某个时间点恢复。典型场景是“AI 生成内容 -> 人工确认 -> 执行后续操作”,人工确认可能要等几分钟甚至一天。
信号三:多人协作维护业务逻辑。状态图让整个流程可视化,每个节点由不同团队维护,边界清晰。而一堆 Service 类里的隐式调用,新同事根本不知道从哪里看起。
你可以给三个信号打个分:满足两个,直接上状态图;只满足一个,再等等;一个都不满足,别折腾。
| 判断维度 | 线性编排 | 状态图 |
|---|---|---|
| 流程复杂度 | 低,顺序为主 | 高,分支、循环、并行 |
| 状态管理 | 方法局部变量够用 | 需要跨节点共享和持久化 |
| 人工介入 | 很难优雅实现 | 通过中断和恢复天然支持 |
| 可观测性 | 靠日志拼凑 | 节点、边、状态均可视化 |
| 团队协作 | 各写各的 Service | 按节点划分责任 |
2. 状态图解决的是控制流与可观测性问题
2.1 一个生活化类比
状态图不是什么高深概念。你网购时的订单状态就是活生生的状态图:待支付、已支付、待发货、已签收。这些状态之间不是乱跳的,支付成功才能从待支付到已支付,退款只能发生在特定状态。
Agent 的状态图是同一个道理。节点是“动作”,边是“什么条件触发下一个动作”,State 是全局账本,记录用户问题、中间结果、工具调用次数等信息。模型判断该调工具了,就走“调用工具”这条边;工具返回了,再走“回到模型”这条边;最终答案生成完毕,才走到 END。
很多人觉得状态图只是为了画一张漂亮的流程图,其实不是。它把顺序编程里那些隐式的控制流,变成了显式的数据和边。你不必在代码里翻来翻去找“下一步到底是谁在调用”,看一眼图就全明白了。
2.2 相比手写 while,状态图赢在哪里
手写 while 加 if 也能实现 Agent 循环,但代价是控制流散落在代码各处。状态图把“下一步去哪”集中到了边的定义里,这对维护是质的改变。
可读性:一张状态图能直接看清完整流程,同事拿到代码不用从头追到尾。可测试性:每个节点是独立单元,单独喂数据就能测,不需要从入口一路模拟到目标节点。可恢复性:配合 Checkpoint,可以把每个节点执行完的状态快照保存下来,异常或人工中断后从指定节点继续跑。可扩展性:加一个节点、加一条边,不影响其他逻辑。
我记得第一次在项目里用状态图重构一个“先检索再生成再重试”的 Agent 时,代码量没少太多,但调试时间明显缩短了。以前出问题要靠日志猜流程,现在直接把当前节点和 State 打出来,问题在哪个环节一目了然。
3. LangGraph4j 快速上手:从 0 到 1 搭一个带状态图的 Agent
3.1 引入依赖
LangGraph4j 是 Java 生态里对标 Python LangGraph 的库,核心思想一致,但不是简单翻译。目前我使用的版本基于 4.x,Maven 依赖大概是这样的:
<dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-core</artifactId> <version>1.0.0</version> </dependency>注意,这个库版本迭代挺快,而且 Java 生态里它不像 Spring Cloud 那样统一,建议先去看官方 GitHub 仓库确认最新版本和 API 变化。下面的代码我会用比较通用的写法,你自己对照版本调整。
3.2 核心概念先过一遍
在写代码之前,先把 LangGraph4j 的几个概念和 Java 对象对应上,后面才不会懵。
| 概念 | Java 对应物 | 作用 |
|---|---|---|
| State | 自定义 Java 类 | 所有节点共享读写的数据 |
| Node | 一个方法或类 | 接收 State,处理完返回要更新的字段 |
| Edge | 连接两个节点 | 表示执行流向 |
| ConditionalEdge | 根据 State 返回路由 key | 动态决定下一个节点 |
| Checkpoint | 状态持久化机制 | 保存快照,支持恢复 |
| END | 特殊终点节点 | 图执行结束 |
Node 在 LangGraph4j 里通常是这样:输入当前 State,返回一个 Map,里面是你要更新的字段。返回空或 null 表示这次不改状态。设计上有点像函数式编程里的“不可变更新”,Java 里用 map 或 with 方法都很自然。
3.3 最小可运行的图
先看一个最简例子,两个节点,一条边,主要帮助你理解执行流程。
public class DemoAgent { public static void main(String[] args) { StateGraph<AgentState> graph = new StateGraph<>(AgentState::new) .addNode("greet", DemoAgent::greet) .addNode("answer", DemoAgent::answer) .addEdge("greet", "answer") .addEdge("answer", END) .compile(); var result = graph.invoke(AgentState.builder().query("你好").build()); System.out.println(result.value("reply").orElse("")); } static Map<String, Object> greet(AgentState state) { return Map.of("greeting", "你好,我是 DemoAgent"); } static Map<String, Object> answer(AgentState state) { return Map.of("reply", state.query() + ",我来帮你查一下"); } }AgentState 是一个普通 Java 类,包含 query、reply 这些字段。invoke 是入口,传入初始状态,走完整个图,返回最终状态。这个例子虽然简单,但已经能看出状态图的骨架:节点负责干活,边负责指路,State 负责传递数据。
3.4 带工具调用的 Agent 循环
真正的 Agent 能力来自“模型决定调工具 -> 执行工具 -> 回到模型”的循环。用状态图实现这个循环非常直观。
StateGraph<AgentState> graph = new StateGraph<>(AgentState::new) .addNode("agent", this::runModel) .addNode("tool", this::runTool) .addEdge("agent", "tool") .addConditionalEdge("tool", this::nextAfterTool, Map.of("continue", "agent", "done", END)) .compile();这里的关键是条件边。runModel 节点调用大模型,解析输出:如果模型说要调用某个工具,就把工具名和参数写进 State 的 toolCalls 字段;如果没有工具调用,就直接生成最终回答。runTool 节点负责真正执行工具,把结果写回 State。
nextAfterTool 方法判断是否继续:
String nextAfterTool(AgentState state) { if (state.stepCount() < MAX_STEPS && state.hasPendingToolCalls()) { return "continue"; } return "done"; }这个循环看起来像递归,但在 LangGraph4j 里,图执行器内部是迭代调度,不是 Java 方法递归,所以不用担心栈溢出。但要注意,业务上必须限制最大迭代次数。否则模型一旦陷入“反复调用同一个工具”的怪圈,你的钱包会比代码先崩溃。
4. 和 Spring AI 编排怎么选
4.1 简单对话任务用 Spring AI 就够
Spring AI 确实把 Java 接入大模型的门槛拉低了一大截。@Tool 注解注册工具、ChatClient 流式输出、Advisor 包装 RAG 和对话历史,这些功能开箱即用。我之前在公司内部做知识库问答机器人,纯 Spring AI,一个月上线,一点问题没有。
如果你只是做一个问答机器人,或者给内部系统加一个自然语言入口,真没必要上状态图。你需要的只是“接收问题 -> 检索知识库 -> 调用大模型 -> 返回答案”,这套流程用 Spring AI 的 API 编排非常顺手。
4.2 复杂流程交给 LangGraph4j
但到了多轮自主决策场景,情况就不一样了。Spring AI 的 ChatClient 每次调用基本是无状态的,流程控制还是得自己写。你可能会在 Service 里手写一个“意图 -> 策略 -> 工具调用 -> 重试”的大方法,刚开始还好,后面加需求会越来越痛苦。
LangGraph4j 正好补上这块:把节点、边、状态、持久化都变成一等公民。它不是为了替代 Spring AI,而是为了流程编排。Spring AI 管“怎么和模型对话”,LangGraph4j 管“什么时候该和模型对话”。
| 对比维度 | Spring AI 编排 | LangGraph4j |
|---|---|---|
| 核心优势 | 模型接入和工具注解 | 状态管理和流程控制 |
| 分支循环 | 自己写 if/while | 条件边天然支持 |
| 状态持久化 | 弱 | 配合 Checkpoint 强 |
| 可视化 | 基本没有 | 节点边可导出查看 |
| 学习曲线 | 平缓 | 有一定门槛 |
| 典型场景 | 对话、RAG、工具调用 | 多步骤规划、人工介入、审计 |
4.3 两者结合的现实套路
很多团队是两者混着用的,我也一样。Spring Boot 里正常注入 ChatClient,负责所有和模型交互的地方,包括工具定义和流式输出;LangGraph4j 负责整张状态图,流程的下一步是该调用模型还是执行工具,由状态图里的边决定。Node 内部直接调用 ChatClient 拿到结果。
这样做的最大好处是解耦。擅长 Spring 的同事继续写熟悉的代码,专门维护流程的人只需要关心节点和边。Spring AI 的 Tool 可以理解为“工具执行器”,LangGraph4j 只在合适的时机去触发它,两边各司其职。
5. 实操中的五个坑与排查方法
5.1 死循环:条件边一直在继续
这是所有人都会踩的坑,我也不例外。最常见的原因是 nextAfterTool 方法里判断条件写错,比如工具调用列表清空失败,导致 state.hasPendingToolCalls() 永远返回 true。
解决办法:每个涉及循环的图都必须带 stepCount 字段,超过上限强制返回 done。另外在 runModel 节点里把 stepCount 和当前工具调用列表打出来,一眼就能看出卡在哪一步。
5.2 条件边返回值映射不到节点
条件边返回一个字符串,图执行器要在配置的 Map 里找对应节点,找不到就直接抛异常。这个错误经常发生在字符串手写拼错的情况下,比如返回的 key 是 agent,Map 里存的却是 Agent。
我现在的做法是定义常量类,把所有节点名和路由 key 都做成 static final String,包括 agent、tool、continue、done 这种。然后在单元测试里遍历所有条件边,断言返回的 key 都能映射到已知节点,跑一遍就放心了。
5.3 共享状态被并发修改
默认情况下图是顺序执行节点的,但如果你引入了并行节点,或者某些节点本身起线程执行,State 对象就可能被多个线程同时读写。Java 里最容易踩坑的就是往 List 里 add 数据,另一个节点同时在遍历。
建议所有节点不修改传入的 State 对象,而是通过返回新 Map 的方式更新字段。如果返回的字段是 List,就创建一个包含旧数据加新数据的新 List。这样看起来多写几行代码,但能省掉并发问题带来的排查成本。
5.4 序列化与持久化问题
要用 Checkpoint 做断点续跑或者人工中断恢复,State 里所有字段必须可以序列化。Java 里自定义对象要实现 Serializable 接口,这很容易忽略。
我踩过一次坑:把 RedisTemplate 放进了 State,结果恢复时直接序列化异常,查了很久才发现。记住,State 里只放业务数据和轻量的快照对象,数据库连接、HTTP 客户端、大模型的客户端这类重资源一律不能进 State。这也符合状态图的设计原则:State 是账本,不是资源池。
5.5 调试依赖日志而不是断点
状态图的执行器会在一层循环里调度节点,传统的断点调试不太友好。我现在的做法是给每个 Node 统一打日志,输出节点名、关键字段、耗时。代码上可以封装一个 NodeWrapper,在真正执行前打印入参摘要,执行后打印出参摘要。
如果你们团队能把图结构导出成 DOT 格式,建议配合 Graphviz 可视化看流程。还有一个很实战的小技巧:把每次执行的关键 State 快照保存到本地文件,线上出问题后拿快照本地复跑,比看日志猜状态高效得多。
6. 一个完整案例:带人工确认的代码审查 Agent
6.1 业务场景
假设你要做一个代码审查 Agent:拿到一个 PR 编号,拉取 diff 内容,让大模型分析并生成评审意见,然后需要管理员人工确认是否采纳。确认通过后,Agent 自动把评审意见发到 PR 上并执行合并;确认拒绝,直接丢弃意见结束。
这个场景里天然有分支:人工确认的结果只有两个方向;也有循环:如果生成的评审意见质量不行,最多重试一次;还有人机协作:大模型不能替管理员做最终决定。非常适合用状态图实现。
6.2 状态与节点设计
先设计 State。一个简单的 Java 类,字段不要太多,但足够覆盖整个流程:
public class ReviewState { private String prNumber; private String diffContent; private List<String> comments = new ArrayList<>(); private boolean approved; private int attemptCount; }节点分四个:fetch_diff,根据 prNumber 拉取 diff;review,调用大模型分析 diff,生成评审意见;human_approve,读取人工审批结果;apply_review,根据审批结果执行后续操作。
var graph = new StateGraph<>(ReviewState::new) .addNode("fetch_diff", this::fetchDiff) .addNode("review", this::reviewByLlm) .addNode("human_approve", this::humanApprove) .addNode("apply_review", this::applyReview) .addEdge("fetch_diff", "review") .addEdge("review", "human_approve") .addConditionalEdge("human_approve", state -> state.approved ? "approved" : "rejected", Map.of("approved", "apply_review", "rejected", END)) .addEdge("apply_review", END) .compile();fetch_diff 和 review 都是普通节点,内部调用已有的 Service。review 节点会把大模型返回的评论列表写进 state 的 comments 字段。
6.3 条件边与中断恢复
human_approve 是这个案例里最核心的节点。它不实际等待,而是从外部“审批表”里读取人工审批结果。如果审批还没完成,图执行到这一步就停下来,State 快照被 Checkpoint 保存;管理员在系统里点完“通过”或“拒绝”后,图从断点继续执行。
这个设计比在代码里写一个阻塞队列或定时轮询要干净得多。状态图的恢复语义天然契合“人不在电脑前等你”的场景。具体到 LangGraph4j,Checkpoint 的落盘方式可以接 Redis 或数据库,把每个节点的 State 快照保存起来。
失败重试的逻辑也简单:review 节点里如果发现 attemptCount 小于阈值,并且评论内容为空,就重新调用一次大模型,attemptCount 加一。这本质上是一个自环边,状态图里画出来非常清楚。
6.4 这个案例的取舍
我做完这个案例最大的感受是:人工介入不再是一个特殊处理,而是一个普通节点。团队里其他同事看状态图,一眼就明白整个流程:拉取代码 -> 生成意见 -> 等人确认 -> 执行操作。
代价也不是没有。状态图多了之后,每个节点都要有单元测试,条件边要做路由测试,Checkpoint 要做持久化测试。如果是一个两个月就上线的临时工具,这些成本可能不划算。但对于要长期维护的业务流程,前期多写一点测试,后期省下的排查时间远超成本。
7. 我踩过几次坑之后留下的两个习惯
最后分享两个我养成的习惯,真的很管用。
第一,画图在先,写码在后。我不管用官方可视化、draw.io 还是直接在白板上画,一定先把节点和边画出来,状态字段就标注在旁边,然后再动代码。画的过程中你就能发现哪些状态字段是多余的,哪些边根本走不到。先画图再编码,看起来多了一步,实际上能改掉大半流程设计错误。
第二,给每个节点写一个最小单元测试。喂一个构造好的 State,断言返回的 Map 字段和下一个路由 key。状态图框架很容易让人觉得“图是整体的,测不了局部”,其实完全相反。每个 Node 都是纯函数般的单元,独立测试非常舒服。
如果看完你还不确定该不该上状态图,那就用最笨的标准判断:你的流程里是不是有两个以上不同方向的下一步?是不是有“失败后换一条路重试”的诉求?是不是需要人来拍板?只要中了其中一条,就可以用 LangGraph4j 替换掉手写流程;如果全都没有,普通 Service 代码就是最好的方案。状态图是个好工具,但它不是拿来炫技的。