Solon v4.1.0

agent - 同步与流式响应(call 与 stream)

</> markdown
2026年9月7日 上午9:57:37

Solon AI Agent 同时提供同步、异步和流式三种结果路径。call() / callAsync() 直接返回完整响应;stream() 返回 Flux<AgentEvent>,用于展示正文增量、思考、工具执行、HITL 和团队协作过程。

1、调用方式

API返回值说明
call()SimpleResponse / ReActResponse / TeamResponse同步取得完整结果,不产生面向调用方的顶层 End 事件
callAsync()CompletableFuture<...>异步取得完整结果,内部委托 call()
stream()Flux<AgentEvent>观察运行过程;正常完成时由 Request 包装器追加顶层 End 事件
ReActResponse response = agent.prompt("北京今天天气怎么样?").call();
System.out.println(response.getContent());
agent.prompt("北京今天天气怎么样?").stream().subscribe(
        event -> handleEvent(event),
        error -> log.warn("Agent 流失败", error));

不同 Agent 的顶层边界如下:

Agent开始事件结束事件完整响应
SimpleAgentSimpleStartEventSimpleEndEventSimpleEndEvent.getResponse()
ReActAgentRunStartEventRunEndEventRunEndEvent.getResponse()
TeamAgentTeamStartEventTeamEndEventTeamEndEvent.getResponse()

ReasonEndEvent、ActionEndEvent、ToolCallEndEvent 和 NodeEndEvent 只是局部阶段结束,不能作为整个请求的终态。

2、AgentEvent 公共接口

所有事件实现 AgentEvent。消费事件时应按具体事件类使用 instanceof 或 Reactor 的 ofType(...)。

方法描述
getRunId()当前逻辑运行标识
getAgentName()事件实际产生者
getSession()当前 AgentSession
getMeta() / hasMeta(name)事件元数据
hasText()是否包含非空文本
getText()事件的文本投影;无文本事件默认返回空字符串

Agent 层没有统一的 AgentEventType 或事件分组。需要精确识别正文、思考和媒体时,应读取 Delta 事件包装的 ChatEvent。

3、正文与思考增量

SimpleDeltaEvent、ReasonDeltaEvent 和 SupervisorDeltaEvent 分别包装 Simple、ReAct Reason 和 Team Supervisor 的模型输出。

agent.prompt(query).stream()
        .ofType(ReasonDeltaEvent.class)
        .subscribe(event -> {
            ChatEvent chatEvent = event.getChatEvent();
            if (chatEvent == null) {
                return;
            }

            if (chatEvent.is(ChatEventType.TEXT_DELTA) && event.hasText()) {
                ui.appendText(event.getText());
            } else if (chatEvent.is(ChatEventType.THINKING_DELTA) && event.hasText()) {
                ui.appendThinking(event.getText());
            } else if (chatEvent.is(ChatEventType.MEDIA_DONE)) {
                ui.appendMedia(chatEvent.getBlock());
            }
        });

内置 Agent 当前只将以下 Chat 事件包装为 Delta:

TEXT_START / TEXT_DELTA / TEXT_END
THINKING_START / THINKING_DELTA / THINKING_END
MEDIA_DONE

边界事件通常没有文本。不要把所有 ReasonDeltaEvent 都当作正文,也不要把 Agent Delta 当成完整的底层 Chat 协议回放。

4、ReAct 事件与工具执行

典型时序:

RunStartEvent
  -> [ContextSizeEvent]
  -> ReasonStartEvent
  -> ReasonDeltaEvent*
  -> ReasonEndEvent
  -> [ActionStartEvent
       -> ToolCallStartEvent
       -> ToolCallEndEvent
       -> ActionEndEvent]
  -> ...下一轮 Reason
  -> RunEndEvent
  -> onComplete
agent.prompt(query).stream().subscribe(
        event -> {
            if (event instanceof ReasonDeltaEvent) {
                ReasonDeltaEvent delta = (ReasonDeltaEvent) event;
                if (delta.hasText()) {
                    if (delta.isThinking()) {
                        ui.appendThinking(delta.getText());
                    } else {
                        ui.appendText(delta.getText());
                    }
                }
            } else if (event instanceof ToolCallStartEvent) {
                ToolCallStartEvent start = (ToolCallStartEvent) event;
                ui.toolStarted(start.getCallId(), start.getToolName(), start.getArgs());
            } else if (event instanceof ToolCallEndEvent) {
                ToolCallEndEvent end = (ToolCallEndEvent) event;
                ui.toolFinished(end.getCallId(), end.getText(), end.getError());
            } else if (event instanceof RunEndEvent) {
                RunEndEvent end = (RunEndEvent) event;
                ui.finish(end.getResponse(), end.isAbnormal());
            }
        },
        error -> ui.fail(error));

Agent 层当前没有工具参数增量事件。模型响应中的 TOOL_CALL_ARGS_DELTA 属于 Chat 层;Agent 的 ToolCallStartEvent / ToolCallEndEvent 表示本地工具调用的处理边界。

普通工具参数错误或执行异常通常会被转换为 observation 文本,此时 ToolCallEndEvent.getError() 仍可能为 null。审计端应综合检查 getText()、getError()、RunEndEvent.isAbnormal() 和 Reactor onError。

5、计划、上下文和 HITL

规划模式使用 PlanEvent:

  • getPlanStage():CREATE、PROGRESS、REVISE;
  • getPlans():当前计划列表;
  • getPlanIndex()、getReasonId():计划进度与 Reason 关联。

ContextSizeEvent 由上下文压缩拦截器在 Reason 开始前产生,并非每个流都有。主要读取 getContextLength()、getMessageCount()、getTokenCount()、isCompressed() 以及压缩前后统计。Token 数是估算值,不是供应商 Usage。

首次 HITL 挂起的典型时序是:

ReasonEndEvent
  -> HITLPendingEvent
  -> 不产生 ActionStartEvent / ToolCallStartEvent / ToolCallEndEvent / ActionEndEvent
  -> RunEndEvent(当前实现 isAbnormal=true,Session 仍为 pending)
  -> onComplete

挂起不是 Reactor 错误。UI 应结合 HITLPendingEvent 或 session.isPending() 判断“等待审批”,不要仅凭 isAbnormal() 将它显示为不可恢复故障。

使用同一 Session 和空 Prompt 恢复后,通常会出现:

RunStartEvent(复用原 runId)
  -> HITLDecidedEvent+
  -> ActionStartEvent
  -> ToolCallStartEvent
  -> ToolCallEndEvent
  -> ActionEndEvent
  -> ...
  -> RunEndEvent

单个敏感调用被拒绝时可能直接结束,因此收到 HITLDecidedEvent 后不能强制期待 Action 或工具事件。

6、TeamAgent 事件

TeamStartEvent(父 Team runId)
  -> SupervisorDeltaEvent*
  -> NodeStartEvent(父 Team runId,携带 Node)
       -> 成员 Agent 的 Start、Reason、Action、Tool 等过程事件*(成员 runId)
       -> 不产生成员自己的顶层 End 事件
  -> NodeEndEvent(父 Team runId,携带 Node)
  -> ...
  -> TeamEndEvent(父 Team runId)
  -> onComplete

Team Flow 直接调用成员的 call(...),不会经过成员 Request 的 stream() 包装器。因此成员可能产生开始和过程事件,但不会产生自己的 SimpleEndEvent、RunEndEvent 或 TeamEndEvent。成员边界使用父 Team 的 NodeStartEvent / NodeEndEvent,团队最终结果从 TeamEndEvent 读取。

并行节点下,多个节点及成员事件可能交错。成员过程事件不携带父 Node 或 parentRunId,不能用“最近收到的 NodeStartEvent”推断归属;需要严格审计时,应在外围上下文或传输 DTO 中补充关联标识。

7、取得完整终态响应

ReActResponse response = agent.prompt(query).stream()
        .ofType(RunEndEvent.class)
        .map(RunEndEvent::getResponse)
        .blockFirst();

异步代码可以保留为 Mono:

Mono<ReActResponse> response = agent.prompt(query).stream()
        .ofType(RunEndEvent.class)
        .map(RunEndEvent::getResponse)
        .next();

不要对未过滤的事件流直接调用 blockFirst(),否则得到的是开始事件。主动取消或流以 onError 结束时不会补发顶层 End 事件。

8、失败与取消

Agent 当前没有统一的 ErrorEvent 或 CancelEvent:

  • 未处理异常通过 Reactor onError 结束,不补发顶层 End 事件;
  • 部分 ReAct 故障会转换为 RunEndEvent(isAbnormal=true) 后正常完成;
  • 主动取消属于 Reactor cancel,不是 onError,也不会补发 End 事件;
  • doOnError 只观察错误;需要恢复时使用 onErrorResume,或在 subscribe 中提供错误消费者。

事件、Session 和 Trace 都是运行态对象。通过 SSE、WebSocket 或消息队列传输时,应投影为自己的稳定 DTO,不要直接序列化整个事件对象。