Spring AI Alibaba 深度技术系列-第 05 章 编译与执行引擎:CompiledGraph、GraphRunner 与三段式 Executor
前四章我们把「图是什么」讲完了:第 03 章讲状态怎么表达,第 04 章讲拓扑怎么声明和改写。到第四章结尾,你手里有的是一个 CompiledGraph------一个持有节点工厂、边表、并行节点和各类配置的不可变对象。
但 CompiledGraph 自己不会跑。
这一章要回答的是那个最直接的问题:当你写下 compiled.invoke(inputs, config) 的那一刻,到底发生了什么? 有多少个对象被创建?控制流在谁手里?线程在谁手里?状态在什么时候被更新?流是怎么穿过一层层 Flux 到达你手上的?
这一章的标题里有个词需要先解释------「三段式 Executor」。翻开 graph/executor 包,你会看到三个类:
| 类 | 行数 | 职责 |
|---|---|---|
BaseGraphExecutor |
74 | 抽象基类,只提供 handleCompletion 一个方法 |
MainGraphExecutor |
161 | 主循环:决定「下一步是继续走、还是停下来」 |
NodeExecutor |
823 | 单节点:执行一个节点并处理它的六种结果 |
它们构成一条互相递归 的链:MainGraphExecutor 决定要不要执行节点 → 调 NodeExecutor → NodeExecutor 执行完 → 又递归回 MainGraphExecutor。
没有 while 循环,没有 for,没有显式的调度队列。 整个执行引擎是一组互相递归、返回 Flux 的方法。这是本章最需要理解的一点,也是后面所有行为(栈安全、中断时机、流式顺序)的根源。
5.1 十七个入口,一个出口
CompiledGraph 对外暴露了十七个 public 的执行入口方法。第一次看这个类的人很容易被劝退,但它们全部收敛到同一个地方。
5.1.1 入口方法全景
先看事实------把类里的执行入口按返回类型分个类:
| 返回类型 | 方法 | 行号 |
|---|---|---|
Flux<NodeOutput> |
stream(Map, config) / stream(Map) / stream() / streamFromInitialNode(OverAllState, config) / streamSnapshots(Map, config) |
567---629 |
Flux<GraphResponse<NodeOutput>> |
graphResponseStream(Map, config) / graphResponseStream(OverAllState, config) |
545/549 |
Optional<OverAllState> |
invoke(Map, config) / invoke(OverAllState, config) / invoke(Map) |
639/650/661 |
Optional<NodeOutput> |
invokeAndGetOutput(OverAllState, config) / invokeAndGetOutput(Map, config) / invokeAndGetOutput(Map) |
665/669/673 |
GraphResponse<NodeOutput> |
invokeAndGetResponse(Map, config) / invokeAndGetResponse(OverAllState, config) |
537/541 |
ScheduledAgentTask |
schedule(ScheduleConfig) |
683 |
十七个方法,看起来多,但收敛关系非常干净:
scss
stream*(...) ──► stateCreate ──► new GraphRunner(this, config)
│
▼
runner.run(state) ← 唯一的出口
│
┌───────────────────┴───────────────────┐
▼ ▼
graphResponseStream 直接用 stream* 再 flatMap 一道
│ │
└─────────────► invoke* = (...).last().block()
唯一的出口是 GraphRunner.run()。 所有 invoke* 都是对应的 stream* 加一个 .last().block()。
注意那两个 graphResponseStream 虽然也是 stream* 家族的成员,但它们绕过了 streamFromInitialNode 的那道 flatMap ------直接返回 runner.run() 的结果。这个差异在 5.1.6 会显得很重要。
5.1.2 invoke 是 stream 的语法糖,而且会阻塞
这一点值得单独说,因为它有实际后果:
java
public Optional<OverAllState> invoke(Map<String, Object> inputs, RunnableConfig config) {
return Optional.ofNullable(stream(inputs, config).last().map(NodeOutput::state).block());
}
(.../graph/CompiledGraph.java:640)
java
public Optional<NodeOutput> invokeAndGetOutput(Map<String, Object> inputs, RunnableConfig config) {
return Optional.ofNullable(stream(inputs, config).last().block());
}
(.../graph/CompiledGraph.java:670)
三个推论:
推论一:invoke 会把整个流跑完。 中间产生的每一个 NodeOutput 都会被创建、被丢弃,只有最后一个被保留。如果你只想要最终状态,这没问题;但如果你以为 invoke 会「跳过中间步骤」,那不会------中间节点的业务逻辑全部照常执行。
推论二(重要):.block() 会阻塞调用线程。 在 Reactor 的响应式链路里(比如 WebFlux 的 Controller 里)调用 invoke(...),就是把响应式链路掐断、改成阻塞式。如果那个线程是 Reactor 的 event loop,你等于把整个应用的吞吐堵住了。
java
// ❌ 在 WebFlux 的 Controller 里这么写会阻塞 event loop
@GetMapping("/chat")
public String chat(@RequestParam String q) {
return agent.invoke(Map.of("messages", q)).get().value("answer").toString();
}
// ✅ 应该直接返回 Flux / Mono
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<NodeOutput> chat(@RequestParam String q) {
return agent.stream(Map.of("messages", q), config);
}
推论三:stream 是冷的,invoke 是热的。 stream(...) 返回的是一个还没订阅的 Flux,你订阅几它就跑几遍(每次都是独立的 GraphRunnerContext);invoke(...) 当场就跑完。
回扣第 03 章 3.10.6 那条硬规则:含阻塞 IO 的节点必须自己切
boundedElastic。 这条规则在这里有了第二个理由------不只是为了不饿死 Reactor 的 parallel 调度器,也是因为invoke(...)的.block()本身就在占用一个线程。你在 Reactor 线程上block()一个跑了 30 秒的 Agent,这 30 秒里那个线程什么都做不了。
5.1.3 GraphRunner:一个 65 行的薄壳
java
public class GraphRunner {
private final CompiledGraph compiledGraph;
private final RunnableConfig config;
private final AtomicReference<Object> resultValue = new AtomicReference<>();
// Handler for main execution flow - demonstrates encapsulation
private final MainGraphExecutor mainGraphExecutor;
public GraphRunner(CompiledGraph compiledGraph, RunnableConfig config) {
this.compiledGraph = compiledGraph;
this.config = config;
// Initialize the main execution handler - demonstrates encapsulation
this.mainGraphExecutor = new MainGraphExecutor();
}
public Flux<GraphResponse<NodeOutput>> run(OverAllState initialState) {
return Flux.defer(() -> {
try {
GraphRunnerContext context = new GraphRunnerContext(initialState, config, compiledGraph);
// Delegate to the main execution handler - demonstrates polymorphism
return mainGraphExecutor.execute(context, resultValue);
}
catch (Exception e) {
return Flux.error(e);
}
});
}
public Optional<Object> resultValue() {
return Optional.ofNullable(resultValue.get());
}
}
(.../graph/GraphRunner.java:30---65)
这是整个执行引擎的全部入口代码。65 行。
几个观察:
① 注释暴露了它的来历。 三处注释分别是 demonstrates encapsulation、demonstrates polymorphism、demonstrates polymorphism。这些不是给使用者看的注释,是作者留给自己看的 OOP 教学标记 ------这个类是一次「把过程式代码改写成 OOP」的重构产物。理解这一点很重要:它解释了为什么这套 Executor 里有不少「为了继承而继承」的结构(比如 BaseGraphExecutor 只有一个方法,MainGraphExecutor 和 NodeExecutor 各自 extends 它却几乎不共享行为)。
② MainGraphExecutor 在构造时创建,且持有 NodeExecutor。
java
public MainGraphExecutor() {
this.nodeExecutor = new NodeExecutor(this);
}
(.../graph/executor/MainGraphExecutor.java:45)
而 NodeExecutor 又持有 MainGraphExecutor:
java
public NodeExecutor(MainGraphExecutor mainGraphExecutor) {
this.mainGraphExecutor = mainGraphExecutor;
}
(.../graph/executor/NodeExecutor.java:72)
这是一个构造期循环引用 ------MainGraphExecutor 的构造器里 new NodeExecutor(this),而 NodeExecutor 的字段是 final MainGraphExecutor。Java 允许这样做(this 逃逸),但它意味着这两个对象只能成对创建、成对存活。
更重要的是:MainGraphExecutor 和 NodeExecutor 都是无状态的 (除了那个互相引用)。所有的执行期状态都在 GraphRunnerContext 里。这是好的设计------Executor 是策略,Context 是状态。
③ 每次 run() 都是新的 GraphRunner,但 resultValue 是它的实例字段。 这意味着 resultValue 的生命周期 = 一次 run() 调用。而由于 Flux.defer(见下),每次订阅都会创建一个新的 GraphRunnerContext------但 resultValue 不会重置 ,因为它是在构造器里创建的,不在 defer 里面。
这是本章第一个值得警惕的细节:GraphRunner 的字段生命周期和 GraphRunnerContext 的生命周期不一致。 前者跟着 GraphRunner 实例,后者跟着订阅。
5.1.4 Flux.defer 的三层含义
run() 里那个 Flux.defer(...) 不是随手写的,它承担了三件事:
java
return Flux.defer(() -> {
try {
GraphRunnerContext context = new GraphRunnerContext(initialState, config, compiledGraph);
return mainGraphExecutor.execute(context, resultValue);
}
catch (Exception e) {
return Flux.error(e);
}
});
含义一:把"创建"推迟到订阅时。
Flux.just(x) 是「先算好 x,再包成 Flux」;Flux.defer(() -> ...) 是「订阅的时候才执行 lambda」。
如果这里用 Flux.just(...) 而不是 defer,那么 new GraphRunnerContext(...) 会在 run() 被调用时就执行------即使没人订阅 。defer 保证了:
不订阅,就不创建 Context;订阅几次,就创建几个 Context。
含义二:每次订阅都得到独立的执行上下文。
这是「冷流」的定义。同一个 Flux 对象订阅两次,会跑出两个完全独立的执行:
java
Flux<NodeOutput> flux = compiled.stream(inputs, config);
flux.blockLast(); // 第一次执行,Context A
flux.blockLast(); // 第二次执行,Context B(全新的,不共享任何状态)
如果没有 defer,两次订阅会共享同一个 GraphRunnerContext------而 GraphRunnerContext 是可变的,第二次执行会看到第一次留下的 currentNodeId、iteration 等残留,直接出错。
含义三:它让递归不爆栈。
这一点最关键,5.3.5 节会展开。简单说:Flux.defer(() -> execute(...)) 里的 execute 是在订阅时才被调用 的,而不是在构造 Flux 时。所以递归 1000 层时,调用栈上同时最多只有一层 execute。
5.1.5 那个零调用的 resultValue()
现在说本章第一个真正的发现。
GraphRunner 有一个 AtomicReference<Object> resultValue 字段,还有一个 resultValue() 方法。它还把 resultValue 传进了 mainGraphExecutor.execute(context, resultValue),并且在整条递归链上不断往下传------MainGraphExecutor 传给 handleCompletion、handleEndNode、NodeExecutor,NodeExecutor 又传给 handleActionResult、各个 handleXxx 方法,最终在某个地方 resultValue.set(...)。
整个链条看起来很完整。但 GraphRunner.resultValue() 这个方法,在整个代码库里没有任何调用点。
验证:
bash
# 谁调用了 runner.resultValue()?
grep -rn "\.resultValue()" --include=*.java spring-ai-alibaba-graph-core/src/main/java \
| grep -v AsyncGenerator | grep -v "GraphResponse.java"
输出全部是 data.resultValue()、response.resultValue()、lastGraphResponse.resultValue()------都是 GraphResponse 的方法,不是 GraphRunner 的。
而 CompiledGraph 里两处创建 Runner 的地方:
java
GraphRunner runner = new GraphRunner(this, config);
return runner.run(state); // ← 552/553,只用 run()
(.../graph/CompiledGraph.java:552)
java
GraphRunner runner = new GraphRunner(this, config);
return runner.run(overAllState).flatMap(data -> { ... }); // ← 581/582,也只用 run()
(.../graph/CompiledGraph.java:581)
两处都只调 run(),从不调 resultValue()。
那么,resultValue 是不是在链内就没人用了?不是------链内还在用,只是对外没有出口。看它唯一被真正消费的地方:
java
protected Flux<GraphResponse<NodeOutput>> handleCompletion(GraphRunnerContext context,
AtomicReference<Object> resultValue) {
return Flux.defer(() -> {
try {
if (context.getCompiledGraph().compileConfig.releaseThread()
&& context.getCompiledGraph().compileConfig.checkpointSaver().isPresent()) {
BaseCheckpointSaver.Tag tag = context
.getCompiledGraph().compileConfig.checkpointSaver()
.get()
.release(context.getConfig());
resultValue.set(tag);
} else {
resultValue.set(new HashMap<>(context.getOverallState().data()));
}
return Flux.just(GraphResponse.done(resultValue.get()));
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
});
}
(.../graph/executor/BaseGraphExecutor.java:52)
resultValue.set(...) 之后立刻 GraphResponse.done(resultValue.get())------它只是个中转变量 。AtomicReference 在这里的作用不是"跨线程共享",而是"在 lambda 里能被修改的局部变量"(因为 lambda 捕获的局部变量必须 effectively final)。
那么为什么 GraphRunner 要持有一个自己的 AtomicReference?
答案在 5.3.2 节:因为 handleStartNode 递归时传了一个新的 AtomicReference,GraphRunner 持有的那个从第一次递归开始就永远拿不到值了。
java
private Flux<GraphResponse<NodeOutput>> handleStartNode(GraphRunnerContext context) {
try {
context.doListeners(START, null);
Command nextCommand = context.getEntryPoint();
context.setNextNodeId(nextCommand.gotoNode());
Optional<Checkpoint> cp = context.addCheckpoint(START, context.getNextNodeId());
NodeOutput output = context.buildOutput(START, cp);
context.setCurrentNodeId(context.getNextNodeId());
// Recursively call the main execution handler
return Flux.just(GraphResponse.of(output))
.concatWith(Flux.defer(() -> execute(context, new AtomicReference<>())));
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
}
(.../graph/executor/MainGraphExecutor.java:123)
看最后那行:execute(context, new AtomicReference<>())。
每次从 START 出发,都会换一个新的 AtomicReference。 而 GraphRunner 持有的那个,只在第一次 execute 调用时被传入------而第一次必然走 handleStartNode(因为 initializeFromStart 设了 currentNodeId = START)------于是它被换掉,从此再无人问津。
完整链路:
scss
GraphRunner.run()
└─ execute(context, resultValue_0) ← GraphRunner 持有的
└─ isStartNode() == true
└─ handleStartNode(context) ← 注意:方法签名里没有 resultValue
└─ execute(context, new AtomicReference<>()) ← 换了!
└─ ... 递归 N 层,传的都是这个新的 ...
└─ handleCompletion(context, resultValue_N)
└─ resultValue_N.set(最终结果)
└─ GraphResponse.done(resultValue_N.get()) ← 从流里出去了
resultValue_0 从头到尾没被 set 过一次。
这就是为什么 GraphRunner.resultValue() 是个死 API------不是它没被调用,而是它就算被调用也永远返回 Optional.empty()。
这不是一个会造成正确性问题的小瑕疵,但它是一个绝佳的考古样本。 它记录了这段代码的历史:曾经
resultValue是「图的最终结果」的载体,后来架构改成「结果从Flux里出去」,resultValue退化成链内的中转变量,但GraphRunner上那个字段和那个方法被忘在那儿了。判断依据很简单:如果你看到一个字段被精心地沿着调用链传递了十几层,却在链的终点没有任何人消费它------那它不是设计,是残留。
5.1.6 stream 的 flatMap 把 done 信号吞了
streamFromInitialNode 比 graphResponseStream 多了一道 flatMap:
java
return runner.run(overAllState).flatMap(data -> {
if (data.isDone()) {
if (data.resultValue().isPresent() && data.resultValue().get() instanceof NodeOutput) {
return Flux.just((NodeOutput) data.resultValue().get());
} else {
return Flux.empty(); // ← 完成信号被丢弃
}
}
if (data.isError()) {
return Mono.fromFuture(data.getOutput()).onErrorMap(throwable -> throwable).flux();
}
return Mono.fromFuture(data.getOutput()).flux();
});
(.../graph/CompiledGraph.java:578)
三个分支:
| 分支 | 条件 | 行为 |
|---|---|---|
| done | isDone()(即 output == null) |
只有 resultValue 是 NodeOutput 实例才发出,否则丢弃 |
| error | isError()(output 异常完成) |
转成错误信号 |
| 正常 | 其他 | 取出 NodeOutput 发出 |
关键在 done 分支那个 instanceof NodeOutput 判断。而 handleCompletion 设的是 Tag 或 HashMap------都不是 NodeOutput,所以完成信号一律被丢弃。
那 stream() 的最后一个元素是什么?是 END 节点的输出。 因为 handleEndNode 用的是 GraphResponse.of(output) 而不是 done(...):
java
public static <E> GraphResponse<E> of(E data) {
return new GraphResponse<>(completedFuture(data), null); // output 非 null,resultValue 为 null
}
public static <E> GraphResponse<E> done(Object resultValue) {
return new GraphResponse<>(null, resultValue); // output 为 null
}
(.../graph/GraphResponse.java:62/74)
of(NodeOutput(END))→isDone()为 false → 走第三分支 → 发出done(状态HashMap)→isDone()为 true → 丢弃
所以 stream() 的最后一个元素是 node() == END、且 state() 已经是完整最终状态的 NodeOutput。 这正好是 invoke 想要的:
java
public Optional<OverAllState> invoke(Map<String, Object> inputs, RunnableConfig config) {
return Optional.ofNullable(stream(inputs, config).last().map(NodeOutput::state).block());
}
而 invokeAndGetResponse(...) 走的是 graphResponseStream------没有那道 flatMap ,所以它的 .last() 拿到的是被 stream 丢弃的 done(状态HashMap):
| API | 走的入口 | .last() 拿到 |
取状态的方式 |
|---|---|---|---|
invoke(...) |
streamFromInitialNode |
NodeOutput(node=END) |
.state() |
invokeAndGetResponse(...) |
graphResponseStream |
GraphResponse.done(Map) |
.resultValue().get() |
两个 API 返回的是两条不同路径的产物,5.3.4 会讲这导致的语义差异。
5.2 GraphRunnerContext:执行期的可变状态盒
GraphRunner 是薄壳,CompiledGraph 是不可变配置,所有执行期状态都在 GraphRunnerContext 里。
5.2.1 九个字段,没有一个 volatile
java
final CompiledGraph compiledGraph;
final AtomicInteger iteration = new AtomicInteger(0);
OverAllState overallState;
RunnableConfig config;
String currentNodeId;
String nextNodeId;
Usage tokenUsage;
String resumeFrom;
ReturnFromEmbed returnFromEmbed;
(.../graph/GraphRunnerContext.java:63---79)
九个字段。观察:
① 只有两个是 final :compiledGraph(不可变配置)和 iteration(因为要自增,用了 AtomicInteger)。
② 剩下七个是可变的,且都是包级私有 (没有 private、没有 protected)。这意味着同一个包(com.alibaba.cloud.ai.graph)下的任何类都能直接读写它们------比如 MainGraphExecutor 在 graph.executor 包下就读不到字段,必须走 getXxx()/setXxx();但同包的类可以直接 context.overallState = ...。
③ 没有一个 volatile。 这在第 03 章讨论 snapShot() 竞态时埋过伏笔,这里要给出完整答案:
GraphRunnerContext的字段是为了单线程顺序访问而设计的。 整个执行引擎虽然吐出的是Flux,但状态推进是严格串行的 ------concatWith保证了「前一个节点完全处理完(包括状态合并)才订阅下一个」。
而跨线程的部分(并行节点内部的分支执行、节点里的异步 IO)都在 snapShot() 出来的副本上工作,不碰 Context 的字段。
这解释了一个反直觉的事实:一个基于 Reactor 的执行引擎,其核心状态对象却连一个 volatile 都没有。 因为Reactor 的操作符(特别是 concatWith / thenMany)内部有内存屏障,保证了「前一个流的副作用 happens-before 后一个流」。
推论:不要在节点的 action 里直接拿 GraphRunnerContext。 如果你在并行分支里持有它并修改 overallState,那就不在保护范围内了。正确的做法是通过 OverAllState 参数+返回值传递数据(框架已经这么做了)。
④ 构造函数有两个分支------这是「全新执行」和「从检查点恢复」的分水岭:
java
public GraphRunnerContext(OverAllState initialState, RunnableConfig config, CompiledGraph compiledGraph)
throws Exception {
this.compiledGraph = compiledGraph;
this.config = config;
if (config.metadata(RunnableConfig.HUMAN_FEEDBACK_METADATA_KEY).isPresent() || config.checkPointId().isPresent()) {
initializeFromResume(initialState, config);
} else {
initializeFromStart(initialState, config);
}
}
(.../graph/GraphRunnerContext.java:81)
判定条件是两个 metadata 的或 :有 HUMAN_FEEDBACK_METADATA_KEY(人工反馈,HITL 场景)或者指定了 checkPointId(从某个检查点恢复)。否则就是从头开始。
5.2.2 两条初始化路径
路径 A:从头开始
java
private void initializeFromStart(OverAllState initialState, RunnableConfig config) {
log.trace("START");
// Use CompiledGraph's getInitialState method
this.overallState = stateCreate(compiledGraph.getInitialState(inputs, config), initialState);
this.currentNodeId = START;
this.nextNodeId = null;
}
(.../graph/GraphRunnerContext.java:123)
路径 B:从检查点恢复
java
private void initializeFromResume(OverAllState initialState, RunnableConfig config) {
log.trace("RESUME REQUEST");
var saver = compiledGraph.compileConfig.checkpointSaver()
.orElseThrow(() -> new IllegalStateException("Resume request without a configured checkpoint saver!"));
var checkpoint = saver.get(config)
.orElseThrow(() -> new IllegalStateException("Resume request without a valid checkpoint!"));
// 如果下一个节点是子图,就打上"我是被恢复的子图"标记
if (compiledGraph.getNodeAction(checkpoint.getNextNodeId()) instanceof ResumableSubGraphAction ra) {
this.config = RunnableConfig.builder(config).checkPointId(null)
.addMetadata(ra.getResumeSubGraphId(), true).build();
this.config.clearContext();
} else {
this.config = config.withCheckPointId(null);
}
this.currentNodeId = null;
this.nextNodeId = checkpoint.getNextNodeId();
this.overallState = initialState.input(checkpoint.getState());
this.resumeFrom = checkpoint.getNodeId();
}
(.../graph/GraphRunnerContext.java:93)
两条路径的三个关键差异:
| 从头开始 | 从检查点恢复 | |
|---|---|---|
currentNodeId |
START |
null |
nextNodeId |
null |
checkpoint.getNextNodeId() |
resumeFrom |
null | checkpoint.getNodeId() |
currentNodeId = null 这个设定很关键:它让第一次 execute 跳过 handleStartNode(因为 isStartNode() 要求 currentNodeId == START),直接进入 nodeExecutor 分支------而 nodeExecutor 第一件事就是 setCurrentNodeId(getNextNodeId()),把 next(= 检查点记录的下一个节点)提升为 current。
这就是"时间旅行"的实现:不重放历史,直接从记录的那个节点继续。
注意那两个 IllegalStateException:没有配 Saver 就想从检查点恢复,是运行时异常而不是受检异常。 所以 checkPointId 要和 Saver 成对配置。
⑤ stateCreate 上有一个 FIXME。
java
// FIXME, duplicated method with CompiledGraph.stateCreate, need to have a
// unified way of when and how to do OverallState creation.
// This temporary fix is to make sure the message provided by user is always the
// last element in the messages list.
private OverAllState stateCreate(Map<String, Object> inputs, OverAllState initialState) {
// Creates a new OverAllState instance using key strategies from the graph and
// provided input data.
return OverAllStateBuilder.builder()
.withKeyStrategies(initialState.keyStrategies())
.withData(inputs)
.withStore(initialState.getStore())
.build();
}
(.../graph/GraphRunnerContext.java:138)
这是 GraphRunnerContext 里三处 FIXME 的第一处。 它说的是:GraphRunnerContext.stateCreate 和 CompiledGraph.stateCreate 是重复的,作者自己也知道需要一个统一的状态创建方式,但目前是两套。
两处的差异在哪?
java
// CompiledGraph.stateCreate ------ 会注入 EXECUTION_ID
private OverAllState stateCreate(Map<String, Object> inputs) {
if (inputs == null) {
inputs = new HashMap<>();
}
// Enforce Execution ID availability
if (!inputs.containsKey(GraphLifecycleListener.EXECUTION_ID_KEY)) {
Map<String, Object> newInputs = new HashMap<>(inputs);
newInputs.put(GraphLifecycleListener.EXECUTION_ID_KEY, java.util.UUID.randomUUID().toString());
inputs = newInputs;
}
return OverAllStateBuilder.builder()
.withKeyStrategies(getKeyStrategyMap())
.withData(inputs)
.withStore(compileConfig.getStore())
.build();
}
(.../graph/CompiledGraph.java:687)
差异有三:
GraphRunnerContext.stateCreate |
CompiledGraph.stateCreate |
|
|---|---|---|
| null 保护 | 无 | 有 (inputs == null → 空 Map) |
注入 EXECUTION_ID |
无 | 有(UUID) |
| keyStrategies 来源 | initialState.keyStrategies() |
getKeyStrategyMap()(来自图) |
| Store 来源 | initialState.getStore() |
compileConfig.getStore() |
所以一个直接后果:走 stream(Map) 入口会注入 EXECUTION_ID,走 streamFromInitialNode(OverAllState) 入口则不会。 因为前者经过 CompiledGraph.stateCreate,后者直接把 OverAllState 交给 GraphRunner,只在 initializeFromStart 里经过 GraphRunnerContext.stateCreate。
java
public Flux<NodeOutput> stream(Map<String, Object> inputs, RunnableConfig config) {
return streamFromInitialNode(stateCreate(inputs), config); // ← 这里调了 CompiledGraph.stateCreate
}
(.../graph/CompiledGraph.java:567)
嗯,实际上 stream(Map) 也调了 CompiledGraph.stateCreate,然后 streamFromInitialNode 里不再调 stateCreate。所以两条路径都会拿到 EXECUTION_ID?
不对。streamFromInitialNode 直接接收 OverAllState,不调 stateCreate 。而 initializeFromStart 里调的是 GraphRunnerContext.stateCreate(不注入 ID)。
所以:
| 入口 | 是否经过 CompiledGraph.stateCreate | 是否注入 EXECUTION_ID |
|---|---|---|
stream(Map, config) |
✅ | ✅ |
streamFromInitialNode(state, config) |
❌ | ❌ |
如果你的监听器依赖 EXECUTION_ID_KEY 做链路追踪,用 streamFromInitialNode 直接传 OverAllState 会拿不到它。 这是那句 FIXME 注释的真实代价。
5.2.3 六个判定方法,有一个有副作用
java
// Helper methods
public boolean shouldStop() {
return nextNodeId == null && currentNodeId == null;
}
public boolean isMaxIterationsReached() {
return iteration.incrementAndGet() > compiledGraph.getMaxIterations();
}
public boolean isStartNode() {
return START.equals(currentNodeId);
}
public boolean isEndNode() {
return END.equals(nextNodeId);
}
(.../graph/GraphRunnerContext.java:153)
前四个很直白,但有两点要注意:
① isMaxIterationsReached() 有副作用。
iteration.incrementAndGet()------调用一次就 +1。它不是纯查询。这带来三个后果:
- 你不能把它用在
if的两个分支里各调一次(会 +2) - 你不能为了调试打印而调用它(会污染计数)
- 它实际上等价于「已执行的迭代层数 +1」
由于 MainGraphExecutor.execute 的开头:
java
if (context.shouldStop() || context.isMaxIterationsReached()) {
return handleCompletion(context, resultValue);
}
(.../graph/executor/MainGraphExecutor.java:59)
这里用了 || 短路------如果 shouldStop() 为 true,isMaxIterationsReached() 不会被调用,iteration 不会自增。
所以真实的计数是:每次 execute 被调用且 shouldStop() 为 false 时 +1 。而 execute 被调用的次数 = 递归层数 ≈ 经过的节点数 + 1(START)+ 1(END)。
这意味着 recursionLimit = 100 的语义不是「最多 100 个节点」,而是「最多约 98 个节点 + START + END」。对 ReAct 循环来说,一次「LLM → 工具 → LLM」是 2 个节点,所以 100 次迭代 ≈ 50 轮 ReAct。
② isEndNode() 看的是 nextNodeId 而不是 currentNodeId。
java
public boolean isEndNode() {
return END.equals(nextNodeId);
}
为什么?因为 END 从来不是"当前正在执行的节点"------它是"下一步要去的地方"。所以判定顺序是:
scss
节点 X 执行完 → nextNodeId 路由计算出 "END" → 回到 MainGraphExecutor
→ isStartNode()? 否
→ isEndNode()? 是(nextNodeId == END)→ handleEndNode
这也解释了为什么 handleEndNode 里 buildNodeOutput(END) 而不需要真的去 getNodeAction(END)------END 没有对应的 action。
③ shouldInterrupt 是一对方法的或。
java
public boolean shouldInterrupt() {
return shouldInterruptBefore(nextNodeId, currentNodeId) || shouldInterruptAfter(currentNodeId, nextNodeId);
}
private boolean shouldInterruptBefore(String nodeId, String previousNodeId) {
if (previousNodeId == null)
return false;
return compiledGraph.compileConfig.interruptsBefore().contains(nodeId);
}
private boolean shouldInterruptAfter(String nodeId, String previousNodeId) {
if (nodeId == null || Objects.equals(nodeId, previousNodeId))
return false;
return (compiledGraph.compileConfig.interruptBeforeEdge() && Objects.equals(nodeId, INTERRUPT_AFTER))
|| compiledGraph.compileConfig.interruptsAfter().contains(nodeId);
}
(.../graph/GraphRunnerContext.java:169)
注意 shouldInterruptBefore(nextNodeId, currentNodeId) 的参数顺序:传的是 (next, current),但方法签名是 (nodeId, previousNodeId)。 所以在方法体内,nodeId = next,previousNodeId = current。
而 shouldInterruptAfter(currentNodeId, nextNodeId) 传的是 (current, next),方法体内 nodeId = current,previousNodeId = next。
两个方法的形参名字相同,但实参顺序相反。 这是纯粹的易错点------读这段代码时必须盯着调用处看,不能只看方法体。
shouldInterruptAfter 里的 Objects.equals(nodeId, previousNodeId) 判断的是「current == next」,即「节点指向自己」的环------这种情况不触发中断(否则自环图会每次都中断)。
5.2.4 doListeners:五个场景与吞掉的异常
java
public void doListeners(String scene, Exception e) {
for (GraphLifecycleListener listener : compiledGraph.compileConfig.lifecycleListeners()) {
try {
switch (scene) {
case START:
listener.onStart(getCurrentNodeId(), getCurrentStateData(), config);
break;
case END:
listener.onComplete(END, getCurrentStateData(), config);
break;
case NODE_BEFORE:
listener.before(getCurrentNodeId(), getCurrentStateData(), config, SystemClock.now());
break;
case NODE_AFTER:
listener.after(getCurrentNodeId(), getCurrentStateData(), config, SystemClock.now());
break;
case ERROR:
listener.onError(getCurrentNodeId(), getCurrentStateData(), e, config);
break;
}
} catch (Exception ex) {
log.error("Error in listener", ex);
}
}
}
(.../graph/GraphRunnerContext.java:305)
第 04 章 4.2.4 讲过这个方法的接口部分,这里补充执行引擎侧的三个观察:
① 遍历的是 Deque,顺序是 LIFO 还是 FIFO?
compileConfig.lifecycleListeners() 返回 Deque<GraphLifecycleListener>,字段初值是 new LinkedBlockingDeque<>(25)。
但这里用的是增强 for 循环直接遍历 Deque 。而 LinkedBlockingDeque 的迭代器顺序是从队首到队尾(FIFO)。所以:
配置时的顺序 = 执行时的顺序。先
addLast的先执行。
注意和第 04 章 4.2 提到的 LifeListenerUtil.processListenersLIFO 对照------那个是并行节点内部 用的,是 LIFO;这里是主循环用的,是 FIFO。
java
// ParallelNode 内部 ------ LIFO
LifeListenerUtil.processListenersLIFO(actualNodeId,
new LinkedBlockingDeque<>(compileConfig.lifecycleListeners()), state.data(), config, NODE_BEFORE, null);
(.../graph/internal/node/ParallelNode.java:288)
同一个监听器列表,在主循环里是 FIFO,在并行节点里是 LIFO。 如果你写了两个有依赖关系的监听器,它们在两条路径上的相对顺序是相反的。
② 异常被吞掉,而且只打 log.error。
catch (Exception ex) { log.error("Error in listener", ex); }------没有 rethrow,没有中断图执行,没有任何标记。
这是有意的:监听器是观测设施,观测不应该影响主流程。第 04 章 4.2.4 讲过这点。
但代价是:你的监听器如果抛异常,你只会看到一行 error 日志,图会继续跑,而你可能以为监听器在工作。 生产环境建议给 GraphLifecycleListener 的每个方法都包一层 try-catch,自己做降级------别依赖框架的这层保护。
③ END 场景传的是常量 END 而不是 getCurrentNodeId()。
java
case END:
listener.onComplete(END, getCurrentStateData(), config);
break;
其他四个场景都传 getCurrentNodeId(),唯独 END 传常量。所以 onComplete(nodeId, ...) 的 nodeId 参数永远是 "__END__",不是最后执行的那个节点。
如果你的监听器里写 if (nodeId.equals("generate")) { ... },它在 onComplete 里永远不会命中。
5.2.5 mergeIntoCurrentState 与三处 FIXME
java
/**
* This method updates both the current state data and the overall state.
*
* @param updateState the state updates to apply
*/
public void mergeIntoCurrentState(Map<String, Object> updateState) {
// Create a new map and filter out ChatResponse entries
Map<String, Object> filteredState = findTokenUsageInDeltaState(updateState);
this.overallState.updateState(filteredState);
}
(.../graph/GraphRunnerContext.java:336)
这是执行引擎里唯一的状态写入口 (除了初始化和 cloneState)。所有节点返回的东西,最终都要经过这里。
注意它调的是 overallState.updateState(filteredState)------实例版(第 03 章 3.4 讲过)。实例版的行为:
- 用
keyStrategies合并 - key 没有策略时 fallback 到 REPLACE
MARK_FOR_REMOVAL会删 key- 值为 null 时保留 key(和第 03 章 3.4.7 一致)
② findTokenUsageInDeltaState 是第二处 FIXME。
java
/**
* FIXME, this method is a temporary fix to separate Usage from state updates.
* works together with AgentLlmNode non-stream node.
*/
private Map<String, Object> findTokenUsageInDeltaState(Map<String, Object> updateState) {
Map<String, Object> filteredState = new HashMap<>();
for (Map.Entry<String, Object> entry : updateState.entrySet()) {
Object value = entry.getValue();
if (value instanceof Usage && entry.getKey().equals("_TOKEN_USAGE_")) {
// Assign ChatResponse to this.chatResponse
this.tokenUsage = (Usage) value;
} else {
filteredState.put(entry.getKey(), value);
}
}
return filteredState;
}
(.../graph/GraphRunnerContext.java:344)
三件事:
- Token 用量不走状态。 key 为
_TOKEN_USAGE_且 值为Usage类型时,被抽出来存到this.tokenUsage字段,不写进overallState。两个条件都要满足------key 对但类型不对(比如你存了个 Map),它会被当作普通状态写进去。 - 注释里的 "Assign ChatResponse to this.chatResponse" 是过时的注释 (字段已经改名叫
tokenUsage了)。 - 为什么 Token 用量不能进状态? 因为状态会被序列化进 Checkpoint。如果每轮的 token 用量都进状态,状态会不断膨胀。抽出来单独存,既能让
NodeOutput携带它(buildNodeOutput会传tokenUsage),又不污染状态。
③ 第三处 FIXME 在 buildOutput 系列上。
java
/**
* FIXME
* Below are duplicated methods. Need to have a unified way of streaming output
* to end user.
*/
public NodeOutput buildNodeOutputAndAddCheckpoint(Map<String, Object> updateStates) throws Exception {
Optional<Checkpoint> cp = addCheckpoint(currentNodeId, nextNodeId);
return buildOutput(currentNodeId, updateStates, cp, false);
}
(.../graph/GraphRunnerContext.java:440)
作者自己标注:下面这几个输出构建方法是重复的,需要一个统一的流式输出方式。
重复在哪?看这一族:
scss
buildOutput(nodeId, checkpoint) → 261
buildNodeOutput(nodeId) → 289
buildOutput(nodeId, updateStates, checkpoint, streaming) → 450
buildNodeOutput(nodeId, updateStates, streaming) → 459
buildStreamingOutput(Message, originData, nodeId, streaming) → 269
buildStreamingOutput(originData, nodeId, streaming) → 278
buildNodeOutputAndAddCheckpoint(updateStates) → 445
七个方法,分成两族:
| 方法 | 实际返回 | 用途 |
|---|---|---|
buildOutput(nodeId, cp) / buildNodeOutput(nodeId) |
NodeOutput(非流式) |
普通节点、START、END |
buildOutput(nodeId, states, cp, streaming) / buildNodeOutput(nodeId, states, streaming) / buildStreamingOutput(...) ×2 / buildNodeOutputAndAddCheckpoint(...) |
StreamingOutput |
流式节点 |
注意 buildNodeOutput(nodeId) 和 buildNodeOutput(nodeId, Map, boolean) 名字几乎一样,返回的是两个完全不同的实现类。 这是最容易误用的地方。
java
public NodeOutput buildNodeOutput(String nodeId) throws Exception {
return NodeOutput.of(
nodeId,
(String) config.metadata("_AGENT_").orElse(""),
cloneState(this.overallState.data()),
this.tokenUsage);
}
(.../graph/GraphRunnerContext.java:289)
④ 每个输出都 cloneState。
注意 289 行:cloneState(this.overallState.data())------每次构建 NodeOutput 都深拷贝整个状态。
cloneState 走的是 CompiledGraph.cloneState:
java
public OverAllState cloneState(Map<String, Object> data) throws IOException, ClassNotFoundException {
return compiledGraph.cloneState(data);
}
(.../graph/GraphRunnerContext.java:297)
而 CompiledGraph.cloneState 是序列化往返(第 03 章 3.11 讲过)。这意味着:
图里有 N 个节点,就至少做 N 次全状态深拷贝;每次深拷贝是一次完整的序列化 + 反序列化。
这是执行引擎最容易被忽视的性能热点。如果你的状态里有大的文档列表(RAG 场景常见),每个节点执行完都要把它整个序列化一遍。
实践结论:控制状态体积。 不要把大对象(文档全文、Base64 图片)放进状态 key。要放大对象就放第 03 章 3.7 讲的 Store,状态里只放引用。
5.3 MainGraphExecutor:八个分支的递归主循环
MainGraphExecutor.execute 是执行引擎的心脏------每次循环都要经过它。它不长(58 行),但八个分支的顺序决定了整个引擎的语义。
5.3.1 八个分支与它们的顺序
java
@Override
public Flux<GraphResponse<NodeOutput>> execute(GraphRunnerContext context, AtomicReference<Object> resultValue) {
try {
if (context.shouldStop() || context.isMaxIterationsReached()) {
return handleCompletion(context, resultValue);
}
final var returnFromEmbed = context.getReturnFromEmbedAndReset();
if (returnFromEmbed.isPresent()) {
var interruption = returnFromEmbed.get().value(new TypeRef<InterruptionMetadata>() {
});
if (interruption.isPresent()) {
return Flux.just(GraphResponse.done(interruption.get()));
}
return Flux.just(GraphResponse.done(context.buildNodeOutputAndAddCheckpoint(Map.of())));
}
if (context.getCurrentNodeId() != null && context.getConfig().isInterrupted(context.getCurrentNodeId())) {
context.getConfig().withNodeResumed(context.getCurrentNodeId());
return Flux.just(GraphResponse.done(context.getCurrentStateData()));
}
if (context.isStartNode()) {
return handleStartNode(context);
}
if (context.isEndNode()) {
return handleEndNode(context, resultValue);
}
// ⑥ resumeFrom:边前中断模式下重算真实路由(详见 5.7.3)
final var resumeFrom = context.getResumeFromAndReset();
if (resumeFrom.isPresent()) { ... }
// ⑦ shouldInterrupt:图级中断
if (context.shouldInterrupt()) {
try {
InterruptionMetadata metadata = InterruptionMetadata
.builder(context.getCurrentNodeId(), context.cloneState(context.getCurrentStateData()))
.build();
return Flux.just(GraphResponse.done(metadata));
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
}
// ⑧ 兜底:执行一个节点
return nodeExecutor.execute(context, resultValue);
}
catch (Exception e) {
context.doListeners(ERROR, e);
org.slf4j.LoggerFactory.getLogger(com.alibaba.cloud.ai.graph.GraphRunner.class)
.error("Error during graph execution", e);
return Flux.just(GraphResponse.error(e));
}
}
(.../graph/executor/MainGraphExecutor.java:57)
八个分支,按落点分类:
| # | 分支 | 落点 | 是否终止 |
|---|---|---|---|
| ① | `shouldStop() | isMaxIterationsReached()` | |
| ② | getReturnFromEmbedAndReset() 有值 |
done(...) |
终止 |
| ③ | isInterrupted(currentNodeId) |
done(状态) |
终止 |
| ④ | isStartNode() |
handleStartNode |
继续(递归) |
| ⑤ | isEndNode() |
handleEndNode |
继续(然后终止) |
| ⑥ | getResumeFromAndReset() 有值 |
改写 nextNodeId | 继续 |
| ⑦ | shouldInterrupt() |
done(InterruptionMetadata) |
终止 |
| ⑧ | 兜底 | nodeExecutor.execute |
继续(递归) |
顺序为什么不能换? 三个关键约束:
约束一:①必须在最前。 它是唯一的"兜底出口"。如果图因为达到迭代上限或 shouldStop() 而该停,必须在做任何别的事之前停。特别是 isMaxIterationsReached() 有自增副作用------如果它不在第一个,后面的分支会干扰计数。
约束二:②必须在 ⑥ 之前,且两者都用 getXxxAndReset()。
java
public Optional<String> getResumeFromAndReset() {
final var result = ofNullable(resumeFrom);
resumeFrom = null;
return result;
}
public Optional<ReturnFromEmbed> getReturnFromEmbedAndReset() {
var result = ofNullable(returnFromEmbed);
returnFromEmbed = null;
return result;
}
(.../graph/GraphRunnerContext.java:418/424)
这两个方法都是"读后清空"(consume-once)。 所以它们只能在每一轮循环里被调用一次------如果分支顺序错了,或者某条路径上被调了两次,第二次必然拿到 empty。
这就是为什么它们必须写在独立的 if 里而不是内联到别的判断里:保证「每轮只消费一次」。
约束三:⑦(shouldInterrupt)必须在 ⑥ 之后。
因为 ⑥ 会改写 nextNodeId(中断恢复时重新计算路由),而 ⑦ 的 shouldInterrupt() 依赖 nextNodeId 和 currentNodeId。如果 ⑦ 在前,它会基于旧的 nextNodeId 判断。
约束四:⑧(nodeExecutor)必须最后。 它是默认路径,前面七个都是特殊情况。
⑤ 那个奇怪的 org.slf4j.LoggerFactory.getLogger(...)。
java
catch (Exception e) {
context.doListeners(ERROR, e);
org.slf4j.LoggerFactory.getLogger(com.alibaba.cloud.ai.graph.GraphRunner.class)
.error("Error during graph execution", e);
return Flux.just(GraphResponse.error(e));
}
注意它用的是完全限定名 ,而且是现取现用 (每次异常都新建一个 Logger)。对比类里其他地方的约定------GraphRunnerContext 里是标准的:
java
private static final Logger log = LoggerFactory.getLogger(GraphRunner.class);
(.../graph/GraphRunnerContext.java:61)
三个问题:
- 每次异常都调
LoggerFactory.getLogger(...)(虽然有缓存,但仍是一次 map 查找) - Logger 的名字是
GraphRunner而不是MainGraphExecutor------所以你在日志里看到[GraphRunner] Error during graph execution,会以为是GraphRunner报的错,实际是在MainGraphExecutor里 - 没有声明成
static final字段
这是一个小瑕疵,但它有实际的排障代价:按类名过滤日志时你会滤错地方。
5.3.2 handleStartNode:断链的那一行
java
private Flux<GraphResponse<NodeOutput>> handleStartNode(GraphRunnerContext context) {
try {
context.doListeners(START, null);
Command nextCommand = context.getEntryPoint();
context.setNextNodeId(nextCommand.gotoNode());
Optional<Checkpoint> cp = context.addCheckpoint(START, context.getNextNodeId());
NodeOutput output = context.buildOutput(START, cp);
context.setCurrentNodeId(context.getNextNodeId());
// Recursively call the main execution handler
return Flux.just(GraphResponse.of(output))
.concatWith(Flux.defer(() -> execute(context, new AtomicReference<>())));
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
}
(.../graph/executor/MainGraphExecutor.java:123)
七步,逐步拆解:
① doListeners(START, null) ------ 触发所有监听器的 onStart(...)。注意传的 getCurrentNodeId() 此时是 START。
② getEntryPoint() ------ 取入口边:
java
public Command getEntryPoint() throws Exception {
var entryPoint = compiledGraph.getEdge(START);
return nextNodeId(entryPoint, overallState.data(), "entryPoint");
}
(.../graph/GraphRunnerContext.java:194)
③ setNextNodeId(nextCommand.gotoNode()) ------ 把入口节点设为"下一步"。
注意:getEntryPoint() 返回的是 Command,它可能有 update 字段。但这里只取了 gotoNode(),忽略了 update------
java
Command nextCommand = context.getEntryPoint();
context.setNextNodeId(nextCommand.gotoNode()); // ← update 字段被丢弃
对比 nextNodeId 内部对条件边的处理:
java
var singleAction = edgeCondition.singleAction();
var command = singleAction.apply(this.overallState, config).get();
var newRoute = command.gotoNode();
String result = route.value().mappings().get(newRoute);
if (result == null) {
throw RunnableErrors.missingNodeInEdgeMapping.exception(nodeId, newRoute);
}
this.mergeIntoCurrentState(command.update()); // ← 这里合并了 update
return new Command(result, state);
(.../graph/GraphRunnerContext.java:225)
nextNodeId 内部会合并 command.update(),但 handleStartNode 拿到返回的 Command 后只用 gotoNode()。
两者不矛盾------因为 nextNodeId 内部已经合并过了,handleStartNode 拿到的 Command 是新构造的(new Command(result, state)),其 update 字段就是传入的 state(即当时的完整状态)。如果这里再合并一次,就把整个状态又合一遍。
但这里藏着一个真实的坑 :入口边如果是条件边,它的 update 会被合并;如果是普通边呢?
java
if (route.id() != null) {
return new Command(route.id(), state); // ← 普通边,不合并任何东西
}
(.../graph/GraphRunnerContext.java:208)
普通边直接返回 new Command(route.id(), state),state 参数就是传入的 overallState.data()。而 handleStartNode 只用 gotoNode(),所以不会把状态再合一遍。正确。
④ addCheckpoint(START, nextNodeId) ------ 如果有 Saver,存一个"从 START 出发"的检查点。
java
public Optional<Checkpoint> addCheckpoint(String nodeId, String nextNodeId) throws Exception {
if (compiledGraph.compileConfig.checkpointSaver().isPresent()) {
var cp = Checkpoint.builder().nodeId(nodeId).state(cloneState(overallState.data())).nextNodeId(nextNodeId)
.build();
// Force checkPointId to null to ensure we append a new checkpoint instead of
// replacing the current one
RunnableConfig appendConfig = RunnableConfig.builder(config).checkPointId(null).build();
this.config = compiledGraph.compileConfig.checkpointSaver().get().put(appendConfig, cp);
return Optional.of(cp);
}
return Optional.empty();
}
(.../graph/GraphRunnerContext.java:244)
两个细节:
checkPointId(null)是强制追加而非替换。注释写明了原因。this.config = ...put(...)------ 每次存检查点都会替换this.config! 因为saver.put返回的新RunnableConfig带有新生成的checkPointId。
这个副作用意味着:Context.config 是会随着执行推进而变化的 。第 07 章讲 Saver 家族时会展开,这里先记住:RunnableConfig 在执行过程中不是常量。
- 每次
addCheckpoint都cloneState(overallState.data()),又是一次深拷贝。
⑤ buildOutput(START, cp) ------ 构建 START 的输出。
java
public NodeOutput buildOutput(String nodeId, Optional<Checkpoint> checkpoint) throws Exception {
if (checkpoint.isPresent() && config.streamMode() == CompiledGraph.StreamMode.SNAPSHOTS) {
return StateSnapshot.of(getKeyStrategyMap(), checkpoint.get(), config,
compiledGraph.stateGraph.getStateSerializer().stateFactory());
}
return buildNodeOutput(nodeId);
}
(.../graph/GraphRunnerContext.java:261)
这里有一个重要分支 :如果 streamMode() 是 SNAPSHOTS,START 的输出是 StateSnapshot 而不是普通 NodeOutput。第 06 章会讲这个枚举。
⑥ setCurrentNodeId(getNextNodeId()) ------ 关键的一行。
ini
之前:current = START, next = 入口节点
之后:current = 入口节点, next = 入口节点
注意 nextNodeId 没有清空 。下一轮 execute 时:
isStartNode()=START.equals(入口节点)= false ✓(不会重复走 handleStartNode)isEndNode()=END.equals(入口节点)------ 如果入口就是 END,直接结束- 否则进 ⑧
nodeExecutor
而 nodeExecutor 的第一步又是 setCurrentNodeId(getNextNodeId())------重复设了同一个值 。这没有危害,但说明 current 和 next 的推进职责分散在两个类里。
⑦ concatWith(Flux.defer(() -> execute(context, new AtomicReference<>()))) ------ 断链的那一行(5.1.5 讲过)。
5.3.3 handleEndNode 与 handleCompletion
java
private Flux<GraphResponse<NodeOutput>> handleEndNode(GraphRunnerContext context,
AtomicReference<Object> resultValue) {
try {
context.doListeners(END, null);
NodeOutput output = context.buildNodeOutput(END);
return Flux.just(GraphResponse.of(output))
.concatWith(Flux.defer(() -> handleCompletion(context, resultValue)));
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
}
(.../graph/executor/MainGraphExecutor.java:148)
三步:
doListeners(END, null)→ 触发onComplete(END, ...)buildNodeOutput(END)→ 构建携带完整状态的NodeOutput- 发出它,然后接
handleCompletion
注意 handleEndNode 不发 done(...),它发的是 of(output)。
所以 stream() 的最后一个元素是 END 的 NodeOutput(5.1.6 讲过)。而紧跟其后的 handleCompletion 发的是 done(...),被 flatMap 丢弃。
为什么还要发 handleCompletion? 因为 graphResponseStream 那条路径(返回 Flux<GraphResponse>)不经过 flatMap,它的调用者能看到 done(...):
java
public GraphResponse<NodeOutput> invokeAndGetResponse(Map<String, Object> inputs, RunnableConfig config) {
return graphResponseStream(inputs, config).last().block();
}
拿到的是 GraphResponse.done(状态HashMap),再 .resultValue() 就能取到最终状态。所以 invokeAndGetResponse 和 invoke 走的是两条不同的取值路径:
| API | 最后拿到的是 | 取状态的方式 |
|---|---|---|
invoke(...) |
NodeOutput(node=END) |
.state() |
invokeAndGetResponse(...) |
GraphResponse.done(Map) |
.resultValue().get() |
5.3.4 handleCompletion 的双重语义
java
protected Flux<GraphResponse<NodeOutput>> handleCompletion(GraphRunnerContext context,
AtomicReference<Object> resultValue) {
return Flux.defer(() -> {
try {
if (context.getCompiledGraph().compileConfig.releaseThread()
&& context.getCompiledGraph().compileConfig.checkpointSaver().isPresent()) {
BaseCheckpointSaver.Tag tag = context
.getCompiledGraph().compileConfig.checkpointSaver()
.get()
.release(context.getConfig());
resultValue.set(tag);
} else {
resultValue.set(new HashMap<>(context.getOverallState().data()));
}
return Flux.just(GraphResponse.done(resultValue.get()));
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
});
}
(.../graph/executor/BaseGraphExecutor.java:52)
有时候它返回的是"最终状态",有时候返回的是"一个 Tag"。
条件是 releaseThread() && checkpointSaver().isPresent()------两个都要满足。
releaseThread 是什么?第 01 章提过:它是 CompileConfig 的一个开关(CompileConfig.java:47,默认 false)。开启后,图执行完会释放线程------这是为 HITL(人在回路)场景设计的:
一个需要人工审批的 Agent,不该在等待人工的几十分钟里占着一个线程。开启
releaseThread后,图执行到中断点就返回,线程释放;等人工反馈来了,再用Tag恢复。
所以 handleCompletion 的语义是:
| 条件 | resultValue 的内容 | 语义 |
|---|---|---|
releaseThread && 有 Saver |
BaseCheckpointSaver.Tag |
"你可以用这个 Tag 恢复我" |
| 其他 | Map(最终状态的副本) |
"这就是结果" |
两个推论:
推论一:开了 releaseThread 但没配 Saver,走的是第二条路。 因为条件是"与"。这意味着你以为开了线程释放,其实没生效 ------图会照常把最终状态塞进 resultValue,然后被 stream 的 flatMap 丢弃(因为 Tag/Map 都不是 NodeOutput)。
等等,如果没开 releaseThread,那 resultValue 是 HashMap,不是 NodeOutput,所以 stream 会丢弃它。没问题。
推论二:invoke(...) 不受影响。 因为 invoke 取的是 END 的 NodeOutput(走 of 路径),不依赖 handleCompletion。所以无论 releaseThread 怎么配,invoke 拿到的都是完整最终状态。
但 invokeAndGetResponse(...) 会受影响 ------它 .last() 拿到的是 handleCompletion 的 done(...):
java
// releaseThread + Saver 场景
GraphResponse<NodeOutput> r = compiled.invokeAndGetResponse(inputs, config);
r.resultValue().get(); // ← 是个 BaseCheckpointSaver.Tag,不是状态!
如果你用 invokeAndGetResponse 拿最终状态,在 releaseThread 场景下你拿到的是一个 Tag。 这是这两个 API 最实质的差异。
5.3.5 递归为什么不爆栈
现在回答 5.1.4 留下的那个问题。
执行引擎的递归结构:
lua
MainGraphExecutor.execute
└─ nodeExecutor.execute
└─ handleActionResult
└─ Flux.just(GraphResponse.of(output))
.concatWith(Flux.defer(() → mainGraphExecutor.execute(context, resultValue)))
每执行一个节点,递归深度 +1。 一个有 1000 个节点的图,递归 1000 层。为什么不会 StackOverflowError?
关键在 Flux.defer 的语义:返回的 Flux 在被订阅时才调用那个 lambda。
所以构造阶段是:
scss
execute(context) 调用
→ 构造 Flux.just(output)
→ 构造 Flux.defer(lambda) ← lambda 此刻【不执行】
→ concatWith 组合两个 Flux
→ 返回 ← 栈上始终只有一层 execute
而 concatWith 的订阅时机是「前一个 Flux 发射完毕后才订阅下一个」,所以时间线是:
css
t0: 订阅 F0 = just(output0).concatWith(defer(λ1))
t1: just(output0) 发射完成
t2: → 订阅 defer(λ1) → λ1 执行 → 构造 F1 = just(output1).concatWith(defer(λ2))
t3: just(output1) 发射完成 → 订阅 defer(λ2) → 构造 F2 = ...
每个时刻栈上只有一层 execute ------λ 执行完就返回,栈帧销毁。这就是 trampoline(蹦床)模式:把嵌套调用转换成序列化的平调用。
代价是堆而不是栈。 递归 1000 层会在堆上创建约 3000 个 Flux 对象(每个节点三个:FluxJust + FluxDefer + FluxConcatArray)。它们随订阅推进逐个创建、逐个消费、逐个变成垃圾------所以峰值是"已构造但未消费"的部分,不是全部。
反过来说,如果不用 defer 而是直接调用,1000 层就会真的爆栈:
java
// ❌ 直接调用 = 立即递归 → StackOverflowError
Flux<NodeOutput> f = just(output0).concatWith(execute(context));
实践结论:
- 这个设计让"迭代 10 万次的图"在栈上是安全的
- 但
recursionLimit仍然必须配------不是为了防爆栈,是为了防跑不完 - 第 01 章讲过:
ReactAgent把它设成了Integer.MAX_VALUE,那才是真正的风险来源
5.3.6 递归的四种退出方式
把 execute 的八个分支按"是否终止"分类,递归有四种退出方式:
| 退出方式 | 触发条件 | 最后的产物 |
|---|---|---|
| 正常结束 | 走到 END(isEndNode()) |
NodeOutput(END) + done(状态) |
| 迭代到上限 | isMaxIterationsReached() |
done(状态),没有 NodeOutput(END) |
| 中断 | shouldInterrupt() / returnFromEmbed / isInterrupted |
done(InterruptionMetadata) |
| 异常 | 任意环节抛异常 | GraphResponse.error(e) |
注意第二种和第一种的产物不同。 迭代到上限时,isMaxIterationsReached() 在① 分支 就直接 handleCompletion,根本不会走到 ⑤ 的 handleEndNode。
所以:
java
// 正常结束
outputs.get(outputs.size() - 1).node(); // "END"
// 迭代到上限
outputs.get(outputs.size() - 1).node(); // 最后一个真实执行的节点,比如 "generate"
这是一个可以用来诊断的判别式 :如果你的 stream() 输出的最后一个元素 node() 不是 END,那说明图没有正常走到终点------它要么被迭代上限截断,要么被中断了。
java
// 一个实用的诊断片段
List<NodeOutput> outs = compiled.stream(inputs, config).collectList().block();
String lastNode = outs.get(outs.size() - 1).node();
if (!StateGraph.END.equals(lastNode)) {
log.warn("图未正常结束,停在节点 {}(可能因 recursionLimit 截断或被中断)", lastNode);
}
5.4 NodeExecutor:一个节点的一次完整执行
如果 MainGraphExecutor 是"要不要走下一步"的决策者,那 NodeExecutor 就是"这一步怎么走"的执行者。它 823 行,是三个 Executor 里最大的。
5.4.1 executeNode 的七步
java
private Flux<GraphResponse<NodeOutput>> executeNode(GraphRunnerContext context,
AtomicReference<Object> resultValue) {
try {
// ① 推进指针:next → current
context.setCurrentNodeId(context.getNextNodeId());
String currentNodeId = context.getCurrentNodeId();
AsyncNodeActionWithConfig action = context.getNodeAction(currentNodeId);
// ② 节点不存在(执行期二次确认)
if (action == null) {
return Flux.just(GraphResponse.error(RunnableErrors.missingNode.exception(currentNodeId)));
}
// ③ InterruptableAction:先合并人工反馈,再判断执行前中断
if (action instanceof InterruptableAction) {
context.getConfig().metadata(RunnableConfig.STATE_UPDATE_METADATA_KEY).ifPresent(fb -> {
if (fb instanceof Map<?, ?>) {
context.mergeIntoCurrentState((Map<String, Object>) fb);
} else {
throw new RuntimeException();
}
});
var interruptMetadata = ((InterruptableAction) action)
.interrupt(currentNodeId, context.cloneState(context.getCurrentStateData()), context.getConfig());
if (interruptMetadata.isPresent()) {
resultValue.set(interruptMetadata.get());
return Flux.just(GraphResponse.done(interruptMetadata.get()));
}
}
// ④ 监听器 + ⑤ 执行 + ⑥ 结果处理
context.doListeners(NODE_BEFORE, null);
CompletableFuture<Map<String, Object>> future = action.apply(context.getOverallState(),
context.getConfig());
return Mono.fromFuture(future)
.flatMapMany(updateState -> handleActionResult(context, updateState, resultValue))
.onErrorResume(error -> {
context.doListeners(ERROR, new Exception(error));
return Flux.just(GraphResponse.error(error));
});
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
}
(.../graph/executor/NodeExecutor.java:94)
七步逐步拆解:
① setCurrentNodeId(getNextNodeId()) ------ 推进指针。这是引擎里唯一的"前进"动作,它把 next 提升为 current。
② getNodeAction(currentNodeId) ------ 从 CompiledGraph 取 action 工厂并实例化。
java
public AsyncNodeActionWithConfig getNodeAction(String nodeId) {
return compiledGraph.getNodeAction(nodeId);
}
(.../graph/GraphRunnerContext.java:190)
③ action == null → missingNode 错误。 这是运行时才发现节点不存在的情况。第 04 章讲过编译期会校验边,但这里是执行期的二次确认。
④ InterruptableAction 的前置处理------两个动作:
- 合并人工反馈 :如果 config 里有
STATE_UPDATE_METADATA_KEY,先把它合并进状态。这就是 HITL 的"人工修改状态"入口:人工在前端改了状态,恢复时用这个 metadata 传进来,在这里被合并。 - 调
interrupt(...):如果返回了InterruptionMetadata,立刻终止 (发done(...)并 return)。
注意这里的顺序:先合并反馈,再判断中断。因为中断判断可能需要读到人工反馈后的状态。
⑤ doListeners(NODE_BEFORE, null) ------ 触发所有监听器的 before(...)。
⑥ action.apply(overallState, config) ------ 真正执行节点。
注意这里传的是 context.getOverallState() 本身,不是副本。 所以:
节点拿到的是主状态对象的引用。 如果节点直接
state.updateState(...),会就地修改主状态------绕过框架的合并语义。
这回扣第 03 章讲的"节点应该返回 Map,不要直接改状态"。理由在这里:你改的是主状态,而引擎后续还会用返回值再合并一次。
⑦ Mono.fromFuture(future).flatMapMany(...) ------ 把 CompletableFuture 转成 Reactor 流。
注意 .onErrorResume(...):节点返回的 future 异常完成时,触发 doListeners(ERROR, ...) 然后发 GraphResponse.error(error)。异常会被包成流里的一个 error 元素,而不是终止流。
5.4.2 handleActionResult 的九个出口
这是 NodeExecutor 里最重要的方法------它决定"节点返回的这个 Map 到底是什么"。
java
private Flux<GraphResponse<NodeOutput>> handleActionResult(GraphRunnerContext context,
Map<String, Object> updateState, AtomicReference<Object> resultValue) {
try {
// Check for Flux
Optional<Flux<GraphResponse<NodeOutput>>> embedFlux = getEmbedFlux(context, updateState);
if (embedFlux.isPresent()) {
return handleEmbeddedFlux(mainGraphExecutor, context, embedFlux.get(), updateState, resultValue);
}
// Check for ParallelGraphFlux (returned from ParallelNode)
Optional<ParallelGraphFlux> embedParallelGraphFlux = getEmbedParallelGraphFlux(updateState);
if (embedParallelGraphFlux.isPresent()) {
return handleParallelGraphFlux(context, embedParallelGraphFlux.get(), updateState, resultValue);
}
// Check for GraphFlux (backward compatibility)
Optional<GraphFlux<?>> embedGraphFlux = getEmbedGraphFlux(updateState, context);
if (embedGraphFlux.isPresent()) {
return handleGraphFlux(context, embedGraphFlux.get(), updateState, resultValue);
}
// ④ interruptAfter 出口 ------ 内部四步顺序见 5.7.2,此处省略 18 行
// (merge → nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners → return done(...))
// ⑤ 合并状态 ------ 必须在路由【之前】,这样条件边能读到刚更新的值
context.mergeIntoCurrentState(updateState);
// ⑥ 路由:边前中断模式设伪节点,否则正常计算
if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
&& context.getCompiledGraph().compileConfig.interruptsAfter()
.contains(context.getCurrentNodeId())) {
context.setNextNodeId(INTERRUPT_AFTER);
}
else {
Command nextCommand = context.nextNodeId(context.getCurrentNodeId(), context.getCurrentStateData());
context.setNextNodeId(nextCommand.gotoNode());
}
// ⑦ 构建输出(含存检查点) → ⑧ 通知监听器 → ⑨ 递归回主循环
NodeOutput output = context.buildNodeOutputAndAddCheckpoint(updateState);
context.doListeners(NODE_AFTER, null);
return Flux.just(GraphResponse.of(output))
.concatWith(Flux.defer(() -> mainGraphExecutor.execute(context, resultValue)));
}
catch (Exception e) {
return Flux.just(GraphResponse.error(e));
}
}
(.../graph/executor/NodeExecutor.java:146)
四个 if 分支 + 一个兜底 = 五个路径,加上每个分支内部的子分支,一共九个出口:
| # | 出口 | 条件 | 产物 |
|---|---|---|---|
| 1 | 嵌入 Flux | 返回的 Map 里有 Flux |
展开子流 |
| 2 | 并行流式 | 有 ParallelGraphFlux |
合并多条流 |
| 3 | 单个流式 | 有 GraphFlux |
展开单条流 |
| 4 | interruptAfter 命中 |
是 InterruptableAction 且 interruptAfter 返回非空 |
done(InterruptionMetadata) |
| 5 | interruptBeforeEdge 命中 |
配置了边前中断 | 设 nextNodeId = INTERRUPT_AFTER 后继续 |
| 6 | 正常路由 | 兜底 | 算出 next,继续 |
| 7 | 异常 | 任意环节抛错 | error(e) |
(其中 1/2/3 各自内部还有"数据为空"的子分支,见 5.4.3。)
顺序为什么是这样? 三条判断是类型检查,必须按特异性从高到低:
Flux(原生) → ParallelGraphFlux → GraphFlux
为什么 ParallelGraphFlux 要在 GraphFlux 之前?
因为 ParallelGraphFlux 内部持有 List<GraphFlux>(第 04 章 4.7.3 讲过)。但它们是不同的类型 ------ParallelGraphFlux 不是 GraphFlux 的子类。让我验证这一点:
java
// getEmbedGraphFlux 的判断
.filter(e -> e.getValue() instanceof GraphFlux)
// getEmbedParallelGraphFlux 的判断
.filter(e -> e.getValue() instanceof ParallelGraphFlux)
如果 ParallelGraphFlux extends GraphFlux,那顺序就必须先判 ParallelGraphFlux。如果两个是平级的,顺序其实无所谓。
实际它们是平级的 (ParallelGraphFlux 是独立的 record),所以顺序只是风格问题。但顺序本身仍然有意义 :如果未来有人让 ParallelGraphFlux 继承 GraphFlux,这个顺序就是对的。
④ 那个 interruptAfter 出口,藏着一个精心设计的顺序。
java
if (interruptMetadata.isPresent()) {
// Merge state first to ensure correct state on resume
context.mergeIntoCurrentState(updateState);
// Determine next node before creating checkpoint
Command nextCommand = context.nextNodeId(currentNodeId, context.getCurrentStateData());
context.setNextNodeId(nextCommand.gotoNode());
// Build checkpoint with correct nextNodeId
context.buildNodeOutputAndAddCheckpoint(updateState);
// Call NODE_AFTER listeners
context.doListeners(NODE_AFTER, null);
// Return interruption
resultValue.set(interruptMetadata.get());
return Flux.just(GraphResponse.done(interruptMetadata.get()));
}
四步,注释里写了理由:
- 先合并状态 ------ "to ensure correct state on resume"。中断后要能从检查点恢复,状态必须是新的。
- 再算路由 ------ "Determine next node before creating checkpoint"。
- 再存检查点 ------ "Build checkpoint with correct nextNodeId"。检查点必须记录正确的 nextNodeId,否则恢复时会跳错地方。
- 最后触发监听器 ------ 保证监听器看到的是最终状态。
这个顺序是自洽的,而且每一步都有注释说明。 这是整个 NodeExecutor 里注释质量最高的一段------因为它踩过坑:如果先存检查点再算路由,检查点里的 nextNodeId 就是旧的。
对比 ⑥ 的正常路径,顺序是:
mergeIntoCurrentState → nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners → 递归
一样的顺序。 所以 interruptAfter 出口 = 正常路径的前四步 + 提前 return。
5.4.3 三条流式路径与"提前 return"的代价
java
// 1
Optional<Flux<GraphResponse<NodeOutput>>> embedFlux = getEmbedFlux(context, updateState);
if (embedFlux.isPresent()) {
return handleEmbeddedFlux(...);
}
// 2
Optional<ParallelGraphFlux> embedParallelGraphFlux = getEmbedParallelGraphFlux(updateState);
if (embedParallelGraphFlux.isPresent()) {
return handleParallelGraphFlux(...);
}
// 3
Optional<GraphFlux<?>> embedGraphFlux = getEmbedGraphFlux(updateState, context);
if (embedGraphFlux.isPresent()) {
return handleGraphFlux(...);
}
这三条路径都是"提前 return"------它们不会走到后面的非流式逻辑。
所以流式节点的状态合并、路由、检查点、监听器触发,都在各自的处理函数里重新实现了一遍 。这就是为什么 NodeExecutor 有 823 行------同一套"合并 → 路由 → 存点 → 通知 → 递归"的逻辑,被写了四遍(三条流式 + 一条非流式)。
对比一下四份实现的对应步骤:
| 步骤 | 非流式(191---207) | 嵌入 Flux(489---507) | GraphFlux(655---676) | 并行流式(775---796) |
|---|---|---|---|---|
| 合并状态 | mergeIntoCurrentState(updateState) |
merge × 2(489/490) |
merge × 2(655/659) |
merge × 2(775/779) |
| 算路由 | nextNodeId(...)(199) |
nextNodeId(...)(493) |
nextNodeId(...)(663) |
nextNodeId(...)(783) |
| 存检查点 | buildNodeOutputAndAddCheckpoint(202) |
同名(496) | 同名(666) | 同名(786) |
| 触发监听器 | doListeners(NODE_AFTER)(204) |
同名(498) | 同名(668) | 同名(788) |
| 递归 | concatWith(defer(...))(207) |
同名(507) | 同名(676) | 同名(796) |
四份几乎一样的代码,只有合并的细节不同。 这正是 GraphRunnerContext 里那句 FIXME 所说的"Need to have a unified way of streaming output to end user"的代价------这个统一还没有发生。
实践推论:四种路径的行为差异,是流式场景下排查 bug 的第一站。 如果你的节点在流式和非流式下表现不一致,先确认它走的是哪条路径。
5.4.4 三个 findFirst:多余的 Flux 会被静默丢弃
java
// 三个方法结构完全一样,只是类型判定不同
public Optional<Flux<GraphResponse<NodeOutput>>> getEmbedFlux(GraphRunnerContext ctx, Map<String, Object> ps) {
return ps.entrySet().stream().filter(e -> e.getValue() instanceof Flux<?>).findFirst()...;
}
private Optional<GraphFlux<?>> getEmbedGraphFlux(Map<String, Object> ps, GraphRunnerContext ctx) {
return ps.entrySet().stream().filter(e -> e.getValue() instanceof GraphFlux).findFirst()
.map(e -> GraphFlux.of(/* 补全 nodeId 和 key */ ...)); // ← 会补全缺省值
}
private Optional<ParallelGraphFlux> getEmbedParallelGraphFlux(Map<String, Object> ps) {
return ps.entrySet().stream().filter(e -> e.getValue() instanceof ParallelGraphFlux).findFirst()...;
}
(.../graph/executor/NodeExecutor.java:516/543/563)
三个方法都是 entrySet().stream().filter(类型).findFirst()。
为什么不做多流支持? 因为流式和状态合并的组合会变得很复杂:两条流完成时谁先合并?如果两个 key 冲突怎么办?ParallelGraphFlux 就是为了这个场景设计的(它内部为每个流单独记 nodeDataRef),而通用的多 Flux 支持还没做。
5.4.5 非流式路径:handleNonStreamingResult
java
private Flux<GraphResponse<NodeOutput>> handleNonStreamingResult(GraphRunnerContext context,
Map<String, Object> partialState, AtomicReference<Object> resultValue) throws Exception {
if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
&& context.getCompiledGraph().compileConfig.interruptsAfter()
.contains(context.getCurrentNodeId())) {
context.setNextNodeId(INTERRUPT_AFTER);
}
else {
Command nextCommand = context.nextNodeId(context.getCurrentNodeId(), context.getCurrentStateData());
context.setNextNodeId(nextCommand.gotoNode());
}
NodeOutput output = context.buildNodeOutputAndAddCheckpoint(partialState);
// Recursively call the main execution handler
return Flux.just(GraphResponse.of(output))
.concatWith(Flux.defer(() -> mainGraphExecutor.execute(context, resultValue)));
}
(.../graph/executor/NodeExecutor.java:806)
注意它其实不是"非流式路径",它是"流式路径的降级处理"。 它只在两处被调用:
java
if (parallelGraphFlux.isEmpty()) {
// Handle empty ParallelGraphFlux
return handleNonStreamingResult(context, partialState, resultValue);
}
(.../graph/executor/NodeExecutor.java:721)
即:当 ParallelGraphFlux 存在但为空时,走这个方法。
这个方法有个特点:它不做 mergeIntoCurrentState。
对比 handleActionResult 的正常路径(191 行有 merge),这个方法直接跳到路由。所以:
空的
ParallelGraphFlux会导致节点返回的状态增量被部分丢失。
具体说:handleNonStreamingResult 收到的是 partialState,它直接用来 buildNodeOutputAndAddCheckpoint,但没有 merge 进主状态 。而 partialState 里含 __parallel_graph_flux__(第 04 章讲过,ParallelNode 塞的载体 key),它在 buildNodeOutputAndAddCheckpoint 里会进 checkpoint......
等等,buildNodeOutputAndAddCheckpoint → addCheckpoint 用的是 cloneState(overallState.data()),不是 partialState。所以 __parallel_graph_flux__ 不会进 checkpoint。
那 partialState 用在哪?只在 buildNodeOutputAndAddCheckpoint 里传给 buildOutput(nodeId, updateStates, cp, false) → buildNodeOutput(nodeId, updateStates, streaming),用于提取 messages 的最后一个 Message(生成 StreamingOutput)。
所以 partialState 在这个路径下只用于构建输出,不进状态。 而节点真正想写进状态的东西(比如并行的非流式结果)被丢弃了。
这是一个边界 case(空的 ParallelGraphFlux),触发条件是并行节点的所有分支都返回了流式但流为空。实际不常见,但值得知道。
5.5 流式处理:四条路径,三份实现
流式是这个框架的一等公民,也是 NodeExecutor 复杂度的主要来源。这一节把 5.4.3 留下的那张"四份实现"对比表展开,看看它们到底差在哪。
5.5.1 transformFluxToGraphResponse:流式聚合的核心
三条流式路径最终都汇聚到这一个方法。它 133 行(223---355),是 NodeExecutor 里最大的方法。
先说它的数据结构:两个 AtomicReference ------lastChatResponseRef 累积聊天响应,lastGraphResponseRef 记住最后一个响应对象。
阶段一:过滤(228---235)
java
.filter(element -> {
// skip ChatResponse.getResult() == null
if (element instanceof ChatResponse response) {
return response.getResult() != null && response.getResult().getOutput() != null;
}
// Don't filter out Exception/Throwable - we need to handle them
return true;
})
过滤掉「getResult() 为 null 或 getResult().getOutput() 为 null」的 ChatResponse。
为什么? 因为 LLM 流式 API 会发一些"空壳"chunk------比如只有 usage 统计、只有 finish_reason 的终止包。这些包没有内容,转发给下游没有意义。
注意第二条注释:"Don't filter out Exception/Throwable - we need to handle them" 。也就是说,如果上游把异常当成数据元素(而不是错误信号)发出来,这个过滤器会放行它,交给 map 处理:
java
.map(element -> {
// Handle Exception/Throwable as data elements (not error signals)
if (element instanceof Throwable throwable) {
log.error("Exception emitted as data element in embedded Flux stream for key '{}': {}",
key, throwable.getMessage(), throwable);
GraphResponse<NodeOutput> errorResponse = GraphResponse.error(throwable);
lastGraphResponseRef.set(errorResponse);
return errorResponse;
}
...
})
(.../graph/executor/NodeExecutor.java:240)
这是一个重要设计:把"数据流里的异常"转换成"流里的 error 元素",而不是让整个流失败。
后果是:如果你的 LLM 流在第 5 个 chunk 抛了异常,前 4 个 chunk 已经发出去了,第 5 个变成 error 元素,流继续(如果还有后续元素的话)。你的订阅者会先收到 4 个正常输出,再收到一个错误元素。
阶段二:空流保护(236---237)
java
.switchIfEmpty(Flux.error(new IllegalStateException(
"Empty flux detected for key '" + key + "'. This may indicate an LLM API error with null result.")))
如果流的元素被过滤后为空,整个流变成错误。
注意它接在 filter 后面------所以是"过滤后为空"才触发,不是"原始流为空"。
这个错误消息本身写得很好:它明确说了 "This may indicate an LLM API error with null result"------这是作者被这个坑咬过之后留下的提示。
实践意义: 当你在日志里看到 Empty flux detected for key 'answer',不要去查图结构,直接去查那个 LLM 调用------它返回了一个全被过滤掉的流(或者干脆是空的)。
阶段三:映射(238---308)
五种元素类型,五个分支,按优先级:
| 优先级 | 元素类型 | 处理 | 行号 |
|---|---|---|---|
| 1 | Throwable |
转成 GraphResponse.error(...) |
240 |
| 2 | ChatResponse |
累积拼接 + 生成 StreamingOutput |
247 |
| 3 | GraphResponse |
直接透传 | 286 |
| 4 | NodeOutput |
包成 GraphResponse.of(...) |
290 |
| 5 | 其他 | 包成 StreamingOutput |
295 |
第 3 条(透传)是子图机制的关键------子图发出来的 GraphResponse 在这里被原样转发,从而"嵌"进父图的流里。
阶段四:收尾(317---354)
java
.concatWith(Flux.defer(() -> {
if (lastChatResponseRef.get() == null) {
// 路 B:把最后一个 GraphResponse 再发一次,但先做两个处理
var lastGraphResponse = lastGraphResponseRef.get();
var result = lastGraphResponse.resultValue();
// 1) InterruptionMetadata 不发(避免和 MainGraphExecutor 的处理重复)
if (result.isPresent() && result.get() instanceof InterruptionMetadata) {
return Flux.empty();
}
// 2) 若 Map 里缺目标 key 但有 messages,补上 key → 最后一条 AssistantMessage 的文本
if (result.isPresent() && result.get() instanceof Map resultMap
&& !resultMap.containsKey(key) && resultMap.containsKey("messages")) {
var msgs = (List<Object>) resultMap.get("messages");
var last = msgs.get(msgs.size() - 1);
if (last instanceof AssistantMessage am) {
resultMap.put(key, am.getText());
}
}
return Flux.just(lastGraphResponse);
} else {
// 路 A:先发聚合后的完整响应,再发 done(结果Map)
var aggregated = GraphResponse.of(context.buildStreamingOutput(
lastChatResponseRef.get().getResult().getOutput(), lastChatResponseRef.get(), nodeId, false));
Map<String, Object> completionResult = new HashMap<>();
completionResult.put(key, lastChatResponseRef.get().getResult().getOutput());
return Flux.just(aggregated, GraphResponse.done(completionResult));
}
}));
两条路:
路 A(有 ChatResponse):发两个元素
aggregatedResponse------StreamingOutput,streaming=false,携带完整拼接后的ChatResponsedoneResponse------GraphResponse.done(completionResult),给下游取"最终结果"用
路 B(无 ChatResponse) :把最后一个 GraphResponse 再发一次,但要先做两个处理
- 如果是
InterruptionMetadata→ 不发 (避免和MainGraphExecutor的处理重复) - 如果是
Map且缺目标 key 但有messages→ 补上key → 最后一条 AssistantMessage 的文本
第二个处理是个补偿逻辑,值得单独说。它解决的问题是:
子图执行完返回
GraphResponse.done(Map),但这个 Map 里可能只有messages而没有业务 key(比如answer)。下游节点想读answer就读不到。所以这里补一个answer → messages 里最后一条的文本。
注意这个补偿会就地修改 resultMap (resultMap.put(key, ...)),而 resultMap 是从 GraphResponse.resultValue() 拿到的引用------所以它修改了那个 GraphResponse 内部的 Map 。这是本书里发现的第二个"就地修改"案例(第一个是第 03 章 AppendStrategy 的 oldList.add)。
5.5.2 O(n²) 的文本拼接
看 ChatResponse 分支的核心:
java
if (element instanceof ChatResponse response) {
ChatResponse lastResponse = lastChatResponseRef.get();
final var currentMessage = response.getResult().getOutput();
if (lastResponse == null) {
lastChatResponseRef.set(response);
} else {
var lastOutput = lastResponse.getResult().getOutput();
var lastMessageText = lastOutput.getText() != null ? lastOutput.getText() : "";
final var currentMessageText = currentMessage.getText();
// ① 文本内容:把之前的完整文本与当前 chunk 拼接
// ② metadata:mergeReasoningContent 时拼接 reasoningContent,否则直接取当前的
// ③ toolCalls:按 id 去重合并
var newMessage = AssistantMessage.builder()
.content(currentMessageText != null ? lastMessageText.concat(currentMessageText) : lastMessageText)
.properties(...)
.toolCalls(mergeToolCalls(lastOutput.getToolCalls(), currentMessage.getToolCalls()))
.media(currentMessage.getMedia())
.build();
lastChatResponseRef.set(new ChatResponse(List.of(new Generation(newMessage, ...)), response.getMetadata()));
}
// 注意:发给下游的是【当前 chunk】,累积的完整响应用在最后那一下
var lastGraphResponse = GraphResponse.of(
context.buildStreamingOutput(response.getResult().getOutput(), response, nodeId, true));
lastGraphResponseRef.set(lastGraphResponse);
return lastGraphResponse;
}
(.../graph/executor/NodeExecutor.java:247)
注意最后一行的细节:每个 chunk 都会生成一个 StreamingOutput 发给下游 ,而 lastChatResponseRef 里的累积响应用来在最后生成聚合输出。所以订阅者看到的是增量 chunk,而不是累积文本。
每个 chunk 到达时,都把之前的完整文本和当前 chunk 拼接,构造一个新的 AssistantMessage。
java
.content(currentMessageText != null ? lastMessageText.concat(currentMessageText) : lastMessageText)
假设 LLM 输出 N 个 chunk,每个 chunk 平均长度 L:
| 维度 | 复杂度 |
|---|---|
| 字符拷贝 | O(N² × L) ------ 第 i 个 chunk 要拼接 i×L 个字符 |
| 对象创建 | O(N) 个 AssistantMessage + Generation + ChatResponse |
具体数字: 一个 3000 字的回答分成 300 个 chunk(每 chunk 10 字),总拼接量是 10 × (1+2+...+300) ≈ 45 万字符,外加 300 个 AssistantMessage + Generation + ChatResponse 对象。
这本身不算灾难(相对一次 LLM 调用的耗时可忽略),但长文本下会明显:3 万字的输出、3000 个 chunk,拼接量是 4500 万字符------这时就真的能观测到了。
mergeToolCalls 每次都要重建 Map:
java
private List<ToolCall> mergeToolCalls(List<ToolCall> lastToolCalls, List<ToolCall> currentToolCalls) {
if (lastToolCalls == null || lastToolCalls.isEmpty()) {
return currentToolCalls != null ? currentToolCalls : List.of();
}
if (currentToolCalls == null || currentToolCalls.isEmpty()) {
return lastToolCalls;
}
// ...建 LinkedHashMap → 放 current → 遍历 last 去重 → 重建 ArrayList...
}
(.../graph/executor/NodeExecutor.java:390)
每个流式 chunk 都要走一遍这段。但两个短路判断挡住了大部分情况 ------流式过程中绝大多数 chunk 的 toolCalls 是空的,会在前两个 if 就返回。只有 toolCall 真的在流式中途出现时才走完整路径。
实践结论:
- 正常短文本不用担心 O(n²)
- 长文本生成场景要意识到这个开销
- 框架没有提供"关掉累积"的开关。如果你的节点不需要完整响应,可以用
GraphFlux并自己处理mapResult
5.5.3 两条路径把 GraphResponse 塞进了状态
这是本章最重要的发现。
先看"正确"的那条路 ------handleEmbeddedFlux → processGraphResponseFlux:
java
Mono<Void> updateContextMono = Mono.fromRunnable(() -> {
var data = lastData.get();
if (data == null) {
log.error("No data returned from last streaming node execution '{}', will goto END node directly.", ...);
context.setNextNodeId(END);
context.doListeners(NODE_AFTER, null);
return;
}
var nodeResultValue = data.resultValue(); // ← 取出 resultValue
if (nodeResultValue.isPresent() && nodeResultValue.get() instanceof InterruptionMetadata) {
context.setReturnFromEmbedWithValue(nodeResultValue.get());
return;
}
// 过滤掉 Flux / GraphFlux / ParallelGraphFlux,只留普通值
Map<String, Object> partialStateWithoutFlux = partialState.entrySet().stream()...;
Map<String, Object> updateState = new HashMap<>();
if (nodeResultValue.isPresent()) {
Object value = nodeResultValue.get();
if (value instanceof Map<?, ?>) {
updateState = (Map<String, Object>) value; // ← 当成 Map 用
}
else {
throw new IllegalArgumentException("Node stream must return Map result using Data.done(),");
}
}
... // 后续:merge × 2 → nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners
});
(.../graph/executor/NodeExecutor.java:451)
关键两行:
java
var nodeResultValue = data.resultValue(); // 从 GraphResponse 里"拆包"取出结果
updateState = (Map<String, Object>) value; // 它应该是个 Map
它从 GraphResponse 里拆出了真正的结果。
注意那个 IllegalArgumentException("Node stream must return Map result using Data.done(),")------如果上游流式节点没有用 GraphResponse.done(Map) 结束,这里会抛异常。 这是"流必须以 done 收尾"这条规矩的执行点。
现在看"错误"的那条路 ------handleGraphFlux:
java
Mono<Void> updateContextMono = Mono.fromRunnable(() -> {
Object lastData = lastDataRef.get();
if (lastData == null) {
log.error("No data returned from last streaming node execution '{}', will goto END node directly.", ...);
context.setNextNodeId(END);
return;
}
// Apply mapResult function if available
Map<String, Object> resultMap = new HashMap<>();
resultMap.put(graphFlux.getKey(), lastData); // ← 直接塞进去,【没有拆包】
// Merge non-GraphFlux state(只过滤 GraphFlux,不过滤原生 Flux)
Map<String, Object> partialStateWithoutGraphFlux = partialState.entrySet().stream()
.filter(e -> !(e.getValue() instanceof GraphFlux))...;
Map<String, Object> combinedUpdateState = new HashMap<>(partialStateWithoutGraphFlux);
combinedUpdateState.putAll(resultMap);
Optional<InterruptionMetadata> interruptAfterMetadata =
interruptAfterForStreaming(context, combinedUpdateState);
context.mergeIntoCurrentState(partialStateWithoutGraphFlux);
if (!resultMap.isEmpty()) {
context.mergeIntoCurrentState(resultMap); // ← 把 GraphResponse 塞进状态
}
... // 后续:nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners
});
(.../graph/executor/NodeExecutor.java:632)
lastData 是 GraphResponse<NodeOutput> 对象,被直接 put 进了 resultMap,然后 merge 进状态。
为什么会这样?因为上游 transformGraphFluxToFlux 里存的就是 GraphResponse 对象:
java
return transformedFlux.map(data -> {
if (data.getOutput() != null && !data.getOutput().isCompletedExceptionally()) {
var output = data.getOutput().join();
output.setSubGraph(true);
GraphResponse<NodeOutput> newData = GraphResponse.of(output);
resultValue.set(newData); // ← 存的是 GraphResponse
return newData;
}
resultValue.set(data); // ← 存的是 GraphResponse
return data;
})
(.../graph/executor/NodeExecutor.java:593)
lastDataRef 从头到尾存的都是 GraphResponse 对象 ,而 handleGraphFlux 把它当成了"节点的结果值"。
第三条路(handleParallelGraphFlux)犯了同样的错:
java
for (GraphFlux<?> graphFlux : parallelGraphFlux.getGraphFluxes()) {
String nodeId = graphFlux.getNodeId();
Object nodeData = nodeDataRefs.get(nodeId).get();
combinedResultMap.put(graphFlux.getKey(), nodeData); // ← 同样是 GraphResponse
}
(.../graph/executor/NodeExecutor.java:757)
三条路径的对照:
| 路径 | 存进 ref 的是 | 取出来时 | 写进状态的是 |
|---|---|---|---|
handleEmbeddedFlux |
GraphResponse |
.resultValue() 拆包 |
Map(正确) |
handleGraphFlux |
GraphResponse |
直接用 | GraphResponse(错误) |
handleParallelGraphFlux |
GraphResponse |
直接用 | GraphResponse(错误) |
5.5.4 这个不一致会带来什么
假设一个节点返回:
java
return Map.of("answer", GraphFlux.of("llm", "answer", chatFlux, null, null));
走 handleGraphFlux 路径后,状态里的 answer 是:
java
state.value("answer").get() // 是个 GraphResponse<NodeOutput> 对象!
而走 handleEmbeddedFlux 路径(节点返回原生 Flux<ChatResponse>)时:
java
state.value("answer").get() // 是个 AssistantMessage(正确)
下游节点读 answer 时会拿到完全不同类型的对象。
三个实际后果:
后果一:下游节点的类型强转会失败。
java
// 下游节点
AsyncNodeActionWithConfig consumer = (state, config) -> {
String text = (String) state.value("answer").orElse(""); // ❌ ClassCastException
...
};
后果二:序列化进 Checkpoint 时会丢内容。
GraphResponse 的 output 字段标了 @JsonIgnore,所以 Jackson 只序列化 resultValue 和 metadata。反序列化后得到一个 output == null 的 GraphResponse,它的 isDone() 为 true。
这意味着:如果图在流式节点之后被中断、存了检查点、然后恢复------恢复后状态里的 answer 是一个"已完成"的空壳 GraphResponse ,而不是你想要的 AssistantMessage。
后果三:并行路径还有 key 覆盖问题。
java
for (GraphFlux<?> graphFlux : parallelGraphFlux.getGraphFluxes()) {
String nodeId = graphFlux.getNodeId();
Object nodeData = nodeDataRefs.get(nodeId).get();
combinedResultMap.put(graphFlux.getKey(), nodeData); // ← 用 key 做键,不是 nodeId
}
如果两个并行分支的 GraphFlux.getKey() 相同(比如都叫 "answer"),第二个会覆盖第一个。
java
// 两个分支都用 key="answer"
ParallelGraphFlux.of(List.of(
GraphFlux.of("branch_a", "answer", fluxA, null, null),
GraphFlux.of("branch_b", "answer", fluxB, null, null)));
// → combinedResultMap 里只剩 branch_b 的结果,branch_a 被覆盖
注意代码里取数据时用 nodeId 做键 (nodeDataRefs.get(nodeId)),存数据时用 graphFlux.getKey() 。两者不一致------前者保证不冲突(因为 nodeDataRefs 是按 nodeId 存的 HashMap),后者会冲突。
实践结论(重要):
| 场景 | 建议 |
|---|---|
| 单节点返回流式 | 用原生 Flux<ChatResponse> 而不是 GraphFlux ------走 handleEmbeddedFlux 路径,行为正确 |
| 并行节点返回流式 | 给每个分支的 GraphFlux 用不同的 key,否则会互相覆盖 |
| 读流式节点的结果 | 不要假设 state.value(key) 是 Message------先做类型检查 |
一个防御性写法:
java
// ✅ 兼容两种路径
Object raw = state.value("answer").orElse(null);
String text;
if (raw instanceof GraphResponse<?> gr) {
// 走的是 handleGraphFlux 路径,需要再拆一层
Object inner = gr.resultValue().orElse(null);
text = (inner instanceof Message m) ? m.getText() : String.valueOf(inner);
} else if (raw instanceof Message m) {
text = m.getText();
} else {
text = String.valueOf(raw);
}
这三条路径的不一致,是"四份实现"这个结构问题的直接证据。 当同一套逻辑被复制四遍时,其中几份必然会在细节上分叉------而这次分叉的是"结果对象要不要拆包"这种根本性的东西。
这也再次印证了第 03 章、第 04 章反复出现的那个判断标准:框架给的"能跑"路径,未必是"对"的路径。 用之前先想清楚你走的是哪条。
5.5.5 空 Flux 会变成错误(以及它为什么重要)
java
.switchIfEmpty(Flux.error(new IllegalStateException(
"Empty flux detected for key '" + key + "'. This may indicate an LLM API error with null result.")))
(.../graph/executor/NodeExecutor.java:236)
这一行是流式场景下最常见的生产异常之一 。触发链是:LLM 返回的流里所有元素都被前面的 filter 掉 → switchIfEmpty 触发 → 整个流变成错误。
四种来源:
| # | 来源 | 可修复性 |
|---|---|---|
| 1 | 供应商 API 只返回了 usage / finish_reason 等非内容包 | 依赖供应商 |
| 2 | 模型被内容安全策略拦截,返回空内容 | 改 prompt |
| 3 | 网络层重试后返回空响应 | 加重试 |
| 4 | 你自己在节点里 Flux.empty() 了 |
完全可以避免 |
第 4 种最容易被忽略:
java
// ❌ 条件不满足时返回空流 ------ 会抛 IllegalStateException
if (!shouldCallLlm(state)) {
return CompletableFuture.completedFuture(Map.of("answer", Flux.empty()));
}
// ✅ 应该返回非流式的占位值
if (!shouldCallLlm(state)) {
return CompletableFuture.completedFuture(Map.of("answer", "(本次跳过)"));
}
关键认知:返回"空的 Flux"和返回"没有 Flux"是两回事。
| 返回 | 走的路径 | 后果 |
|---|---|---|
Map.of("answer", Flux.empty()) |
流式 → switchIfEmpty |
抛 IllegalStateException |
Map.of("answer", "xxx") |
非流式 | 正常 |
Map.of() |
非流式 | 正常(什么都没写) |
实践结论:节点要么返回真实内容,要么返回不含 Flux 的 Map。绝不返回空 Flux。
5.6 子图:把整个执行流当成一个值
第 04 章讲了子图的声明 (SubStateGraphNode vs SubCompiledGraphNode)。这一节讲它的执行------而执行机制的设计,是整个框架里最巧妙的一处。
5.6.1 子图返回的是一个 Flux
java
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
final boolean resumeSubgraph = ...;
// ① 清洗 config:不继承父图的检查点位置和节点位置
RunnableConfig subGraphRunnableConfig = RunnableConfig.builder(config).checkPointId(null).nextNode(null).build();
subGraphRunnableConfig.clearContext();
// ② Saver 一致性检查(见 5.6.2)
var parentSaver = parentCompileConfig.checkpointSaver();
var subGraphSaver = subGraph.compileConfig.checkpointSaver();
if (subGraphSaver.isPresent()) {
if (parentSaver.isEmpty()) {
return CompletableFuture
.failedFuture(new IllegalStateException("Missing CheckpointSaver in parent graph!"));
}
if (parentSaver.get() == subGraphSaver.get()) {
subGraphRunnableConfig = RunnableConfig.builder(config)
.threadId(config.threadId()
.map(threadId -> format("%s_%s", threadId, subGraphId(nodeId)))
.orElseGet(() -> subGraphId(nodeId)))
.nextNode(null).checkPointId(null)
.build();
subGraphRunnableConfig.clearContext();
}
}
final CompletableFuture<Map<String, Object>> future = new CompletableFuture<>();
try {
if (resumeSubgraph) {
subGraphRunnableConfig = subGraph.updateState(subGraphRunnableConfig, state.data());
}
// ③ 核心:创建子图的流,然后【当作一个值】返回
var fluxStream = subGraph.graphResponseStream(state, subGraphRunnableConfig);
future.complete(Map.of(outputKeyToParent(nodeId), fluxStream));
}
catch (Exception e) {
future.completeExceptionally(e);
}
return future;
}
(.../graph/internal/node/SubCompiledGraphNodeAction.java:68)
核心是 ③ 那两行:子图的 action 返回的不是"结果",而是"一个会产出结果的 Flux"。
然后父图的 handleActionResult 第一件事就是 getEmbedFlux------发现 Map 里有个 Flux,走 handleEmbeddedFlux 路径,把这个 Flux 展开成父图流的一部分。
这就是「embed(嵌入)」这个名字的含义:子图的整个执行流,被当作一个值塞进父图的节点返回值里,再由父图展开。
对比一下三种做法:
| 做法 | 子图怎么返回 | 父图怎么处理 |
|---|---|---|
| 阻塞等待(朴素做法) | 跑完,返回结果 Map | 直接拿到结果 |
| 回调 | 接受一个 callback | 在 callback 里继续 |
| 本框架的做法 | 返回 Flux |
把 Flux 展开进自己的流 |
第三种的好处是:父图的流和子图的流无缝合并 。订阅父图 stream() 的人,会看到子图内部每个节点的输出,就像它们直接属于父图一样------只是 subGraph 标志被设成了 true。
这个机制的完整链条:
arduino
父图 NodeExecutor.executeNode
→ 拿到 currentNodeId = "sub"(子图节点 ID)
→ action.apply(...) → SubCompiledGraphNodeAction
→ subGraph.graphResponseStream(state, cfg) ← 创建子图的 Flux(此时还没跑)
→ 返回 Map.of("subgraph_sub_compiled_graph", <那个 Flux>)
→ handleActionResult
→ getEmbedFlux 发现 Flux
→ handleEmbeddedFlux
→ transformFluxToGraphResponse 给每个元素包装
→ 子图流里的 GraphResponse 走"透传"分支
→ 元素流进父图的流
注意子图的 Flux 是在 action.apply 里创建的,但那时还没订阅------真正跑起来要等到父图的流订阅到它。这是冷流的组合。
5.6.2 Saver 的父子一致性要求
java
var parentSaver = parentCompileConfig.checkpointSaver();
var subGraphSaver = subGraph.compileConfig.checkpointSaver();
if (subGraphSaver.isPresent()) {
if (parentSaver.isEmpty()) {
return CompletableFuture
.failedFuture(new IllegalStateException("Missing CheckpointSaver in parent graph!"));
}
// Check saver are the same instance
if (parentSaver.get() == subGraphSaver.get()) {
...
}
}
两条规则:
规则一:子图配了 Saver,父图必须也配。 否则直接 IllegalStateException("Missing CheckpointSaver in parent graph!")。
这个检查在每次执行子图节点时 都会做,而且是返回一个 failedFuture------所以异常会在 Mono.fromFuture 那里被捕获,变成流里的 error 元素。
注意这个检查的方向 :只检查「子图有 → 父图也要有」,不检查反向。「父图有、子图没有」是允许的------那时子图不存检查点,只有父图存。
规则二:只有父子用同一个 Saver 实例时,才启用"子图独立 threadId"。
java
if (parentSaver.get() == subGraphSaver.get()) {
subGraphRunnableConfig = RunnableConfig.builder(config)
.threadId(config.threadId()
.map(threadId -> format("%s_%s", threadId, subGraphId(nodeId)))
.orElseGet(() -> subGraphId(nodeId)))
.build();
}
注意判断是 == 引用比较 ,不是 equals。所以:
java
// ❌ 两个不同的 MemorySaver 实例,即使配置一样也不算"同一个"
parent: CompileConfig.builder().saverConfig(cfg1.register(new MemorySaver())).build()
sub: CompileConfig.builder().saverConfig(cfg2.register(new MemorySaver())).build()
// ✅ 必须是同一个实例
MemorySaver saver = new MemorySaver();
parent: ...register(saver)
sub: ...register(saver)
为什么要用派生 threadId? 因为子图和父图共享同一个 Saver 时,如果也共享同一个 threadId,两者的检查点会混在一条链上------父图存一个、子图存一个,交替写入,恢复时无法区分。
派生后:
| threadId | |
|---|---|
| 父图 | session-abc |
子图(id = sub) |
session-abc_sub |
这样两者的检查点链完全隔离。
还有第三种情况:子图有 Saver,父图也有,但不是同一实例 → 两个 if 都不满足 → subGraphRunnableConfig 保持第一个构造的那份(继承父图的 threadId)。
java
RunnableConfig subGraphRunnableConfig = RunnableConfig.builder(config).checkPointId(null).nextNode(null).build();
subGraphRunnableConfig.clearContext();
这时子图会用父图的 threadId 存检查点 → 子图和父图的检查点会混在同一条链上。 这是三种组合里最危险的一种:
| 父 Saver | 子 Saver | 是否同一实例 | 结果 |
|---|---|---|---|
| 无 | 有 | --- | 抛异常(Missing CheckpointSaver in parent graph) |
| 有 | 无 | --- | 正常,只有父图存点 |
| 有 | 有 | ✅ 是 | 正常,子图用派生 threadId,链隔离 |
| 有 | 有 | ❌ 否 | 能跑,但检查点链混在一起 |
实践结论:子图要用 Saver,就必须和父图共享同一个 Saver 实例。
5.6.3 subGraph 标志的语义丢失
java
public StreamingOutput<?> buildStreamingOutput(Message message, Object originData, String nodeId, boolean streaming) {
// Create StreamingOutput with chunk and originData
OutputType outputType = OutputType.from(streaming, nodeId);
StreamingOutput<?> output = new StreamingOutput<>(message, originData, nodeId,
(String) config.metadata("_AGENT_").orElse(""), this.overallState, outputType);
output.setSubGraph(true);
return output;
}
(.../graph/GraphRunnerContext.java:269)
setSubGraph(true) 是硬编码的。 这个方法的每一个调用者拿到的输出,都被标记成"来自子图"。
而 processGraphResponseFlux 里也是:
java
.map(data -> {
if (data.getOutput() != null && !data.getOutput().isCompletedExceptionally()) {
var output = data.getOutput().join();
output.setSubGraph(true); // ← 无条件设置
GraphResponse<NodeOutput> newData = GraphResponse.of(output);
lastData.set(newData);
return newData;
}
lastData.set(data);
return data;
})
(.../graph/executor/NodeExecutor.java:433)
后果:任何从 embed Flux 路径出来的 NodeOutput,subGraph 都是 true------不管它是不是真的来自子图。
这意味着:
java
// 一个普通的流式 LLM 节点,返回原生 Flux<ChatResponse>
AsyncNodeActionWithConfig llmNode = (state, config) ->
CompletableFuture.completedFuture(Map.of("answer", chatClient.prompt().stream().chatResponse()));
// 它走 getEmbedFlux → handleEmbeddedFlux → 被 setSubGraph(true)
// 下游看到 output.isSubGraph() == true,但它根本不是子图!
实践结论:不要用 isSubGraph() 判断"这个输出来自子图"。 它实际表达的是"这个输出经过了 embed Flux 路径"。
想真正区分,要用 node() 判断------子图内部节点的 node() 是子图里的节点 ID:
java
// ✅ 用 node ID 判断
if (output.node().startsWith("sub_")) { ... }
// ❌ 不要用 isSubGraph()
if (output.isSubGraph()) { ... } // 普通流式节点也会是 true
这又是那句话的一次印证:字段的"名义语义"和"实际语义"不一致。
subGraph名义上是"来自子图",实际是"经过了 embed 路径"。理解这类不一致,是读源码的价值所在------文档不会告诉你这些。
5.7 中断:执行引擎里的三个插手点
中断(HITL、人工审批)是执行引擎最复杂的一块。它在引擎里有三个插手点,分别位于不同的 Executor 里。
5.7.1 InterruptableAction 的两个时机
java
public interface InterruptableAction {
Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config);
default Optional<InterruptionMetadata> interruptAfter(String nodeId, OverAllState state,
Map<String, Object> actionResult, RunnableConfig config) {
return Optional.empty();
}
}
(.../graph/action/InterruptableAction.java)
两个方法,注释里写明了执行流:interrupt() → apply() → interruptAfter()。
| 方法 | 时机 | 能看到 | 默认实现 |
|---|---|---|---|
interrupt(...) |
节点 执行前 | 状态(未含本节点结果) | 无(必须实现) |
interruptAfter(...) |
节点 执行后、状态合并前 | 状态 + 本节点的原始返回 | 返回 empty |
interruptAfter 的价值在于它能看到"节点刚算出来但还没合进状态"的东西。 典型用途是人工审批:模型生成了一份退款方案,你要在它生效之前拦下来给人看。
java
// 一个典型的人工审批节点
public class RefundApprovalNode implements AsyncNodeActionWithConfig, InterruptableAction {
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
// 生成退款方案(不执行)
return CompletableFuture.completedFuture(Map.of("refund_plan", buildPlan(state)));
}
@Override
public Optional<InterruptionMetadata> interruptAfter(String nodeId, OverAllState state,
Map<String, Object> actionResult, RunnableConfig config) {
// 看到方案了 ------ 拦下来等人审
if (needsApproval(actionResult)) {
return Optional.of(InterruptionMetadata.builder(nodeId, state).build());
}
return Optional.empty();
}
@Override
public Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config) {
return Optional.empty();
}
}
注意 interrupt(...) 没有 default 实现,所以实现这个接口必须写它。 如果你只需要 interruptAfter,也要写一个空的 interrupt。
5.7.2 三个插手点在引擎里的位置
| # | 位置 | 代码 | 触发条件 |
|---|---|---|---|
| ① | NodeExecutor.executeNode 内,apply 之前 |
(.../NodeExecutor.java:113) |
interrupt(...) 返回非空 |
| ② | NodeExecutor.handleActionResult 内,merge 之前 |
(.../NodeExecutor.java:172) |
interruptAfter(...) 返回非空 |
| ③ | MainGraphExecutor.execute 的 ⑦ 分支 |
(.../MainGraphExecutor.java:96) |
shouldInterrupt() 为真 |
① 和 ② 是"节点级"的 (由节点自己决定是否中断),③ 是"图级"的 (由 CompileConfig 的 interruptsBefore/interruptsAfter 决定)。
三者产生的产物也不同:
| 插手点 | 产物 |
|---|---|
| ① | done(InterruptionMetadata),状态没被更新(因为还没执行) |
| ② | done(InterruptionMetadata),状态已合并(代码里先 merge 再返回) |
| ③ | done(InterruptionMetadata),cloneState 出来的当前状态 |
② 那个"先合并再中断"的处理非常关键 (5.4.2 讲过那个四步顺序)。它保证了:中断时存的检查点里,状态是包含本节点结果的。恢复时才能正确继续。
而 ① 是在 apply 之前,所以状态里没有本节点的结果------这是对的,因为节点还没跑。
5.7.3 INTERRUPT_AFTER 是个伪节点
java
public static final String INTERRUPT_AFTER = "__INTERRUPTED__"; // GraphRunnerContext:59
private static String INTERRUPT_AFTER = "__INTERRUPTED__"; // CompiledGraph:62
先说一个容易忽略的事实:这个常量被定义了两遍,在两个类里。
| 类 | 修饰符 | 值 |
|---|---|---|
GraphRunnerContext |
public static final String |
"__INTERRUPTED__" |
CompiledGraph |
private static String(没有 final) |
"__INTERRUPTED__" |
而 MainGraphExecutor 和 NodeExecutor 用的是 GraphRunnerContext 那份(static import),CompiledGraph:432 用的是自己那份。两处值相同,所以目前行为一致------但它们是两个独立的常量,改一处不会同步另一处。
这是第 04 章那个「三个哨兵常量散落在各处」问题的延续:框架里的"魔法字符串"没有统一的常量表。 你在使用时应该 import GraphRunnerContext 那份(它是 public static final,是"官方"那份)。
它是个节点 ID,但永远不会有对应的 action。它被用在两个地方:
java
// NodeExecutor:节点执行完,如果配置了边前中断,把 nextNodeId 设成它
if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
&& context.getCompiledGraph().compileConfig.interruptsAfter()
.contains(context.getCurrentNodeId())) {
context.setNextNodeId(INTERRUPT_AFTER);
}
(.../graph/executor/NodeExecutor.java:193)
java
// MainGraphExecutor:下一轮看到 nextNodeId 是它,且是边前中断模式 + 有 resumeFrom,
// 就重新计算真实路由
final var resumeFrom = context.getResumeFromAndReset();
if (resumeFrom.isPresent()) {
if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
&& java.util.Objects.equals(context.getNextNodeId(), INTERRUPT_AFTER)) {
var nextNodeCommand = context.nextNodeId(resumeFrom.get(), context.getCurrentStateData());
context.setNextNodeId(nextNodeCommand.gotoNode());
context.setCurrentNodeId(null);
}
}
(.../graph/executor/MainGraphExecutor.java:86)
语义是:"我故意不告诉你下一步去哪,等你恢复时再算。"
这是一个巧妙但也脆弱的设计。它依赖三个条件同时成立:
interruptBeforeEdge()为 truegetResumeFromAndReset()有值(即从检查点恢复)nextNodeId == INTERRUPT_AFTER
如果第 2 条不满足(比如你是全新执行而不是恢复),那 nextNodeId 就永远停在 __INTERRUPTED__ ------然后引擎会走到 shouldInterrupt():
java
private boolean shouldInterruptAfter(String nodeId, String previousNodeId) {
if (nodeId == null || Objects.equals(nodeId, previousNodeId))
return false;
return (compiledGraph.compileConfig.interruptBeforeEdge() && Objects.equals(nodeId, INTERRUPT_AFTER))
|| compiledGraph.compileConfig.interruptsAfter().contains(nodeId);
}
(.../graph/GraphRunnerContext.java:179)
shouldInterruptAfter(currentNodeId, nextNodeId) ------ 这里形参 nodeId = current,previousNodeId = next = __INTERRUPTED__。
Objects.equals(nodeId, INTERRUPT_AFTER) 判断的是 current 是否等于 __INTERRUPTED__------而 current 是真实节点 ID,不是。所以这个条件不成立。
所以 interruptBeforeEdge 的中断判定,依赖的是 interruptsAfter().contains(currentNodeId) 这条 ,而 INTERRUPT_AFTER 那条在 shouldInterruptAfter 里永远不会命中 (因为传进去的 nodeId 是 current 而不是 next)。
这个细节说明 INTERRUPT_AFTER 只用于"设置 nextNodeId"和"恢复时识别"两个场合,不参与中断判定本身。 中断判定发生在更早------在 NodeExecutor 里设置 INTERRUPT_AFTER 的那一刻,就已经决定了"这一轮结束后不再继续"。
等等,那中断是在哪发生的?
重读 handleActionResult:设置 nextNodeId = INTERRUPT_AFTER 后,没有 return ,继续执行 buildNodeOutputAndAddCheckpoint → doListeners(NODE_AFTER) → 递归 mainGraphExecutor.execute。
下一轮 execute:
- ① shouldStop? next=
__INTERRUPTED__不为 null → false - ② returnFromEmbed? 无
- ③ isInterrupted? 否
- ④ isStartNode? 否
- ⑤ isEndNode?
END.equals("__INTERRUPTED__")→ 否 - ⑥ resumeFrom? 无(全新执行)
- ⑦ shouldInterrupt?
shouldInterruptBefore(next=__INTERRUPTED__, current)→interruptsBefore().contains("__INTERRUPTED__")→ false;shouldInterruptAfter(current, next)→ 上面分析过,false - ⑧
nodeExecutor.execute
→ 走到 ⑧,会尝试执行一个 ID 为 __INTERRUPTED__ 的节点!
而 nodeExecutor.executeNode 第一步:
java
context.setCurrentNodeId(context.getNextNodeId()); // current = "__INTERRUPTED__"
String currentNodeId = context.getCurrentNodeId();
AsyncNodeActionWithConfig action = context.getNodeAction(currentNodeId);
if (action == null) {
return Flux.just(GraphResponse.error(RunnableErrors.missingNode.exception(currentNodeId)));
}
getNodeAction("__INTERRUPTED__") 返回 null → 发 missingNode 错误。
所以:如果开了 interruptBeforeEdge 但没有从检查点恢复,nextNodeId = INTERRUPT_AFTER 会导致下一轮抛 missingNode 错误。
这是一个非常隐蔽的行为。不过在实践中,interruptBeforeEdge 通常配合 HITL 使用(必然有恢复环节),所以这条路径不常走到。
实践结论:interruptBeforeEdge(true) 只在"一定会从检查点恢复"的场景下使用。 如果你只是想在某个节点后停一下,用 interruptAfter("nodeId") 而不是 interruptBeforeEdge。
5.7.4 中断的恢复路径
中断后,Flux 发出 done(InterruptionMetadata) 并结束。恢复时:
java
RunnableConfig config = RunnableConfig.builder()
.threadId("session-abc")
.checkPointId("<检查点ID>") // ← 有这个就走 initializeFromResume
.build();
compiled.invoke(Map.of(), config);
GraphRunnerContext 构造时检测到 checkPointId().isPresent() → initializeFromResume:
java
this.currentNodeId = null;
this.nextNodeId = checkpoint.getNextNodeId();
this.overallState = initialState.input(checkpoint.getState());
this.resumeFrom = checkpoint.getNodeId();
然后第一次 execute:
- ① shouldStop? 否(next 不为 null)
- ② returnFromEmbed? 无
- ③ isInterrupted? 否
- ④ isStartNode? 否(current 是 null 不是 START)✓ 跳过 handleStartNode
- ⑤ isEndNode? 否
- ⑥ resumeFrom 有值 → 如果是边前中断模式就重算路由
- ⑦ shouldInterrupt? 否
- ⑧ nodeExecutor → 从
next(= 检查点记录的下一个节点)开始
注意 ⑥ 里 getResumeFromAndReset() 是 consume-once,所以"重算路由"只在第一轮发生一次。 之后 resumeFrom 就是 null 了。
5.8 执行引擎的六个反模式
反模式一:在 Reactor 线程上调用 invoke(...)
症状:应用吞吐下降,其他请求跟着变慢。
根因 :invoke = stream(...).last().block()(5.1.2)。.block() 阻塞调用线程。
修复 :在响应式链路里用 stream(...) 直接返回 Flux;在 WebFlux 场景用 MediaType.TEXT_EVENT_STREAM_VALUE。
反模式二:在节点里直接改传入的 OverAllState
症状:状态被改了两遍,或者 APPEND key 出现意料之外的重复。
根因 :action.apply(context.getOverallState(), ...) 传的是主状态引用(5.4.1 步骤 ⑥),不是副本。
修复 :节点只通过返回的 Map 表达变更,不要调 state.updateState(...)。
反模式三:返回空的 Flux
症状 :IllegalStateException: Empty flux detected for key 'xxx'。
根因 :switchIfEmpty(Flux.error(...))(5.5.5)。
修复:条件不满足时返回非流式的占位值,或返回空 Map。
反模式四:一个节点返回多个 Flux
症状:只有一个流的输出被处理,其余静默丢失。
根因 :三个 getEmbedXxx 都用 findFirst()(5.4.4)。
修复 :用 Flux.merge(...) 合并,或用 ParallelGraphFlux。
反模式五:给并行分支的 GraphFlux 用相同的 key
症状:并行流式场景下,只有最后一个分支的结果进状态。
根因 :combinedResultMap.put(graphFlux.getKey(), nodeData) 用 key 做 map 键(5.5.4)。
修复 :每个分支用不同的 key,比如 "answer_a" / "answer_b"。
反模式六:给子图配 Saver 却和父图用不同实例
症状:子图和父图的检查点混在同一条链上,恢复时串了。
根因 :parentSaver.get() == subGraphSaver.get() 是引用比较(5.6.2)。
修复:共享同一个 Saver 实例。
其中反模式二到五,都是同一类问题:用"看起来能跑"的写法,触发了框架里那条没人走过的分支。
5.9 执行引擎侧的六条规范
- 入口选择 :响应式链路用
stream(...);批处理/定时任务用invoke(...);HITL 场景用invokeAndGetResponse(...)拿Tag。 - 节点是纯函数式的 :读
state,返回Map,不改state。 - 流式返回用原生
Flux<ChatResponse>,除非你需要GraphFlux的 nodeId 或mapResult------因为GraphFlux路径会把GraphResponse塞进状态(5.5.3)。 - 读流式结果先做类型检查,别直接强转。
- 子图与父图共享 Saver 实例,且父图必须先配 Saver。
- 中断要显式选模式 :节点级用
interruptAfter,图级用interruptAfter("nodeId"),别用interruptBeforeEdge除非一定会恢复。
5.10 本章小结
这一章把「一次调用到底发生了什么」讲完了:
-
十七个入口收敛到一个出口 。所有
invoke*都是stream*+.last().block();所有stream*都走到GraphRunner.run()。 -
GraphRunner是 65 行的薄壳 ,MainGraphExecutor(161 行)和NodeExecutor(823 行)构成互相递归的一对。它们都是无状态的,状态全在GraphRunnerContext。 -
GraphRunner.resultValue()是零调用的死 API 。因为handleStartNode递归时换了new AtomicReference<>(),导致它永远拿不到值。 -
GraphRunnerContext九个字段没有一个volatile------因为状态推进是严格串行的,concatWith提供了内存屏障。 -
isMaxIterationsReached()有副作用 (incrementAndGet())。它等价于"递归层数"。 -
MainGraphExecutor.execute有八个分支 ,顺序不可换。其中两个分支用getXxxAndReset()消费一次性状态,必须各调一次。 -
递归不爆栈靠
Flux.defer的 trampoline:构造时不递归,订阅时才递归,栈上始终只有一层。 -
NodeExecutor.handleActionResult有九个出口,其中三条流式路径各自重新实现了一遍"合并 → 路由 → 存点 → 通知 → 递归"。 -
三条流式路径里,两条有缺陷 :
handleGraphFlux和handleParallelGraphFlux会把GraphResponse对象塞进状态,而handleEmbeddedFlux会正确拆包。 -
空
Flux会变成IllegalStateException(switchIfEmpty接在filter后面)。 -
子图返回的是
Flux而不是结果------整个子执行流被当成一个值嵌进父图。 -
中断有三个插手点 :节点执行前(
interrupt)、节点执行后(interruptAfter)、图级(shouldInterrupt)。 -
INTERRUPT_AFTER(__INTERRUPTED__)是个伪节点,用于"故意不告诉你下一步去哪",只在边前中断 + 恢复场景下才被正确消费。
本章的三条硬规则
- 节点是纯函数式的 :读
state,返回Map,绝不改state,绝不返回空Flux。 - 流式优先用原生
Flux<ChatResponse>;用GraphFlux时必须知道结果会被包一层。 - 响应式链路里不用
invoke------它是block()的语法糖。
三条与执行引擎相关的补充硬规则
- 必经之路的四个 API 都要显式配 :
recursionLimit(裸图默认 100,ReactAgent下无上界)、ModelCallLimitHook、threadId、含阻塞 IO 节点的线程池。 - 子图要用 Saver,父图必须先配,且必须是同一实例。
- 读任何流式节点写进状态的值,都要先做类型检查。
一个贯穿本章的判断标准
这一章的每一个"不一致"------三份重复的流式实现、两条路径的分叉、resultValue 的死链、subGraph 标志的语义漂移------都能用第 01 章的第 ② 条原则串起来:
看一个抽象好不好,别看它的接口,看它的实现有几份。
handleActionResult 里那四份几乎一样的"合并→路由→存点→通知→递归",是本章最重要的观察。它们不是设计出来的架构,是演进出来的沉积:
- 先有非流式路径
- 后来要支持 LLM 流式 → 复制一份改成
handleEmbeddedFlux - 后来要支持
GraphFlux(带 nodeId)→ 复制一份 - 后来要支持并行流式 → 再复制一份
每一次复制都解决了一个当时的需求,也埋下了一处未来的分叉。 而 GraphRunnerContext 里那三句 FIXME 注释,就是作者自己留下的施工标记------他知道该统一,只是还没做。
所以本章的判断标准是:
当你发现同一个"套路"在框架里出现了三份以上,且它们的细节各不相同------选那个被最多测试覆盖、注释最完整的那份走,不要选最新加的那份。
落到具体选择上就是:流式返回用原生 Flux,不要用 GraphFlux;子图用 SubCompiledGraphNode 而共享 Saver 实例;中断用 interruptAfter 而不是 interruptBeforeEdge。
下一章我们讲流式管线本身------Flux、StreamingOutput、OutputType 那三个枚举。理解了执行引擎是怎么把一个个 GraphResponse 吐出来的,你才有资格问:「这些 NodeOutput 到底有哪几种?我该怎么按类型分流?」
延伸验证清单
bash
# 2. 确认 resultValue() 没有任何外部调用点
# 预期:只有 GraphRunner.java:53/61/62 三处(声明+传参+方法体),
# 没有任何 "runner.resultValue()" 或 ".resultValue()" 指向 GraphRunner
grep -rn "\.resultValue()" --include=*.java spring-ai-alibaba-graph-core/src/main/java \
| grep -v AsyncGenerator | grep -v "GraphResponse.java"
# 3. 确认 handleStartNode 递归时换了 AtomicReference
# 预期:execute(context, new AtomicReference<>())
grep -n "new AtomicReference<>" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/MainGraphExecutor.java
# 4. 确认 isMaxIterationsReached 有副作用
# 预期:return iteration.incrementAndGet() > compiledGraph.getMaxIterations();
grep -n -A2 "public boolean isMaxIterationsReached" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/GraphRunnerContext.java
# 5. 确认 CompileConfig 的 recursionLimit 默认值(第 01 章那张表的依据)
# 预期:private int recursionLimit = 100;
grep -n "private int recursionLimit" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/CompileConfig.java
# 6. 确认 MainGraphExecutor.execute 的八个分支顺序
# 预期:shouldStop → ReturnFromEmbed → isInterrupted → isStartNode
# → isEndNode → ResumeFrom → shouldInterrupt → nodeExecutor
grep -n "shouldStop()\|getReturnFromEmbedAndReset()\|isInterrupted(\|isStartNode()\|isEndNode()\|getResumeFromAndReset()\|shouldInterrupt()\|nodeExecutor.execute" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/MainGraphExecutor.java
# 7. 确认三个 getEmbedXxx 都用 findFirst
# 预期:3
grep -c "findFirst" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
# 8. 确认三条流式路径的提取方式差异(5.5.3 的 BUG 证据)
# 预期:460 行有 .resultValue();643 行和 761 行没有
sed -n '460p' spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
sed -n '643p' spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
sed -n '761p' spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
# 9. 确认空 Flux 保护接在 filter 之后
# 预期:先 filter 后 switchIfEmpty
grep -n "\.filter(element -> {\|switchIfEmpty" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
# 10. 确认 O(n²) 的文本拼接
# 预期:lastMessageText.concat(currentMessageText)
grep -n "lastMessageText.concat" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
# 11. 确认 subGraph 标志是硬编码的
# 预期:2 处 setSubGraph(true)
grep -c "setSubGraph(true)" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
# 12. 确认子图返回的是 Flux
# 预期:future.complete(Map.of(outputKeyToParent(nodeId), fluxStream));
grep -n "fluxStream" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/internal/node/SubCompiledGraphNodeAction.java
# 13. 确认子图 Saver 一致性用的是引用比较
# 预期:parentSaver.get() == subGraphSaver.get()
grep -n "parentSaver.get() == subGraphSaver.get()" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/internal/node/SubCompiledGraphNodeAction.java
# 15. 确认 GraphRunnerContext 有三处 FIXME
# 预期:3
grep -c "FIXME" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/GraphRunnerContext.java
# 17. 确认监听器异常被吞
# 预期:catch (Exception ex) { log.error("Error in listener", ex); }
grep -n -A2 "catch (Exception ex)" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/GraphRunnerContext.java
# 18. 确认 MainGraphExecutor 异常分支用的是完全限定名取 Logger
# 预期:org.slf4j.LoggerFactory.getLogger(com.alibaba.cloud.ai.graph.GraphRunner.class)
grep -n "org.slf4j.LoggerFactory" \
spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/MainGraphExecutor.java
# 19. 确认 INTERRUPT_AFTER 的值与两处使用
# 预期:Context 里是 "__INTERRUPTED__";MainGraphExecutor 与 NodeExecutor 各引用
grep -rn "INTERRUPT_AFTER = \|INTERRUPT_AFTER)" --include=*.java \
spring-ai-alibaba-graph-core/src/main/java
思考题(第 06 章会给出答案):
-
5.1.5 说
GraphRunner.resultValue()永远返回 empty。那么handleStartNode之外,还有没有路径能让resultValue_0被 set?(提示:第一次execute时isStartNode()为 false 的情况。) -
5.3.5 说递归靠
Flux.defer不爆栈。如果一个图有 10 万个节点(且recursionLimit设得足够大),堆上会有多少个Flux对象?什么时候它们会被 GC? -
5.4.1 里
action.apply(context.getOverallState(), ...)传的是主状态引用。如果节点在执行中抛异常,handleActionResult不会被调用------那么节点在抛异常前已经做的state.updateState(...)会生效吗? -
5.5.3 说
handleGraphFlux会把GraphResponse塞进状态。如果你用ParallelGraphFlux且两个分支用了不同的 key,状态里会是什么?两个 key 会不会都是GraphResponse? -
5.6.2 说子图 Saver 用引用比较。如果父图和子图都用
MemorySaver,但是两个不同的实例(各自new的),会发生什么?会抛异常吗? -
5.7.3 说
INTERRUPT_AFTER在shouldInterruptAfter里永远不会命中。那如果我在CompileConfig里配interruptBeforeEdge(true)且interruptAfter("generate"),但从不恢复,stream()最终会发出什么?
本章思考题答案(简版)
-
有,只有一条:从检查点恢复的路径。
initializeFromResume设currentNodeId = null,所以第一次execute的isStartNode()为 false,直接走到 ⑧ 并传入原始的resultValue_0。所以恢复场景下resultValue_0是活的------只是仍然没人读它。 -
约 30 万个 (每个节点三个:
FluxJust+FluxDefer+FluxConcatArray)。但整条链是边构造边消费的,所以峰值是"已构造但未消费"的部分,不是 30 万。真正的风险是订阅者处理太慢导致背压积压。 -
会生效。 因为传的是主状态引用,
updateState直接改主状态。这恰恰是反模式二的理由------异常路径下污染会残留 ,且由于handleActionResult没被调用,合并、存点、通知全都不会发生。 -
两个 key 都会是
GraphResponse,且互不覆盖(因为 key 不同)。所以但凡走handleParallelGraphFlux路径,无论 key 是否相同,写进状态的类型都是错的。 -
不会抛异常,能正常跑。 引用比较不满足 → 不启用派生 threadId → 子图用父图的 threadId 存检查点 → 父子检查点混在同一条链上 (5.6.2 表格第四行)。排查时会看到
getStateHistory()里父子节点交替,恢复时可能跳错。 -
stream()会先发出generate节点的输出,然后抛missingNode错误。 因为interruptBeforeEdge(true)+interruptsAfter.contains("generate")命中 →nextNodeId = __INTERRUPTED__→ 下一轮shouldInterrupt()不命中(5.7.3 分析过)→ 走到 ⑧ →getNodeAction("__INTERRUPTED__")返回 null →missingNode错误。所以interruptBeforeEdge不能单独使用,必须配合检查点恢复。