LangGraph4j之三:状态与通道

LangGraph4j之三:状态与通道

我把上一篇那个客服分流器往下做了一层。分流器跑起来之后,我很快撞上一个真实需求:机器人越聊话越多,每一轮都得把之前的对话都带在状态里,可有些话是临时的,比如用户发错、改口,或者我不该记住的敏感内容,得能让它忘掉。更麻烦的是,不同信息的合并方式根本不一样:对话历史要一条条累加,当前处理阶段是后来的覆盖前一个,而某一条具体消息要能按内容删掉。我一开始在一个大 Map 里手动写 if else 去区分这三种合并,写到三十行就越写越乱,还老分不清哪条消息该留该删。后来我才看懂,LangGraph4j 把这件事拆成了两半:状态本身只是一个被所有节点共享的 Map,每个键配一个通道,通道专门决定这个键的新值怎么和旧值合并。这篇我专门拆状态与通道这一层。

本篇只做一件事:把状态与通道这一层拆透,重点讲一份共享 Map 是怎么被合并出来的。 很多人一上手就以为状态是「每个节点各自一份、用完再合并回来」,于是拼命在节点之间倒腾数据。

状态只是一个被所有节点共享的 Map

AgentState 这个类,源码里核心就一个字段:private final Map<String,Object> data。它对外暴露 data()、value(String) 这些方法,但底下都是在这一份 Map 上读读写写。节点之间从不直接传对象,一个节点跑完,返回的是「我想更新的那几个键值」,也就是一张局部的小 Map,框架再把它合并进那份共享的 data。所以状态不是流水线上的包裹,不是从这个节点递到那个节点,它是一块所有节点都能读写的公共黑板。这也解释了为什么下一篇要讲的子图能和父图共享状态:它们操作的自始至终是同一份 data。

通道是每个键的合并策略

那「新值怎么和旧值合并」这件事,到底谁说了算?答案是通道。声明图的时候你会传一张 Map<String,Channel<?>>,一个键对应一个 Channel,这个通道就绑定了该键的合并规则。通道背后是一个 Reducer<T>,它的定义是 interface Reducer<T> extends BiFunction<T,T,T>,也就是「旧值和新值进去,一个新值出来」。最常用的两个工厂方法在 Channels 接口上:Channels.appender(Supplier) 得到的是会把新 List 追加进旧 List 的通道,Channels.base(Supplier) 得到的是新值直接覆盖旧值的通道。换句话说,状态里每个键是累加还是覆盖,不是节点运行时决定的,是你在建图时用通道声明死的。

updateState 是合并的入口

节点返回的局部 Map,不是直接 put 进共享 Map 的,中间隔着一层 AgentState.updateState。我把它的字节码读了一遍,流程是这样的:先调 updatePartialStateFromSchema,对着局部 Map 的每个键,取该键对应的通道,执行一次 channel.update(键, 当前旧值, 节点新值),拿到归约后的值;然后再把旧状态和这份归约结果并起来,归约结果覆盖旧值,值为 null 的键直接丢弃。所以一次合并其实是两步:先逐键按各自的通道归约,再整体收集、新值胜出、扔到 null。这也解释了为什么你不能靠返回一个 null 去「清空」某个键。

AgentState 到底装着什么

把 AgentState 拆开看,它比你想象的空。一个 private final Map<String,Object> data,外加一个静态的合并入口。它提供两类读方法:value(String) 返回 Optional,value(String, T) 和 value(String, Supplier) 提供带默认值的读法。真正关键的是那两个静态方法 updateState(Map current, Map partial, Map<String,Channel<?>>) 和它的 AgentState 重载。前者是引擎每个节点跑完后调用的地方。我读字节码时特别确认了参数顺序:引擎调用通道时传的是 channel.update(key, currentState.get(key), entry.getValue()),也就是第二个参数是旧值、第三个参数才是节点返回的新值。这个顺序在理解 appender 的删除分支时是决定性的,我一开始就把它搞反了。

三种合并,逐个拆

回到我那个要能「忘记」的机器人。我用了两个通道:messages 挂 Channels.appender,stage 挂 Channels.base。

appender 通道的实现在 AppenderChannel 里,它自己重写了 update。节点返回的 List 会被规范化之后追加进旧 List。默认的 Channels.appender(Supplier) 用的 reducer 是 ReducerDisallowDuplicate,也就是相同的元素不会进第二次;如果你确实想留重复,得显式用 Channels.appenderWithDuplicate(Supplier),它换成了 ReducerAllowDuplicate。这就是为什么同样是「往列表里加」,加两条一样的文本,第二次会凭空消失。

base 通道走的是 Channel 接口的默认 update:新值非空时 reducer.apply(新值, 旧值),它的 reducer 是最后写赢的 (a, b) -> a,所以新值直接覆盖旧值。我的 stage 从 received 被下一个节点写成 triaged,靠的就是这个。

最妙的是删除。AppenderChannel.update 里有一条分支:当旧值是 List、而新值是一个 RemoveIdentifier 时,它走的是 remove,只删不加。框架给了一个现成的删除标识 RemoveByHash,它按元素内容比对,compareTo 返回 0 就删。于是我在 forget 节点返回 RemoveByHash.of("user: 我要退货"),那条消息就被从累积列表里精准摘掉了,别的消息原地不动。

我踩过的四个坑

第一个坑,appender 默认去重。我往 messages 里连着加了两条一模一样的确认语,结果第二条像掉进了黑洞。排查半天才发现 Channels.appender 默认挂的是去重 reducer。解法是要么保证每条消息内容唯一,要么改用 appenderWithDuplicate。

第二个坑,一个节点里既想删又想加,行不通。我图省事,在 forget 节点里同时返回一条新消息和一个 RemoveByHash,结果只有删除生效,新消息没进去。回头看字节码就明白了:新值是 RemoveIdentifier 时走的是「只删不加」的 remove 分支。解法是把追加和删除拆成两个节点。

第三个坑,返回 null 清空不了键。我想把某个标记重置成空,节点里返回了 key -> null,结果旧值还在。因为 updateState 收集时会把值为 null 的键直接丢弃,根本到不了覆盖那一步。解法是想清空就写一个空值(比如空字符串或空列表),而不是 null。

第四个坑,appender 的默认值给了不可变 List。我一开始用 List.of() 当默认列表,一追加就抛 UnsupportedOperationException。AppenderChannel 会捕获这个异常、打一条日志提示「可能用了不可变 List」再抛出来。解法是默认 provider 必须返回 new ArrayList<>()。

一个能跑的最小例子

下面这张图不调任何真实大模型,全部是确定性逻辑,所以输出可以逐字符复现。它演示三种合并:ingest 和 tag 各自往 messages 追加一条(累积),并把 stage 覆盖成新值(覆盖),最后 forget 用 RemoveByHash 删掉那条用户消息(删除)。

java 复制代码
// 状态 schema:messages 是 appender 累积通道,stage 是 base 覆盖通道
Map<String, Channel<?>> channels = new HashMap<>();
channels.put("messages", Channels.appender(() -> new ArrayList<>()));
channels.put("stage", Channels.base(() -> ""));

StateGraph<ChatState> g = new StateGraph<>(channels, ChatState.FACTORY);

// ingest:追加一条用户消息,同时把 stage 覆盖成 received
g.addNode("ingest", node(s -> Map.of(
        "messages", List.of("user: 我要退货"),
        "stage", "received")));
// tag:再追加一条机器人消息(于是累积成两条),stage 覆盖成 triaged
g.addNode("tag", node(s -> Map.of(
        "messages", List.of("bot: 已记录"),
        "stage", "triaged")));
// forget:返回一个 RemoveByHash 删除标识,删掉「user: 我要退货」这一条
g.addNode("forget", node(s -> Map.of(
        "messages", RemoveByHash.of("user: 我要退货"))));

g.addEdge(StateGraph.START, "ingest");
g.addEdge("ingest", "tag");
g.addEdge("tag", "forget");
g.addEdge("forget", StateGraph.END);

实际运行结果(已实跑,可复现)如下:

ini 复制代码
生命周期          = [__START__, ingest, tag, forget, __END__]
final.messages   = [bot: 已记录]
final.stage      = triaged

你可以对照着看:final.messages 只剩 [bot: 已记录],说明 ingest 和 tag 各自追加过一条、累积成功,随后 forget 又按内容把 user: 我要退货 删掉,只剩 bot 那条;final.stage 是 triaged 而不是 received,说明 base 通道是后来的值覆盖前一个。三种合并语义,一张共享 Map,全在这三行输出里。

复现

代码地址:github.com/beverlyLee/...

运行命令(在 code/repro/ 目录下):

bash 复制代码
mvn -q compile
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
java -cp "target/classes:$(cat cp.txt)" org.example.langgraph4j.Demo3

结尾

回到开头那个要「忘记」的机器人:你现在系统里的那份对话历史,每一轮是有人按规则逐条合并的,还是干脆整体替换?如果哪天产品说「把用户半小时前那条敏感消息从上下文里抹掉」,你的状态层是能按内容精准删除,还是只能连锅端、整段重来?想清楚这个,你就明白通道不是语法糖,是状态合并的决策点。下一篇我们拆运行引擎与流式,看节点是凭什么顺序被一个个驱动起来的,stream 吐出来的每一帧又到底承载了什么。

相关推荐
桃西西呀1 小时前
LangGraph4j之二:图模型与节点边
人工智能·llm·ai编程
hrrrrxeeeee1 小时前
AI底层小知识:什么是Token?它如何影响你的AI使用成本?
人工智能
一水鉴天1 小时前
UML 元层级架构与制造技术(MOM)的对应关系探讨 20261012(元宝)
人工智能·机器学习
深圳市快瞳科技有限公司1 小时前
城市犬只身份底库工程实战:图片检测、分片索引、场景阀值与本地比对落地方案
人工智能
CCIE-Yasuo1 小时前
个人电脑搭建AI-Qwen3-8B-学习记录
人工智能·python·学习
奈落241 小时前
把 CLI 当工程系统用:从 Plan 模式到截图复现,Codex CLI 类工具的正确打开方式
人工智能·工作流·ai 编程
技灵AI1 小时前
AI 家具视频提示词教程:单张客厅参考图生成竖版带货短片,提示词逐层怎么写
人工智能·prompt·aigc·电商运营
拜小白1 小时前
【论文解读】HoHoNet:用潜在水平特征做360°室内全景的整体理解
人工智能·计算机视觉·全景图
众链网络1 小时前
Agent 重试的工程解法:幂等、断点续跑与熔断怎么落地
java·前端·数据库·人工智能