版本:LangGraph4j 1.8.25 目标:弄清图为何异步优先;节点三种「慢」怎么处理;
stream里 chunk 从哪来、怎么取消;并行有界池挂在哪。 本章不接真实模型:用假 token 把StreamingOutput跑通。

上图三层:接入层接 HTTP/SSE;图调度用 CompiledGraph.stream 推进;节点要么返回普通 Map,要么在 Map 里塞 AsyncGenerator 往外推 chunk。有界池挂在 RunnableConfig metadata 上,别堵公共 ForkJoin。
20.1 异步优先
节点统一走 AsyncNodeAction:入参状态,返回 CompletableFuture<Map<String, Object>>(partial update)。同步逻辑用 node_async 包一层;addNode(id, NodeAction) 也会替你包。
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;
graph.addNode("work", node_async(state -> {
// 同步业务;框架按异步调度推进
return Map.of("done", true);
}));
这样设计,主要对上三件事:
-
LLM / HTTP / DB 是 IO:节点自然适合 Future,而不是占着调用线程干等。
-
并行要
allOf:同一 source 扇出多条无条件边时,合成并行节点,各分支 Future 汇齐再合并状态。 -
流式与 Future 同族:节点可以返回「还没跑完」的
AsyncGenerator,外层stream把它嵌进去一起消费。
图的调度是异步的;你是否在业务代码里再开线程,是另一回事。 node_async 只保证接口形状是 Future,不保证内部一定跑在专用池上。节点里若长时间同步阻塞,仍可能占着调度用的线程。
20.2 三种「慢」
| 形态 | 节点返回什么 | 典型场景 | 注意 |
|---|---|---|---|
| 同步阻塞 | 立刻完成的 Future,但 apply 里卡了很久 | JDBC、同步 HTTP | 别堵公共 ForkJoin / 容器线程 |
| 未完成 Future | CompletableFuture 尚未 complete | 异步客户端回调 | 超时、异常要 completeExceptionally |
| 流式生成器 | Map 某个 value 是 AsyncGenerator | 模型 token 流 | 外层 stream 展开为 StreamingOutput |
前两种最终都是「一次节点、一份 partial Map」。第三种会在节点完成前,先往 stream 迭代器里塞多帧 chunk,结束时再并入状态。
并行分支里各节点应返回普通数据。流式放在串行的生成节点上,避免多个 generator 抢同一条输出通道。
20.3 一整段能跑的流式节点
START → draft → END。draft 不调模型,按词吐出假 token;外层 stream 一边收 chunk,一边在节点结束后看状态。对照上图右侧「流式节点」读。
import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.StateGraph.START;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import org.bsc.async.AsyncGenerator;
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.NodeOutput;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.action.AsyncNodeActionWithConfig;
import org.bsc.langgraph4j.state.AgentState;
import org.bsc.langgraph4j.state.Channel;
import org.bsc.langgraph4j.state.Channels;
import org.bsc.langgraph4j.streaming.StreamingOutput;
public class StreamingDraftGraph {
public static class DraftState extends AgentState {
public static final String TEXT = "text";
public static final String LOGS = "logs";
public static final Map<String, Channel<?>> SCHEMA = Map.of(
TEXT, Channels.base(() -> ""),
LOGS, Channels.appenderWithDuplicate(ArrayList::new)
);
public DraftState(Map<String, Object> initData) {
super(initData);
}
}
/** 假 token 流:每个词一帧 StreamingOutput,结束帧写入 TEXT。 */
static AsyncNodeActionWithConfig<DraftState> draftNode(String nodeId, List<String> tokens) {
return (state, config) -> {
Iterator<String> it = tokens.iterator();
AsyncGenerator<StreamingOutput<DraftState>> generator =
new AsyncGenerator.Base<>() {
@Override
public Data<StreamingOutput<DraftState>> next() {
if (it.hasNext()) {
return AsyncGenerator.Data.of(
new StreamingOutput<>(it.next(), nodeId, state, null));
}
// done 的 Map 会并入节点 partial,再进 Channel
return AsyncGenerator.Data.done(Map.of(
DraftState.TEXT, String.join("", tokens),
DraftState.LOGS, List.of("draft:done")
));
}
};
// key 名随意;运行时找「值为 AsyncGenerator」的那一项展开
return CompletableFuture.completedFuture(Map.of("content", generator));
};
}
public static void main(String[] args) throws Exception {
CompiledGraph<DraftState> compiled = new StateGraph<>(DraftState.SCHEMA, DraftState::new)
.addNode("draft", draftNode("draft", List.of("你", "好", ",", "LangGraph4j")))
.addEdge(START, "draft")
.addEdge("draft", END)
.compile();
var config = RunnableConfig.builder().threadId("stream-1").build();
StringBuilder buf = new StringBuilder();
for (NodeOutput<DraftState> out : compiled.stream(Map.of(), config)) {
if (out instanceof StreamingOutput<?> chunk) {
System.out.println("chunk=" + chunk.chunk());
if (chunk.chunk() != null) {
buf.append(chunk.chunk());
}
} else {
System.out.println("node=" + out.node()
+ " text=" + out.state().<String>value(DraftState.TEXT).orElse(""));
}
}
System.out.println("拼接=" + buf);
}
}
跑起来你会先看到若干行 chunk=…,再看到 draft 的节点完成帧;TEXT 已是整句。接真实模型时,把 tokens 换成模型流回调往队列里 offer 的词即可,拓扑不用改。
20.4 消费 stream:chunk、结束、取消
compiled.stream(...) 返回 AsyncGenerator.Cancellable<NodeOutput<S>>。迭代时大致两类:
| 类型 | 何时出现 | 怎么用 |
|---|---|---|
StreamingOutput | 节点 Map 里塞了 AsyncGenerator | 读 chunk(),拼到 UI |
普通 NodeOutput | 节点(含流式展开)结束后 | 读 node() / state() |
StreamingOutputEnd 是结束帧的一种形态(isStreamingEnd() == true)。业务上:chunk 阶段只拼字;收到结束或普通节点帧再解锁输入框。
取消:
var generator = compiled.stream(Map.of(), config);
// 另一条线程:用户点了停止
generator.cancel(true); // 尽量打断当前节点线程
// generator.cancel(false); // 等当前节点跑完,再停在下一跳之前
generator.forEachAsync(out -> { /* ... */ })
.exceptionally(ex -> {
if (generator.isCancelled()) {
System.out.println("已取消");
}
return null;
});
| 消费方式 | cancel(true) | cancel(false) |
|---|---|---|
forEachAsync | 当前执行线程可被中断,Future 异常结束 | 当前节点跑完再停 |
for (out : generator) | hasNext() 变 false,循环结束 | 同左 |
取消之后,模型 HTTP 是否真断得开,取决于客户端实现。产品侧要事先定:取消后状态当草稿留着,还是当失败丢掉。
背压:前端或中间层用有界队列接 chunk,不要无限缓存。
20.5 接入层与有界池
接入层(HTTP / SSE) → 图调度(stream / invoke) → 节点 Future 或内嵌 AsyncGenerator
短图 + invoke:在 MVC 同步线程里跑完再返回,通常可接受。长图或模型流:用 SSE / 队列把 stream 帧推出去,避免占满容器线程。WebFlux 上不要对图结果 block() / blockLast()。
同一 source 挂多条无条件边时,编译期会合成并行节点,内部 id 形如 __PARALLEL__(sourceId)。要真并行,把有界 Executor 放进本次运行的 metadata,key 与合成 id 对齐:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService ioPool = Executors.newFixedThreadPool(8); // 进程级 Bean,不要每请求 new
var config = RunnableConfig.builder()
.threadId("job-1")
.addMetadata("__PARALLEL__(plan)", ioPool) // plan 是扇出源节点 id
.build();
不塞 Executor 时,并行分支可能仍在同一条调度链上顺序求值,并不等于多线程。用有界、可共享的池;不要每个请求 newFixedThreadPool。
JDK 21 可用虚拟线程跑阻塞 call,但仍要限制打到模型侧的并发,否则把上游配额打满。
20.6 安全边界
-
单次执行的状态合并由框架汇聚。 不要多线程原地改同一个
AgentState;节点只返回 partial Map。 -
Executor 放
RunnableConfigmetadata,不要塞进 state(state 可能要序列化)。 -
state 禁入:
HttpServletRequest、未配置序列化的巨型对象、打开的 Stream / Connection。 -
异步后 MDC / Tracing 上下文容易丢: 需要时在节点外层包一层,把上下文拷进任务再清。
-
节点里再同步
invoke同一张图: 有界池下可能死锁。子流程优先用框架提供的挂法,而不是手写嵌套invoke。
上线前核这四项即可:有界 IO 池、模型调用超时、SSE 可 cancel、日志带上 threadId。
20.7 常见错误
| 现象 | 处理 |
|---|---|
| 容器线程被拖死 | 长跑改 stream + SSE;节点内别同步堵死 |
stream 只有节点帧、没有 chunk | 节点没返回 AsyncGenerator,或 value 类型不对 |
| chunk 有了,最终 state 没有正文 | Data.done(Map) 没写回业务键,或 Channel 类型不对 |
| 取消了模型还在跑 | 客户端未响应中断;取消规则与资源释放要单独做 |
| 「开了并行」却像串行 | metadata 未挂 __PARALLEL__(源id) 的 Executor |
| 每请求新建线程池 | 改成共享有界池 Bean |
| 并行分支里各自流式吐字 | 流式收到达串行生成节点;分支只回普通 Map |
WebFlux 里 block() 等图结果 | 直接订阅 stream / 转 SSE,别堵事件循环 |
先把假 token 的 StreamingOutput 跑通,再换成真实模型流;线程与取消规则定清楚,比先接模型再补洞省事。
367



被折叠的 条评论
为什么被折叠?



