节点明明执行了,状态却丢了
最近尝试着把原来基于 LangChain4j 的串行流程改造成 LangGraph 工作流,遇到了一个问题。 这种场景在实际项目里非常常见,而且很容易让人怀疑是框架 Bug,最后却发现是自己对 LangGraph 状态机制理解不够。
节点明明执行了,状态却丢了
整体流程很简单:
用户评价
↓
情感分析
↓
意图识别
↓
知识库检索
↓
回复生成
↓
人工审核
因为后面还计划增加条件路由和人工节点,所以决定尝试一下LangGraph。
刚开始开发的时候非常顺利,节点也都跑通了。
结果到了第三个节点突然出现一个特别诡异的问题。
问题现象
情感分析节点输出正常:
state.put("sentiment", "NEGATIVE");
日志也能看到:
EmotionNode execute
sentiment=NEGATIVE
到了意图识别节点:
String sentiment = state.get("sentiment");
结果直接变成:
null
整个流程继续往下走。
然后生成回复时报错:
java.lang.NullPointerException
第一反应是:
LangGraph状态没有传递成功?
于是开始疯狂排查。
第一轮排查
先怀疑是不是Spring Bean作用域问题。
因为节点都是:
@Component
public class EmotionNode implements Node
怀疑多个请求共享状态。
于是加日志:
System.out.println(state);
发现每个请求拿到的 state 都不一样。
排除。
第二轮排查
怀疑是不是异步执行导致。
因为工作流配置里用了:
ExecutorService executor =
Executors.newFixedThreadPool(10);
于是把线程池全部关掉。
改成单线程。
问题依旧。
第三轮排查
开始翻官方文档。
看到一句不起眼的话:
State updates are merged using reducers.
当时完全没理解。
继续往下看源码。
发现异常点
在 LangGraph 中:
节点并不是直接修改全局状态。
而是:
return Map.of(
"sentiment",
"NEGATIVE"
);
框架会把节点返回结果合并到 State 中。
而我写成了:
state.put("sentiment", "NEGATIVE");
return state;
看起来没问题。
实际上问题就出在这里。
LangGraph状态机制原理
很多人第一次接触 LangGraph 都会下意识认为:
Node A
↓
修改State
↓
Node B读取State
实际上内部更接近:
State
↓
Node A
↓
Delta
↓
Reducer
↓
New State
也就是说:
节点返回的是状态增量(Delta)。
框架负责合并。
而不是直接共享同一个对象。
所以:
state.put(...)
只是改了当前节点拿到的对象。
最终状态树未必会使用这个对象。
尤其涉及:
并行节点
条件路由
Checkpoint恢复
时更明显。
为什么设计成这样
后来读源码才理解。
假设有两个并行节点:
→ NodeB →
NodeA →
→ NodeC →
NodeB返回:
{
"intent":"物流问题"
}
NodeC返回:
{
"product":"苹果"
}
框架最后统一合并:
{
"intent":"物流问题",
"product":"苹果"
}
如果所有节点都直接修改同一个对象:
sharedState
那么并发冲突会非常严重。
所以 LangGraph 采用:
节点输出
↓
Reducer
↓
新State
的不可变状态思想。
这其实和 React 的 State 更新逻辑很像。
真正解决方案
节点不要修改原 State。
只返回变更内容。
错误写法:
public Map<String,Object> execute(
Map<String,Object> state){
state.put("sentiment","NEGATIVE");
return state;
}
正确写法:
public Map<String,Object> execute(
Map<String,Object> state){
return Map.of(
"sentiment",
"NEGATIVE"
);
}
再次执行:
EmotionNode execute
sentiment=NEGATIVE
IntentNode execute
sentiment=NEGATIVE
恢复正常。
第二个隐藏坑:字段覆盖
问题解决后没两天又遇到一个新坑。
知识库检索节点:
return Map.of(
"documents",
docs
);
生成节点:
return Map.of(
"documents",
rerankDocs
);
结果发现最终 State 中只剩下:
rerankDocs
原始召回结果消失。
最开始以为是框架缓存问题。
后来发现是 Reducer 默认行为。
默认情况下:
同名字段
=
后写覆盖前写
即:
documents = newValue
而不是:
documents.addAll(...)
为什么官方一直强调Reducer
看到这里终于理解官方文档为什么一直在讲:
Reducer
State Schema
Merge Strategy
因为 LangGraph 本质上不是流程编排框架。
而是:
状态驱动框架
流程只是表象。
State 才是核心。
所有节点都围绕 State 演化。
这也是它和传统工作流引擎最大的区别。
这个坑背后的启示
这次问题前后排查了接近两个小时。
从 Spring Bean、线程池、异步执行一路查到 LangGraph 源码,最后发现根本不是框架问题,而是自己把 LangGraph 当成了普通责任链模式来理解。
如果把 LangGraph 看成:
Node1
↓
Node2
↓
Node3
很多设计都会觉得奇怪。
但如果换个视角:
State
↓
Node
↓
State
↓
Node
↓
State
很多源码设计瞬间就合理了。