文章目录
- [1. 前言](#1. 前言)
- [2. Graph 流式输出](#2. Graph 流式输出)
-
- [2.1 整体层级结构](#2.1 整体层级结构)
-
- [2.1.1 图级别流式输出](#2.1.1 图级别流式输出)
- [2.1.2 节点级别流式输出](#2.1.2 节点级别流式输出)
- [2.2 数据类型层次结构](#2.2 数据类型层次结构)
-
- [2.2.1 基类:NodeOutput](#2.2.1 基类:NodeOutput)
- [2.2.2 流式输出:StreamingOutput](#2.2.2 流式输出:StreamingOutput)
- [2.2.3 自定义输出:CustomOutput](#2.2.3 自定义输出:CustomOutput)
- [2.3 新版本说明 !!!](#2.3 新版本说明 !!!)
-
- [2.3.1 OutputType 枚举类](#2.3.1 OutputType 枚举类)
- [2.3.2 构建流式输出对象](#2.3.2 构建流式输出对象)
- [2.4 并行流式输出](#2.4 并行流式输出)
- [3. 入门案例](#3. 入门案例)
-
- [3.1 关键词检查节点](#3.1 关键词检查节点)
- [3.2 大模型流式审核节点](#3.2 大模型流式审核节点)
- [3.3 结论节点](#3.3 结论节点)
- [3.4 构建状态图](#3.4 构建状态图)
- [3.5 访问接口](#3.5 访问接口)
- [3.6 对话测试](#3.6 对话测试)
1. 前言
ReactAgent 是开箱即用的 ReAct 智能体,它只有大模型 和工具 两个节点,需要进行流式输出的只有大模型节点 ,我们直接从 StreamingOutput 中获取对应类型的消息对象,就能轻松实现流式输出。
Graph 是通用状态工作流引擎,每个节点都需要我们用代码去编排,其流式输出会稍微复杂一点。
2. Graph 流式输出
Spring AI Alibaba Graph 内置了对流式处理的原生支持,统一使用 Flux 来在框架中定义和传递流,与 Spring 生态的流式处理保持一致。
核心特点:
- 统一使用
Flux<NodeOutput>传递流式数据 - 支持
LLM流式Token实时推送 - 支持普通节点 + 流式节点混合编排
- 框架自动订阅、消费、聚合流结果
- 流执行完成后自动合并状态、保存检查点
最佳实践:
- 使用适当的订阅方式:根据需求选择
subscribe()、blockLast()或其他Reactor操作符 - 错误处理:始终使用
doOnError()处理流式输出中的错误 - 资源清理:确保在流完成或取消时正确清理资源
- 性能考虑:对于大量数据,使用背压(
backpressure)机制控制流的速度
2.1 整体层级结构
Graph 的流式输出采用双层架构,两者协同完成全链路流式响应:
- 图级别 (
Graph Level)负责整体工作流调度 - 节点级别 (
Node Level) 负责细粒度流式生成
2.1.1 图级别流式输出
图级别是工作流视角的全局流式调度 ,负责串联所有节点、管理状态、控制执行流程。无论内部节点是否为流式,Graph 都会将所有节点输出统一编排为一个完整的响应流。
text
┌─────────────────────────────────────────────────────────────────┐
│ 图级别流式输出 (Graph Level) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Node A │ ───> │ Node B │ ───> │ Node C │ │
│ │ (普通节点) │ │ (流式LLM节点) │ │ (普通节点) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ NodeOutput ┌─────┴─────┐ NodeOutput │
│ │ │ │
│ StreamingOutput StreamingOutput │
│ (Token 1) (Token 2) │
│ │
│ 获取方式: │
│ • graph.stream() → Flux<NodeOutput> │
└─────────────────────────────────────────────────────────────────┘
两种获取方式对比:
| 方法 | 返回类型 | 适用场景 |
|---|---|---|
graph.stream() |
Flux<NodeOutput> |
标准单图执行,直接获取节点输出 |
graph.graphResponseStream() |
Flux<GraphResponse<NodeOutput>> |
子图嵌套、复杂工作流,需要包装响应 |
标准输出序列示例:
java
NodeOutput(node="A", state={...}) ← 普通节点 A 执行完成
↓
StreamingOutput(chunk="Hello") ← LLM 流式 Token 1
↓
StreamingOutput(chunk=" World") ← LLM 流式 Token 2
↓
StreamingOutput(chunk="!") ← LLM 流式 Token 3
↓
NodeOutput(node="C", state={...}) ← 普通节点 C 执行完成
↓
NodeOutput(node="__END__", state={...}) ← 图执行结束
2.1.2 节点级别流式输出
节点级别是流式输出的最小执行单元 ,主要用于 LLM 对话 、逐 Token 生成等需要实时响应的场景。
text
┌─────────────────────────────────────────────────────────────────┐
│ 节点级别流式输出 (Node Level) │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ StreamingNode (LLM 节点) │ │
│ │ │
│ │ chatClient.prompt() │
│ │ .user(query) │
│ │ .stream() │
│ │ .chatResponse() │
│ │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │Token1│→ │Token2│→ │Token3│→ │Token4│→ │Token5│→ ... │ │
│ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ │ │
│ │ 这些 Token 会作为整个图流的一部分输出 │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
执行流程:
用户 Query → LLM API 流式调用 → Token1 → Token2 → Token3 → ... → TokenN
核心机制:
- 节点内部通过
chatClient.stream()发起流式请求 LLM实时返回的每个Token会被框架包装为StreamingOutput- 该输出会自动合并到图级别总流中,最终推送给前端
典型适用节点:
LLM问答节点- 思考推理节点(
ReactAgent reasoning) - 总结/生成类节点
2.2 数据类型层次结构
所有流式输出统一基于 NodeOutput 基类实现,支持标准输出 、流式输出 、自定义输出三种扩展形态。
text
┌─────────────────────────────────────────────────────────────────┐
│ 流输出数据类型层次 (Data Types) │
│ │
│ NodeOutput │
│ (基类/接口) │
│ │ │
│ ┌─────────────────────┼─────────────────────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌───────▼───────┐ ┌─────▼─────┐ │
│ │NodeOutput│ │StreamingOutput│ │CustomOutput│ │
│ │(普通节点) │ │ (LLM流式节点) │ │ (用户自定义) │ │
│ └─────────┘ └───────────────┘ └───────────┘ │
│ │
│ 包含内容: │
│ • OverallState (全局状态) │
│ • Message (节点消息) │
│ • Node ID (节点标识) │
└─────────────────────────────────────────────────────────────────┘
继承结构:

2.2.1 基类:NodeOutput
所有输出的统一父类,承载节点标识、智能体信息、Token 用量、全局状态等核心执行数据:
java
public class NodeOutput {
/**
* 节点执行完成后,使用对话响应构建 NodeOutput 实例
* @param node 节点唯一标识
* @param agentName 执行该节点的智能体名称
* @param state 图执行的全局状态
* @param tokenUsage 当前节点消耗的 Token 统计信息
* @return 构建完成的 NodeOutput 对象
*/
public static NodeOutput of(String node, String agentName, OverAllState state, Usage tokenUsage) {
return new NodeOutput(node, agentName, tokenUsage, state);
}
/**
* 节点唯一标识(如:reasoning、tool、answer、__START__、__END__)
*/
protected final String node;
/**
* 执行当前节点的智能体名称
*/
protected String agent;
/**
* 当前节点执行的 Token 消耗统计(输入/输出/总 Token 数)
*/
protected Usage tokenUsage;
/**
* 图执行的全局状态(存储会话数据、上下文、执行结果等)
*/
protected final OverAllState state;
/**
* 是否为子图执行节点(true=子图输出,false=主图输出)
*/
protected boolean subGraph = false;
/**
* 判断当前节点是否为【图执行开始节点】
* @return true=是图开始节点,false=不是
*/
public boolean isSTART() {
return Objects.equals(node(), START);
}
/**
* 判断当前节点是否为【图执行结束节点】
* 可用于判断工作流是否正常执行完成/被中断
* @return true=是图结束节点,false=不是
*/
public boolean isEND() {
return Objects.equals(node(), END);
}
}
2.2.2 流式输出:StreamingOutput
Graph 流式输出对象,继承自 NodeOutput,是前端打字机效果的核心数据载体,用于封装 LLM 流式响应的 Token 片段、消息体、原始响应数据、Token 消耗等信息:
java
public class StreamingOutput<T> extends NodeOutput {
/**
* 已废弃:流式文本片段(推荐使用 message 替代)
*/
@Deprecated
private final String chunk;
/**
* 流式消息对象(推荐使用,支持工具调用、多轮对话)
*/
private final Message message;
/**
* 原始响应数据(如 ChatResponse),序列化时忽略不返回前端
*/
@JsonIgnore
private final T originData;
/**
* 输出类型(用于区分思考、工具、答案、错误等类型)
*/
private OutputType outputType;
/**
* 构造函数:仅原始数据 + 节点 + 状态
*/
public StreamingOutput(T originData, String node, OverAllState state) {
super(node, state);
this.chunk = null;
this.message = null;
this.originData = originData;
trySetTokenUsage(originData);
}
/**
* 构造函数:原始数据 + 节点 + 智能体名称 + 状态(Agent 模式)
*/
public StreamingOutput(T originData, String node, String agentName, OverAllState state) {
super(node, agentName, state);
this.chunk = null;
this.message = null;
this.originData = originData;
trySetTokenUsage(originData);
}
/**
* 构造函数:原始数据 + 节点 + 智能体名称 + 状态 + 输出类型
*/
public StreamingOutput(T originData, String node, String agentName, OverAllState state, OutputType outputType) {
super(node, agentName, state);
this.chunk = null;
this.message = null;
this.originData = originData;
this.outputType = outputType;
trySetTokenUsage(originData);
}
/**
* 构造函数:标准 Message + 原始数据 + 节点 + 智能体 + 状态(推荐)
*/
public StreamingOutput(Message message, T originData, String node, String agentName, OverAllState state) {
super(node, agentName, state);
this.message = message;
this.originData = originData;
this.chunk = extractChunkFromMessage(message);
trySetTokenUsage(originData);
}
/**
* 构造函数:Message + 原始数据 + 节点 + 智能体 + 状态 + 输出类型(推荐)
*/
public StreamingOutput(Message message, T originData, String node, String agentName, OverAllState state, OutputType outputType) {
super(node, agentName, state);
this.message = message;
this.originData = originData;
this.chunk = extractChunkFromMessage(message);
this.outputType = outputType;
trySetTokenUsage(originData);
}
/**
* 构造函数:仅 Message + 节点 + 智能体 + 状态
*/
public StreamingOutput(Message message, String node, String agentName, OverAllState state) {
super(node, agentName, state);
this.message = message;
this.chunk = extractChunkFromMessage(message);
this.originData = null;
}
/**
* 构造函数:Message + 节点 + 智能体 + 状态 + 输出类型
*/
public StreamingOutput(Message message, String node, String agentName, OverAllState state, OutputType outputType) {
super(node, agentName, state);
this.message = message;
this.chunk = extractChunkFromMessage(message);
this.originData = null;
this.outputType = outputType;
}
/**
* 构造函数:Message + 节点 + 智能体 + 状态 + Token 用量
*/
public StreamingOutput(Message message, String node, String agentName, OverAllState state, Usage usage) {
super(node, agentName, state);
this.message = message;
this.chunk = extractChunkFromMessage(message);
this.originData = null;
setTokenUsage(usage);
}
/**
* 构造函数:Message + 节点 + 智能体 + 状态 + Token 用量 + 输出类型
*/
public StreamingOutput(Message message, String node, String agentName, OverAllState state, Usage usage, OutputType outputType) {
super(node, agentName, state);
this.message = message;
this.chunk = extractChunkFromMessage(message);
this.originData = null;
this.outputType = outputType;
setTokenUsage(usage);
}
/**
* 构造函数:无 Message,仅节点 + 智能体 + 状态 + Token 用量
*/
public StreamingOutput(String node, String agentName, OverAllState state, Usage usage) {
super(node, agentName, state);
this.message = null;
this.chunk = null;
this.originData = null;
setTokenUsage(usage);
}
/**
* 构造函数:无 Message,节点 + 智能体 + 状态 + Token 用量 + 输出类型
*/
public StreamingOutput(String node, String agentName, OverAllState state, Usage usage, OutputType outputType) {
super(node, agentName, state);
this.message = null;
this.chunk = null;
this.originData = null;
this.outputType = outputType;
setTokenUsage(usage);
}
/**
* 已废弃构造函数:使用文本 chunk 创建流式输出
*/
@Deprecated
public StreamingOutput(String chunk, T originData, String node, String agentName, OverAllState state) {
super(node, agentName, state);
this.chunk = chunk;
this.message = null;
this.originData = originData;
trySetTokenUsage(originData);
}
/**
* 已废弃构造函数:仅使用文本 chunk 创建流式输出
*/
@Deprecated
public StreamingOutput(String chunk, String node, String agentName, OverAllState state) {
super(node, agentName, state);
this.chunk = chunk;
this.message = null;
this.originData = null;
}
/**
* 从 Message 中提取文本 chunk(仅非工具调用的 AssistantMessage)
*/
private static String extractChunkFromMessage(Message message) {
if (message instanceof AssistantMessage assistantMessage) {
if (!assistantMessage.hasToolCalls()) {
return assistantMessage.getText();
}
}
return null;
}
/**
* 尝试从原始数据中自动提取 Token 用量
*/
private void trySetTokenUsage(T originData) {
if (originData instanceof ChatResponse chatResponse) {
setTokenUsage(chatResponse.getMetadata().getUsage());
} else if (originData instanceof Usage usage) {
setTokenUsage(usage);
}
}
/**
* 已废弃:获取流式文本片段
*/
@Deprecated
public String chunk() {
return chunk;
}
/**
* 获取原始响应数据(序列化忽略)
*/
@JsonIgnore
public T getOriginData() {
return originData;
}
/**
* 获取流式消息对象(推荐使用)
*/
public Message message() {
return message;
}
/**
* 获取输出类型
*/
public OutputType getOutputType() {
return outputType;
}
/**
* 格式化输出日志信息
*/
@Override
public String toString() {
if (node() == null) {
return format("StreamingOutput{message=%s, chunk=%s}", message(), chunk());
}
return format("StreamingOutput{node=%s, agent=%s, message=%s, chunk=%s, tokenUsage=%s, state=%s, subGraph=%s}",
node(), agent(), message(), chunk(), tokenUsage(), state(), isSubGraph());
}
}
2.2.3 自定义输出:CustomOutput
用户可继承 NodeOutput 实现业务扩展字段,用于自定义节点:
java
CustomOutput extends NodeOutput {
Object customField; // 业务自定义字段
}
2.3 新版本说明 !!!
!!!上面相关说明整理自官方文档,在最新的 1.1.2.2 版本中,上述流式处理方式存在些许差异。
在 NodeExecutor 节点执行处理结果,统一流式输出逻辑时,先构建节点输出并自动添加检查点:
java
/**
* 构建节点输出并自动添加执行 checkpoint
* 核心作用:状态更新后,生成节点输出 + 持久化执行快照,用于普通同步节点执行
*
* @param updateStates 节点执行后的状态更新数据
* @return 统一的节点输出对象 NodeOutput
* @throws Exception 构建过程异常
*/
public NodeOutput buildNodeOutputAndAddCheckpoint(Map<String, Object> updateStates) throws Exception {
// 为当前节点执行流程添加 checkpoint 快照(用于断点续跑、日志回溯)
Optional<Checkpoint> cp = addCheckpoint(currentNodeId, nextNodeId);
// 委托统一的 buildOutput 方法构建输出(非流式模式)
return buildOutput(currentNodeId, updateStates, cp, false);
}
根据状态更新构建节点输出方法处理逻辑:
- 尝试提取大模型返回的
Message消息 - 生成对应的输出类型
- 构建并返回标准
StreamingOutput流式输出对象
java
/**
* 【核心方法】根据状态更新构建节点输出,支持流式/非流式两种场景
* 从状态中提取最新消息,封装成标准 StreamingOutput 流式输出对象,是所有节点输出的统一出口
*
* @param nodeId 当前执行的节点 ID
* @param updateStates 节点执行后产生的状态更新(包含 messages、上下文数据等)
* @param streaming 是否为流式输出(true=流式,false=非流式)
* @return 封装好的 StreamingOutput 节点输出
* @throws Exception 消息提取、对象构建异常
*/
public NodeOutput buildNodeOutput(String nodeId, Map<String, Object> updateStates, boolean streaming) throws Exception {
Message message = null;
// 1. 状态非空校验,开始从 updateStates 中提取最新的 AI Message
if (updateStates != null && !updateStates.isEmpty()) {
// 从状态中获取 messages 集合(可能是 List 或单个 Message 对象)
Object messagesObj = updateStates.get("messages");
// 情况1:messages 是 List 集合,取最后一条作为最新消息
if (messagesObj instanceof List<?> messagesList && !messagesList.isEmpty()) {
Object lastElement = messagesList.get(messagesList.size() - 1);
// 仅提取 Message 类型,过滤无效数据
if (lastElement instanceof Message) {
message = (Message) lastElement;
}
}
// 情况2:messages 是单个 Message 对象,直接赋值
else if (messagesObj instanceof Message singleMessage) {
message = singleMessage;
}
}
// 2. 根据是否流式 + 节点ID,生成对应的输出类型
OutputType outputType = OutputType.from(streaming, nodeId);
// 3. 构建并返回标准 StreamingOutput 流式输出对象
if (message != null) {
// 携带消息:包含AI回复文本/工具调用,用于前端流式渲染
return new StreamingOutput<>(message, nodeId, (String) config.metadata("_AGENT_").orElse(""),
cloneState(this.overallState.data()), tokenUsage, outputType);
} else {
// 不携带消息:仅节点状态、Token 信息,用于普通节点/无消息场景
return new StreamingOutput<>(nodeId, (String) config.metadata("_AGENT_").orElse(""),
cloneState(this.overallState.data()), tokenUsage, outputType);
}
}
2.3.1 OutputType 枚举类
OutputType 就是给每一条 StreamingOutput 打标签,是 AI 工作流流式输出的「事件类型标记」,用来告诉前端 / 上层:当前这条消息是谁发的 、处于什么状态(流式中 / 已完成)。
OutputType 枚举全量说明:
| 枚举值 | 类型说明 | 状态 | 适用场景 |
|---|---|---|---|
| AGENT_MODEL_STREAMING | Agent 大模型节点 | 流式输出中 | LLM 实时返回文本片段,前端流式打字机渲染 |
| AGENT_MODEL_FINISHED | Agent 大模型节点 | 执行完成 | LLM 输出完毕,生成完整 AssistantMessage |
| AGENT_TOOL_STREAMING | Agent 工具节点 | 流式输出中 | 工具执行中,支持流式返回结果 |
| AGENT_TOOL_FINISHED | Agent 工具节点 | 执行完成 | 工具调用完毕,生成工具执行结果 |
| AGENT_HOOK_STREAMING | Agent 钩子节点 | 流式输出中 | 自定义 Hook 逻辑执行,流式输出 |
| AGENT_HOOK_FINISHED | Agent 钩子节点 | 执行完成 | Hook 逻辑执行完毕 |
| GRAPH_NODE_STREAMING | 普通工作流节点 | 流式输出中 | 非 Agent 普通节点产生流式数据 |
| GRAPH_NODE_FINISHED | 普通工作流节点 | 执行完成 | 普通节点执行完毕 |
根据【是否流式】+【节点ID前缀】自动推导输出类型方法中,是直接根据节点 ID 判断输出类型:
java
/**
* 根据【是否流式】+【节点ID前缀】自动推导输出类型
* 自动判断是 模型/工具/Hook/普通节点,并返回 STREAMING / FINISHED
*
* @param streaming 是否流式输出(true=流式中,false=执行完成)
* @param nodeId 节点ID(根据前缀判断节点类型)
* @return 自动匹配后的 OutputType 枚举
*/
public static OutputType from(boolean streaming, String nodeId) {
// 1. AI 大模型节点(agent_model 开头)
if (nodeId.startsWith(AGENT_MODEL_NAME)) {
return streaming ? AGENT_MODEL_STREAMING : AGENT_MODEL_FINISHED;
}
// 2. 工具调用节点(agent_tool 开头)
else if (nodeId.startsWith(AGENT_TOOL_NAME)) {
return streaming ? AGENT_TOOL_STREAMING : AGENT_TOOL_FINISHED;
}
// 3. 自定义 Hook 节点(hook_ 开头)
else if (nodeId.startsWith(AGENT_HOOK_NAME_PREFIX)) {
return streaming ? AGENT_HOOK_STREAMING : AGENT_HOOK_FINISHED;
}
// 4. 其他 → 普通工作流节点
else {
return streaming ? GRAPH_NODE_STREAMING : GRAPH_NODE_FINISHED;
}
}
在 RunnableConfig 中定义的一些常量:
java
public final class RunnableConfig implements HasMetadata<RunnableConfig.Builder> {
public static final String AGENT_MODEL_NAME = "_AGENT_MODEL_";
public static final String AGENT_TOOL_NAME = "_AGENT_TOOL_";
public static final String AGENT_HOOK_NAME_PREFIX = "_AGENT_HOOK_";
public static final String AGENT_NAME_KEY = "_AGENT_";
public static final String HUMAN_FEEDBACK_METADATA_KEY = "HUMAN_FEEDBACK";
public static final String STATE_UPDATE_METADATA_KEY = "STATE_UPDATE";
public static final String DEFAULT_PARALLEL_EXECUTOR_KEY = "_DEFAULT_PARALLEL_EXECUTOR_";
public static final String DEFAULT_PARALLEL_AGGREGATION_STRATEGY_KEY = "_DEFAULT_PARALLEL_AGGREGATION_STRATEGY_";
按照这个处理逻辑,和官方文档的说法就对不上:
buildNodeOutputAndAddCheckpoint方法中streaming参数固定传的false- 在
Graph中节点ID都是我们自定义的,只有ReactAgent才是这些定义好的常量开头
buildNodeOutputAndAddCheckpoint 方法上的注释也提到,需要给终端用户提供一套【统一的流式输出方式】,未来要重构优化...
java
/**
* FIXME
* 下面这些是重复的方法。
* 需要给终端用户提供一套【统一的流式输出方式】。
*/
2.3.2 构建流式输出对象
在 buildNodeOutput 最后构建流式输出对象中,可以看到不管是流式还是非流式 都是返回 StreamingOutput ,只是如果状态里有 Message 对象,说明是 AI 节点,多返回了一个 message 字段,只能根据这个字段判断是大模型节点,然后执行流式输出了...
构建流式输出对象方法:
java
// 3. 构建并返回标准 StreamingOutput 流式输出对象
if (message != null) {
// 携带消息:包含AI回复文本/工具调用,用于前端流式渲染
return new StreamingOutput<>(message, nodeId, (String) config.metadata("_AGENT_").orElse(""),
cloneState(this.overallState.data()), tokenUsage, outputType);
} else {
// 不携带消息:仅节点状态、Token 信息,用于普通节点/无消息场景
return new StreamingOutput<>(nodeId, (String) config.metadata("_AGENT_").orElse(""),
cloneState(this.overallState.data()), tokenUsage, outputType);
}
2.4 并行流式输出
并行流式输出支持在图并行分支内通过 Flux 实现流式数据推送,多个并行节点可独立生成流式内容,并且完整保留各自节点标识,业务层可精准区分不同分支、不同节点的输出数据。
关键特性:
- 原生 Flux 支持 :节点直接返回
Flux即可开启流式能力,无需额外改造 - 节点 ID 持久保留:流式输出携带所属节点名称,轻松区分多分支数据来源
- 并行独立处理:多节点同时生产流数据,各自业务逻辑独立运行
- 输出数量可统计:支持统计单个并行节点流式消息总量,用于日志排查与运行监控
- 实时渐进输出:数据分段实时推送,实现进度展示、实时反馈等交互效果
适用场景:
- 并行批量数据拆分处理
- 多任务同时执行并实时反馈执行进度
- 多数据源并行拉取数据并统一聚合
- 多
AI推理节点并行生成内容、多路答案对比输出 - 大规模异步分片任务实时状态推送
最佳实践:
- 节点直接返回 Flux :并行业务节点直接定义返回值为
Flux<NodeOutput/StreamingOutput>,框架自动接入全局流。 - 规范节点 ID 命名:为不同并行分支设置语义化节点名称,便于日志筛选、前端区分渲染。
- 合理控制输出节奏 :使用
delayElements()控制流式推送频率,避免流量突增造成服务压力。 - 统一流式异常捕获 :在节点内部流中使用
doOnError、onErrorResume捕获分支内异常,防止单个分支异常导致整体流程中断。 - 增加流式进度统计:借助响应式操作符统计消息产出数量、执行耗时,完善线上监控能力。
- 线程观测调试:开发调试阶段打印当前执行线程信息,直观验证并行调度与线程隔离效果。
3. 入门案例
AI 文章审核工作流:
java
开始
↓
【关键词检查节点】普通节点
↓
【AI 流式审核节点】流式 LLM(你给的风格)
↓
【结论节点】接收流式结果,输出
↓
结束
3.1 关键词检查节点
接收 content 输入进行检查后返回 keywordPass (是否通过):
java
public class KeywordCheckNode implements NodeAction {
private static final List<String> FORBIDDEN = List.of("赌博", "色情", "暴力", "代开发票");
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
String content = (String) state.value("content").get();
boolean pass = FORBIDDEN.stream()
.noneMatch(word -> content.contains(word));
return Map.of(
"keywordPass", pass,
"node", "keywordCheck"
);
}
}
3.2 大模型流式审核节点
使用 ChatClient 进行大模型对话,在节点操作中直接返回 Flux 对象,框架会自动处理流式输出:
java
public class AiAuditStreamingNode implements NodeAction {
private final ChatClient chatClient;
public AiAuditStreamingNode(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
@Override
public Map<String, Object> apply(OverAllState state) {
// 1. 获取待审核内容
String content = (String) state.value("content").get();
// 2. 构造审核提示词
String prompt = """
你是专业内容安全审核员,请判断以下内容是否合规,并返回分析结果
内容:%s
""".formatted(content);
// 3. 获取流式 Flux<ChatResponse>
Flux<ChatResponse> flux = chatClient.prompt()
.user(prompt)
.stream()
.chatResponse();
// 4. 将流式响应存储在状态中
return Map.of("messages", chatResponseFlux);
}
}
3.3 结论节点
虽然上一个节点返回的是 Flux 对象,但是在引擎运行到当前节点时,框架已经完成了对上一个节点 Flux 对象的自动订阅与消费,并将最终的结果汇总后添加到了 messages 中(基于 AppendStrategy 替换策略):
java
public class FinalResultNode implements NodeAction {
@Override
public Map<String, Object> apply(OverAllState state) {
Object messages = state.value("messages").orElse("");
boolean keywordPass = (boolean) state.value("keywordPass").get();
String result;
if (!keywordPass) {
result = "审核不通过:包含违规关键词";
} else {
result = "AI 内容审核结果:" + messages;
}
// 与你参考代码风格完全一致:返回最终 result
return Map.of("result", result);
}
}
3.4 构建状态图
构建状态图:
java
@Bean
public CompiledGraph auditGraph(ChatModel zhiPuAiChatModel) throws GraphStateException {
// Spring AI 大模型对话客户端构建者
ChatClient.Builder builder = ChatClient.builder(zhiPuAiChatModel);
// 构建状态图
StateGraph graph = new StateGraph()
.addNode("keywordCheck", node_async(new KeywordCheckNode()))
.addNode("aiAudit", node_async(new AiAuditStreamingNode(builder)))
.addNode("process", node_async(new FinalResultNode()))
.addEdge(StateGraph.START, "keywordCheck")
.addEdge("keywordCheck", "aiAudit")
.addEdge("aiAudit", "process")
.addEdge("process", StateGraph.END);
// 检查点持久化
var memorySaver = new MemorySaver();
// 编译配置
var compileConfig = CompileConfig.builder()
.saverConfig(SaverConfig.builder()
.register(memorySaver)
.build())
.build();
return graph.compile(compileConfig);
}
3.5 访问接口
定义一个图引擎 SSE 流式响应数据传输对象,每一条流式事件均携带当前执行节点名称、可选消息/文本片段,以及该节点执行完成后的全局流转状态:
java
public class GraphRunResponse {
private String node;
private String agent;
private Message message;
private Usage tokenUsage;
private String content;
private Map<String, Object> state;
public GraphRunResponse(String node, String agent, Message message, Usage tokenUsage, String content, Map<String, Object> state) {
this.node = node;
this.agent = agent;
this.message = message;
this.tokenUsage = tokenUsage;
this.content = content;
this.state = state;
}
//...............
}
定义对话控制器,核心处理逻辑:
- 启动工作流 :封装入参内容至全局状态,调用Graph流式执行方法,获取全流程节点输出流
Flux<NodeOutput>。 - 过滤无效事件:过滤智能体模型执行完成标识事件,屏蔽框架内部回调消息,仅保留业务有效事件。
- 封装统一响应实体 :
- 提取当前执行节点标识、所属智能体名称、
Token消耗数据、全流程状态数据 - 区分
StreamingOutput流式输出与普通节点输出 - 识别
AI助手消息,区分纯文本内容与工具调用消息,按需组装返回文本片段
- 提取当前执行节点标识、所属智能体名称、
- 序列化构建SSE事件 :将统一响应实体转为
JSON字符串,封装为标准SSE数据事件向外推送。 - 全局异常兜底 : 捕获流程执行、数据序列化等所有异常,自定义
error类型事件推送错误信息。
java
@RestController
@RequestMapping("/api/audit")
public class AuditController {
// 原生日志(完全替代 @Slf4j)
private static final Logger log = LoggerFactory.getLogger(AuditController.class);
@Autowired
private CompiledGraph auditGraph;
private ObjectMapper objectMapper =new ObjectMapper();
/**
* 执行AI文章审核工作流,并以SSE流式方式返回前端
* @param content 待审核的文章内容
* @return 流式SSE响应
*/
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(@RequestParam String content) {
// 1. 初始化工作流状态,传入待审核文章内容
Flux<NodeOutput> graphStream = auditGraph.stream(Map.of("content", content));
// 2. 处理图执行的流式输出
return graphStream
// 过滤掉智能体模型执行完成的无用事件,避免前端重复渲染
.filter(nodeOutput -> !(nodeOutput instanceof StreamingOutput<?> so
&& so.getOutputType() == OutputType.AGENT_MODEL_FINISHED))
// 将节点输出转换为前端需要的GraphRunResponse格式
.map(nodeOutput -> {
// 获取当前执行节点ID
String node = nodeOutput.node();
// 获取执行的智能体名称
String agentName = nodeOutput.agent();
// 获取Token消耗统计
var tokenUsage = nodeOutput.tokenUsage();
// 获取当前全局状态数据(转为LinkedHashMap保证有序)
Map<String, Object> stateData = nodeOutput.state() != null
? new LinkedHashMap<>(nodeOutput.state().data()) : null;
GraphRunResponse graphResponse = null;
// 判断是否为流式输出节点(LLM流式返回)
if (nodeOutput instanceof StreamingOutput<?> streamingOutput) {
// 获取流式消息体
var message = streamingOutput.message();
// 消息为空时,返回基础结构
if (message == null) {
graphResponse = new GraphRunResponse(node, agentName, (Message) null, tokenUsage, "", stateData);
}
// 如果是助手消息(AI返回的消息)
else if (message instanceof AssistantMessage assistantMessage) {
// 包含工具调用,不返回文本内容
if (assistantMessage.hasToolCalls()) {
graphResponse = new GraphRunResponse(node, agentName, assistantMessage, tokenUsage, "", stateData);
}
// 纯文本流式消息,返回文本内容
else {
graphResponse = new GraphRunResponse(node, agentName, assistantMessage, tokenUsage,
assistantMessage.getText(), stateData);
}
}
// 其他类型消息
else {
graphResponse = new GraphRunResponse(node, agentName, message, tokenUsage, "", stateData);
}
}
// 非流式节点(普通节点)
else {
graphResponse = new GraphRunResponse(node, agentName, null, tokenUsage, "", stateData);
}
try {
// 将响应对象序列化为JSON
String json = objectMapper.writeValueAsString(graphResponse);
// 构建SSE事件返回前端
return ServerSentEvent.<String>builder().data(json).build();
} catch (Exception e) {
// 序列化异常处理
log.error("序列化失败", e);
return ServerSentEvent.<String>builder().data("{\"error\":\"序列化失败\"}").build();
}
})
// 全局异常捕获:工作流执行出错时返回错误事件
.onErrorResume(error -> {
log.error("流执行异常", error);
return Flux.just(ServerSentEvent.<String>builder()
.event("error") // 事件类型:error
.data("{\"error\":\"%s\",\"message\":\"%s\"}"
.formatted(error.getClass().getSimpleName(), error.getMessage()))
.build());
});
}
}
3.6 对话测试
访问:
java
http://localhost:8080/api/audit/stream?content=%E6%88%91%E6%98%AF%E4%B8%80%E4%B8%AA%E6%AD%A3%E5%B8%B8%E7%9A%84%E6%96%87%E6%A1%A3
可以看到 aiAudit 节点已经是流式响应:

以上我们就已经实现了简单的入门案例,返回了每个节点的执行信息,前端需要根据流式返回进行相应展示,在实际开发中,肯定复杂的多,下一篇会继续介绍