Spring AI Alibaba 1.x 系列【68】Graph SSE 流式输出

文章目录

  • [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() 控制流式推送频率,避免流量突增造成服务压力。
  • 统一流式异常捕获 :在节点内部流中使用 doOnErroronErrorResume 捕获分支内异常,防止单个分支异常导致整体流程中断。
  • 增加流式进度统计:借助响应式操作符统计消息产出数量、执行耗时,完善线上监控能力。
  • 线程观测调试:开发调试阶段打印当前执行线程信息,直观验证并行调度与线程隔离效果。

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;
    }
	//...............
}

定义对话控制器,核心处理逻辑:

  1. 启动工作流 :封装入参内容至全局状态,调用Graph流式执行方法,获取全流程节点输出流Flux<NodeOutput>
  2. 过滤无效事件:过滤智能体模型执行完成标识事件,屏蔽框架内部回调消息,仅保留业务有效事件。
  3. 封装统一响应实体
    • 提取当前执行节点标识、所属智能体名称、Token 消耗数据、全流程状态数据
    • 区分StreamingOutput流式输出与普通节点输出
    • 识别 AI 助手消息,区分纯文本内容与工具调用消息,按需组装返回文本片段
  4. 序列化构建SSE事件 :将统一响应实体转为 JSON 字符串,封装为标准 SSE 数据事件向外推送。
  5. 全局异常兜底 : 捕获流程执行、数据序列化等所有异常,自定义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 节点已经是流式响应:

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

相关推荐
硅谷秋水1 小时前
τ0-WM:用于机器人操纵的统一视频-动作世界模型
人工智能·机器学习·计算机视觉·语言模型·机器人·音视频
吃好睡好便好1 小时前
矩阵的求逆运算
人工智能·学习·线性代数·matlab·矩阵
:1211 小时前
Java泛型
java·开发语言
愿天垂怜1 小时前
【C++脚手架】etcd 的介绍与使用
java·linux·服务器·c语言·c++·中间件·etcd
_Oracle1 小时前
机器学习——常见算法
人工智能·算法·机器学习
飞翔中文网1 小时前
Java学习笔记之泛型
java·笔记·学习
Komorebi_99991 小时前
Day3:监控、日志、限流、成本管控、版本灰度
大数据·运维·人工智能·大模型
ITyunwei09871 小时前
运维团队如何抓住AI?
大数据·运维·人工智能