Skip to main content

节点明明执行了,状态却丢了

· 4 min read

最近尝试着把原来基于 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

很多源码设计瞬间就合理了。