1. 从一次线上事故说起:为什么我要啃langgraph4j的源码
去年底我接手了一个Java技术栈的智能体项目,业务侧要求把原来基于规则引擎的工单处理流程升级成能自主决策的Agent。团队一开始想用Python那套LangGraph,但我们的服务全是Spring Boot,跨语言调用带来的运维成本和序列化开销实在受不了。后来发现了langgraph4j——一个把LangGraph核心思想移植到Java生态的Agent编排框架。当时我天真地以为,照着官方README跑通几个Demo就能上手,结果第一个版本上线三天就出了事故:多个Agent节点在并发场景下状态互相覆盖,导致工单被重复处理。
排查了两天才定位到问题:我对langgraph4j的状态图(StateGraph)和通道(Channel)机制理解太浅,把共享状态当成了普通的HashMap在用。那次事故之后,我花了整整两周时间把langgraph4j的源码从头到尾读了一遍,从StateGraph的构建到CompiledGraph的执行,从Channel的读写语义到Checkpoint的持久化策略。这篇文章就是那次源码阅读的完整沉淀,我会从架构层面把langgraph4j拆开给你看,讲清楚它为什么这么设计、每个核心类承担什么职责、以及在实际项目中怎么用才不踩坑。
如果你正在做Java技术栈的Agent开发,或者纠结于“现在到底用Spring AI还是langgraph4j”,又或者你只是想搞明白一个Agent框架的骨架到底长什么样,那这篇内容应该能帮到你。我不会只贴源码截图,而是会结合我踩过的坑,把每个设计决策背后的“为什么”讲透。
2. langgraph4j的整体分层:它到底由哪几块拼起来
2.1 从包结构看设计者的意图
拿到一个开源框架,我习惯先看它的包结构,因为包名往往暴露了作者对职责边界的划分。langgraph4j的核心包大致可以分成这么几层:
org.bsc.langgraph4j:顶层入口,包含StateGraph、CompiledGraph、AgentState这些面向使用者的核心类。org.bsc.langgraph4j.state:状态管理相关,AgentState、Channel、Reducers都在这里。org.bsc.langgraph4j.action:节点行为的抽象,NodeAction、EdgeAction等函数式接口。org.bsc.langgraph4j.checkpoint:持久化与断点恢复,Checkpoint、CheckpointSaver。org.bsc.langgraph4j.prebuilt:预置的图模板,比如MessagesStateGraph。org.bsc.langgraph4j.spring.ai:和Spring AI的适配层(如果你用Spring AI做模型调用,这层很关键)。
这个分层其实对应了一个Agent框架最核心的四个问题:图怎么定义、状态怎么管、节点怎么执行、执行过程怎么存。langgraph4j把每个问题都拆成了独立的包,彼此通过接口解耦。这种设计的好处是,你可以只替换其中一层而不影响其他层——比如把CheckpointSaver从内存实现换成基于Redis的实现,上层图定义完全不用改。
2.2 核心类之间的关系网
光看包还不够,得把核心类之间的引用关系理清楚。我用一张表把几个关键类的职责和依赖关系列出来:
| 类名 | 所在包 | 核心职责 | 依赖关系 |
|---|---|---|---|
StateGraph | 顶层 | 图的构建器,定义节点、边、状态schema | 持有AgentState的schema定义 |
CompiledGraph | 顶层 | 图编译后的可执行形态,负责调度节点执行 | 依赖Channel读写状态,依赖CheckpointSaver持久化 |
AgentState | state | 状态的不可变快照,本质是一个Map的包装 | 内部由多个Channel组成 |
Channel | state | 单个状态字段的读写语义抽象 | 被AgentState持有 |
NodeAction | action | 节点执行逻辑的函数式接口 | 接收AgentState,返回状态更新 |
Checkpoint | checkpoint | 某一时刻完整状态的快照 | 由CheckpointSaver存取 |
理解这张表的关键在于:StateGraph是“图纸”,CompiledGraph是“机器”,AgentState是“物料”,Channel是“传送带”。图纸定义机器怎么组装,机器运行时通过传送带搬运物料,而Checkpoint就是给物料拍快照存档。
2.3 为什么要有“编译”这一步
很多人第一次用langgraph4j会疑惑:为什么定义完图之后还要调一个compile()方法?直接执行不行吗?这个设计其实是借鉴了LangGraph Python版的思路,但背后有实打实的工程理由。
图定义阶段(StateGraph)做的是声明式的工作:你告诉框架有哪些节点、节点之间怎么连、状态有哪些字段。这个阶段是纯配置,不涉及任何执行逻辑。而compile()做的是校验和优化:它会检查图是否有环、是否有孤立节点、边的起点终点是否都存在、状态schema是否完整。校验通过后,生成一个CompiledGraph,这个对象持有执行所需的所有运行时信息。
我实测下来,这个分离带来的最大好处是图定义可以被复用和缓存。比如你有十个不同的Agent流程,它们共享同一套状态schema和部分节点,你可以把公共部分抽成一个方法返回StateGraph,然后各自compile()出不同的执行图。如果图定义和执行混在一起,这种复用就很难做。
注意:
compile()不是线程安全的,如果你在多线程环境里动态编译图,需要自己加锁。我踩过一次坑,两个线程同时编译同一个StateGraph实例,结果生成的CompiledGraph里节点顺序错乱。后来改成每个线程持有独立的StateGraph实例才解决。
3. 状态管理的底层逻辑:Channel才是真正的核心
3.1 AgentState不是简单的Map
刚接触langgraph4j时,我以为AgentState就是个Map<String, Object>的包装,直到我看了它的源码才发现完全不是。AgentState内部维护的是一个Map<String, Channel>,每个状态字段对应一个Channel。这个设计是整个框架最精妙的地方,也是我那次线上事故的根源。
Channel定义了单个字段的读写语义。举个例子,普通字段的Channel就是简单的覆盖写:新值直接替换旧值。但有些字段需要的是追加语义,比如对话历史,每次节点执行都应该往列表里加消息,而不是覆盖。langgraph4j通过不同的Channel实现来区分这些语义:
LastValueChannel:默认实现,新值覆盖旧值。AppendChannel:追加语义,适合消息列表、日志等。BinaryOperatorChannel:自定义聚合函数,你可以传入一个BinaryOperator来决定新旧值怎么合并。
这个设计的价值在于:状态的合并逻辑被下沉到了Channel层,节点只需要返回“我要更新什么”,不需要关心“怎么合并”。节点返回一个Map<String, Object>表示状态更新,框架会根据每个字段对应的Channel类型自动执行合并。
3.2 并发场景下的状态合并:我踩过的那个坑
回到开头说的那次事故。我们的工单处理流程里有一个processedOrders字段,用来记录已经处理过的工单ID。我当时的写法是把它当成普通字段,节点里直接state.get("processedOrders")拿到列表,加一个新ID,再put回去。单线程跑没问题,但线上是多线程并发处理不同工单,两个节点同时读到同一个旧列表,各自加了一个ID再写回,结果就是后写的覆盖了先写的,丢了一个工单记录。
正确的做法是把这个字段定义成AppendChannel,节点只需要返回Map.of("processedOrders", newOrderId),框架会自动把新ID追加到列表里。这样即使两个节点并发执行,只要它们的更新最终被框架按顺序合并,就不会丢数据。
// 错误做法:手动读-改-写,并发下会丢更新 Map<String, Object> update = new HashMap<>(); List<String> orders = (List<String>) state.get("processedOrders"); orders.add(newOrderId); update.put("processedOrders", orders); // 正确做法:声明为AppendChannel,节点只返回增量 var schema = Map.of( "processedOrders", Channels.appendChannel() ); // 节点内 return Map.of("processedOrders", newOrderId);这个坑让我明白一个道理:在Agent框架里,状态不是“数据”,而是“数据+合并规则”。你定义状态schema的时候,实际上是在定义一套并发安全的合并协议。忽略这一点,单机测试再充分,上线也会出问题。
3.3 Reducer:状态合并的扩展点
除了内置的Channel类型,langgraph4j还提供了Reducers工具类,让你可以自定义合并逻辑。比如你有一个Map<String, Integer>类型的计数器字段,希望多个节点的更新能按key累加,就可以写一个自定义Reducer:
Reducer<Map<String, Integer>> mergeCounters = (oldMap, newMap) -> { Map<String, Integer> merged = new HashMap<>(oldMap); newMap.forEach((k, v) -> merged.merge(k, v, Integer::sum)); return merged; };然后在定义状态schema时用Channels.reducerChannel(mergeCounters)。这个扩展点的存在,意味着langgraph4j的状态管理不是写死的,你可以根据业务需要定义任意复杂的合并语义。我在一个多Agent协作的项目里,用自定义Reducer实现了“多个Agent对同一份评分表的加权平均”,效果很好。
4. 节点与边的执行模型:CompiledGraph怎么跑起来
4.1 节点执行的完整生命周期
CompiledGraph的执行入口是invoke()或stream()方法。当你调用invoke(initialState)时,框架内部经历这么几个阶段:
- 初始化状态:把传入的初始状态包装成
AgentState,根据schema为每个字段创建对应的Channel。 - 确定入口节点:从图的入口节点开始,通常是
START节点。 - 执行节点:调用节点的
NodeAction.apply(state),拿到返回的状态更新Map。 - 合并状态:根据每个字段的Channel类型,把更新合并到当前状态,生成新的
AgentState。 - 计算下一步:根据当前节点的出边和条件边,决定下一个要执行的节点。
- 循环或终止:如果下一个节点是
END,执行结束;否则回到第3步。
这个流程看起来简单,但有几个细节值得深挖。首先是状态的不可变性:每次合并都会生成新的AgentState实例,而不是原地修改。这个设计保证了执行过程中的状态快照是稳定的,方便做Checkpoint和回滚。代价是有一定的对象创建开销,但在Agent场景下,节点执行本身(比如调用大模型)的耗时远大于对象创建,所以这个开销可以忽略。
4.2 条件边与路由:Agent的“决策”发生在哪里
Agent和普通工作流最大的区别在于运行时决策:下一步走哪个节点不是写死的,而是根据当前状态动态决定的。langgraph4j用条件边(Conditional Edge)来实现这个能力。
条件边的定义方式是给某个节点添加一个路由函数,这个函数接收当前状态,返回下一个节点的名称:
graph.addConditionalEdges( "classify", state -> { String category = (String) state.get("category"); return switch (category) { case "refund" -> "handleRefund"; case "complaint" -> "handleComplaint"; default -> "handleGeneral"; }; }, Map.of( "handleRefund", "handleRefund", "handleComplaint", "handleComplaint", "handleGeneral", "handleGeneral" ) );这里有个容易忽略的点:路由函数的返回值必须是第三个参数Map里的key之一,否则运行时会抛异常。我建议在路由函数里加一个default分支兜底,避免因为状态字段缺失导致整个图执行失败。
从架构角度看,条件边把“决策逻辑”和“执行逻辑”分离了。节点只负责干活,路由函数只负责决策,两者通过状态通信。这种分离让Agent的行为更容易测试:你可以单独测试路由函数在各种状态下的返回值,不需要真的执行节点。
4.3 并行执行与扇出扇入
langgraph4j支持从一个节点扇出到多个节点并行执行,然后再扇入汇聚。这个能力在多Agent协作场景下特别有用,比如你有一个“调研”节点,需要同时调用三个不同的信息检索Agent,然后汇总结果。
实现方式是在一个节点后面添加多条出边,框架会并行调度这些下游节点。但这里有个关键问题:并行节点的状态更新怎么合并?答案还是Channel。如果多个并行节点都更新同一个字段,框架会按照Channel定义的合并规则依次合并。如果这个字段是LastValueChannel,那最终值取决于哪个节点先完成——这是不确定的,所以并行场景下一定要用AppendChannel或自定义Reducer。
我实测下来,langgraph4j的并行调度是基于Java的CompletableFuture实现的,默认使用ForkJoinPool.commonPool()。如果你的节点是IO密集型的(比如调用外部API),建议传入自定义的Executor,避免commonPool被占满影响其他任务。
5. Checkpoint机制:Agent执行的可恢复性设计
5.1 为什么Agent框架必须要有Checkpoint
普通的工作流引擎,执行失败了重跑一遍就行。但Agent不一样:Agent的节点执行往往涉及大模型调用,一次调用可能花几毛钱、几秒钟,而且大模型的输出是不确定的——同样的输入重跑可能得到不同的结果。如果执行到第8个节点失败了,你肯定不希望从第1个节点重新跑一遍。
Checkpoint机制就是解决这个问题的:在每个节点执行完成后,把当前完整状态存一份快照。如果后续节点失败,可以从最近的快照恢复,只重跑失败的节点。langgraph4j的CheckpointSaver接口定义了快照的存取契约,框架内置了内存实现,你也可以自己实现基于数据库或Redis的持久化。
5.2 Checkpoint的存储结构与恢复流程
一个Checkpoint对象包含的信息比你想的要多:
- 状态快照:当前所有Channel的值。
- 节点ID:快照是在哪个节点执行后生成的。
- 版本号:用于处理并发写入冲突。
- 父Checkpoint ID:形成一条链,支持回溯到任意历史状态。
恢复的时候,框架根据Checkpoint里的节点ID,找到该节点的出边,从下一个节点继续执行。这里有个细节:恢复时不会重新执行已经完成的节点,但会重新计算路由。也就是说,如果路由函数依赖的状态在快照之后没有变化,路由结果是一致的;如果路由函数有随机性(比如调用了大模型做决策),恢复后的路径可能和原来不同。这一点在设计Agent流程时要特别注意,尽量让路由函数是确定性的。
5.3 自定义CheckpointSaver的实操要点
内置的内存Saver只适合开发和测试,生产环境必须换成持久化实现。我自己实现过一个基于Redis的CheckpointSaver,分享几个关键点:
- 序列化:
AgentState里的值可能是任意Java对象,需要选择兼容性好的序列化方案。我用的是Jackson,但要注意配置FAIL_ON_UNKNOWN_PROPERTIES=false,否则版本升级后反序列化旧快照会失败。 - Key设计:建议用
{threadId}:{checkpointId}作为Redis key,threadId对应一次完整的Agent执行会话,checkpointId是快照ID。这样按会话查询和清理都很方便。 - 过期策略:Agent执行完成后,历史快照不需要永久保留。我设置的是7天过期,既够排查问题,又不会无限膨胀。
- 并发控制:多个线程可能同时写同一个会话的Checkpoint,需要用Redis的乐观锁(WATCH/MULTI)或分布式锁保证版本号递增的正确性。
提示:如果你用的是Spring AI做模型调用,langgraph4j的
spring-ai模块已经提供了和Spring AIChatMemory的集成,Checkpoint可以直接复用Spring AI的会话存储。但要注意两者的生命周期管理,我遇到过Checkpoint没清理导致内存泄漏的情况。
6. 和Spring AI的边界:到底该用哪个
6.1 两者解决的不是同一个问题
“现在到底用Spring AI还是langgraph4j”这个问题我被问过很多次。我的回答是:它们不是竞品,是互补的。Spring AI解决的是“怎么调大模型、怎么管理Prompt、怎么处理多模态输入”,它是一套模型接入和抽象层。langgraph4j解决的是“多个Agent节点怎么编排、状态怎么流转、执行怎么恢复”,它是一套流程编排层。
一个典型的Java Agent项目,两者都会用到:用Spring AI的ChatClient在节点里调模型,用langgraph4j的StateGraph编排节点。langgraph4j的spring-ai模块就是干这个粘合工作的,它提供了SpringAiNodeAction之类的适配类,让你在节点里直接注入ChatClient。
6.2 选型决策表
| 你的需求 | 推荐方案 | 理由 |
|---|---|---|
| 只是简单调模型,单轮问答 | Spring AI足够 | 不需要图编排,引入langgraph4j是过度设计 |
| 多步骤流程,但步骤固定 | Spring AI + 简单状态机 | 用langgraph4j也可以,但收益不明显 |
| 需要动态路由、条件分支 | langgraph4j | 条件边是它的核心能力 |
| 需要执行中断恢复 | langgraph4j | Checkpoint机制是刚需 |
| 多Agent协作、并行扇出 | langgraph4j | 并行调度和状态合并是它的强项 |
| 需要可视化执行流程 | langgraph4j | 图结构天然适合可视化 |
我个人的经验是:当你的Agent流程超过5个节点,或者有超过2个条件分支,或者需要执行恢复能力时,langgraph4j的价值就体现出来了。低于这个复杂度,用Spring AI加一些if-else可能更简单直接。
6.3 集成时的版本兼容坑
langgraph4j的spring-ai模块对Spring AI的版本有要求,我踩过一次版本不匹配的坑:项目里用的是Spring AI 1.0.0-M2,但langgraph4j的适配层是针对M1写的,结果ChatClient的API签名对不上,编译都过不了。后来把Spring AI降到M1才解决。
建议在pom.xml里显式锁定两者的版本,并且定期关注langgraph4j的release notes,看它适配到了哪个Spring AI版本。如果你用的是Spring Boot 3.x,还要注意JDK版本要求——langgraph4j最低要求JDK 17,如果你还在用JDK 11,需要先升级。
7. 源码阅读的方法论:我是怎么啃下这块硬骨头的
7.1 从测试用例入手比从源码入手快
很多人读源码的习惯是从main类或者核心类开始逐行看,但这样效率很低,容易迷失在细节里。我的方法是先看测试用例。langgraph4j的src/test/java目录下有大量测试,每个测试都对应一个具体的使用场景。通过看测试,你能快速理解“这个类是用来干什么的”“怎么用”“边界情况是什么”。
比如我想搞明白AppendChannel的行为,直接找到对应的测试类,看它怎么构造状态、怎么执行节点、断言什么结果。看完测试再回头看源码,目标就明确多了。
7.2 用调试器跟踪一次完整执行
光看代码不够,还得实际跑一遍。我在CompiledGraph.invoke()方法里打了断点,然后跑一个最简单的两节点图,单步跟踪整个执行流程。这样能直观地看到:
AgentState是怎么从初始Map构造出来的。- 每个
Channel是怎么被创建和初始化的。 - 节点执行后返回的Map是怎么被合并的。
- 路由函数是在哪一步被调用的。
这个过程比看十遍源码都管用。我建议你也这么做,尤其是当你看不懂某个设计为什么要这样的时候,跑一遍就明白了。
7.3 画一张自己的类图
最后一步,我会根据自己的理解画一张类图(不用工具,纸上画就行),把核心类和它们的关系画出来。画的过程中如果发现某个关系说不清楚,说明那里还没理解透,回去再看。这张图不用给别人看,但画完你对框架的理解会上一个台阶。
我画的那张图里,最关键的一条线是CompiledGraph到Channel的依赖:CompiledGraph不直接操作状态值,而是通过Channel间接读写。这条线理解清楚了,整个框架的设计哲学就通了——一切状态变更都要经过Channel,Channel是状态安全的守门人。
8. 几个实际项目中的经验教训
第一个教训是关于状态字段的粒度。我一开始把太多东西塞进状态里,包括一些只在单个节点内部用的临时变量。结果状态快照变得很大,Checkpoint存储开销高,而且并发合并时冲突概率也大。后来我把状态字段精简到只保留“需要跨节点共享”的数据,临时变量放在节点方法内部,问题就解决了。
第二个教训是关于节点职责的划分。我见过有人把一个复杂的业务逻辑全塞进一个节点,导致这个节点代码上千行,测试困难,复用性差。正确的做法是按“单一职责”拆分节点,每个节点只做一件事。langgraph4j的图编排能力就是为这种细粒度拆分准备的,拆得越细,路由越灵活。
第三个教训是关于异常处理。节点执行抛异常时,CompiledGraph默认会中断整个执行。但有些异常是可恢复的(比如外部API超时),你希望重试而不是中断。我的做法是在节点内部捕获可恢复异常,返回一个标记了错误信息的状态更新,然后通过条件边路由到一个“错误处理”节点。这样整个流程不会因为一次超时就全盘失败。
第四个教训是关于日志和可观测性。Agent执行链路长,出问题时如果没有足够的日志,排查起来很痛苦。我在每个节点的入口和出口都加了结构化日志,记录节点ID、输入状态摘要、输出状态摘要、耗时。配合Checkpoint的节点ID,能快速定位是哪个节点出了问题。
9. 后续还可以往哪些方向深入
这篇主要讲的是架构层面的东西,langgraph4j还有很多细节值得单独拿出来分析。比如AgentState的序列化机制,它怎么处理不可序列化的对象;比如CompiledGraph的并行调度器,它怎么决定哪些节点可以并行;比如和Spring AI集成的具体实现,ChatClient是怎么被注入到节点里的。
我接下来打算写一篇关于langgraph4j状态序列化和Checkpoint持久化的深度分析,因为这块在实际生产环境里坑最多。如果你也在用这个框架,欢迎交流你踩过的坑。源码阅读这件事,一个人看容易钻牛角尖,多几个人讨论,很多设计意图就清晰了。