Spring AI Alibaba 深度技术系列-第 05 章 编译与执行引擎:CompiledGraph、GraphRunner与三段式 Executor

3 阅读1小时+

Spring AI Alibaba 深度技术系列-第 05 章 编译与执行引擎:CompiledGraph、GraphRunner 与三段式 Executor

前四章我们把「图是什么」讲完了:第 03 章讲状态怎么表达,第 04 章讲拓扑怎么声明和改写。到第四章结尾,你手里有的是一个 CompiledGraph——一个持有节点工厂、边表、并行节点和各类配置的不可变对象。

但 CompiledGraph 自己不会跑。

这一章要回答的是那个最直接的问题:当你写下 compiled.invoke(inputs, config) 的那一刻,到底发生了什么? 有多少个对象被创建?控制流在谁手里?线程在谁手里?状态在什么时候被更新?流是怎么穿过一层层 Flux 到达你手上的?

这一章的标题里有个词需要先解释——「三段式 Executor」。翻开 graph/executor 包,你会看到三个类:

类行数职责
BaseGraphExecutor74抽象基类,只提供 handleCompletion 一个方法
MainGraphExecutor161主循环:决定「下一步是继续走、还是停下来」
NodeExecutor823单节点:执行一个节点并处理它的六种结果

它们构成一条互相递归的链: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
ScheduledAgentTaskschedule(ScheduleConfig)683

十七个方法,看起来多,但收敛关系非常干净:

   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 的语法糖,而且会阻塞

这一点值得单独说,因为它有实际后果:

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)

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,你等于把整个应用的吞吐堵住了。

// ❌ 在 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 行的薄壳

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。

public MainGraphExecutor() {
    this.nodeExecutor = new NodeExecutor(this);
}

(.../graph/executor/MainGraphExecutor.java:45)

而 NodeExecutor 又持有 MainGraphExecutor:

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(...) 不是随手写的,它承担了三件事:

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 对象订阅两次,会跑出两个完全独立的执行:

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() 这个方法,在整个代码库里没有任何调用点。

验证:

# 谁调用了 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 的地方:

GraphRunner runner = new GraphRunner(this, config);
return runner.run(state);                    // ← 552/553,只用 run()

(.../graph/CompiledGraph.java:552)

GraphRunner runner = new GraphRunner(this, config);
return runner.run(overAllState).flatMap(data -> { ... });   // ← 581/582,也只用 run()

(.../graph/CompiledGraph.java:581)

两处都只调 run(),从不调 resultValue()。

那么,resultValue 是不是在链内就没人用了?不是——链内还在用,只是对外没有出口。看它唯一被真正消费的地方:

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 持有的那个从第一次递归开始就永远拿不到值了。

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)——于是它被换掉,从此再无人问津。

完整链路:

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:

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)

三个分支:

分支条件行为
doneisDone()(即 output == null)只有 resultValue 是 NodeOutput 实例才发出,否则丢弃
errorisError()(output 异常完成)转成错误信号
正常其他取出 NodeOutput 发出

关键在 done 分支那个 instanceof NodeOutput 判断。而 handleCompletion 设的是 Tag 或 HashMap——都不是 NodeOutput,所以完成信号一律被丢弃。

那 stream() 的最后一个元素是什么?是 END 节点的输出。 因为 handleEndNode 用的是 GraphResponse.of(output) 而不是 done(...):

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 想要的:

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(...)streamFromInitialNodeNodeOutput(node=END).state()
invokeAndGetResponse(...)graphResponseStreamGraphResponse.done(Map).resultValue().get()

两个 API 返回的是两条不同路径的产物,5.3.4 会讲这导致的语义差异。


5.2 GraphRunnerContext:执行期的可变状态盒

GraphRunner 是薄壳,CompiledGraph 是不可变配置,所有执行期状态都在 GraphRunnerContext 里。

5.2.1 九个字段,没有一个 volatile

	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 参数+返回值传递数据(框架已经这么做了)。

④ 构造函数有两个分支——这是「全新执行」和「从检查点恢复」的分水岭:

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:从头开始

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:从检查点恢复

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)

两条路径的三个关键差异:

从头开始从检查点恢复
currentNodeIdSTARTnull
nextNodeIdnullcheckpoint.getNextNodeId()
resumeFromnullcheckpoint.getNodeId()

currentNodeId = null 这个设定很关键:它让第一次 execute 跳过 handleStartNode(因为 isStartNode() 要求 currentNodeId == START),直接进入 nodeExecutor 分支——而 nodeExecutor 第一件事就是 setCurrentNodeId(getNextNodeId()),把 next(= 检查点记录的下一个节点)提升为 current。

这就是"时间旅行"的实现:不重放历史,直接从记录的那个节点继续。

注意那两个 IllegalStateException:没有配 Saver 就想从检查点恢复,是运行时异常而不是受检异常。 所以 checkPointId 要和 Saver 成对配置。

⑤ stateCreate 上有一个 FIXME。

// 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 是重复的,作者自己也知道需要一个统一的状态创建方式,但目前是两套。

两处的差异在哪?

// 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.stateCreateCompiledGraph.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。

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 六个判定方法,有一个有副作用

// 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 的开头:

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。

public boolean isEndNode() {
	return END.equals(nextNodeId);
}

为什么?因为 END 从来不是"当前正在执行的节点"——它是"下一步要去的地方"。所以判定顺序是:

节点 X 执行完 → nextNodeId 路由计算出 "END" → 回到 MainGraphExecutor
  → isStartNode()? 否
  → isEndNode()? 是(nextNodeId == END)→ handleEndNode

这也解释了为什么 handleEndNode 里 buildNodeOutput(END) 而不需要真的去 getNodeAction(END)——END 没有对应的 action。

③ shouldInterrupt 是一对方法的或。

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:五个场景与吞掉的异常

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。

// 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()。

case END:
    listener.onComplete(END, getCurrentStateData(), config);
    break;

其他四个场景都传 getCurrentNodeId(),唯独 END 传常量。所以 onComplete(nodeId, ...) 的 nodeId 参数永远是 "__END__",不是最后执行的那个节点。

如果你的监听器里写 if (nodeId.equals("generate")) { ... },它在 onComplete 里永远不会命中。

5.2.5 mergeIntoCurrentState 与三处 FIXME

/**
 * 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。

/**
 * 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 系列上。

/**
 * 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)

作者自己标注:下面这几个输出构建方法是重复的,需要一个统一的流式输出方式。

重复在哪?看这一族:

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) 名字几乎一样,返回的是两个完全不同的实现类。 这是最容易误用的地方。

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:

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 八个分支与它们的顺序

@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()handleCompletion终止
②getReturnFromEmbedAndReset() 有值done(...)终止
③isInterrupted(currentNodeId)done(状态)终止
④isStartNode()handleStartNode继续(递归)
⑤isEndNode()handleEndNode继续(然后终止)
⑥getResumeFromAndReset() 有值改写 nextNodeId继续
⑦shouldInterrupt()done(InterruptionMetadata)终止
⑧兜底nodeExecutor.execute继续(递归)

顺序为什么不能换? 三个关键约束:

约束一:①必须在最前。 它是唯一的"兜底出口"。如果图因为达到迭代上限或 shouldStop() 而该停,必须在做任何别的事之前停。特别是 isMaxIterationsReached() 有自增副作用——如果它不在第一个,后面的分支会干扰计数。

约束二:②必须在 ⑥ 之前,且两者都用 getXxxAndReset()。

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(...)。

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 里是标准的:

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:断链的那一行

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() —— 取入口边:

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——

Command nextCommand = context.getEntryPoint();
context.setNextNodeId(nextCommand.gotoNode());   // ← update 字段被丢弃

对比 nextNodeId 内部对条件边的处理:

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 会被合并;如果是普通边呢?

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 出发"的检查点。

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 的输出。

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()) —— 关键的一行。

之前: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

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)

三步:

  1. doListeners(END, null) → 触发 onComplete(END, ...)
  2. buildNodeOutput(END) → 构建携带完整状态的 NodeOutput
  3. 发出它,然后接 handleCompletion

注意 handleEndNode 不发 done(...),它发的是 of(output)。

所以 stream() 的最后一个元素是 END 的 NodeOutput(5.1.6 讲过)。而紧跟其后的 handleCompletion 发的是 done(...),被 flatMap 丢弃。

为什么还要发 handleCompletion? 因为 graphResponseStream 那条路径(返回 Flux<GraphResponse>)不经过 flatMap,它的调用者能看到 done(...):

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 的双重语义

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 && 有 SaverBaseCheckpointSaver.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(...):

// releaseThread + Saver 场景
GraphResponse<NodeOutput> r = compiled.invokeAndGetResponse(inputs, config);
r.resultValue().get();   // ← 是个 BaseCheckpointSaver.Tag,不是状态!

如果你用 invokeAndGetResponse 拿最终状态,在 releaseThread 场景下你拿到的是一个 Tag。 这是这两个 API 最实质的差异。

5.3.5 递归为什么不爆栈

现在回答 5.1.4 留下的那个问题。

执行引擎的递归结构:

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。

所以构造阶段是:

execute(context) 调用
  → 构造 Flux.just(output)
  → 构造 Flux.defer(lambda)     ← lambda 此刻【不执行】
  → concatWith 组合两个 Flux
  → 返回                        ← 栈上始终只有一层 execute

而 concatWith 的订阅时机是「前一个 Flux 发射完毕后才订阅下一个」,所以时间线是:

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 层就会真的爆栈:

// ❌ 直接调用 = 立即递归 → 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 / isInterrupteddone(InterruptionMetadata)
异常任意环节抛异常GraphResponse.error(e)

注意第二种和第一种的产物不同。 迭代到上限时,isMaxIterationsReached() 在① 分支就直接 handleCompletion,根本不会走到 ⑤ 的 handleEndNode。

所以:

// 正常结束
outputs.get(outputs.size() - 1).node();    // "END"

// 迭代到上限
outputs.get(outputs.size() - 1).node();    // 最后一个真实执行的节点,比如 "generate"

这是一个可以用来诊断的判别式:如果你的 stream() 输出的最后一个元素 node() 不是 END,那说明图没有正常走到终点——它要么被迭代上限截断,要么被中断了。

// 一个实用的诊断片段
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 的七步

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 工厂并实例化。

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 到底是什么"。

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展开单条流
4interruptAfter 命中是 InterruptableAction 且 interruptAfter 返回非空done(InterruptionMetadata)
5interruptBeforeEdge 命中配置了边前中断设 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 的子类。让我验证这一点:

// getEmbedGraphFlux 的判断
.filter(e -> e.getValue() instanceof GraphFlux)

// getEmbedParallelGraphFlux 的判断
.filter(e -> e.getValue() instanceof ParallelGraphFlux)

如果 ParallelGraphFlux extends GraphFlux,那顺序就必须先判 ParallelGraphFlux。如果两个是平级的,顺序其实无所谓。

实际它们是平级的(ParallelGraphFlux 是独立的 record),所以顺序只是风格问题。但顺序本身仍然有意义:如果未来有人让 ParallelGraphFlux 继承 GraphFlux,这个顺序就是对的。

④ 那个 interruptAfter 出口,藏着一个精心设计的顺序。

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()));
}

四步,注释里写了理由:

  1. 先合并状态 —— "to ensure correct state on resume"。中断后要能从检查点恢复,状态必须是新的。
  2. 再算路由 —— "Determine next node before creating checkpoint"。
  3. 再存检查点 —— "Build checkpoint with correct nextNodeId"。检查点必须记录正确的 nextNodeId,否则恢复时会跳错地方。
  4. 最后触发监听器 —— 保证监听器看到的是最终状态。

这个顺序是自洽的,而且每一步都有注释说明。 这是整个 NodeExecutor 里注释质量最高的一段——因为它踩过坑:如果先存检查点再算路由,检查点里的 nextNodeId 就是旧的。

对比 ⑥ 的正常路径,顺序是:

mergeIntoCurrentState → nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners → 递归

一样的顺序。 所以 interruptAfter 出口 = 正常路径的前四步 + 提前 return。

5.4.3 三条流式路径与"提前 return"的代价

// 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 会被静默丢弃

// 三个方法结构完全一样,只是类型判定不同
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

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)

注意它其实不是"非流式路径",它是"流式路径的降级处理"。 它只在两处被调用:

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)

.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 处理:

.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)

.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)

五种元素类型,五个分支,按优先级:

优先级元素类型处理行号
1Throwable转成 GraphResponse.error(...)240
2ChatResponse累积拼接 + 生成 StreamingOutput247
3GraphResponse直接透传286
4NodeOutput包成 GraphResponse.of(...)290
5其他包成 StreamingOutput295

第 3 条(透传)是子图机制的关键——子图发出来的 GraphResponse 在这里被原样转发,从而"嵌"进父图的流里。

阶段四:收尾(317—354)

.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):发两个元素

  1. aggregatedResponse —— StreamingOutput,streaming=false,携带完整拼接后的 ChatResponse
  2. doneResponse —— 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 分支的核心:

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。

.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:

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:

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)

关键两行:

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:

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 对象:

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)犯了同样的错:

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 的是取出来时写进状态的是
handleEmbeddedFluxGraphResponse.resultValue() 拆包Map(正确)
handleGraphFluxGraphResponse直接用GraphResponse(错误)
handleParallelGraphFluxGraphResponse直接用GraphResponse(错误)

5.5.4 这个不一致会带来什么

假设一个节点返回:

return Map.of("answer", GraphFlux.of("llm", "answer", chatFlux, null, null));

走 handleGraphFlux 路径后,状态里的 answer 是:

state.value("answer").get()   // 是个 GraphResponse<NodeOutput> 对象!

而走 handleEmbeddedFlux 路径(节点返回原生 Flux<ChatResponse>)时:

state.value("answer").get()   // 是个 AssistantMessage(正确)

下游节点读 answer 时会拿到完全不同类型的对象。

三个实际后果:

后果一:下游节点的类型强转会失败。

// 下游节点
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 覆盖问题。

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"),第二个会覆盖第一个。

// 两个分支都用 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——先做类型检查

一个防御性写法:

// ✅ 兼容两种路径
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 会变成错误(以及它为什么重要)

.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 种最容易被忽略:

// ❌ 条件不满足时返回空流 —— 会抛 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

@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。

这个机制的完整链条:

父图 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 的父子一致性要求

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"。

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。所以:

// ❌ 两个不同的 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)。

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 标志的语义丢失

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 里也是:

.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——不管它是不是真的来自子图。

这意味着:

// 一个普通的流式 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:

// ✅ 用 node ID 判断
if (output.node().startsWith("sub_")) { ... }

// ❌ 不要用 isSubGraph()
if (output.isSubGraph()) { ... }   // 普通流式节点也会是 true

这又是那句话的一次印证:字段的"名义语义"和"实际语义"不一致。 subGraph 名义上是"来自子图",实际是"经过了 embed 路径"。理解这类不一致,是读源码的价值所在——文档不会告诉你这些。


5.7 中断:执行引擎里的三个插手点

中断(HITL、人工审批)是执行引擎最复杂的一块。它在引擎里有三个插手点,分别位于不同的 Executor 里。

5.7.1 InterruptableAction 的两个时机

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 的价值在于它能看到"节点刚算出来但还没合进状态"的东西。 典型用途是人工审批:模型生成了一份退款方案,你要在它生效之前拦下来给人看。

// 一个典型的人工审批节点
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 是个伪节点

public static final String INTERRUPT_AFTER = "__INTERRUPTED__";       // GraphRunnerContext:59
private static String INTERRUPT_AFTER = "__INTERRUPTED__";             // CompiledGraph:62

先说一个容易忽略的事实:这个常量被定义了两遍,在两个类里。

类修饰符值
GraphRunnerContextpublic static final String"__INTERRUPTED__"
CompiledGraphprivate static String(没有 final)"__INTERRUPTED__"

而 MainGraphExecutor 和 NodeExecutor 用的是 GraphRunnerContext 那份(static import),CompiledGraph:432 用的是自己那份。两处值相同,所以目前行为一致——但它们是两个独立的常量,改一处不会同步另一处。

这是第 04 章那个「三个哨兵常量散落在各处」问题的延续:框架里的"魔法字符串"没有统一的常量表。 你在使用时应该 import GraphRunnerContext 那份(它是 public static final,是"官方"那份)。

它是个节点 ID,但永远不会有对应的 action。它被用在两个地方:

// NodeExecutor:节点执行完,如果配置了边前中断,把 nextNodeId 设成它
if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
        && context.getCompiledGraph().compileConfig.interruptsAfter()
            .contains(context.getCurrentNodeId())) {
    context.setNextNodeId(INTERRUPT_AFTER);
}

(.../graph/executor/NodeExecutor.java:193)

// 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)

语义是:"我故意不告诉你下一步去哪,等你恢复时再算。"

这是一个巧妙但也脆弱的设计。它依赖三个条件同时成立:

  1. interruptBeforeEdge() 为 true
  2. getResumeFromAndReset() 有值(即从检查点恢复)
  3. nextNodeId == INTERRUPT_AFTER

如果第 2 条不满足(比如你是全新执行而不是恢复),那 nextNodeId 就永远停在 __INTERRUPTED__——然后引擎会走到 shouldInterrupt():

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 第一步:

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) 并结束。恢复时:

RunnableConfig config = RunnableConfig.builder()
        .threadId("session-abc")
        .checkPointId("<检查点ID>")       // ← 有这个就走 initializeFromResume
        .build();

compiled.invoke(Map.of(), config);

GraphRunnerContext 构造时检测到 checkPointId().isPresent() → initializeFromResume:

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 执行引擎侧的六条规范

  1. 入口选择:响应式链路用 stream(...);批处理/定时任务用 invoke(...);HITL 场景用 invokeAndGetResponse(...) 拿 Tag。
  2. 节点是纯函数式的:读 state,返回 Map,不改 state。
  3. 流式返回用原生 Flux<ChatResponse>,除非你需要 GraphFlux 的 nodeId 或 mapResult——因为 GraphFlux 路径会把 GraphResponse 塞进状态(5.5.3)。
  4. 读流式结果先做类型检查,别直接强转。
  5. 子图与父图共享 Saver 实例,且父图必须先配 Saver。
  6. 中断要显式选模式:节点级用 interruptAfter,图级用 interruptAfter("nodeId"),别用 interruptBeforeEdge 除非一定会恢复。

5.10 本章小结

这一章把「一次调用到底发生了什么」讲完了:

  1. 十七个入口收敛到一个出口。所有 invoke* 都是 stream* + .last().block();所有 stream* 都走到 GraphRunner.run()。

  2. GraphRunner 是 65 行的薄壳,MainGraphExecutor(161 行)和 NodeExecutor(823 行)构成互相递归的一对。它们都是无状态的,状态全在 GraphRunnerContext。

  3. GraphRunner.resultValue() 是零调用的死 API。因为 handleStartNode 递归时换了 new AtomicReference<>(),导致它永远拿不到值。

  4. GraphRunnerContext 九个字段没有一个 volatile——因为状态推进是严格串行的,concatWith 提供了内存屏障。

  5. isMaxIterationsReached() 有副作用(incrementAndGet())。它等价于"递归层数"。

  6. MainGraphExecutor.execute 有八个分支,顺序不可换。其中两个分支用 getXxxAndReset() 消费一次性状态,必须各调一次。

  7. 递归不爆栈靠 Flux.defer 的 trampoline:构造时不递归,订阅时才递归,栈上始终只有一层。

  8. NodeExecutor.handleActionResult 有九个出口,其中三条流式路径各自重新实现了一遍"合并 → 路由 → 存点 → 通知 → 递归"。

  9. 三条流式路径里,两条有缺陷:handleGraphFlux 和 handleParallelGraphFlux 会把 GraphResponse 对象塞进状态,而 handleEmbeddedFlux 会正确拆包。

  10. 空 Flux 会变成 IllegalStateException(switchIfEmpty 接在 filter 后面)。

  11. 子图返回的是 Flux 而不是结果——整个子执行流被当成一个值嵌进父图。

  12. 中断有三个插手点:节点执行前(interrupt)、节点执行后(interruptAfter)、图级(shouldInterrupt)。

  13. INTERRUPT_AFTER(__INTERRUPTED__)是个伪节点,用于"故意不告诉你下一步去哪",只在边前中断 + 恢复场景下才被正确消费。

本章的三条硬规则

  1. 节点是纯函数式的:读 state,返回 Map,绝不改 state,绝不返回空 Flux。
  2. 流式优先用原生 Flux<ChatResponse>;用 GraphFlux 时必须知道结果会被包一层。
  3. 响应式链路里不用 invoke——它是 block() 的语法糖。

三条与执行引擎相关的补充硬规则

  1. 必经之路的四个 API 都要显式配:recursionLimit(裸图默认 100,ReactAgent 下无上界)、ModelCallLimitHook、threadId、含阻塞 IO 节点的线程池。
  2. 子图要用 Saver,父图必须先配,且必须是同一实例。
  3. 读任何流式节点写进状态的值,都要先做类型检查。

一个贯穿本章的判断标准

这一章的每一个"不一致"——三份重复的流式实现、两条路径的分叉、resultValue 的死链、subGraph 标志的语义漂移——都能用第 01 章的第 ② 条原则串起来:

看一个抽象好不好,别看它的接口,看它的实现有几份。

handleActionResult 里那四份几乎一样的"合并→路由→存点→通知→递归",是本章最重要的观察。它们不是设计出来的架构,是演进出来的沉积:

  • 先有非流式路径
  • 后来要支持 LLM 流式 → 复制一份改成 handleEmbeddedFlux
  • 后来要支持 GraphFlux(带 nodeId)→ 复制一份
  • 后来要支持并行流式 → 再复制一份

每一次复制都解决了一个当时的需求,也埋下了一处未来的分叉。 而 GraphRunnerContext 里那三句 FIXME 注释,就是作者自己留下的施工标记——他知道该统一,只是还没做。

所以本章的判断标准是:

当你发现同一个"套路"在框架里出现了三份以上,且它们的细节各不相同——选那个被最多测试覆盖、注释最完整的那份走,不要选最新加的那份。

落到具体选择上就是:流式返回用原生 Flux,不要用 GraphFlux;子图用 SubCompiledGraphNode 而共享 Saver 实例;中断用 interruptAfter 而不是 interruptBeforeEdge。


下一章我们讲流式管线本身——Flux、StreamingOutput、OutputType 那三个枚举。理解了执行引擎是怎么把一个个 GraphResponse 吐出来的,你才有资格问:「这些 NodeOutput 到底有哪几种?我该怎么按类型分流?」


延伸验证清单

# 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 章会给出答案):

  1. 5.1.5 说 GraphRunner.resultValue() 永远返回 empty。那么 handleStartNode 之外,还有没有路径能让 resultValue_0 被 set?(提示:第一次 execute 时 isStartNode() 为 false 的情况。)

  2. 5.3.5 说递归靠 Flux.defer 不爆栈。如果一个图有 10 万个节点(且 recursionLimit 设得足够大),堆上会有多少个 Flux 对象?什么时候它们会被 GC?

  3. 5.4.1 里 action.apply(context.getOverallState(), ...) 传的是主状态引用。如果节点在执行中抛异常,handleActionResult 不会被调用——那么节点在抛异常前已经做的 state.updateState(...) 会生效吗?

  4. 5.5.3 说 handleGraphFlux 会把 GraphResponse 塞进状态。如果你用 ParallelGraphFlux 且两个分支用了不同的 key,状态里会是什么?两个 key 会不会都是 GraphResponse?

  5. 5.6.2 说子图 Saver 用引用比较。如果父图和子图都用 MemorySaver,但是两个不同的实例(各自 new 的),会发生什么?会抛异常吗?

  6. 5.7.3 说 INTERRUPT_AFTER 在 shouldInterruptAfter 里永远不会命中。那如果我在 CompileConfig 里配 interruptBeforeEdge(true) 且 interruptAfter("generate"),但从不恢复,stream() 最终会发出什么?


本章思考题答案(简版)

  1. 有,只有一条:从检查点恢复的路径。 initializeFromResume 设 currentNodeId = null,所以第一次 execute 的 isStartNode() 为 false,直接走到 ⑧ 并传入原始的 resultValue_0。所以恢复场景下 resultValue_0 是活的——只是仍然没人读它。

  2. 约 30 万个(每个节点三个:FluxJust + FluxDefer + FluxConcatArray)。但整条链是边构造边消费的,所以峰值是"已构造但未消费"的部分,不是 30 万。真正的风险是订阅者处理太慢导致背压积压。

  3. 会生效。 因为传的是主状态引用,updateState 直接改主状态。这恰恰是反模式二的理由——异常路径下污染会残留,且由于 handleActionResult 没被调用,合并、存点、通知全都不会发生。

  4. 两个 key 都会是 GraphResponse,且互不覆盖(因为 key 不同)。所以但凡走 handleParallelGraphFlux 路径,无论 key 是否相同,写进状态的类型都是错的。

  5. 不会抛异常,能正常跑。 引用比较不满足 → 不启用派生 threadId → 子图用父图的 threadId 存检查点 → 父子检查点混在同一条链上(5.6.2 表格第四行)。排查时会看到 getStateHistory() 里父子节点交替,恢复时可能跳错。

  6. stream() 会先发出 generate 节点的输出,然后抛 missingNode 错误。 因为 interruptBeforeEdge(true) + interruptsAfter.contains("generate") 命中 → nextNodeId = __INTERRUPTED__ → 下一轮 shouldInterrupt() 不命中(5.7.3 分析过)→ 走到 ⑧ → getNodeAction("__INTERRUPTED__") 返回 null → missingNode 错误。所以 interruptBeforeEdge 不能单独使用,必须配合检查点恢复。