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

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

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

但 CompiledGraph 自己不会跑。

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

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

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

它们构成一条互相递归 的链:MainGraphExecutor 决定要不要执行节点 → 调 NodeExecutor → NodeExecutor 执行完 → 又递归回 MainGraphExecutor。

没有 while 循环,没有 for,没有显式的调度队列。 整个执行引擎是一组互相递归、返回 Flux 的方法。这是本章最需要理解的一点,也是后面所有行为(栈安全、中断时机、流式顺序)的根源。


5.1 十七个入口,一个出口

CompiledGraph 对外暴露了十七个 public 的执行入口方法。第一次看这个类的人很容易被劝退,但它们全部收敛到同一个地方。

5.1.1 入口方法全景

先看事实------把类里的执行入口按返回类型分个类:

返回类型 方法 行号
Flux<NodeOutput> stream(Map, config) / stream(Map) / stream() / streamFromInitialNode(OverAllState, config) / streamSnapshots(Map, config) 567---629
Flux<GraphResponse<NodeOutput>> graphResponseStream(Map, config) / graphResponseStream(OverAllState, config) 545/549
Optional<OverAllState> invoke(Map, config) / invoke(OverAllState, config) / invoke(Map) 639/650/661
Optional<NodeOutput> invokeAndGetOutput(OverAllState, config) / invokeAndGetOutput(Map, config) / invokeAndGetOutput(Map) 665/669/673
GraphResponse<NodeOutput> invokeAndGetResponse(Map, config) / invokeAndGetResponse(OverAllState, config) 537/541
ScheduledAgentTask schedule(ScheduleConfig) 683

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

scss 复制代码
   stream*(...)  ──►  stateCreate  ──►  new GraphRunner(this, config)
                                            │
                                            ▼
                                     runner.run(state)   ← 唯一的出口
                                            │
                        ┌───────────────────┴───────────────────┐
                        ▼                                       ▼
              graphResponseStream 直接用              stream* 再 flatMap 一道
                        │                                       │
                        └─────────────► invoke* = (...).last().block()

唯一的出口是 GraphRunner.run()。 所有 invoke* 都是对应的 stream* 加一个 .last().block()。

注意那两个 graphResponseStream 虽然也是 stream* 家族的成员,但它们绕过了 streamFromInitialNode 的那道 flatMap ------直接返回 runner.run() 的结果。这个差异在 5.1.6 会显得很重要。

5.1.2 invoke 是 stream 的语法糖,而且会阻塞

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

java 复制代码
public Optional<OverAllState> invoke(Map<String, Object> inputs, RunnableConfig config) {
    return Optional.ofNullable(stream(inputs, config).last().map(NodeOutput::state).block());
}

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

java 复制代码
public Optional<NodeOutput> invokeAndGetOutput(Map<String, Object> inputs, RunnableConfig config) {
    return Optional.ofNullable(stream(inputs, config).last().block());
}

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

三个推论:

推论一:invoke 会把整个流跑完。 中间产生的每一个 NodeOutput 都会被创建、被丢弃,只有最后一个被保留。如果你只想要最终状态,这没问题;但如果你以为 invoke 会「跳过中间步骤」,那不会------中间节点的业务逻辑全部照常执行。

推论二(重要):.block() 会阻塞调用线程。 在 Reactor 的响应式链路里(比如 WebFlux 的 Controller 里)调用 invoke(...),就是把响应式链路掐断、改成阻塞式。如果那个线程是 Reactor 的 event loop,你等于把整个应用的吞吐堵住了。

java 复制代码
// ❌ 在 WebFlux 的 Controller 里这么写会阻塞 event loop
@GetMapping("/chat")
public String chat(@RequestParam String q) {
    return agent.invoke(Map.of("messages", q)).get().value("answer").toString();
}

// ✅ 应该直接返回 Flux / Mono
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<NodeOutput> chat(@RequestParam String q) {
    return agent.stream(Map.of("messages", q), config);
}

推论三:stream 是冷的,invoke 是热的。 stream(...) 返回的是一个还没订阅的 Flux,你订阅几它就跑几遍(每次都是独立的 GraphRunnerContext);invoke(...) 当场就跑完。

回扣第 03 章 3.10.6 那条硬规则:含阻塞 IO 的节点必须自己切 boundedElastic。 这条规则在这里有了第二个理由------不只是为了不饿死 Reactor 的 parallel 调度器,也是因为 invoke(...) 的 .block() 本身就在占用一个线程。你在 Reactor 线程上 block() 一个跑了 30 秒的 Agent,这 30 秒里那个线程什么都做不了。

5.1.3 GraphRunner:一个 65 行的薄壳

java 复制代码
public class GraphRunner {

	private final CompiledGraph compiledGraph;

	private final RunnableConfig config;

	private final AtomicReference<Object> resultValue = new AtomicReference<>();

	// Handler for main execution flow - demonstrates encapsulation
	private final MainGraphExecutor mainGraphExecutor;

	public GraphRunner(CompiledGraph compiledGraph, RunnableConfig config) {
		this.compiledGraph = compiledGraph;
		this.config = config;
		// Initialize the main execution handler - demonstrates encapsulation
		this.mainGraphExecutor = new MainGraphExecutor();
	}

	public Flux<GraphResponse<NodeOutput>> run(OverAllState initialState) {
		return Flux.defer(() -> {
			try {
				GraphRunnerContext context = new GraphRunnerContext(initialState, config, compiledGraph);
				// Delegate to the main execution handler - demonstrates polymorphism
				return mainGraphExecutor.execute(context, resultValue);
			}
			catch (Exception e) {
				return Flux.error(e);
			}
		});
	}

	public Optional<Object> resultValue() {
		return Optional.ofNullable(resultValue.get());
	}

}

(.../graph/GraphRunner.java:30---65)

这是整个执行引擎的全部入口代码。65 行。

几个观察:

① 注释暴露了它的来历。 三处注释分别是 demonstrates encapsulation、demonstrates polymorphism、demonstrates polymorphism。这些不是给使用者看的注释,是作者留给自己看的 OOP 教学标记 ------这个类是一次「把过程式代码改写成 OOP」的重构产物。理解这一点很重要:它解释了为什么这套 Executor 里有不少「为了继承而继承」的结构(比如 BaseGraphExecutor 只有一个方法,MainGraphExecutor 和 NodeExecutor 各自 extends 它却几乎不共享行为)。

② MainGraphExecutor 在构造时创建,且持有 NodeExecutor。

java 复制代码
public MainGraphExecutor() {
    this.nodeExecutor = new NodeExecutor(this);
}

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

而 NodeExecutor 又持有 MainGraphExecutor:

java 复制代码
public NodeExecutor(MainGraphExecutor mainGraphExecutor) {
    this.mainGraphExecutor = mainGraphExecutor;
}

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

这是一个构造期循环引用 ------MainGraphExecutor 的构造器里 new NodeExecutor(this),而 NodeExecutor 的字段是 final MainGraphExecutor。Java 允许这样做(this 逃逸),但它意味着这两个对象只能成对创建、成对存活。

更重要的是:MainGraphExecutor 和 NodeExecutor 都是无状态的 (除了那个互相引用)。所有的执行期状态都在 GraphRunnerContext 里。这是好的设计------Executor 是策略,Context 是状态。

③ 每次 run() 都是新的 GraphRunner,但 resultValue 是它的实例字段。 这意味着 resultValue 的生命周期 = 一次 run() 调用。而由于 Flux.defer(见下),每次订阅都会创建一个新的 GraphRunnerContext------但 resultValue 不会重置 ,因为它是在构造器里创建的,不在 defer 里面。

这是本章第一个值得警惕的细节:GraphRunner 的字段生命周期和 GraphRunnerContext 的生命周期不一致。 前者跟着 GraphRunner 实例,后者跟着订阅。

5.1.4 Flux.defer 的三层含义

run() 里那个 Flux.defer(...) 不是随手写的,它承担了三件事:

java 复制代码
return Flux.defer(() -> {
    try {
        GraphRunnerContext context = new GraphRunnerContext(initialState, config, compiledGraph);
        return mainGraphExecutor.execute(context, resultValue);
    }
    catch (Exception e) {
        return Flux.error(e);
    }
});

含义一:把"创建"推迟到订阅时。

Flux.just(x) 是「先算好 x,再包成 Flux」;Flux.defer(() -> ...) 是「订阅的时候才执行 lambda」。

如果这里用 Flux.just(...) 而不是 defer,那么 new GraphRunnerContext(...) 会在 run() 被调用时就执行------即使没人订阅 。defer 保证了:

不订阅,就不创建 Context;订阅几次,就创建几个 Context。

含义二:每次订阅都得到独立的执行上下文。

这是「冷流」的定义。同一个 Flux 对象订阅两次,会跑出两个完全独立的执行:

java 复制代码
Flux<NodeOutput> flux = compiled.stream(inputs, config);

flux.blockLast();      // 第一次执行,Context A
flux.blockLast();      // 第二次执行,Context B(全新的,不共享任何状态)

如果没有 defer,两次订阅会共享同一个 GraphRunnerContext------而 GraphRunnerContext 是可变的,第二次执行会看到第一次留下的 currentNodeId、iteration 等残留,直接出错。

含义三:它让递归不爆栈。

这一点最关键,5.3.5 节会展开。简单说:Flux.defer(() -> execute(...)) 里的 execute 是在订阅时才被调用 的,而不是在构造 Flux 时。所以递归 1000 层时,调用栈上同时最多只有一层 execute。

5.1.5 那个零调用的 resultValue()

现在说本章第一个真正的发现。

GraphRunner 有一个 AtomicReference<Object> resultValue 字段,还有一个 resultValue() 方法。它还把 resultValue 传进了 mainGraphExecutor.execute(context, resultValue),并且在整条递归链上不断往下传------MainGraphExecutor 传给 handleCompletion、handleEndNode、NodeExecutor,NodeExecutor 又传给 handleActionResult、各个 handleXxx 方法,最终在某个地方 resultValue.set(...)。

整个链条看起来很完整。但 GraphRunner.resultValue() 这个方法,在整个代码库里没有任何调用点。

验证:

bash 复制代码
# 谁调用了 runner.resultValue()?
grep -rn "\.resultValue()" --include=*.java spring-ai-alibaba-graph-core/src/main/java \
    | grep -v AsyncGenerator | grep -v "GraphResponse.java"

输出全部是 data.resultValue()、response.resultValue()、lastGraphResponse.resultValue()------都是 GraphResponse 的方法,不是 GraphRunner 的。

而 CompiledGraph 里两处创建 Runner 的地方:

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

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

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

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

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

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

java 复制代码
protected Flux<GraphResponse<NodeOutput>> handleCompletion(GraphRunnerContext context,
        AtomicReference<Object> resultValue) {
    return Flux.defer(() -> {
        try {
            if (context.getCompiledGraph().compileConfig.releaseThread()
                    && context.getCompiledGraph().compileConfig.checkpointSaver().isPresent()) {
                BaseCheckpointSaver.Tag tag = context
                    .getCompiledGraph().compileConfig.checkpointSaver()
                    .get()
                    .release(context.getConfig());
                resultValue.set(tag);
            } else {
                resultValue.set(new HashMap<>(context.getOverallState().data()));
            }
            return Flux.just(GraphResponse.done(resultValue.get()));
        }
        catch (Exception e) {
            return Flux.just(GraphResponse.error(e));
        }
    });
}

(.../graph/executor/BaseGraphExecutor.java:52)

resultValue.set(...) 之后立刻 GraphResponse.done(resultValue.get())------它只是个中转变量 。AtomicReference 在这里的作用不是"跨线程共享",而是"在 lambda 里能被修改的局部变量"(因为 lambda 捕获的局部变量必须 effectively final)。

那么为什么 GraphRunner 要持有一个自己的 AtomicReference?

答案在 5.3.2 节:因为 handleStartNode 递归时传了一个新的 AtomicReference,GraphRunner 持有的那个从第一次递归开始就永远拿不到值了。

java 复制代码
private Flux<GraphResponse<NodeOutput>> handleStartNode(GraphRunnerContext context) {
    try {
        context.doListeners(START, null);
        Command nextCommand = context.getEntryPoint();
        context.setNextNodeId(nextCommand.gotoNode());

        Optional<Checkpoint> cp = context.addCheckpoint(START, context.getNextNodeId());
        NodeOutput output = context.buildOutput(START, cp);

        context.setCurrentNodeId(context.getNextNodeId());
        // Recursively call the main execution handler
        return Flux.just(GraphResponse.of(output))
            .concatWith(Flux.defer(() -> execute(context, new AtomicReference<>())));
    }
    catch (Exception e) {
        return Flux.just(GraphResponse.error(e));
    }
}

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

看最后那行:execute(context, new AtomicReference<>())。

每次从 START 出发,都会换一个新的 AtomicReference。 而 GraphRunner 持有的那个,只在第一次 execute 调用时被传入------而第一次必然走 handleStartNode(因为 initializeFromStart 设了 currentNodeId = START)------于是它被换掉,从此再无人问津。

完整链路:

scss 复制代码
GraphRunner.run()
  └─ execute(context, resultValue_0)          ← GraphRunner 持有的
       └─ isStartNode() == true
            └─ handleStartNode(context)       ← 注意:方法签名里没有 resultValue
                 └─ execute(context, new AtomicReference<>())   ← 换了!
                      └─ ... 递归 N 层,传的都是这个新的 ...
                           └─ handleCompletion(context, resultValue_N)
                                └─ resultValue_N.set(最终结果)
                                     └─ GraphResponse.done(resultValue_N.get())  ← 从流里出去了

resultValue_0 从头到尾没被 set 过一次。

这就是为什么 GraphRunner.resultValue() 是个死 API------不是它没被调用,而是它就算被调用也永远返回 Optional.empty()。

这不是一个会造成正确性问题的小瑕疵,但它是一个绝佳的考古样本。 它记录了这段代码的历史:曾经 resultValue 是「图的最终结果」的载体,后来架构改成「结果从 Flux 里出去」,resultValue 退化成链内的中转变量,但 GraphRunner 上那个字段和那个方法被忘在那儿了。

判断依据很简单:如果你看到一个字段被精心地沿着调用链传递了十几层,却在链的终点没有任何人消费它------那它不是设计,是残留。

5.1.6 stream 的 flatMap 把 done 信号吞了

streamFromInitialNode 比 graphResponseStream 多了一道 flatMap:

java 复制代码
return runner.run(overAllState).flatMap(data -> {
    if (data.isDone()) {
        if (data.resultValue().isPresent() && data.resultValue().get() instanceof NodeOutput) {
            return Flux.just((NodeOutput) data.resultValue().get());
        } else {
            return Flux.empty();                      // ← 完成信号被丢弃
        }
    }
    if (data.isError()) {
        return Mono.fromFuture(data.getOutput()).onErrorMap(throwable -> throwable).flux();
    }
    return Mono.fromFuture(data.getOutput()).flux();
});

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

三个分支:

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

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

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

java 复制代码
public static <E> GraphResponse<E> of(E data) {
    return new GraphResponse<>(completedFuture(data), null);   // output 非 null,resultValue 为 null
}
public static <E> GraphResponse<E> done(Object resultValue) {
    return new GraphResponse<>(null, resultValue);             // output 为 null
}

(.../graph/GraphResponse.java:62/74)

  • of(NodeOutput(END)) → isDone() 为 false → 走第三分支 → 发出
  • done(状态HashMap) → isDone() 为 true → 丢弃

所以 stream() 的最后一个元素是 node() == END、且 state() 已经是完整最终状态的 NodeOutput。 这正好是 invoke 想要的:

java 复制代码
public Optional<OverAllState> invoke(Map<String, Object> inputs, RunnableConfig config) {
    return Optional.ofNullable(stream(inputs, config).last().map(NodeOutput::state).block());
}

而 invokeAndGetResponse(...) 走的是 graphResponseStream------没有那道 flatMap ,所以它的 .last() 拿到的是被 stream 丢弃的 done(状态HashMap):

API 走的入口 .last() 拿到 取状态的方式
invoke(...) streamFromInitialNode NodeOutput(node=END) .state()
invokeAndGetResponse(...) graphResponseStream GraphResponse.done(Map) .resultValue().get()

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


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

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

5.2.1 九个字段,没有一个 volatile

java 复制代码
	final CompiledGraph compiledGraph;

	final AtomicInteger iteration = new AtomicInteger(0);

	OverAllState overallState;

	RunnableConfig config;

	String currentNodeId;

	String nextNodeId;

	Usage tokenUsage;

	String resumeFrom;

	ReturnFromEmbed returnFromEmbed;

(.../graph/GraphRunnerContext.java:63---79)

九个字段。观察:

① 只有两个是 final :compiledGraph(不可变配置)和 iteration(因为要自增,用了 AtomicInteger)。

② 剩下七个是可变的,且都是包级私有 (没有 private、没有 protected)。这意味着同一个包(com.alibaba.cloud.ai.graph)下的任何类都能直接读写它们------比如 MainGraphExecutor 在 graph.executor 包下就读不到字段,必须走 getXxx()/setXxx();但同包的类可以直接 context.overallState = ...。

③ 没有一个 volatile。 这在第 03 章讨论 snapShot() 竞态时埋过伏笔,这里要给出完整答案:

GraphRunnerContext 的字段是为了单线程顺序访问而设计的。 整个执行引擎虽然吐出的是 Flux,但状态推进是严格串行的 ------concatWith 保证了「前一个节点完全处理完(包括状态合并)才订阅下一个」。

而跨线程的部分(并行节点内部的分支执行、节点里的异步 IO)都在 snapShot() 出来的副本上工作,不碰 Context 的字段。

这解释了一个反直觉的事实:一个基于 Reactor 的执行引擎,其核心状态对象却连一个 volatile 都没有。 因为Reactor 的操作符(特别是 concatWith / thenMany)内部有内存屏障,保证了「前一个流的副作用 happens-before 后一个流」。

推论:不要在节点的 action 里直接拿 GraphRunnerContext。 如果你在并行分支里持有它并修改 overallState,那就不在保护范围内了。正确的做法是通过 OverAllState 参数+返回值传递数据(框架已经这么做了)。

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

java 复制代码
public GraphRunnerContext(OverAllState initialState, RunnableConfig config, CompiledGraph compiledGraph)
		throws Exception {
	this.compiledGraph = compiledGraph;
	this.config = config;

	if (config.metadata(RunnableConfig.HUMAN_FEEDBACK_METADATA_KEY).isPresent() || config.checkPointId().isPresent()) {
		initializeFromResume(initialState, config);
	} else {
		initializeFromStart(initialState, config);
	}
}

(.../graph/GraphRunnerContext.java:81)

判定条件是两个 metadata 的或 :有 HUMAN_FEEDBACK_METADATA_KEY(人工反馈,HITL 场景)或者指定了 checkPointId(从某个检查点恢复)。否则就是从头开始。

5.2.2 两条初始化路径

路径 A:从头开始

java 复制代码
private void initializeFromStart(OverAllState initialState, RunnableConfig config) {
	log.trace("START");
	// Use CompiledGraph's getInitialState method
	this.overallState = stateCreate(compiledGraph.getInitialState(inputs, config), initialState);
	this.currentNodeId = START;
	this.nextNodeId = null;
}

(.../graph/GraphRunnerContext.java:123)

路径 B:从检查点恢复

java 复制代码
private void initializeFromResume(OverAllState initialState, RunnableConfig config) {
	log.trace("RESUME REQUEST");

	var saver = compiledGraph.compileConfig.checkpointSaver()
			.orElseThrow(() -> new IllegalStateException("Resume request without a configured checkpoint saver!"));
	var checkpoint = saver.get(config)
			.orElseThrow(() -> new IllegalStateException("Resume request without a valid checkpoint!"));

	// 如果下一个节点是子图,就打上"我是被恢复的子图"标记
	if (compiledGraph.getNodeAction(checkpoint.getNextNodeId()) instanceof ResumableSubGraphAction ra) {
		this.config = RunnableConfig.builder(config).checkPointId(null)
				.addMetadata(ra.getResumeSubGraphId(), true).build();
		this.config.clearContext();
	} else {
		this.config = config.withCheckPointId(null);
	}

	this.currentNodeId = null;
	this.nextNodeId = checkpoint.getNextNodeId();
	this.overallState = initialState.input(checkpoint.getState());
	this.resumeFrom = checkpoint.getNodeId();
}

(.../graph/GraphRunnerContext.java:93)

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

从头开始 从检查点恢复
currentNodeId START null
nextNodeId null checkpoint.getNextNodeId()
resumeFrom null checkpoint.getNodeId()

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

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

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

⑤ stateCreate 上有一个 FIXME。

java 复制代码
// FIXME, duplicated method with CompiledGraph.stateCreate, need to have a
// unified way of when and how to do OverallState creation.
// This temporary fix is to make sure the message provided by user is always the
// last element in the messages list.
private OverAllState stateCreate(Map<String, Object> inputs, OverAllState initialState) {
	// Creates a new OverAllState instance using key strategies from the graph and
	// provided input data.
	return OverAllStateBuilder.builder()
			.withKeyStrategies(initialState.keyStrategies())
			.withData(inputs)
			.withStore(initialState.getStore())
			.build();
}

(.../graph/GraphRunnerContext.java:138)

这是 GraphRunnerContext 里三处 FIXME 的第一处。 它说的是:GraphRunnerContext.stateCreate 和 CompiledGraph.stateCreate 是重复的,作者自己也知道需要一个统一的状态创建方式,但目前是两套。

两处的差异在哪?

java 复制代码
// CompiledGraph.stateCreate ------ 会注入 EXECUTION_ID
private OverAllState stateCreate(Map<String, Object> inputs) {
	if (inputs == null) {
		inputs = new HashMap<>();
	}

	// Enforce Execution ID availability
	if (!inputs.containsKey(GraphLifecycleListener.EXECUTION_ID_KEY)) {
		Map<String, Object> newInputs = new HashMap<>(inputs);
		newInputs.put(GraphLifecycleListener.EXECUTION_ID_KEY, java.util.UUID.randomUUID().toString());
		inputs = newInputs;
	}

	return OverAllStateBuilder.builder()
			.withKeyStrategies(getKeyStrategyMap())
			.withData(inputs)
			.withStore(compileConfig.getStore())
			.build();
}

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

差异有三:

GraphRunnerContext.stateCreate CompiledGraph.stateCreate
null 保护 无 有 (inputs == null → 空 Map)
注入 EXECUTION_ID 无 有(UUID)
keyStrategies 来源 initialState.keyStrategies() getKeyStrategyMap()(来自图)
Store 来源 initialState.getStore() compileConfig.getStore()

所以一个直接后果:走 stream(Map) 入口会注入 EXECUTION_ID,走 streamFromInitialNode(OverAllState) 入口则不会。 因为前者经过 CompiledGraph.stateCreate,后者直接把 OverAllState 交给 GraphRunner,只在 initializeFromStart 里经过 GraphRunnerContext.stateCreate。

java 复制代码
public Flux<NodeOutput> stream(Map<String, Object> inputs, RunnableConfig config) {
    return streamFromInitialNode(stateCreate(inputs), config);   // ← 这里调了 CompiledGraph.stateCreate
}

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

嗯,实际上 stream(Map) 也调了 CompiledGraph.stateCreate,然后 streamFromInitialNode 里不再调 stateCreate。所以两条路径都会拿到 EXECUTION_ID?

不对。streamFromInitialNode 直接接收 OverAllState,不调 stateCreate 。而 initializeFromStart 里调的是 GraphRunnerContext.stateCreate(不注入 ID)。

所以:

入口 是否经过 CompiledGraph.stateCreate 是否注入 EXECUTION_ID
stream(Map, config) ✅ ✅
streamFromInitialNode(state, config) ❌ ❌

如果你的监听器依赖 EXECUTION_ID_KEY 做链路追踪,用 streamFromInitialNode 直接传 OverAllState 会拿不到它。 这是那句 FIXME 注释的真实代价。

5.2.3 六个判定方法,有一个有副作用

java 复制代码
// Helper methods
public boolean shouldStop() {
	return nextNodeId == null && currentNodeId == null;
}

public boolean isMaxIterationsReached() {
	return iteration.incrementAndGet() > compiledGraph.getMaxIterations();
}

public boolean isStartNode() {
	return START.equals(currentNodeId);
}

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

(.../graph/GraphRunnerContext.java:153)

前四个很直白,但有两点要注意:

① isMaxIterationsReached() 有副作用。

iteration.incrementAndGet()------调用一次就 +1。它不是纯查询。这带来三个后果:

  • 你不能把它用在 if 的两个分支里各调一次(会 +2)
  • 你不能为了调试打印而调用它(会污染计数)
  • 它实际上等价于「已执行的迭代层数 +1」

由于 MainGraphExecutor.execute 的开头:

java 复制代码
if (context.shouldStop() || context.isMaxIterationsReached()) {
    return handleCompletion(context, resultValue);
}

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

这里用了 || 短路------如果 shouldStop() 为 true,isMaxIterationsReached() 不会被调用,iteration 不会自增。

所以真实的计数是:每次 execute 被调用且 shouldStop() 为 false 时 +1 。而 execute 被调用的次数 = 递归层数 ≈ 经过的节点数 + 1(START)+ 1(END)。

这意味着 recursionLimit = 100 的语义不是「最多 100 个节点」,而是「最多约 98 个节点 + START + END」。对 ReAct 循环来说,一次「LLM → 工具 → LLM」是 2 个节点,所以 100 次迭代 ≈ 50 轮 ReAct。

② isEndNode() 看的是 nextNodeId 而不是 currentNodeId。

java 复制代码
public boolean isEndNode() {
	return END.equals(nextNodeId);
}

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

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

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

③ shouldInterrupt 是一对方法的或。

java 复制代码
public boolean shouldInterrupt() {
	return shouldInterruptBefore(nextNodeId, currentNodeId) || shouldInterruptAfter(currentNodeId, nextNodeId);
}

private boolean shouldInterruptBefore(String nodeId, String previousNodeId) {
	if (previousNodeId == null)
		return false;
	return compiledGraph.compileConfig.interruptsBefore().contains(nodeId);
}

private boolean shouldInterruptAfter(String nodeId, String previousNodeId) {
	if (nodeId == null || Objects.equals(nodeId, previousNodeId))
		return false;
	return (compiledGraph.compileConfig.interruptBeforeEdge() && Objects.equals(nodeId, INTERRUPT_AFTER))
			|| compiledGraph.compileConfig.interruptsAfter().contains(nodeId);
}

(.../graph/GraphRunnerContext.java:169)

注意 shouldInterruptBefore(nextNodeId, currentNodeId) 的参数顺序:传的是 (next, current),但方法签名是 (nodeId, previousNodeId)。 所以在方法体内,nodeId = next,previousNodeId = current。

而 shouldInterruptAfter(currentNodeId, nextNodeId) 传的是 (current, next),方法体内 nodeId = current,previousNodeId = next。

两个方法的形参名字相同,但实参顺序相反。 这是纯粹的易错点------读这段代码时必须盯着调用处看,不能只看方法体。

shouldInterruptAfter 里的 Objects.equals(nodeId, previousNodeId) 判断的是「current == next」,即「节点指向自己」的环------这种情况不触发中断(否则自环图会每次都中断)。

5.2.4 doListeners:五个场景与吞掉的异常

java 复制代码
public void doListeners(String scene, Exception e) {
	for (GraphLifecycleListener listener : compiledGraph.compileConfig.lifecycleListeners()) {
		try {
			switch (scene) {
				case START:
					listener.onStart(getCurrentNodeId(), getCurrentStateData(), config);
					break;
				case END:
					listener.onComplete(END, getCurrentStateData(), config);
					break;
				case NODE_BEFORE:
					listener.before(getCurrentNodeId(), getCurrentStateData(), config, SystemClock.now());
					break;
				case NODE_AFTER:
					listener.after(getCurrentNodeId(), getCurrentStateData(), config, SystemClock.now());
					break;
				case ERROR:
					listener.onError(getCurrentNodeId(), getCurrentStateData(), e, config);
					break;
			}
		} catch (Exception ex) {
			log.error("Error in listener", ex);
		}
	}
}

(.../graph/GraphRunnerContext.java:305)

第 04 章 4.2.4 讲过这个方法的接口部分,这里补充执行引擎侧的三个观察:

① 遍历的是 Deque,顺序是 LIFO 还是 FIFO?

compileConfig.lifecycleListeners() 返回 Deque<GraphLifecycleListener>,字段初值是 new LinkedBlockingDeque<>(25)。

但这里用的是增强 for 循环直接遍历 Deque 。而 LinkedBlockingDeque 的迭代器顺序是从队首到队尾(FIFO)。所以:

配置时的顺序 = 执行时的顺序。先 addLast 的先执行。

注意和第 04 章 4.2 提到的 LifeListenerUtil.processListenersLIFO 对照------那个是并行节点内部 用的,是 LIFO;这里是主循环用的,是 FIFO。

java 复制代码
// ParallelNode 内部 ------ LIFO
LifeListenerUtil.processListenersLIFO(actualNodeId,
        new LinkedBlockingDeque<>(compileConfig.lifecycleListeners()), state.data(), config, NODE_BEFORE, null);

(.../graph/internal/node/ParallelNode.java:288)

同一个监听器列表,在主循环里是 FIFO,在并行节点里是 LIFO。 如果你写了两个有依赖关系的监听器,它们在两条路径上的相对顺序是相反的。

② 异常被吞掉,而且只打 log.error。

catch (Exception ex) { log.error("Error in listener", ex); }------没有 rethrow,没有中断图执行,没有任何标记。

这是有意的:监听器是观测设施,观测不应该影响主流程。第 04 章 4.2.4 讲过这点。

但代价是:你的监听器如果抛异常,你只会看到一行 error 日志,图会继续跑,而你可能以为监听器在工作。 生产环境建议给 GraphLifecycleListener 的每个方法都包一层 try-catch,自己做降级------别依赖框架的这层保护。

③ END 场景传的是常量 END 而不是 getCurrentNodeId()。

java 复制代码
case END:
    listener.onComplete(END, getCurrentStateData(), config);
    break;

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

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

5.2.5 mergeIntoCurrentState 与三处 FIXME

java 复制代码
/**
 * This method updates both the current state data and the overall state.
 *
 * @param updateState the state updates to apply
 */
public void mergeIntoCurrentState(Map<String, Object> updateState) {
	// Create a new map and filter out ChatResponse entries
	Map<String, Object> filteredState = findTokenUsageInDeltaState(updateState);

	this.overallState.updateState(filteredState);
}

(.../graph/GraphRunnerContext.java:336)

这是执行引擎里唯一的状态写入口 (除了初始化和 cloneState)。所有节点返回的东西,最终都要经过这里。

注意它调的是 overallState.updateState(filteredState)------实例版(第 03 章 3.4 讲过)。实例版的行为:

  • 用 keyStrategies 合并
  • key 没有策略时 fallback 到 REPLACE
  • MARK_FOR_REMOVAL 会删 key
  • 值为 null 时保留 key(和第 03 章 3.4.7 一致)

② findTokenUsageInDeltaState 是第二处 FIXME。

java 复制代码
/**
 * FIXME, this method is a temporary fix to separate Usage from state updates.
 * works together with AgentLlmNode non-stream node.
 */
private Map<String, Object> findTokenUsageInDeltaState(Map<String, Object> updateState) {
	Map<String, Object> filteredState = new HashMap<>();
	for (Map.Entry<String, Object> entry : updateState.entrySet()) {
		Object value = entry.getValue();
		if (value instanceof Usage && entry.getKey().equals("_TOKEN_USAGE_")) {
			// Assign ChatResponse to this.chatResponse
			this.tokenUsage = (Usage) value;
		} else {
			filteredState.put(entry.getKey(), value);
		}
	}
	return filteredState;
}

(.../graph/GraphRunnerContext.java:344)

三件事:

  • Token 用量不走状态。 key 为 _TOKEN_USAGE_ 且 值为 Usage 类型时,被抽出来存到 this.tokenUsage 字段,不写进 overallState。两个条件都要满足------key 对但类型不对(比如你存了个 Map),它会被当作普通状态写进去。
  • 注释里的 "Assign ChatResponse to this.chatResponse" 是过时的注释 (字段已经改名叫 tokenUsage 了)。
  • 为什么 Token 用量不能进状态? 因为状态会被序列化进 Checkpoint。如果每轮的 token 用量都进状态,状态会不断膨胀。抽出来单独存,既能让 NodeOutput 携带它(buildNodeOutput 会传 tokenUsage),又不污染状态。

③ 第三处 FIXME 在 buildOutput 系列上。

java 复制代码
/**
 * FIXME
 * Below are duplicated methods. Need to have a unified way of streaming output
 * to end user.
 */
public NodeOutput buildNodeOutputAndAddCheckpoint(Map<String, Object> updateStates) throws Exception {
	Optional<Checkpoint> cp = addCheckpoint(currentNodeId, nextNodeId);
	return buildOutput(currentNodeId, updateStates, cp, false);
}

(.../graph/GraphRunnerContext.java:440)

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

重复在哪?看这一族:

scss 复制代码
buildOutput(nodeId, checkpoint)                              → 261
buildNodeOutput(nodeId)                                      → 289
buildOutput(nodeId, updateStates, checkpoint, streaming)     → 450
buildNodeOutput(nodeId, updateStates, streaming)             → 459
buildStreamingOutput(Message, originData, nodeId, streaming) → 269
buildStreamingOutput(originData, nodeId, streaming)          → 278
buildNodeOutputAndAddCheckpoint(updateStates)                → 445

七个方法,分成两族:

方法 实际返回 用途
buildOutput(nodeId, cp) / buildNodeOutput(nodeId) NodeOutput(非流式) 普通节点、START、END
buildOutput(nodeId, states, cp, streaming) / buildNodeOutput(nodeId, states, streaming) / buildStreamingOutput(...) ×2 / buildNodeOutputAndAddCheckpoint(...) StreamingOutput 流式节点

注意 buildNodeOutput(nodeId) 和 buildNodeOutput(nodeId, Map, boolean) 名字几乎一样,返回的是两个完全不同的实现类。 这是最容易误用的地方。

java 复制代码
public NodeOutput buildNodeOutput(String nodeId) throws Exception {
	return NodeOutput.of(
			nodeId,
			(String) config.metadata("_AGENT_").orElse(""),
			cloneState(this.overallState.data()),
			this.tokenUsage);
}

(.../graph/GraphRunnerContext.java:289)

④ 每个输出都 cloneState。

注意 289 行:cloneState(this.overallState.data())------每次构建 NodeOutput 都深拷贝整个状态。

cloneState 走的是 CompiledGraph.cloneState:

java 复制代码
public OverAllState cloneState(Map<String, Object> data) throws IOException, ClassNotFoundException {
    return compiledGraph.cloneState(data);
}

(.../graph/GraphRunnerContext.java:297)

而 CompiledGraph.cloneState 是序列化往返(第 03 章 3.11 讲过)。这意味着:

图里有 N 个节点,就至少做 N 次全状态深拷贝;每次深拷贝是一次完整的序列化 + 反序列化。

这是执行引擎最容易被忽视的性能热点。如果你的状态里有大的文档列表(RAG 场景常见),每个节点执行完都要把它整个序列化一遍。

实践结论:控制状态体积。 不要把大对象(文档全文、Base64 图片)放进状态 key。要放大对象就放第 03 章 3.7 讲的 Store,状态里只放引用。


5.3 MainGraphExecutor:八个分支的递归主循环

MainGraphExecutor.execute 是执行引擎的心脏------每次循环都要经过它。它不长(58 行),但八个分支的顺序决定了整个引擎的语义。

5.3.1 八个分支与它们的顺序

java 复制代码
@Override
public Flux<GraphResponse<NodeOutput>> execute(GraphRunnerContext context, AtomicReference<Object> resultValue) {
	try {
		if (context.shouldStop() || context.isMaxIterationsReached()) {
			return handleCompletion(context, resultValue);
		}

		final var returnFromEmbed = context.getReturnFromEmbedAndReset();
		if (returnFromEmbed.isPresent()) {
			var interruption = returnFromEmbed.get().value(new TypeRef<InterruptionMetadata>() {
			});
			if (interruption.isPresent()) {
				return Flux.just(GraphResponse.done(interruption.get()));
			}
			return Flux.just(GraphResponse.done(context.buildNodeOutputAndAddCheckpoint(Map.of())));
		}

		if (context.getCurrentNodeId() != null && context.getConfig().isInterrupted(context.getCurrentNodeId())) {
			context.getConfig().withNodeResumed(context.getCurrentNodeId());
			return Flux.just(GraphResponse.done(context.getCurrentStateData()));
		}

		if (context.isStartNode()) {
			return handleStartNode(context);
		}

		if (context.isEndNode()) {
			return handleEndNode(context, resultValue);
		}

		// ⑥ resumeFrom:边前中断模式下重算真实路由(详见 5.7.3)
		final var resumeFrom = context.getResumeFromAndReset();
		if (resumeFrom.isPresent()) { ... }

		// ⑦ shouldInterrupt:图级中断
		if (context.shouldInterrupt()) {
			try {
				InterruptionMetadata metadata = InterruptionMetadata
					.builder(context.getCurrentNodeId(), context.cloneState(context.getCurrentStateData()))
					.build();
				return Flux.just(GraphResponse.done(metadata));
			}
			catch (Exception e) {
				return Flux.just(GraphResponse.error(e));
			}
		}

		// ⑧ 兜底:执行一个节点
		return nodeExecutor.execute(context, resultValue);
	}
	catch (Exception e) {
		context.doListeners(ERROR, e);
		org.slf4j.LoggerFactory.getLogger(com.alibaba.cloud.ai.graph.GraphRunner.class)
			.error("Error during graph execution", e);
		return Flux.just(GraphResponse.error(e));
	}
}

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

八个分支,按落点分类:

# 分支 落点 是否终止
① `shouldStop() isMaxIterationsReached()`
② getReturnFromEmbedAndReset() 有值 done(...) 终止
③ isInterrupted(currentNodeId) done(状态) 终止
④ isStartNode() handleStartNode 继续(递归)
⑤ isEndNode() handleEndNode 继续(然后终止)
⑥ getResumeFromAndReset() 有值 改写 nextNodeId 继续
⑦ shouldInterrupt() done(InterruptionMetadata) 终止
⑧ 兜底 nodeExecutor.execute 继续(递归)

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

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

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

java 复制代码
public Optional<String> getResumeFromAndReset() {
	final var result = ofNullable(resumeFrom);
	resumeFrom = null;
	return result;
}

public Optional<ReturnFromEmbed> getReturnFromEmbedAndReset() {
	var result = ofNullable(returnFromEmbed);
	returnFromEmbed = null;
	return result;
}

(.../graph/GraphRunnerContext.java:418/424)

这两个方法都是"读后清空"(consume-once)。 所以它们只能在每一轮循环里被调用一次------如果分支顺序错了,或者某条路径上被调了两次,第二次必然拿到 empty。

这就是为什么它们必须写在独立的 if 里而不是内联到别的判断里:保证「每轮只消费一次」。

约束三:⑦(shouldInterrupt)必须在 ⑥ 之后。

因为 ⑥ 会改写 nextNodeId(中断恢复时重新计算路由),而 ⑦ 的 shouldInterrupt() 依赖 nextNodeId 和 currentNodeId。如果 ⑦ 在前,它会基于旧的 nextNodeId 判断。

约束四:⑧(nodeExecutor)必须最后。 它是默认路径,前面七个都是特殊情况。

⑤ 那个奇怪的 org.slf4j.LoggerFactory.getLogger(...)。

java 复制代码
catch (Exception e) {
    context.doListeners(ERROR, e);
    org.slf4j.LoggerFactory.getLogger(com.alibaba.cloud.ai.graph.GraphRunner.class)
        .error("Error during graph execution", e);
    return Flux.just(GraphResponse.error(e));
}

注意它用的是完全限定名 ,而且是现取现用 (每次异常都新建一个 Logger)。对比类里其他地方的约定------GraphRunnerContext 里是标准的:

java 复制代码
private static final Logger log = LoggerFactory.getLogger(GraphRunner.class);

(.../graph/GraphRunnerContext.java:61)

三个问题:

  • 每次异常都调 LoggerFactory.getLogger(...)(虽然有缓存,但仍是一次 map 查找)
  • Logger 的名字是 GraphRunner 而不是 MainGraphExecutor------所以你在日志里看到 [GraphRunner] Error during graph execution,会以为是 GraphRunner 报的错,实际是在 MainGraphExecutor 里
  • 没有声明成 static final 字段

这是一个小瑕疵,但它有实际的排障代价:按类名过滤日志时你会滤错地方。

5.3.2 handleStartNode:断链的那一行

java 复制代码
private Flux<GraphResponse<NodeOutput>> handleStartNode(GraphRunnerContext context) {
	try {
		context.doListeners(START, null);
		Command nextCommand = context.getEntryPoint();
		context.setNextNodeId(nextCommand.gotoNode());

		Optional<Checkpoint> cp = context.addCheckpoint(START, context.getNextNodeId());
		NodeOutput output = context.buildOutput(START, cp);

		context.setCurrentNodeId(context.getNextNodeId());
		// Recursively call the main execution handler
		return Flux.just(GraphResponse.of(output))
			.concatWith(Flux.defer(() -> execute(context, new AtomicReference<>())));
	}
	catch (Exception e) {
		return Flux.just(GraphResponse.error(e));
	}
}

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

七步,逐步拆解:

① doListeners(START, null) ------ 触发所有监听器的 onStart(...)。注意传的 getCurrentNodeId() 此时是 START。

② getEntryPoint() ------ 取入口边:

java 复制代码
public Command getEntryPoint() throws Exception {
	var entryPoint = compiledGraph.getEdge(START);
	return nextNodeId(entryPoint, overallState.data(), "entryPoint");
}

(.../graph/GraphRunnerContext.java:194)

③ setNextNodeId(nextCommand.gotoNode()) ------ 把入口节点设为"下一步"。

注意:getEntryPoint() 返回的是 Command,它可能有 update 字段。但这里只取了 gotoNode(),忽略了 update------

java 复制代码
Command nextCommand = context.getEntryPoint();
context.setNextNodeId(nextCommand.gotoNode());   // ← update 字段被丢弃

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

java 复制代码
var singleAction = edgeCondition.singleAction();
var command = singleAction.apply(this.overallState, config).get();
var newRoute = command.gotoNode();
String result = route.value().mappings().get(newRoute);
if (result == null) {
    throw RunnableErrors.missingNodeInEdgeMapping.exception(nodeId, newRoute);
}
this.mergeIntoCurrentState(command.update());      // ← 这里合并了 update
return new Command(result, state);

(.../graph/GraphRunnerContext.java:225)

nextNodeId 内部会合并 command.update(),但 handleStartNode 拿到返回的 Command 后只用 gotoNode()。

两者不矛盾------因为 nextNodeId 内部已经合并过了,handleStartNode 拿到的 Command 是新构造的(new Command(result, state)),其 update 字段就是传入的 state(即当时的完整状态)。如果这里再合并一次,就把整个状态又合一遍。

但这里藏着一个真实的坑 :入口边如果是条件边,它的 update 会被合并;如果是普通边呢?

java 复制代码
if (route.id() != null) {
    return new Command(route.id(), state);     // ← 普通边,不合并任何东西
}

(.../graph/GraphRunnerContext.java:208)

普通边直接返回 new Command(route.id(), state),state 参数就是传入的 overallState.data()。而 handleStartNode 只用 gotoNode(),所以不会把状态再合一遍。正确。

④ addCheckpoint(START, nextNodeId) ------ 如果有 Saver,存一个"从 START 出发"的检查点。

java 复制代码
public Optional<Checkpoint> addCheckpoint(String nodeId, String nextNodeId) throws Exception {
	if (compiledGraph.compileConfig.checkpointSaver().isPresent()) {
		var cp = Checkpoint.builder().nodeId(nodeId).state(cloneState(overallState.data())).nextNodeId(nextNodeId)
				.build();
		// Force checkPointId to null to ensure we append a new checkpoint instead of
		// replacing the current one
		RunnableConfig appendConfig = RunnableConfig.builder(config).checkPointId(null).build();
		this.config = compiledGraph.compileConfig.checkpointSaver().get().put(appendConfig, cp);
		return Optional.of(cp);
	}
	return Optional.empty();
}

(.../graph/GraphRunnerContext.java:244)

两个细节:

  • checkPointId(null) 是强制追加而非替换。注释写明了原因。
  • this.config = ...put(...) ------ 每次存检查点都会替换 this.config! 因为 saver.put 返回的新 RunnableConfig 带有新生成的 checkPointId。

这个副作用意味着:Context.config 是会随着执行推进而变化的 。第 07 章讲 Saver 家族时会展开,这里先记住:RunnableConfig 在执行过程中不是常量。

  • 每次 addCheckpoint 都 cloneState(overallState.data()),又是一次深拷贝。

⑤ buildOutput(START, cp) ------ 构建 START 的输出。

java 复制代码
public NodeOutput buildOutput(String nodeId, Optional<Checkpoint> checkpoint) throws Exception {
	if (checkpoint.isPresent() && config.streamMode() == CompiledGraph.StreamMode.SNAPSHOTS) {
		return StateSnapshot.of(getKeyStrategyMap(), checkpoint.get(), config,
				compiledGraph.stateGraph.getStateSerializer().stateFactory());
	}
	return buildNodeOutput(nodeId);
}

(.../graph/GraphRunnerContext.java:261)

这里有一个重要分支 :如果 streamMode() 是 SNAPSHOTS,START 的输出是 StateSnapshot 而不是普通 NodeOutput。第 06 章会讲这个枚举。

⑥ setCurrentNodeId(getNextNodeId()) ------ 关键的一行。

ini 复制代码
之前:current = START,  next = 入口节点
之后:current = 入口节点, next = 入口节点

注意 nextNodeId 没有清空 。下一轮 execute 时:

  • isStartNode() = START.equals(入口节点) = false ✓(不会重复走 handleStartNode)
  • isEndNode() = END.equals(入口节点) ------ 如果入口就是 END,直接结束
  • 否则进 ⑧ nodeExecutor

而 nodeExecutor 的第一步又是 setCurrentNodeId(getNextNodeId())------重复设了同一个值 。这没有危害,但说明 current 和 next 的推进职责分散在两个类里。

⑦ concatWith(Flux.defer(() -> execute(context, new AtomicReference<>()))) ------ 断链的那一行(5.1.5 讲过)。

5.3.3 handleEndNode 与 handleCompletion

java 复制代码
private Flux<GraphResponse<NodeOutput>> handleEndNode(GraphRunnerContext context,
		AtomicReference<Object> resultValue) {
	try {
		context.doListeners(END, null);
		NodeOutput output = context.buildNodeOutput(END);
		return Flux.just(GraphResponse.of(output))
			.concatWith(Flux.defer(() -> handleCompletion(context, resultValue)));
	}
	catch (Exception e) {
		return Flux.just(GraphResponse.error(e));
	}
}

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

三步:

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

java 复制代码
public GraphResponse<NodeOutput> invokeAndGetResponse(Map<String, Object> inputs, RunnableConfig config) {
    return graphResponseStream(inputs, config).last().block();
}

拿到的是 GraphResponse.done(状态HashMap),再 .resultValue() 就能取到最终状态。所以 invokeAndGetResponse 和 invoke 走的是两条不同的取值路径:

API 最后拿到的是 取状态的方式
invoke(...) NodeOutput(node=END) .state()
invokeAndGetResponse(...) GraphResponse.done(Map) .resultValue().get()

5.3.4 handleCompletion 的双重语义

java 复制代码
protected Flux<GraphResponse<NodeOutput>> handleCompletion(GraphRunnerContext context,
		AtomicReference<Object> resultValue) {
	return Flux.defer(() -> {
		try {
			if (context.getCompiledGraph().compileConfig.releaseThread()
					&& context.getCompiledGraph().compileConfig.checkpointSaver().isPresent()) {
				BaseCheckpointSaver.Tag tag = context
					.getCompiledGraph().compileConfig.checkpointSaver()
					.get()
					.release(context.getConfig());
				resultValue.set(tag);
			} else {
				resultValue.set(new HashMap<>(context.getOverallState().data()));
			}
			return Flux.just(GraphResponse.done(resultValue.get()));
		}
		catch (Exception e) {
			return Flux.just(GraphResponse.error(e));
		}
	});
}

(.../graph/executor/BaseGraphExecutor.java:52)

有时候它返回的是"最终状态",有时候返回的是"一个 Tag"。

条件是 releaseThread() && checkpointSaver().isPresent()------两个都要满足。

releaseThread 是什么?第 01 章提过:它是 CompileConfig 的一个开关(CompileConfig.java:47,默认 false)。开启后,图执行完会释放线程------这是为 HITL(人在回路)场景设计的:

一个需要人工审批的 Agent,不该在等待人工的几十分钟里占着一个线程。开启 releaseThread 后,图执行到中断点就返回,线程释放;等人工反馈来了,再用 Tag 恢复。

所以 handleCompletion 的语义是:

条件 resultValue 的内容 语义
releaseThread && 有 Saver BaseCheckpointSaver.Tag "你可以用这个 Tag 恢复我"
其他 Map(最终状态的副本) "这就是结果"

两个推论:

推论一:开了 releaseThread 但没配 Saver,走的是第二条路。 因为条件是"与"。这意味着你以为开了线程释放,其实没生效 ------图会照常把最终状态塞进 resultValue,然后被 stream 的 flatMap 丢弃(因为 Tag/Map 都不是 NodeOutput)。

等等,如果没开 releaseThread,那 resultValue 是 HashMap,不是 NodeOutput,所以 stream 会丢弃它。没问题。

推论二:invoke(...) 不受影响。 因为 invoke 取的是 END 的 NodeOutput(走 of 路径),不依赖 handleCompletion。所以无论 releaseThread 怎么配,invoke 拿到的都是完整最终状态。

但 invokeAndGetResponse(...) 会受影响 ------它 .last() 拿到的是 handleCompletion 的 done(...):

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

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

5.3.5 递归为什么不爆栈

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

执行引擎的递归结构:

lua 复制代码
MainGraphExecutor.execute
  └─ nodeExecutor.execute
       └─ handleActionResult
            └─ Flux.just(GraphResponse.of(output))
                 .concatWith(Flux.defer(() → mainGraphExecutor.execute(context, resultValue)))

每执行一个节点,递归深度 +1。 一个有 1000 个节点的图,递归 1000 层。为什么不会 StackOverflowError?

关键在 Flux.defer 的语义:返回的 Flux 在被订阅时才调用那个 lambda。

所以构造阶段是:

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

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

css 复制代码
t0: 订阅 F0 = just(output0).concatWith(defer(λ1))
t1: just(output0) 发射完成
t2: → 订阅 defer(λ1) → λ1 执行 → 构造 F1 = just(output1).concatWith(defer(λ2))
t3: just(output1) 发射完成 → 订阅 defer(λ2) → 构造 F2 = ...

每个时刻栈上只有一层 execute ------λ 执行完就返回,栈帧销毁。这就是 trampoline(蹦床)模式:把嵌套调用转换成序列化的平调用。

代价是堆而不是栈。 递归 1000 层会在堆上创建约 3000 个 Flux 对象(每个节点三个:FluxJust + FluxDefer + FluxConcatArray)。它们随订阅推进逐个创建、逐个消费、逐个变成垃圾------所以峰值是"已构造但未消费"的部分,不是全部。

反过来说,如果不用 defer 而是直接调用,1000 层就会真的爆栈:

java 复制代码
// ❌ 直接调用 = 立即递归 → StackOverflowError
Flux<NodeOutput> f = just(output0).concatWith(execute(context));

实践结论:

  • 这个设计让"迭代 10 万次的图"在栈上是安全的
  • 但 recursionLimit 仍然必须配------不是为了防爆栈,是为了防跑不完
  • 第 01 章讲过:ReactAgent 把它设成了 Integer.MAX_VALUE,那才是真正的风险来源

5.3.6 递归的四种退出方式

把 execute 的八个分支按"是否终止"分类,递归有四种退出方式:

退出方式 触发条件 最后的产物
正常结束 走到 END(isEndNode()) NodeOutput(END) + done(状态)
迭代到上限 isMaxIterationsReached() done(状态),没有 NodeOutput(END)
中断 shouldInterrupt() / returnFromEmbed / isInterrupted done(InterruptionMetadata)
异常 任意环节抛异常 GraphResponse.error(e)

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

所以:

java 复制代码
// 正常结束
outputs.get(outputs.size() - 1).node();    // "END"

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

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

java 复制代码
// 一个实用的诊断片段
List<NodeOutput> outs = compiled.stream(inputs, config).collectList().block();
String lastNode = outs.get(outs.size() - 1).node();
if (!StateGraph.END.equals(lastNode)) {
    log.warn("图未正常结束,停在节点 {}(可能因 recursionLimit 截断或被中断)", lastNode);
}

5.4 NodeExecutor:一个节点的一次完整执行

如果 MainGraphExecutor 是"要不要走下一步"的决策者,那 NodeExecutor 就是"这一步怎么走"的执行者。它 823 行,是三个 Executor 里最大的。

5.4.1 executeNode 的七步

java 复制代码
private Flux<GraphResponse<NodeOutput>> executeNode(GraphRunnerContext context,
		AtomicReference<Object> resultValue) {
	try {
		// ① 推进指针:next → current
		context.setCurrentNodeId(context.getNextNodeId());
		String currentNodeId = context.getCurrentNodeId();
		AsyncNodeActionWithConfig action = context.getNodeAction(currentNodeId);

		// ② 节点不存在(执行期二次确认)
		if (action == null) {
			return Flux.just(GraphResponse.error(RunnableErrors.missingNode.exception(currentNodeId)));
		}

		// ③ InterruptableAction:先合并人工反馈,再判断执行前中断
		if (action instanceof InterruptableAction) {
			context.getConfig().metadata(RunnableConfig.STATE_UPDATE_METADATA_KEY).ifPresent(fb -> {
				if (fb instanceof Map<?, ?>) {
					context.mergeIntoCurrentState((Map<String, Object>) fb);
				} else {
					throw new RuntimeException();
				}
			});
			var interruptMetadata = ((InterruptableAction) action)
				.interrupt(currentNodeId, context.cloneState(context.getCurrentStateData()), context.getConfig());
			if (interruptMetadata.isPresent()) {
				resultValue.set(interruptMetadata.get());
				return Flux.just(GraphResponse.done(interruptMetadata.get()));
			}
		}

		// ④ 监听器 + ⑤ 执行 + ⑥ 结果处理
		context.doListeners(NODE_BEFORE, null);

		CompletableFuture<Map<String, Object>> future = action.apply(context.getOverallState(),
				context.getConfig());

		return Mono.fromFuture(future)
				.flatMapMany(updateState -> handleActionResult(context, updateState, resultValue))
				.onErrorResume(error -> {
					context.doListeners(ERROR, new Exception(error));
					return Flux.just(GraphResponse.error(error));
				});

	}
	catch (Exception e) {
		return Flux.just(GraphResponse.error(e));
	}
}

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

七步逐步拆解:

① setCurrentNodeId(getNextNodeId()) ------ 推进指针。这是引擎里唯一的"前进"动作,它把 next 提升为 current。

② getNodeAction(currentNodeId) ------ 从 CompiledGraph 取 action 工厂并实例化。

java 复制代码
public AsyncNodeActionWithConfig getNodeAction(String nodeId) {
    return compiledGraph.getNodeAction(nodeId);
}

(.../graph/GraphRunnerContext.java:190)

③ action == null → missingNode 错误。 这是运行时才发现节点不存在的情况。第 04 章讲过编译期会校验边,但这里是执行期的二次确认。

④ InterruptableAction 的前置处理------两个动作:

  • 合并人工反馈 :如果 config 里有 STATE_UPDATE_METADATA_KEY,先把它合并进状态。这就是 HITL 的"人工修改状态"入口:人工在前端改了状态,恢复时用这个 metadata 传进来,在这里被合并。
  • 调 interrupt(...) :如果返回了 InterruptionMetadata,立刻终止 (发 done(...) 并 return)。

注意这里的顺序:先合并反馈,再判断中断。因为中断判断可能需要读到人工反馈后的状态。

⑤ doListeners(NODE_BEFORE, null) ------ 触发所有监听器的 before(...)。

⑥ action.apply(overallState, config) ------ 真正执行节点。

注意这里传的是 context.getOverallState() 本身,不是副本。 所以:

节点拿到的是主状态对象的引用。 如果节点直接 state.updateState(...),会就地修改主状态------绕过框架的合并语义。

这回扣第 03 章讲的"节点应该返回 Map,不要直接改状态"。理由在这里:你改的是主状态,而引擎后续还会用返回值再合并一次。

⑦ Mono.fromFuture(future).flatMapMany(...) ------ 把 CompletableFuture 转成 Reactor 流。

注意 .onErrorResume(...):节点返回的 future 异常完成时,触发 doListeners(ERROR, ...) 然后发 GraphResponse.error(error)。异常会被包成流里的一个 error 元素,而不是终止流。

5.4.2 handleActionResult 的九个出口

这是 NodeExecutor 里最重要的方法------它决定"节点返回的这个 Map 到底是什么"。

java 复制代码
private Flux<GraphResponse<NodeOutput>> handleActionResult(GraphRunnerContext context,
		Map<String, Object> updateState, AtomicReference<Object> resultValue) {
	try {

		// Check for Flux
		Optional<Flux<GraphResponse<NodeOutput>>> embedFlux = getEmbedFlux(context, updateState);
		if (embedFlux.isPresent()) {
			return handleEmbeddedFlux(mainGraphExecutor, context, embedFlux.get(), updateState, resultValue);
		}

		// Check for ParallelGraphFlux (returned from ParallelNode)
		Optional<ParallelGraphFlux> embedParallelGraphFlux = getEmbedParallelGraphFlux(updateState);
		if (embedParallelGraphFlux.isPresent()) {
			return handleParallelGraphFlux(context, embedParallelGraphFlux.get(), updateState, resultValue);
		}

		// Check for GraphFlux (backward compatibility)
		Optional<GraphFlux<?>> embedGraphFlux = getEmbedGraphFlux(updateState, context);
		if (embedGraphFlux.isPresent()) {
			return handleGraphFlux(context, embedGraphFlux.get(), updateState, resultValue);
		}

		// ④ interruptAfter 出口 ------ 内部四步顺序见 5.7.2,此处省略 18 行
		//    (merge → nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners → return done(...))

		// ⑤ 合并状态 ------ 必须在路由【之前】,这样条件边能读到刚更新的值
		context.mergeIntoCurrentState(updateState);

		// ⑥ 路由:边前中断模式设伪节点,否则正常计算
		if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
				&& context.getCompiledGraph().compileConfig.interruptsAfter()
					.contains(context.getCurrentNodeId())) {
			context.setNextNodeId(INTERRUPT_AFTER);
		}
		else {
			Command nextCommand = context.nextNodeId(context.getCurrentNodeId(), context.getCurrentStateData());
			context.setNextNodeId(nextCommand.gotoNode());
		}

		// ⑦ 构建输出(含存检查点) → ⑧ 通知监听器 → ⑨ 递归回主循环
		NodeOutput output = context.buildNodeOutputAndAddCheckpoint(updateState);
		context.doListeners(NODE_AFTER, null);
		return Flux.just(GraphResponse.of(output))
			.concatWith(Flux.defer(() -> mainGraphExecutor.execute(context, resultValue)));
	}
	catch (Exception e) {
		return Flux.just(GraphResponse.error(e));
	}
}

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

四个 if 分支 + 一个兜底 = 五个路径,加上每个分支内部的子分支,一共九个出口:

# 出口 条件 产物
1 嵌入 Flux 返回的 Map 里有 Flux 展开子流
2 并行流式 有 ParallelGraphFlux 合并多条流
3 单个流式 有 GraphFlux 展开单条流
4 interruptAfter 命中 是 InterruptableAction 且 interruptAfter 返回非空 done(InterruptionMetadata)
5 interruptBeforeEdge 命中 配置了边前中断 设 nextNodeId = INTERRUPT_AFTER 后继续
6 正常路由 兜底 算出 next,继续
7 异常 任意环节抛错 error(e)

(其中 1/2/3 各自内部还有"数据为空"的子分支,见 5.4.3。)

顺序为什么是这样? 三条判断是类型检查,必须按特异性从高到低:

复制代码
Flux(原生)  →  ParallelGraphFlux  →  GraphFlux

为什么 ParallelGraphFlux 要在 GraphFlux 之前?

因为 ParallelGraphFlux 内部持有 List<GraphFlux>(第 04 章 4.7.3 讲过)。但它们是不同的类型 ------ParallelGraphFlux 不是 GraphFlux 的子类。让我验证这一点:

java 复制代码
// getEmbedGraphFlux 的判断
.filter(e -> e.getValue() instanceof GraphFlux)

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

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

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

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

java 复制代码
if (interruptMetadata.isPresent()) {
    // Merge state first to ensure correct state on resume
    context.mergeIntoCurrentState(updateState);
    // Determine next node before creating checkpoint
    Command nextCommand = context.nextNodeId(currentNodeId, context.getCurrentStateData());
    context.setNextNodeId(nextCommand.gotoNode());
    // Build checkpoint with correct nextNodeId
    context.buildNodeOutputAndAddCheckpoint(updateState);
    // Call NODE_AFTER listeners
    context.doListeners(NODE_AFTER, null);
    // Return interruption
    resultValue.set(interruptMetadata.get());
    return Flux.just(GraphResponse.done(interruptMetadata.get()));
}

四步,注释里写了理由:

  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"的代价

java 复制代码
// 1
Optional<Flux<GraphResponse<NodeOutput>>> embedFlux = getEmbedFlux(context, updateState);
if (embedFlux.isPresent()) {
    return handleEmbeddedFlux(...);
}

// 2
Optional<ParallelGraphFlux> embedParallelGraphFlux = getEmbedParallelGraphFlux(updateState);
if (embedParallelGraphFlux.isPresent()) {
    return handleParallelGraphFlux(...);
}

// 3
Optional<GraphFlux<?>> embedGraphFlux = getEmbedGraphFlux(updateState, context);
if (embedGraphFlux.isPresent()) {
    return handleGraphFlux(...);
}

这三条路径都是"提前 return"------它们不会走到后面的非流式逻辑。

所以流式节点的状态合并、路由、检查点、监听器触发,都在各自的处理函数里重新实现了一遍 。这就是为什么 NodeExecutor 有 823 行------同一套"合并 → 路由 → 存点 → 通知 → 递归"的逻辑,被写了四遍(三条流式 + 一条非流式)。

对比一下四份实现的对应步骤:

步骤 非流式(191---207) 嵌入 Flux(489---507) GraphFlux(655---676) 并行流式(775---796)
合并状态 mergeIntoCurrentState(updateState) merge × 2(489/490) merge × 2(655/659) merge × 2(775/779)
算路由 nextNodeId(...)(199) nextNodeId(...)(493) nextNodeId(...)(663) nextNodeId(...)(783)
存检查点 buildNodeOutputAndAddCheckpoint(202) 同名(496) 同名(666) 同名(786)
触发监听器 doListeners(NODE_AFTER)(204) 同名(498) 同名(668) 同名(788)
递归 concatWith(defer(...))(207) 同名(507) 同名(676) 同名(796)

四份几乎一样的代码,只有合并的细节不同。 这正是 GraphRunnerContext 里那句 FIXME 所说的"Need to have a unified way of streaming output to end user"的代价------这个统一还没有发生。

实践推论:四种路径的行为差异,是流式场景下排查 bug 的第一站。 如果你的节点在流式和非流式下表现不一致,先确认它走的是哪条路径。

5.4.4 三个 findFirst:多余的 Flux 会被静默丢弃

java 复制代码
// 三个方法结构完全一样,只是类型判定不同
public Optional<Flux<GraphResponse<NodeOutput>>> getEmbedFlux(GraphRunnerContext ctx, Map<String, Object> ps) {
    return ps.entrySet().stream().filter(e -> e.getValue() instanceof Flux<?>).findFirst()...;
}
private Optional<GraphFlux<?>> getEmbedGraphFlux(Map<String, Object> ps, GraphRunnerContext ctx) {
    return ps.entrySet().stream().filter(e -> e.getValue() instanceof GraphFlux).findFirst()
            .map(e -> GraphFlux.of(/* 补全 nodeId 和 key */ ...));      // ← 会补全缺省值
}
private Optional<ParallelGraphFlux> getEmbedParallelGraphFlux(Map<String, Object> ps) {
    return ps.entrySet().stream().filter(e -> e.getValue() instanceof ParallelGraphFlux).findFirst()...;
}

(.../graph/executor/NodeExecutor.java:516/543/563)

三个方法都是 entrySet().stream().filter(类型).findFirst()。

为什么不做多流支持? 因为流式和状态合并的组合会变得很复杂:两条流完成时谁先合并?如果两个 key 冲突怎么办?ParallelGraphFlux 就是为了这个场景设计的(它内部为每个流单独记 nodeDataRef),而通用的多 Flux 支持还没做。

5.4.5 非流式路径:handleNonStreamingResult

java 复制代码
private Flux<GraphResponse<NodeOutput>> handleNonStreamingResult(GraphRunnerContext context,
																 Map<String, Object> partialState, AtomicReference<Object> resultValue) throws Exception {
	if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
			&& context.getCompiledGraph().compileConfig.interruptsAfter()
			.contains(context.getCurrentNodeId())) {
		context.setNextNodeId(INTERRUPT_AFTER);
	}
	else {
		Command nextCommand = context.nextNodeId(context.getCurrentNodeId(), context.getCurrentStateData());
		context.setNextNodeId(nextCommand.gotoNode());
	}

	NodeOutput output = context.buildNodeOutputAndAddCheckpoint(partialState);
	// Recursively call the main execution handler
	return Flux.just(GraphResponse.of(output))
			.concatWith(Flux.defer(() -> mainGraphExecutor.execute(context, resultValue)));
}

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

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

java 复制代码
if (parallelGraphFlux.isEmpty()) {
    // Handle empty ParallelGraphFlux
    return handleNonStreamingResult(context, partialState, resultValue);
}

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

即:当 ParallelGraphFlux 存在但为空时,走这个方法。

这个方法有个特点:它不做 mergeIntoCurrentState。

对比 handleActionResult 的正常路径(191 行有 merge),这个方法直接跳到路由。所以:

空的 ParallelGraphFlux 会导致节点返回的状态增量被部分丢失。

具体说:handleNonStreamingResult 收到的是 partialState,它直接用来 buildNodeOutputAndAddCheckpoint,但没有 merge 进主状态 。而 partialState 里含 __parallel_graph_flux__(第 04 章讲过,ParallelNode 塞的载体 key),它在 buildNodeOutputAndAddCheckpoint 里会进 checkpoint......

等等,buildNodeOutputAndAddCheckpoint → addCheckpoint 用的是 cloneState(overallState.data()),不是 partialState。所以 __parallel_graph_flux__ 不会进 checkpoint。

那 partialState 用在哪?只在 buildNodeOutputAndAddCheckpoint 里传给 buildOutput(nodeId, updateStates, cp, false) → buildNodeOutput(nodeId, updateStates, streaming),用于提取 messages 的最后一个 Message(生成 StreamingOutput)。

所以 partialState 在这个路径下只用于构建输出,不进状态。 而节点真正想写进状态的东西(比如并行的非流式结果)被丢弃了。

这是一个边界 case(空的 ParallelGraphFlux),触发条件是并行节点的所有分支都返回了流式但流为空。实际不常见,但值得知道。


5.5 流式处理:四条路径,三份实现

流式是这个框架的一等公民,也是 NodeExecutor 复杂度的主要来源。这一节把 5.4.3 留下的那张"四份实现"对比表展开,看看它们到底差在哪。

5.5.1 transformFluxToGraphResponse:流式聚合的核心

三条流式路径最终都汇聚到这一个方法。它 133 行(223---355),是 NodeExecutor 里最大的方法。

先说它的数据结构:两个 AtomicReference ------lastChatResponseRef 累积聊天响应,lastGraphResponseRef 记住最后一个响应对象。

阶段一:过滤(228---235)

java 复制代码
.filter(element -> {
    // skip ChatResponse.getResult() == null
    if (element instanceof ChatResponse response) {
        return response.getResult() != null &&  response.getResult().getOutput() != null;
    }
    // Don't filter out Exception/Throwable - we need to handle them
    return true;
})

过滤掉「getResult() 为 null 或 getResult().getOutput() 为 null」的 ChatResponse。

为什么? 因为 LLM 流式 API 会发一些"空壳"chunk------比如只有 usage 统计、只有 finish_reason 的终止包。这些包没有内容,转发给下游没有意义。

注意第二条注释:"Don't filter out Exception/Throwable - we need to handle them" 。也就是说,如果上游把异常当成数据元素(而不是错误信号)发出来,这个过滤器会放行它,交给 map 处理:

java 复制代码
.map(element -> {
    // Handle Exception/Throwable as data elements (not error signals)
    if (element instanceof Throwable throwable) {
        log.error("Exception emitted as data element in embedded Flux stream for key '{}': {}",
            key, throwable.getMessage(), throwable);
        GraphResponse<NodeOutput> errorResponse = GraphResponse.error(throwable);
        lastGraphResponseRef.set(errorResponse);
        return errorResponse;
    }
    ...
})

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

这是一个重要设计:把"数据流里的异常"转换成"流里的 error 元素",而不是让整个流失败。

后果是:如果你的 LLM 流在第 5 个 chunk 抛了异常,前 4 个 chunk 已经发出去了,第 5 个变成 error 元素,流继续(如果还有后续元素的话)。你的订阅者会先收到 4 个正常输出,再收到一个错误元素。

阶段二:空流保护(236---237)

java 复制代码
.switchIfEmpty(Flux.error(new IllegalStateException(
    "Empty flux detected for key '" + key + "'. This may indicate an LLM API error with null result.")))

如果流的元素被过滤后为空,整个流变成错误。

注意它接在 filter 后面------所以是"过滤后为空"才触发,不是"原始流为空"。

这个错误消息本身写得很好:它明确说了 "This may indicate an LLM API error with null result"------这是作者被这个坑咬过之后留下的提示。

实践意义: 当你在日志里看到 Empty flux detected for key 'answer',不要去查图结构,直接去查那个 LLM 调用------它返回了一个全被过滤掉的流(或者干脆是空的)。

阶段三:映射(238---308)

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

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

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

阶段四:收尾(317---354)

java 复制代码
.concatWith(Flux.defer(() -> {
    if (lastChatResponseRef.get() == null) {
        // 路 B:把最后一个 GraphResponse 再发一次,但先做两个处理
        var lastGraphResponse = lastGraphResponseRef.get();
        var result = lastGraphResponse.resultValue();
        // 1) InterruptionMetadata 不发(避免和 MainGraphExecutor 的处理重复)
        if (result.isPresent() && result.get() instanceof InterruptionMetadata) {
            return Flux.empty();
        }
        // 2) 若 Map 里缺目标 key 但有 messages,补上 key → 最后一条 AssistantMessage 的文本
        if (result.isPresent() && result.get() instanceof Map resultMap
                && !resultMap.containsKey(key) && resultMap.containsKey("messages")) {
            var msgs = (List<Object>) resultMap.get("messages");
            var last = msgs.get(msgs.size() - 1);
            if (last instanceof AssistantMessage am) {
                resultMap.put(key, am.getText());
            }
        }
        return Flux.just(lastGraphResponse);
    } else {
        // 路 A:先发聚合后的完整响应,再发 done(结果Map)
        var aggregated = GraphResponse.of(context.buildStreamingOutput(
                lastChatResponseRef.get().getResult().getOutput(), lastChatResponseRef.get(), nodeId, false));
        Map<String, Object> completionResult = new HashMap<>();
        completionResult.put(key, lastChatResponseRef.get().getResult().getOutput());
        return Flux.just(aggregated, GraphResponse.done(completionResult));
    }
}));

两条路:

路 A(有 ChatResponse):发两个元素

  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 分支的核心:

java 复制代码
if (element instanceof ChatResponse response) {
    ChatResponse lastResponse = lastChatResponseRef.get();
    final var currentMessage = response.getResult().getOutput();

    if (lastResponse == null) {
        lastChatResponseRef.set(response);
    } else {
        var lastOutput = lastResponse.getResult().getOutput();
        var lastMessageText = lastOutput.getText() != null ? lastOutput.getText() : "";
        final var currentMessageText = currentMessage.getText();

        // ① 文本内容:把之前的完整文本与当前 chunk 拼接
        // ② metadata:mergeReasoningContent 时拼接 reasoningContent,否则直接取当前的
        // ③ toolCalls:按 id 去重合并
        var newMessage = AssistantMessage.builder()
                .content(currentMessageText != null ? lastMessageText.concat(currentMessageText) : lastMessageText)
                .properties(...)
                .toolCalls(mergeToolCalls(lastOutput.getToolCalls(), currentMessage.getToolCalls()))
                .media(currentMessage.getMedia())
                .build();

        lastChatResponseRef.set(new ChatResponse(List.of(new Generation(newMessage, ...)), response.getMetadata()));
    }
    // 注意:发给下游的是【当前 chunk】,累积的完整响应用在最后那一下
    var lastGraphResponse = GraphResponse.of(
            context.buildStreamingOutput(response.getResult().getOutput(), response, nodeId, true));
    lastGraphResponseRef.set(lastGraphResponse);
    return lastGraphResponse;
}

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

注意最后一行的细节:每个 chunk 都会生成一个 StreamingOutput 发给下游 ,而 lastChatResponseRef 里的累积响应用来在最后生成聚合输出。所以订阅者看到的是增量 chunk,而不是累积文本。

每个 chunk 到达时,都把之前的完整文本和当前 chunk 拼接,构造一个新的 AssistantMessage。

java 复制代码
.content(currentMessageText != null ? lastMessageText.concat(currentMessageText) : lastMessageText)

假设 LLM 输出 N 个 chunk,每个 chunk 平均长度 L:

维度 复杂度
字符拷贝 O(N² × L) ------ 第 i 个 chunk 要拼接 i×L 个字符
对象创建 O(N) 个 AssistantMessage + Generation + ChatResponse

具体数字: 一个 3000 字的回答分成 300 个 chunk(每 chunk 10 字),总拼接量是 10 × (1+2+...+300) ≈ 45 万字符,外加 300 个 AssistantMessage + Generation + ChatResponse 对象。

这本身不算灾难(相对一次 LLM 调用的耗时可忽略),但长文本下会明显:3 万字的输出、3000 个 chunk,拼接量是 4500 万字符------这时就真的能观测到了。

mergeToolCalls 每次都要重建 Map:

java 复制代码
private List<ToolCall> mergeToolCalls(List<ToolCall> lastToolCalls, List<ToolCall> currentToolCalls) {
	if (lastToolCalls == null || lastToolCalls.isEmpty()) {
		return currentToolCalls != null ? currentToolCalls : List.of();
	}
	if (currentToolCalls == null || currentToolCalls.isEmpty()) {
		return lastToolCalls;
	}
	// ...建 LinkedHashMap → 放 current → 遍历 last 去重 → 重建 ArrayList...
}

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

每个流式 chunk 都要走一遍这段。但两个短路判断挡住了大部分情况 ------流式过程中绝大多数 chunk 的 toolCalls 是空的,会在前两个 if 就返回。只有 toolCall 真的在流式中途出现时才走完整路径。

实践结论:

  • 正常短文本不用担心 O(n²)
  • 长文本生成场景要意识到这个开销
  • 框架没有提供"关掉累积"的开关。如果你的节点不需要完整响应,可以用 GraphFlux 并自己处理 mapResult

5.5.3 两条路径把 GraphResponse 塞进了状态

这是本章最重要的发现。

先看"正确"的那条路 ------handleEmbeddedFlux → processGraphResponseFlux:

java 复制代码
Mono<Void> updateContextMono = Mono.fromRunnable(() -> {
    var data = lastData.get();
    if (data == null) {
        log.error("No data returned from last streaming node execution '{}', will goto END node directly.", ...);
        context.setNextNodeId(END);
        context.doListeners(NODE_AFTER, null);
        return;
    }

    var nodeResultValue = data.resultValue();                    // ← 取出 resultValue

    if (nodeResultValue.isPresent() && nodeResultValue.get() instanceof InterruptionMetadata) {
        context.setReturnFromEmbedWithValue(nodeResultValue.get());
        return;
    }

    // 过滤掉 Flux / GraphFlux / ParallelGraphFlux,只留普通值
    Map<String, Object> partialStateWithoutFlux = partialState.entrySet().stream()...;

    Map<String, Object> updateState = new HashMap<>();
    if (nodeResultValue.isPresent()) {
        Object value = nodeResultValue.get();
        if (value instanceof Map<?, ?>) {
            updateState = (Map<String, Object>) value;           // ← 当成 Map 用
        }
        else {
            throw new IllegalArgumentException("Node stream must return Map result using Data.done(),");
        }
    }
    ...  // 后续:merge × 2 → nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners
});

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

关键两行:

java 复制代码
var nodeResultValue = data.resultValue();          // 从 GraphResponse 里"拆包"取出结果
updateState = (Map<String, Object>) value;         // 它应该是个 Map

它从 GraphResponse 里拆出了真正的结果。

注意那个 IllegalArgumentException("Node stream must return Map result using Data.done(),")------如果上游流式节点没有用 GraphResponse.done(Map) 结束,这里会抛异常。 这是"流必须以 done 收尾"这条规矩的执行点。

现在看"错误"的那条路 ------handleGraphFlux:

java 复制代码
Mono<Void> updateContextMono = Mono.fromRunnable(() -> {
    Object lastData = lastDataRef.get();

    if (lastData == null) {
        log.error("No data returned from last streaming node execution '{}', will goto END node directly.", ...);
        context.setNextNodeId(END);
        return;
    }

    // Apply mapResult function if available
    Map<String, Object> resultMap = new HashMap<>();
    resultMap.put(graphFlux.getKey(), lastData);      // ← 直接塞进去,【没有拆包】

    // Merge non-GraphFlux state(只过滤 GraphFlux,不过滤原生 Flux)
    Map<String, Object> partialStateWithoutGraphFlux = partialState.entrySet().stream()
            .filter(e -> !(e.getValue() instanceof GraphFlux))...;

    Map<String, Object> combinedUpdateState = new HashMap<>(partialStateWithoutGraphFlux);
    combinedUpdateState.putAll(resultMap);
    Optional<InterruptionMetadata> interruptAfterMetadata =
            interruptAfterForStreaming(context, combinedUpdateState);

    context.mergeIntoCurrentState(partialStateWithoutGraphFlux);
    if (!resultMap.isEmpty()) {
        context.mergeIntoCurrentState(resultMap);      // ← 把 GraphResponse 塞进状态
    }
    ...  // 后续:nextNodeId → buildNodeOutputAndAddCheckpoint → doListeners
});

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

lastData 是 GraphResponse<NodeOutput> 对象,被直接 put 进了 resultMap,然后 merge 进状态。

为什么会这样?因为上游 transformGraphFluxToFlux 里存的就是 GraphResponse 对象:

java 复制代码
return transformedFlux.map(data -> {
        if (data.getOutput() != null && !data.getOutput().isCompletedExceptionally()) {
            var output = data.getOutput().join();
            output.setSubGraph(true);
            GraphResponse<NodeOutput> newData = GraphResponse.of(output);
            resultValue.set(newData);          // ← 存的是 GraphResponse
            return newData;
        }
        resultValue.set(data);                 // ← 存的是 GraphResponse
        return data;
    })

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

lastDataRef 从头到尾存的都是 GraphResponse 对象 ,而 handleGraphFlux 把它当成了"节点的结果值"。

第三条路(handleParallelGraphFlux)犯了同样的错:

java 复制代码
for (GraphFlux<?> graphFlux : parallelGraphFlux.getGraphFluxes()) {
    String nodeId = graphFlux.getNodeId();
    Object nodeData = nodeDataRefs.get(nodeId).get();

    combinedResultMap.put(graphFlux.getKey(), nodeData);      // ← 同样是 GraphResponse
}

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

三条路径的对照:

路径 存进 ref 的是 取出来时 写进状态的是
handleEmbeddedFlux GraphResponse .resultValue() 拆包 Map(正确)
handleGraphFlux GraphResponse 直接用 GraphResponse(错误)
handleParallelGraphFlux GraphResponse 直接用 GraphResponse(错误)

5.5.4 这个不一致会带来什么

假设一个节点返回:

java 复制代码
return Map.of("answer", GraphFlux.of("llm", "answer", chatFlux, null, null));

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

java 复制代码
state.value("answer").get()   // 是个 GraphResponse<NodeOutput> 对象!

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

java 复制代码
state.value("answer").get()   // 是个 AssistantMessage(正确)

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

三个实际后果:

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

java 复制代码
// 下游节点
AsyncNodeActionWithConfig consumer = (state, config) -> {
    String text = (String) state.value("answer").orElse("");   // ❌ ClassCastException
    ...
};

后果二:序列化进 Checkpoint 时会丢内容。

GraphResponse 的 output 字段标了 @JsonIgnore,所以 Jackson 只序列化 resultValue 和 metadata。反序列化后得到一个 output == null 的 GraphResponse,它的 isDone() 为 true。

这意味着:如果图在流式节点之后被中断、存了检查点、然后恢复------恢复后状态里的 answer 是一个"已完成"的空壳 GraphResponse ,而不是你想要的 AssistantMessage。

后果三:并行路径还有 key 覆盖问题。

java 复制代码
for (GraphFlux<?> graphFlux : parallelGraphFlux.getGraphFluxes()) {
    String nodeId = graphFlux.getNodeId();
    Object nodeData = nodeDataRefs.get(nodeId).get();
    combinedResultMap.put(graphFlux.getKey(), nodeData);      // ← 用 key 做键,不是 nodeId
}

如果两个并行分支的 GraphFlux.getKey() 相同(比如都叫 "answer"),第二个会覆盖第一个。

java 复制代码
// 两个分支都用 key="answer"
ParallelGraphFlux.of(List.of(
    GraphFlux.of("branch_a", "answer", fluxA, null, null),
    GraphFlux.of("branch_b", "answer", fluxB, null, null)));
// → combinedResultMap 里只剩 branch_b 的结果,branch_a 被覆盖

注意代码里取数据时用 nodeId 做键 (nodeDataRefs.get(nodeId)),存数据时用 graphFlux.getKey() 。两者不一致------前者保证不冲突(因为 nodeDataRefs 是按 nodeId 存的 HashMap),后者会冲突。

实践结论(重要):

场景 建议
单节点返回流式 用原生 Flux<ChatResponse> 而不是 GraphFlux ------走 handleEmbeddedFlux 路径,行为正确
并行节点返回流式 给每个分支的 GraphFlux 用不同的 key,否则会互相覆盖
读流式节点的结果 不要假设 state.value(key) 是 Message------先做类型检查

一个防御性写法:

java 复制代码
// ✅ 兼容两种路径
Object raw = state.value("answer").orElse(null);
String text;
if (raw instanceof GraphResponse<?> gr) {
    // 走的是 handleGraphFlux 路径,需要再拆一层
    Object inner = gr.resultValue().orElse(null);
    text = (inner instanceof Message m) ? m.getText() : String.valueOf(inner);
} else if (raw instanceof Message m) {
    text = m.getText();
} else {
    text = String.valueOf(raw);
}

这三条路径的不一致,是"四份实现"这个结构问题的直接证据。 当同一套逻辑被复制四遍时,其中几份必然会在细节上分叉------而这次分叉的是"结果对象要不要拆包"这种根本性的东西。

这也再次印证了第 03 章、第 04 章反复出现的那个判断标准:框架给的"能跑"路径,未必是"对"的路径。 用之前先想清楚你走的是哪条。

5.5.5 空 Flux 会变成错误(以及它为什么重要)

java 复制代码
.switchIfEmpty(Flux.error(new IllegalStateException(
    "Empty flux detected for key '" + key + "'. This may indicate an LLM API error with null result.")))

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

这一行是流式场景下最常见的生产异常之一 。触发链是:LLM 返回的流里所有元素都被前面的 filter 掉 → switchIfEmpty 触发 → 整个流变成错误。

四种来源:

# 来源 可修复性
1 供应商 API 只返回了 usage / finish_reason 等非内容包 依赖供应商
2 模型被内容安全策略拦截,返回空内容 改 prompt
3 网络层重试后返回空响应 加重试
4 你自己在节点里 Flux.empty() 了 完全可以避免

第 4 种最容易被忽略:

java 复制代码
// ❌ 条件不满足时返回空流 ------ 会抛 IllegalStateException
if (!shouldCallLlm(state)) {
    return CompletableFuture.completedFuture(Map.of("answer", Flux.empty()));
}

// ✅ 应该返回非流式的占位值
if (!shouldCallLlm(state)) {
    return CompletableFuture.completedFuture(Map.of("answer", "(本次跳过)"));
}

关键认知:返回"空的 Flux"和返回"没有 Flux"是两回事。

返回 走的路径 后果
Map.of("answer", Flux.empty()) 流式 → switchIfEmpty 抛 IllegalStateException
Map.of("answer", "xxx") 非流式 正常
Map.of() 非流式 正常(什么都没写)

实践结论:节点要么返回真实内容,要么返回不含 Flux 的 Map。绝不返回空 Flux。


5.6 子图:把整个执行流当成一个值

第 04 章讲了子图的声明 (SubStateGraphNode vs SubCompiledGraphNode)。这一节讲它的执行------而执行机制的设计,是整个框架里最巧妙的一处。

5.6.1 子图返回的是一个 Flux

java 复制代码
@Override
public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
	final boolean resumeSubgraph = ...;

	// ① 清洗 config:不继承父图的检查点位置和节点位置
	RunnableConfig subGraphRunnableConfig = RunnableConfig.builder(config).checkPointId(null).nextNode(null).build();
	subGraphRunnableConfig.clearContext();

	// ② Saver 一致性检查(见 5.6.2)
	var parentSaver = parentCompileConfig.checkpointSaver();
	var subGraphSaver = subGraph.compileConfig.checkpointSaver();
	if (subGraphSaver.isPresent()) {
		if (parentSaver.isEmpty()) {
			return CompletableFuture
				.failedFuture(new IllegalStateException("Missing CheckpointSaver in parent graph!"));
		}
		if (parentSaver.get() == subGraphSaver.get()) {
			subGraphRunnableConfig = RunnableConfig.builder(config)
				.threadId(config.threadId()
					.map(threadId -> format("%s_%s", threadId, subGraphId(nodeId)))
					.orElseGet(() -> subGraphId(nodeId)))
				.nextNode(null).checkPointId(null)
				.build();
			subGraphRunnableConfig.clearContext();
		}
	}

	final CompletableFuture<Map<String, Object>> future = new CompletableFuture<>();
	try {
		if (resumeSubgraph) {
			subGraphRunnableConfig = subGraph.updateState(subGraphRunnableConfig, state.data());
		}

		// ③ 核心:创建子图的流,然后【当作一个值】返回
		var fluxStream = subGraph.graphResponseStream(state, subGraphRunnableConfig);
		future.complete(Map.of(outputKeyToParent(nodeId), fluxStream));
	}
	catch (Exception e) {
		future.completeExceptionally(e);
	}
	return future;
}

(.../graph/internal/node/SubCompiledGraphNodeAction.java:68)

核心是 ③ 那两行:子图的 action 返回的不是"结果",而是"一个会产出结果的 Flux"。

然后父图的 handleActionResult 第一件事就是 getEmbedFlux------发现 Map 里有个 Flux,走 handleEmbeddedFlux 路径,把这个 Flux 展开成父图流的一部分。

这就是「embed(嵌入)」这个名字的含义:子图的整个执行流,被当作一个值塞进父图的节点返回值里,再由父图展开。

对比一下三种做法:

做法 子图怎么返回 父图怎么处理
阻塞等待(朴素做法) 跑完,返回结果 Map 直接拿到结果
回调 接受一个 callback 在 callback 里继续
本框架的做法 返回 Flux 把 Flux 展开进自己的流

第三种的好处是:父图的流和子图的流无缝合并 。订阅父图 stream() 的人,会看到子图内部每个节点的输出,就像它们直接属于父图一样------只是 subGraph 标志被设成了 true。

这个机制的完整链条:

arduino 复制代码
父图 NodeExecutor.executeNode
  → 拿到 currentNodeId = "sub"(子图节点 ID)
  → action.apply(...)  → SubCompiledGraphNodeAction
       → subGraph.graphResponseStream(state, cfg)   ← 创建子图的 Flux(此时还没跑)
       → 返回 Map.of("subgraph_sub_compiled_graph", <那个 Flux>)
  → handleActionResult
       → getEmbedFlux 发现 Flux
       → handleEmbeddedFlux
            → transformFluxToGraphResponse 给每个元素包装
            → 子图流里的 GraphResponse 走"透传"分支
       → 元素流进父图的流

注意子图的 Flux 是在 action.apply 里创建的,但那时还没订阅------真正跑起来要等到父图的流订阅到它。这是冷流的组合。

5.6.2 Saver 的父子一致性要求

java 复制代码
var parentSaver = parentCompileConfig.checkpointSaver();
var subGraphSaver = subGraph.compileConfig.checkpointSaver();

if (subGraphSaver.isPresent()) {
    if (parentSaver.isEmpty()) {
        return CompletableFuture
            .failedFuture(new IllegalStateException("Missing CheckpointSaver in parent graph!"));
    }

    // Check saver are the same instance
    if (parentSaver.get() == subGraphSaver.get()) {
        ...
    }
}

两条规则:

规则一:子图配了 Saver,父图必须也配。 否则直接 IllegalStateException("Missing CheckpointSaver in parent graph!")。

这个检查在每次执行子图节点时 都会做,而且是返回一个 failedFuture------所以异常会在 Mono.fromFuture 那里被捕获,变成流里的 error 元素。

注意这个检查的方向 :只检查「子图有 → 父图也要有」,不检查反向。「父图有、子图没有」是允许的------那时子图不存检查点,只有父图存。

规则二:只有父子用同一个 Saver 实例时,才启用"子图独立 threadId"。

java 复制代码
if (parentSaver.get() == subGraphSaver.get()) {
    subGraphRunnableConfig = RunnableConfig.builder(config)
        .threadId(config.threadId()
            .map(threadId -> format("%s_%s", threadId, subGraphId(nodeId)))
            .orElseGet(() -> subGraphId(nodeId)))
        .build();
}

注意判断是 == 引用比较 ,不是 equals。所以:

java 复制代码
// ❌ 两个不同的 MemorySaver 实例,即使配置一样也不算"同一个"
parent:  CompileConfig.builder().saverConfig(cfg1.register(new MemorySaver())).build()
sub:     CompileConfig.builder().saverConfig(cfg2.register(new MemorySaver())).build()

// ✅ 必须是同一个实例
MemorySaver saver = new MemorySaver();
parent:  ...register(saver)
sub:     ...register(saver)

为什么要用派生 threadId? 因为子图和父图共享同一个 Saver 时,如果也共享同一个 threadId,两者的检查点会混在一条链上------父图存一个、子图存一个,交替写入,恢复时无法区分。

派生后:

threadId
父图 session-abc
子图(id = sub) session-abc_sub

这样两者的检查点链完全隔离。

还有第三种情况:子图有 Saver,父图也有,但不是同一实例 → 两个 if 都不满足 → subGraphRunnableConfig 保持第一个构造的那份(继承父图的 threadId)。

java 复制代码
RunnableConfig subGraphRunnableConfig = RunnableConfig.builder(config).checkPointId(null).nextNode(null).build();
subGraphRunnableConfig.clearContext();

这时子图会用父图的 threadId 存检查点 → 子图和父图的检查点会混在同一条链上。 这是三种组合里最危险的一种:

父 Saver 子 Saver 是否同一实例 结果
无 有 --- 抛异常(Missing CheckpointSaver in parent graph)
有 无 --- 正常,只有父图存点
有 有 ✅ 是 正常,子图用派生 threadId,链隔离
有 有 ❌ 否 能跑,但检查点链混在一起

实践结论:子图要用 Saver,就必须和父图共享同一个 Saver 实例。

5.6.3 subGraph 标志的语义丢失

java 复制代码
public StreamingOutput<?> buildStreamingOutput(Message message, Object originData, String nodeId, boolean streaming) {
    // Create StreamingOutput with chunk and originData
    OutputType outputType = OutputType.from(streaming, nodeId);
    StreamingOutput<?> output = new StreamingOutput<>(message, originData, nodeId,
            (String) config.metadata("_AGENT_").orElse(""), this.overallState, outputType);
    output.setSubGraph(true);
    return output;
}

(.../graph/GraphRunnerContext.java:269)

setSubGraph(true) 是硬编码的。 这个方法的每一个调用者拿到的输出,都被标记成"来自子图"。

而 processGraphResponseFlux 里也是:

java 复制代码
.map(data -> {
        if (data.getOutput() != null && !data.getOutput().isCompletedExceptionally()) {
            var output = data.getOutput().join();
            output.setSubGraph(true);          // ← 无条件设置
            GraphResponse<NodeOutput> newData = GraphResponse.of(output);
            lastData.set(newData);
            return newData;
        }
        lastData.set(data);
        return data;
    })

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

后果:任何从 embed Flux 路径出来的 NodeOutput,subGraph 都是 true------不管它是不是真的来自子图。

这意味着:

java 复制代码
// 一个普通的流式 LLM 节点,返回原生 Flux<ChatResponse>
AsyncNodeActionWithConfig llmNode = (state, config) ->
        CompletableFuture.completedFuture(Map.of("answer", chatClient.prompt().stream().chatResponse()));

// 它走 getEmbedFlux → handleEmbeddedFlux → 被 setSubGraph(true)
// 下游看到 output.isSubGraph() == true,但它根本不是子图!

实践结论:不要用 isSubGraph() 判断"这个输出来自子图"。 它实际表达的是"这个输出经过了 embed Flux 路径"。

想真正区分,要用 node() 判断------子图内部节点的 node() 是子图里的节点 ID:

java 复制代码
// ✅ 用 node ID 判断
if (output.node().startsWith("sub_")) { ... }

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

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


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

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

5.7.1 InterruptableAction 的两个时机

java 复制代码
public interface InterruptableAction {
	Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config);

	default Optional<InterruptionMetadata> interruptAfter(String nodeId, OverAllState state,
			Map<String, Object> actionResult, RunnableConfig config) {
		return Optional.empty();
	}
}

(.../graph/action/InterruptableAction.java)

两个方法,注释里写明了执行流:interrupt() → apply() → interruptAfter()。

方法 时机 能看到 默认实现
interrupt(...) 节点 执行前 状态(未含本节点结果) 无(必须实现)
interruptAfter(...) 节点 执行后、状态合并前 状态 + 本节点的原始返回 返回 empty

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

java 复制代码
// 一个典型的人工审批节点
public class RefundApprovalNode implements AsyncNodeActionWithConfig, InterruptableAction {

    @Override
    public CompletableFuture<Map<String, Object>> apply(OverAllState state, RunnableConfig config) {
        // 生成退款方案(不执行)
        return CompletableFuture.completedFuture(Map.of("refund_plan", buildPlan(state)));
    }

    @Override
    public Optional<InterruptionMetadata> interruptAfter(String nodeId, OverAllState state,
            Map<String, Object> actionResult, RunnableConfig config) {
        // 看到方案了 ------ 拦下来等人审
        if (needsApproval(actionResult)) {
            return Optional.of(InterruptionMetadata.builder(nodeId, state).build());
        }
        return Optional.empty();
    }

    @Override
    public Optional<InterruptionMetadata> interrupt(String nodeId, OverAllState state, RunnableConfig config) {
        return Optional.empty();
    }
}

注意 interrupt(...) 没有 default 实现,所以实现这个接口必须写它。 如果你只需要 interruptAfter,也要写一个空的 interrupt。

5.7.2 三个插手点在引擎里的位置

# 位置 代码 触发条件
① NodeExecutor.executeNode 内,apply 之前 (.../NodeExecutor.java:113) interrupt(...) 返回非空
② NodeExecutor.handleActionResult 内,merge 之前 (.../NodeExecutor.java:172) interruptAfter(...) 返回非空
③ MainGraphExecutor.execute 的 ⑦ 分支 (.../MainGraphExecutor.java:96) shouldInterrupt() 为真

① 和 ② 是"节点级"的 (由节点自己决定是否中断),③ 是"图级"的 (由 CompileConfig 的 interruptsBefore/interruptsAfter 决定)。

三者产生的产物也不同:

插手点 产物
① done(InterruptionMetadata),状态没被更新(因为还没执行)
② done(InterruptionMetadata),状态已合并(代码里先 merge 再返回)
③ done(InterruptionMetadata),cloneState 出来的当前状态

② 那个"先合并再中断"的处理非常关键 (5.4.2 讲过那个四步顺序)。它保证了:中断时存的检查点里,状态是包含本节点结果的。恢复时才能正确继续。

而 ① 是在 apply 之前,所以状态里没有本节点的结果------这是对的,因为节点还没跑。

5.7.3 INTERRUPT_AFTER 是个伪节点

java 复制代码
public static final String INTERRUPT_AFTER = "__INTERRUPTED__";       // GraphRunnerContext:59
private static String INTERRUPT_AFTER = "__INTERRUPTED__";             // CompiledGraph:62

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

类 修饰符 值
GraphRunnerContext public static final String "__INTERRUPTED__"
CompiledGraph private static String(没有 final) "__INTERRUPTED__"

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

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

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

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

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

java 复制代码
// MainGraphExecutor:下一轮看到 nextNodeId 是它,且是边前中断模式 + 有 resumeFrom,
// 就重新计算真实路由
final var resumeFrom = context.getResumeFromAndReset();
if (resumeFrom.isPresent()) {
    if (context.getCompiledGraph().compileConfig.interruptBeforeEdge()
            && java.util.Objects.equals(context.getNextNodeId(), INTERRUPT_AFTER)) {
        var nextNodeCommand = context.nextNodeId(resumeFrom.get(), context.getCurrentStateData());
        context.setNextNodeId(nextNodeCommand.gotoNode());
        context.setCurrentNodeId(null);
    }
}

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

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

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

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

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

java 复制代码
private boolean shouldInterruptAfter(String nodeId, String previousNodeId) {
	if (nodeId == null || Objects.equals(nodeId, previousNodeId))
		return false;
	return (compiledGraph.compileConfig.interruptBeforeEdge() && Objects.equals(nodeId, INTERRUPT_AFTER))
			|| compiledGraph.compileConfig.interruptsAfter().contains(nodeId);
}

(.../graph/GraphRunnerContext.java:179)

shouldInterruptAfter(currentNodeId, nextNodeId) ------ 这里形参 nodeId = current,previousNodeId = next = __INTERRUPTED__。

Objects.equals(nodeId, INTERRUPT_AFTER) 判断的是 current 是否等于 __INTERRUPTED__------而 current 是真实节点 ID,不是。所以这个条件不成立。

所以 interruptBeforeEdge 的中断判定,依赖的是 interruptsAfter().contains(currentNodeId) 这条 ,而 INTERRUPT_AFTER 那条在 shouldInterruptAfter 里永远不会命中 (因为传进去的 nodeId 是 current 而不是 next)。

这个细节说明 INTERRUPT_AFTER 只用于"设置 nextNodeId"和"恢复时识别"两个场合,不参与中断判定本身。 中断判定发生在更早------在 NodeExecutor 里设置 INTERRUPT_AFTER 的那一刻,就已经决定了"这一轮结束后不再继续"。

等等,那中断是在哪发生的?

重读 handleActionResult:设置 nextNodeId = INTERRUPT_AFTER 后,没有 return ,继续执行 buildNodeOutputAndAddCheckpoint → doListeners(NODE_AFTER) → 递归 mainGraphExecutor.execute。

下一轮 execute:

  • ① shouldStop? next=__INTERRUPTED__ 不为 null → false
  • ② returnFromEmbed? 无
  • ③ isInterrupted? 否
  • ④ isStartNode? 否
  • ⑤ isEndNode? END.equals("__INTERRUPTED__") → 否
  • ⑥ resumeFrom? 无(全新执行)
  • ⑦ shouldInterrupt? shouldInterruptBefore(next=__INTERRUPTED__, current) → interruptsBefore().contains("__INTERRUPTED__") → false;shouldInterruptAfter(current, next) → 上面分析过,false
  • ⑧ nodeExecutor.execute

→ 走到 ⑧,会尝试执行一个 ID 为 __INTERRUPTED__ 的节点!

而 nodeExecutor.executeNode 第一步:

java 复制代码
context.setCurrentNodeId(context.getNextNodeId());      // current = "__INTERRUPTED__"
String currentNodeId = context.getCurrentNodeId();
AsyncNodeActionWithConfig action = context.getNodeAction(currentNodeId);

if (action == null) {
    return Flux.just(GraphResponse.error(RunnableErrors.missingNode.exception(currentNodeId)));
}

getNodeAction("__INTERRUPTED__") 返回 null → 发 missingNode 错误。

所以:如果开了 interruptBeforeEdge 但没有从检查点恢复,nextNodeId = INTERRUPT_AFTER 会导致下一轮抛 missingNode 错误。

这是一个非常隐蔽的行为。不过在实践中,interruptBeforeEdge 通常配合 HITL 使用(必然有恢复环节),所以这条路径不常走到。

实践结论:interruptBeforeEdge(true) 只在"一定会从检查点恢复"的场景下使用。 如果你只是想在某个节点后停一下,用 interruptAfter("nodeId") 而不是 interruptBeforeEdge。

5.7.4 中断的恢复路径

中断后,Flux 发出 done(InterruptionMetadata) 并结束。恢复时:

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

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

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

java 复制代码
this.currentNodeId = null;
this.nextNodeId = checkpoint.getNextNodeId();
this.overallState = initialState.input(checkpoint.getState());
this.resumeFrom = checkpoint.getNodeId();

然后第一次 execute:

  • ① shouldStop? 否(next 不为 null)
  • ② returnFromEmbed? 无
  • ③ isInterrupted? 否
  • ④ isStartNode? 否(current 是 null 不是 START)✓ 跳过 handleStartNode
  • ⑤ isEndNode? 否
  • ⑥ resumeFrom 有值 → 如果是边前中断模式就重算路由
  • ⑦ shouldInterrupt? 否
  • ⑧ nodeExecutor → 从 next(= 检查点记录的下一个节点)开始

注意 ⑥ 里 getResumeFromAndReset() 是 consume-once,所以"重算路由"只在第一轮发生一次。 之后 resumeFrom 就是 null 了。


5.8 执行引擎的六个反模式

反模式一:在 Reactor 线程上调用 invoke(...)

症状:应用吞吐下降,其他请求跟着变慢。

根因 :invoke = stream(...).last().block()(5.1.2)。.block() 阻塞调用线程。

修复 :在响应式链路里用 stream(...) 直接返回 Flux;在 WebFlux 场景用 MediaType.TEXT_EVENT_STREAM_VALUE。

反模式二:在节点里直接改传入的 OverAllState

症状:状态被改了两遍,或者 APPEND key 出现意料之外的重复。

根因 :action.apply(context.getOverallState(), ...) 传的是主状态引用(5.4.1 步骤 ⑥),不是副本。

修复 :节点只通过返回的 Map 表达变更,不要调 state.updateState(...)。

反模式三:返回空的 Flux

症状 :IllegalStateException: Empty flux detected for key 'xxx'。

根因 :switchIfEmpty(Flux.error(...))(5.5.5)。

修复:条件不满足时返回非流式的占位值,或返回空 Map。

反模式四:一个节点返回多个 Flux

症状:只有一个流的输出被处理,其余静默丢失。

根因 :三个 getEmbedXxx 都用 findFirst()(5.4.4)。

修复 :用 Flux.merge(...) 合并,或用 ParallelGraphFlux。

反模式五:给并行分支的 GraphFlux 用相同的 key

症状:并行流式场景下,只有最后一个分支的结果进状态。

根因 :combinedResultMap.put(graphFlux.getKey(), nodeData) 用 key 做 map 键(5.5.4)。

修复 :每个分支用不同的 key,比如 "answer_a" / "answer_b"。

反模式六:给子图配 Saver 却和父图用不同实例

症状:子图和父图的检查点混在同一条链上,恢复时串了。

根因 :parentSaver.get() == subGraphSaver.get() 是引用比较(5.6.2)。

修复:共享同一个 Saver 实例。

其中反模式二到五,都是同一类问题:用"看起来能跑"的写法,触发了框架里那条没人走过的分支。


5.9 执行引擎侧的六条规范

  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 到底有哪几种?我该怎么按类型分流?」


延伸验证清单

bash 复制代码
# 2. 确认 resultValue() 没有任何外部调用点
#    预期:只有 GraphRunner.java:53/61/62 三处(声明+传参+方法体),
#          没有任何 "runner.resultValue()" 或 ".resultValue()" 指向 GraphRunner
grep -rn "\.resultValue()" --include=*.java spring-ai-alibaba-graph-core/src/main/java \
    | grep -v AsyncGenerator | grep -v "GraphResponse.java"

# 3. 确认 handleStartNode 递归时换了 AtomicReference
#    预期:execute(context, new AtomicReference<>())
grep -n "new AtomicReference<>" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/MainGraphExecutor.java

# 4. 确认 isMaxIterationsReached 有副作用
#    预期:return iteration.incrementAndGet() > compiledGraph.getMaxIterations();
grep -n -A2 "public boolean isMaxIterationsReached" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/GraphRunnerContext.java

# 5. 确认 CompileConfig 的 recursionLimit 默认值(第 01 章那张表的依据)
#    预期:private int recursionLimit = 100;
grep -n "private int recursionLimit" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/CompileConfig.java

# 6. 确认 MainGraphExecutor.execute 的八个分支顺序
#    预期:shouldStop → ReturnFromEmbed → isInterrupted → isStartNode
#          → isEndNode → ResumeFrom → shouldInterrupt → nodeExecutor
grep -n "shouldStop()\|getReturnFromEmbedAndReset()\|isInterrupted(\|isStartNode()\|isEndNode()\|getResumeFromAndReset()\|shouldInterrupt()\|nodeExecutor.execute" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/MainGraphExecutor.java

# 7. 确认三个 getEmbedXxx 都用 findFirst
#    预期:3
grep -c "findFirst" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java

# 8. 确认三条流式路径的提取方式差异(5.5.3 的 BUG 证据)
#    预期:460 行有 .resultValue();643 行和 761 行没有
sed -n '460p' spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
sed -n '643p' spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java
sed -n '761p' spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java

# 9. 确认空 Flux 保护接在 filter 之后
#    预期:先 filter 后 switchIfEmpty
grep -n "\.filter(element -> {\|switchIfEmpty" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java

# 10. 确认 O(n²) 的文本拼接
#     预期:lastMessageText.concat(currentMessageText)
grep -n "lastMessageText.concat" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java

# 11. 确认 subGraph 标志是硬编码的
#     预期:2 处 setSubGraph(true)
grep -c "setSubGraph(true)" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/NodeExecutor.java

# 12. 确认子图返回的是 Flux
#     预期:future.complete(Map.of(outputKeyToParent(nodeId), fluxStream));
grep -n "fluxStream" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/internal/node/SubCompiledGraphNodeAction.java

# 13. 确认子图 Saver 一致性用的是引用比较
#     预期:parentSaver.get() == subGraphSaver.get()
grep -n "parentSaver.get() == subGraphSaver.get()" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/internal/node/SubCompiledGraphNodeAction.java

# 15. 确认 GraphRunnerContext 有三处 FIXME
#     预期:3
grep -c "FIXME" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/GraphRunnerContext.java

# 17. 确认监听器异常被吞
#     预期:catch (Exception ex) { log.error("Error in listener", ex); }
grep -n -A2 "catch (Exception ex)" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/GraphRunnerContext.java

# 18. 确认 MainGraphExecutor 异常分支用的是完全限定名取 Logger
#     预期:org.slf4j.LoggerFactory.getLogger(com.alibaba.cloud.ai.graph.GraphRunner.class)
grep -n "org.slf4j.LoggerFactory" \
    spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/executor/MainGraphExecutor.java

# 19. 确认 INTERRUPT_AFTER 的值与两处使用
#     预期:Context 里是 "__INTERRUPTED__";MainGraphExecutor 与 NodeExecutor 各引用
grep -rn "INTERRUPT_AFTER = \|INTERRUPT_AFTER)" --include=*.java \
    spring-ai-alibaba-graph-core/src/main/java

思考题(第 06 章会给出答案):

  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 不能单独使用,必须配合检查点恢复。

相关推荐
workflower1 小时前
无人机注册量与飞行量
大数据·网络·人工智能·云计算·汽车·无人机
灵动的艺术1 小时前
如一 Agent 架构解读(六):阈值、联动与坑位——压缩系统的肌理
人工智能·agent·如一
Yyq130208696821 小时前
KTM5220 ——21 位高分辨率、高精度在轴磁性角度编码器IC,校准后INL 可达±0.015°,小封装
人工智能
GlobalInfo1 小时前
全球人工智能数据中心保险市场动态、发展趋势及项目可行性研究报告2026-2032
数据库·人工智能·ai·数据中心
星马梦缘1 小时前
关于人工智能学院游戏设计大赛决赛的内部分工通知
数据库·人工智能·游戏
不悔哥1 小时前
ESP32上跑 Linux: 乐鑫给 ESP32-S31开放了官方 BSP
linux·嵌入式硬件·开源
开开心心_Every1 小时前
免费压缩软件WinRAR无弹窗、广告干扰
java·开发语言·前端·spring·智能手机·word·winrar
vivo互联网技术1 小时前
记忆不是存历史,而是治理未来行动|KDC 系列 04
人工智能·架构
黑旋风小威1 小时前
小白自制漫剧没有思路,知漫剧参考剧情生成创作方案
人工智能