AgentScope第7式·听风事件

掌握 Msg、Role、ContentBlock 与 AgentEvent 的职责

一次推理不是一句话,是一串允许被看见的事实。

[读者] 用 Java 做数字人 / AI Agent 的工程师
[痛点] 页面等三秒才蹦出一整段回复,工具调用全程黑盒
[现在读] 第 6 式已经跑通阻塞式 call(),流式是绕不过去的下一步
[读完] 分得清 Msg 与 AgentEvent,能把公开事件做成有序的 SSE 流
[旧方案] call() 等最终 Msg,前端一次性渲染
    |
    v
[新需求] 数字人要说一句显一句,工具调用得让用户看见
    |
    v
[冲突] 最终 Msg 里没有过程,把 Msg 切块又丢掉了工具语义
    |
    v
[后果] 要么做假打字机骗用户,要么把思维链原样推给用户

我是老李,带一个四五人的小团队在做企业数字人。第 3 式冻结的那个最小交互数字人 Demo,现在已经长到第 6 式:Spring Boot 管项目管理和运行页,MySQL 存项目与 Agent 配置,运行页通过 LiveKit 建实时房间,Realtime Bridge 把消息递给 AgentScope Java 的 ReActAgent,ReActAgent 再往下接 LLM。LiveKit 只管实时传输,推理、工具、状态全在 AgentScope Java 这一侧,这条边界从第 3 式定下来就没动过。

第 6 式跑通之后,运行页上的数字人是这样的:用户说一句话,页面转三秒圈,然后整段回复“啪”地一次出现。功能是对的,演示是难看的。客户第一次看会以为页面卡死了,我们自己盯着那三秒也心虚。

更难受的是工具。我们给 Agent 挂了查项目配置之类的工具,调用在页面上没有任何痕迹,只存在于后端日志。用户问一句“你怎么知道的”,我们只能当场翻日志念给他听。

第一个问题:能不能让过程被看见?

第二个问题:是把最终 Msg 切成小块推给前端,还是换一条叫 streamEvents() 的出口?

第三个问题:如果这两条路都走歪了,代价是什么?

01、故事

演示前一天下午,会议室里最后一轮彩排。脚本很简单:打开 /run/{projectId},等数字人说完开场白,然后问一句“这个项目的主题色配的是什么”。

前半段很顺。开场白准时出现,右侧的数字人形象转着圈。用户问完那句话,页面开始转圈——一秒、两秒、三秒——然后一行字整段蹦出来:“配的是 #1F6FEB,深蓝色的那一种。”

坐我旁边的同事小声说了一句:刚才那三秒,用户会以为死机了。

脚本里还埋了一个彩蛋:Agent 会调一个 getProjectConfig 工具去查主题色。这个调用在页面上没有痕迹,只在日志里。客户如果追问“它是真查了还是一本正经胡说八道”,我们没法当场证明。

任务是:在不改 LiveKit 链路、不换 Demo 场景的前提下,让运行页从“等整段”变成“说一句显一句”,并且让工具调用在用户面前露一次脸。

回到工位,我干了两件事。一件是把 call() 换成 streamEvents(),另一件是把最终文本按二十个字一段定时推给前端。第一件事换来一屏事件日志,第二件事做出来一个挺像样的假打字机。

两件事都只对了一半。

一分等三秒
一语落成章
过程藏何处
无人知去向

02、问题

旧方案并不算错。第 6 式里,运行接口是这样的:agent.call(userMsg) 拿到 Mono,block 住,取文本,塞进 HTTP 响应体。业务上完全成立,代码不到二十行。

它有三个层面同时失效。

业务层面:三秒空窗期。用户不知道它是在思考还是挂了,也不知道它到底做了没有。

技术层面:一次完整推理的所有中间状态,都被折叠进了一个最终 Msg。工具被调用、工具返回了什么、模型分了几段输出,全都消失在折叠过程里。这个折叠不是缺陷,是 Msg 这个类型的职责决定的——它描述的是一次完整会话语义,不是一段执行过程。

可观测层面:出了问题只能翻日志。页面上什么都看不到,客户面前也解释不了。

我们的完成标准很具体,可以逐条验证:

  • 用户提交后,页面上立刻出现“开始”信号,不再是三秒纯空白;
  • 工具被调用时,出现一条工具调用事件;工具返回时,出现一条工具结果事件;
  • 最终文本以增量形式连续出现,而不是整段蹦出;
  • 任何模型内部推理文本都不允许出现在用户屏幕上;
  • 事件顺序必须稳定为:开始 → 若干文本/工具事件 → 结束。

对应的工程动作也定死了:从第 6 式的分支切出 chapter/07-message-event,提交信息用 feat(ch07): stream messages and public agent events。

旧法求整答
新需见微光
语义若错位
越流越荒唐

03、原理

这一章只需要四个概念,但每个概念都得站对位置,否则后面的架构、排查、优化全会歪。

Msg 是一次完整会话语义。它回答的是“谁,以什么角色,说了什么”。官方文档 building-blocks/message-and-event 里把这条线讲得很清楚:Msg 描述会话,不描述过程。

Role 是这条消息在会话里的身份,不是 UI 上显示的名字。它是喂给模型的角色约束。用户消息、助手消息、系统消息、工具结果,各有各的角色归属。把 Role 当成“昵称字段”来用,是新手最常见的误解之一。

ContentBlock 是 Msg 的内容单元。Msg 的内容不是 String,而是一组块:文本是一块,工具调用是一块,工具结果是一块,模型内部推理也是一块。这一点很关键——它意味着 Msg 天生能表达“我调用过工具并拿到了结果”,只是这件事的粒度是一轮之后,而不是一轮之中。

AgentEvent 是执行过程中的增量事实。模型吐了一个字是事件,决定调工具是事件,工具返回是事件,整轮开始和结束也是事件。事件不负责“完整会话”,它只负责“此刻发生了什么”。

反直觉判断一:把最终 Msg 切成小块推给前端,不是流式,是事后分片。它丢了三样东西——时序(前端不知道谁先谁后)、语义(工具调用不再是一个独立的、可渲染的对象)、状态(没有开始和结束,UI 只能靠回调猜)。前端拿到的仍然是一个已经结束的结果,只是被拉长了播放时间。

反直觉判断二:call() 不是 streamEvents() 的简化版。两者共享同一个 ReAct 循环,但出口的语义完全不同:call() 对“结果”负责,它承诺给你唯一一个最终 Msg;streamEvents() 对“过程”负责,它承诺给你一串按序发生的事件。想要过程却用 call(),只能靠猜。

再往下就是 Reactor 的类型语义。Mono 表示 0 到 1 个元素的异步序列,Flux 表示 0 到 N 个。call() 返回 Mono,类型本身就表达了“这次只会有一个 Msg”;streamEvents() 返回 Flux,类型本身也表达了“这次会有 N 个事件陆续到来”。用 Mono 装过程,或者用 Flux 装唯一结果,都是类型层面的语义错误,编译器不会拦你,但后面一定别扭。

还有一件事必须提前讲:思维链。ContentBlock 家族里除了公开文本块,还有承载模型内部推理的块类型。它存在于 Msg 里,也会以事件形式出现在流里。框架没有义务替你判断“这块能不能给用户看”——这是宿主的责任。

所以本章真正的技术决策只有一句话:公开事件必须走白名单,默认拒绝,而不是默认放行后过滤。

这句话看着朴素,但它决定了后面所有代码的形状。

文是完整意
事为增量痕
二者若混用
流亦不成文

04、架构

[输入] 用户一句话 + projectId
    |
    v
[模块] RunStreamController(Spring WebFlux,text/event-stream)
    |
    v
[数据/状态] Msg(role=USER, blocks=[TextBlock]) + AgentFactory 里取出的 ReActAgent
    |
    v
[处理] agent.streamEvents(msg) -> Flux<AgentEvent> -> PublicEventMapper 白名单映射
    |
    v
[输出] SSE 事件流:agent.start / text.delta / tool.call / tool.result / agent.end

边界:白名单在宿主侧,也就是 Controller / Bridge 这一层,不在 Agent 侧。Agent 该产出什么就产出什么,公开哪些由宿主决定。

收益:SSE 和 LiveKit DataChannel 可以复用同一条事件流;Agent 侧零改动,后续加 Memory、Skill、Subagent 时,事件类型扩展只在白名单里加分支。

代价:多一层映射,多一份维护成本。白名单写窄了会丢事件,写宽了会漏事件,两头都是线上问题。

适用条件:交互式数字人、需要过程可见、需要向用户解释“它做了什么”的场景。

不适用条件:后台批处理任务、只需最终结果的系统间 API、连接数预算极紧的高并发接口。

涉及框架行为的地方,落点要具体:Msg 及其构造方式看 agentscope-core 的 io.agentscope.core.message.Msg(v2.0.3 路径为 agentscope-core/src/main/java/io/agentscope/core/message/Msg.java);事件家族看同版本的 io.agentscope.core.event.AgentEvent(路径 agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java);概念边界看官方文档 docs/v2/zh/docs/building-blocks/message-and-event.md。

版本基线说明:本文实验以 v2.0.3 为基线。写作时如果官方 Releases 已经出现比 v2.0.3 更新的正式版,本文的实验仍可在 v2.0.3 上复现;涉及“最新版是否仍然如此”的判断,请以对应 Release 和该 tag 下的源码为准,不要把 v2.0.3 的限制直接写成当前最新版的永久限制。

一流入白名
六出各有形
边界不在器
在人之所守

05、实战一次

环境:JDK 17 起,Spring Boot 3 的 WebFlux,Reactor,AgentScope Java 基线 v2.0.3。

依赖坐标以官方 Release 页面给出的为准:

<!-- 坐标以 https://github.com/agentscope-ai/agentscope-java/releases/tag/v2.0.3 给出的为准 -->
<dependency>
    <groupId>io.agentscope</groupId>
    <artifactId>agentscope-core</artifactId>
    <version>2.0.3</version>
</dependency>

第一步,定义公开事件。它是一个小小的 record,和框架类型完全解耦:

package com.example.digitalhuman.stream;

public record PublicAgentEvent(String type, String data) {
}

第二步,写白名单映射。注意结构:先列出允许的类型,其余一律拒绝,不要反过来写“过滤掉思维链”。

package com.example.digitalhuman.stream;

import io.agentscope.core.event.AgentEvent;
import io.agentscope.core.event.AgentStartEvent;
import io.agentscope.core.event.AgentEndEvent;
import io.agentscope.core.event.TextBlockDeltaEvent;
import io.agentscope.core.event.ToolCallStartEvent;
import io.agentscope.core.event.ToolResultEvent;

import java.util.Optional;

public final class PublicEventMapper {

    private PublicEventMapper() {
    }

    public static Optional<PublicAgentEvent> map(AgentEvent event) {
        if (event instanceof AgentStartEvent) {
            return Optional.of(new PublicAgentEvent("agent.start", ""));
        }
        if (event instanceof TextBlockDeltaEvent delta) {
            // 两层判断:事件类型对了,还要确认承载的是允许公开的文本块
            if (!isPublicText(delta)) {
                return Optional.empty();
            }
            return Optional.of(new PublicAgentEvent("text.delta", text(delta)));
        }
        if (event instanceof ToolCallStartEvent call) {
            return Optional.of(new PublicAgentEvent("tool.call", name(call)));
        }
        if (event instanceof ToolResultEvent result) {
            return Optional.of(new PublicAgentEvent("tool.result", text(result)));
        }
        if (event instanceof AgentEndEvent) {
            return Optional.of(new PublicAgentEvent("agent.end", ""));
        }
        return Optional.empty();
    }

    private static boolean isPublicText(TextBlockDeltaEvent delta) {
        // 具体访问器以 v2.0.3 的 AgentEvent 子类定义为准
        return delta.isTextBlock();
    }

    private static String text(Object event) {
        return String.valueOf(event);
    }

    private static String name(Object event) {
        return String.valueOf(event);
    }
}

这里必须诚实说一句:上面用到的 isTextBlock()、取值访问器这些方法名,以 v2.0.3 的 AgentEvent 子类定义为准,落地前请打开 AgentEvent.java 对照一次;教程仓库 chapter/07-message-event 分支里用的是同一份签名。本文未在当前环境实测,下面的输出是按代码逻辑推演的示例输出,不是已验证结果。

第三步,SSE 端点:

package com.example.digitalhuman.web;

import com.example.digitalhuman.stream.PublicAgentEvent;
import com.example.digitalhuman.stream.PublicEventMapper;
import io.agentscope.core.agent.ReActAgent;
import io.agentscope.core.message.Msg;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;

import reactor.core.publisher.Flux;

import java.util.Optional;

@RestController
@RequestMapping("/api/run")
public class RunStreamController {

    private final AgentFactory agentFactory;

    public RunStreamController(AgentFactory agentFactory) {
        this.agentFactory = agentFactory;
    }

    @GetMapping(value = "/{projectId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<PublicAgentEvent>> stream(@PathVariable Long projectId,
                                                          @RequestParam String input) {
        ReActAgent agent = agentFactory.get(projectId);

        Msg userMsg = Msg.builder()
                .name("user")
                .role(Msg.Role.USER)
                .textContent(input)
                .build();

        return agent.streamEvents(userMsg)
                .map(PublicEventMapper::map)
                .filter(Optional::isPresent)
                .map(Optional::get)
                .map(e -> ServerSentEvent.builder(e).event(e.type()).build())
                .onErrorResume(err -> Flux.just(
                        ServerSentEvent.builder(new PublicAgentEvent("agent.error", "推理失败"))
                                .event("agent.error")
                                .build()));
    }
}

Msg 的构造方式(builder 还是静态工厂、textContent 还是 content)同样以 v2.0.3 的 Msg.java 为准;这里写出的是教程仓库里那一版的写法。AgentFactory 是第 5、6 式里已经建好的东西,本章不重复造。

第四步,用 CLI 验证事件顺序:

curl -N "http://localhost:8080/api/run/1/stream?input=%E4%BD%A0%E5%A5%BD"

示例输出(按代码逻辑推演,非实测结果):

event: agent.start
data: {"type":"agent.start","data":""}

event: tool.call
data: {"type":"tool.call","data":"getProjectConfig"}

event: tool.result
data: {"type":"tool.result","data":"themeColor=#1F6FEB"}

event: text.delta
data: {"type":"text.delta","data":"这个项目"}

event: text.delta
data: {"type":"text.delta","data":"的主题色是"}

event: agent.end
data: {"type":"agent.end","data":""}

第五步,前端消费。EventSource 按事件名分发,简单直接:

const es = new EventSource(`/api/run/${projectId}/stream?input=${encodeURIComponent(text)}`);
es.addEventListener('agent.start', () => showThinking());
es.addEventListener('text.delta', e => appendText(JSON.parse(e.data).data));
es.addEventListener('tool.call', e => showToolCall(JSON.parse(e.data).data));
es.addEventListener('tool.result', e => showToolResult(JSON.parse(e.data).data));
es.addEventListener('agent.end', () => es.close());
es.addEventListener('agent.error', () => showError());

验收点就落在这一屏上:开始、工具调用、工具结果、最终回复按序出现,中间没有任何推理文本。

先跑一条链
再论三分层
能见与可见
中间隔一关

06、排查

接上去之后踩了三个坑,分别对应“事件少了”“事件重了”“事件多了”。三条都是宿主侧的问题,没有一条是框架的锅。

证据链一:事件少了。

现象:换成 streamEvents() 之后,前端只出现 text.delta,工具事件一条都没有。

错误尝试: 我第一反应是框架的问题,甚至打开官方 Issue 列表准备提一个“streamEvents 不产出工具事件”。这个判断错在把“我没看到”直接等价成了“框架没产出”,跳过了中间唯一能证伪的一步——打日志。

检查:在 Flux 上加一次 tap,把事件类型全打出来。

return agent.streamEvents(userMsg)
        .doOnNext(e -> log.info("EVENT {}", e.getClass().getSimpleName()))
        .map(PublicEventMapper::map)
        .filter(Optional::isPresent)
        .map(Optional::get)
        .map(e -> ServerSentEvent.builder(e).event(e.type()).build());

证据:日志里 tool.call、tool.result 一条不少,全部到达了消费侧。

根因:事件根本没丢,是白名单漏了工具分支,map 返回 Optional.empty(),被后面的 filter 干掉了。事件一直在门外,是我没开门。

修复:逐个检查白名单分支,补齐工具调用和工具结果。

证据链二:事件重了。

现象:补齐白名单后,前端每句文本出现两遍,agent.start 也出现两次。

怀疑:SSE 自动重连。

检查:先数推理次数,而不是数页面上的重复。在模型调用处加一行计数日志。

证据:一次用户提交,模型被完整调用了两次。

根因:Flux 是冷的。agent.streamEvents(...) 每被订阅一次,就完整跑一次推理。我在调试时加过一句 flux.subscribe(e -> log.debug(...)) 忘了删,Spring 拿到返回值又订阅了一次——两条订阅线各自跑了一遍完整推理。

修复:删掉那条调试订阅。如果确实需要两个消费端(比如 SSE 和 LiveKit DataChannel 同时要),改成单次订阅 + Sinks.Many 多播,而不是让两个订阅者各跑一遍。

证据链三:事件多了。

现象:去掉重复订阅后,文本流里混进了一段“我需要先查一下这个项目的配置”之类的话。

怀疑:模型不听话,把推理写进了正式回复。

检查:打印每个 text.delta 的来源块类型,而不是只看文本内容。

证据:这段话来自内部推理块,不是正式回复的文本块。它顺着文本增量事件漏了出去。

根因:白名单只按“事件类型”放行,没有按“承载的内容块类型”二次判断。事件类型对了,块类型不对,照样泄露。

修复:白名单改成两层判断——先判事件类型,再判内容块类型,只放行允许公开的那一类。

三条证据链合起来看,问题都不在上游框架。这里补一条上游贡献判断,免得后面有人重复踩:

  • 是否可在最小 Demo 复现:现象一、现象二可以在最小 Demo 稳定复现,属于消费侧写法问题;现象三依赖具体模型是否产出推理块,复现不稳定。
  • v2.0.3 是否存在:三条都不是 v2.0.3 的行为缺陷。
  • main 是否仍存在:同上,属于宿主侧设计,不随版本变化。
  • 是否已有 Issue/PR:截至本章写作时,未检索到与这三条对应的官方 Issue 或 PR。
  • 结论:只能记录为设计注意点,不构成上游处理对象。

疑从心头起
证到手上来
错路记一次
根因自分明

07、优化

根因 → 修改 → 新行为,一条一条来。

修改一:白名单集中,两层判断。

public final class PublicEventMapper {

    public static Optional<PublicAgentEvent> map(AgentEvent event) {
        if (event instanceof AgentStartEvent) {
            return Optional.of(new PublicAgentEvent("agent.start", ""));
        }
        if (event instanceof AgentEndEvent) {
            return Optional.of(new PublicAgentEvent("agent.end", ""));
        }
        if (event instanceof TextBlockDeltaEvent delta) {
            if (!isPublicText(delta)) {
                return Optional.empty();
            }
            return Optional.of(new PublicAgentEvent("text.delta", text(delta)));
        }
        if (event instanceof ToolCallStartEvent call) {
            return Optional.of(new PublicAgentEvent("tool.call", name(call)));
        }
        if (event instanceof ToolResultEvent result) {
            return Optional.of(new PublicAgentEvent("tool.result", text(result)));
        }
        return Optional.empty();
    }
}

新行为:白名单只有一处,新增事件类型只需加一个分支;思维链默认没有出口。原因是把“允许”变成显式枚举,而不是“禁止”的补集——补集会随着框架新增事件类型自动变大,显式枚举不会。

修改二:从冷流多订阅改成单次订阅多播。

Sinks.Many<PublicAgentEvent> sink = Sinks.many().multicast().onBackpressureBuffer();

agent.streamEvents(userMsg)
        .map(PublicEventMapper::map)
        .filter(Optional::isPresent)
        .map(Optional::get)
        .subscribe(sink::tryEmitNext,
                   error -> sink.tryEmitError(error),
                   () -> sink.tryEmitComplete());

return sink.asFlux().map(e -> ServerSentEvent.builder(e).event(e.type()).build());

新行为:推理只跑一次,SSE 和 LiveKit DataChannel 从同一个 sink 取事件。代价是引入了 Sinks 的生命周期管理,连接提前关闭时要记得取消上游订阅。

修改三:错误兜底。onErrorResume 把异常映射成一条 agent.error 事件,前端按事件名处理,不再靠连接断开猜。

验证方法:用 StepVerifier 对映射后的序列做顺序断言,把“开始 → 工具调用 → 工具结果 → 文本 → 结束”这条顺序锁进测试里。下面这段是测试骨架,不是实测结果:

StepVerifier.create(PublicEventMapper.mapAll(events))
        .expectNextMatches(e -> e.type().equals("agent.start"))
        .expectNextMatches(e -> e.type().equals("tool.call"))
        .expectNextMatches(e -> e.type().equals("tool.result"))
        .expectNextMatches(e -> e.type().equals("text.delta"))
        .expectNextMatches(e -> e.type().equals("agent.end"))
        .verifyComplete();

mapAll 是教程里对 map + filter + 取值那三步做的一个小封装,只为了测试里读起来干净。

白名分内外
单订定身心
背压须留位
断线亦有凭

08、演进

[同一次用户输入]
    |
    +--[V1] call() + block:只拿到最终 Msg / 前端假打字机 / 过程黑盒
    |
    +--[V2] streamEvents() + 公开白名单:Flux<AgentEvent> / 有序公开事件 / 过程可见
    |
[Trade-off]
得到:过程可见、工具可解释、SSE 与 LiveKit 复用同一条事件流
失去:请求生命周期变长、长连接开销、白名单维护成本、仍有推理块泄露风险

正确性:V1 只有最终答案,没有过程可言;V2 能把“开始 → 工具调用 → 工具结果 → 最终文本 → 结束”这条链完整呈现,可验证的触点从 1 个变成 5 个。

稳定性:V1 的失败模式只有一种——接口报错或超时;V2 多了长连接断开、背压堆积、多订阅重复推理这几种。稳定性并没有自动变好,只是把问题从“看不见”变成“看得见”。

复杂度:V1 一个方法十行;V2 多了一个映射类、一个 Sinks 生命周期、一份白名单维护责任。

成本:SSE 每条活跃会话占用一个连接,WebFlux 扛得住,但要提前算连接预算。LiveKit 那一路是另一个出口,两个出口共用一条事件流,成本可控。

适用范围:交互式、需要解释过程的数字人场景。

遗留问题:白名单是“事后过滤”,不是“事前约束”。模型仍然可能把不该说的话放进一个合法的公开文本块里。框架管不了这件事,只能靠系统提示词和行为评测去约束。这一条会一直留在我们的检查清单上。

一路两出口
取舍各成章
得见失亦见
边界自生光

09、洞见

9.1 Msg 是给模型看的,AgentEvent 是给宿主看的

反直觉判断:把 AgentEvent 当成“Msg 的碎片”,你会下意识想把它们重新拼回一个 Msg,而在拼回去的那一刻,工具调用、时序、开始结束这些信息全丢了。Event 只有被当成独立的一等公民,才有价值。

Msg 的读者是模型。它要的是干净的会话历史:谁说了什么、调了什么工具、拿到了什么结果。AgentEvent 的读者是宿主程序。它要的是“此刻正在发生什么”。两者的消费者不同,生命周期不同,表达粒度也不同。混用它们,等于让一个类型同时服务两个目标,最后两个都不称职。

9.2 反直觉判断:流式的难点不在分片,在分级

把一段文本切成二十个字的块,是十行代码的事。真正难的是决定哪些块、哪些事件可以出门。

白名单要写窄,还是写宽?写窄了过程看不见,写宽了思维链泄露。这个判断没有框架能替你拍板,它取决于你的产品承诺——你到底承诺给用户看多少。这也是为什么本章把白名单放在宿主侧,而不是 Agent 侧。

9.3 反直觉判断:call() 不是 streamEvents() 的语法糖

它们的返回类型就暴露了语义差异。Mono 承诺“这次只会有一个结果”,Flux 承诺“这次会有 N 个事件陆续到来”。

如果你的业务只需要最终答案,用 call() 是对的,而且更简单——错误处理只有一条路径,连接生命周期只有一个终点。反过来,如果你需要过程,用 call() 再事后切分,等于把一个已经结束的对话重新演一遍给用户看。那不是流式,那是录像回放。

9.4 什么时候不应该用本章这套东西

  • 后台批处理任务:没有人在看,SSE 长连接是纯成本,call() 更合适。
  • 只给别的系统调用的 API:对方要的是一个结构化结果,给事件流反而增加对接成本。
  • 连接数预算紧张的场景:先算清楚同时在线会话数,再决定上不上 SSE。
  • 工具结果含敏感信息:白名单必须把工具结果也纳入过滤,不能只过滤文本。工具返回值可能带内部字段,直接推给前端就是把内网信息送到浏览器。
  • 团队没有测试事件顺序的能力:没有 StepVerifier 这类断言,事件流的回归是裸奔。先把测试骨架搭起来,再上生产。

9.5 白名单的位置,决定了系统的可演进性

把白名单写在 Controller 里,SSE 能用,LiveKit 那一路就得复制一份。写在 Agent 里,框架就被业务污染了。

正确的位置是中间:一个只依赖 AgentEvent 和 PublicAgentEvent 的映射层。它不理解 Spring,不理解 LiveKit,不理解前端。后续加 Memory、Skill、Subagent,新增的事件类型全部从这一个入口进出。这是本章唯一一处“为未来多花五分钟、但值得”的地方。

语义归模型
事实归宿主
分级非分片
公开即承诺

10、系统落地

原来有什么:第 6 式的数字人,能对话、能调工具,但只交付一个最终结果。运行页的反应是“转圈 → 整段出现”。

本篇新增什么:chapter/07-message-event 分支上的一层公开事件——PublicAgentEvent 与 PublicEventMapper 组成的白名单映射层,一个 SSE 端点,以及前端按事件名分发的消费代码。Agent 侧一行没改。

现在能做什么:用户提交后立刻看到“开始”;工具被调用时页面上出现工具名;工具返回时出现结果;最终文本逐字出现;结束时连接关闭。这五步就是本章的验收标准,也是“过程可见”的最小完整形态。

还缺什么:其一,事件目前只接了 SSE 一个出口,LiveKit DataChannel 那一路还没接同一份 sink;其二,断线之后没有补偿机制,重连就从零开始,历史事件拿不回来;其三,推理块只做了白名单拦截,没有在系统提示词层面约束模型“不要产出内部推理”;其四,事件顺序只有手工验证,StepVerifier 的断言还没进 CI。

下一步如何演进:把公开事件层从 Controller 里抽出来,做成 Bridge 与 SSE 共用的一个组件,让它成为后续 Memory、Skill、Subagent 等能力向用户暴露过程的唯一出口。这件事不急,但方向要定死——以后再想“这个新事件要不要给用户看”,只需要改白名单一处。

开源参与点:本章未发现明确上游问题。

三个坑(事件少了、重了、多了)全部来自宿主侧消费设计——白名单写窄、订阅写重、判断写浅,不是框架行为缺陷。如果你在自己的项目里遇到“streamEvents 只出文本事件”这类现象,建议先按第 06 章的办法打一次事件类型全量日志,再决定要不要进 Issue;真要进,也要带上最小复现仓库、事件类型全量日志和确切的版本号。没有真实复现证据的 Issue,对维护者没有价值。

底座已成舟
新桨添一柄
能见工具行
未见思维影

11、小结

Q1 → 能。前提是承认 Msg 是一次完整会话语义,AgentEvent 才是执行过程中的增量事实;把 Msg 切块不是流式,是回放
Q2 → 都不是。正解既不是切 Msg,也不是把事件原样播出去,而是宿主自己定义一层“公开事件”白名单
Q3 → 代价有两条:假流式骗用户,过程依旧黑盒;或者思维链原样泄露,把内部推理送到用户屏幕上
状态 → chapter/07-message-event 可交付:运行页按序呈现 开始 / 工具调用 / 工具结果 / 最终文本,且不泄露推理内容

一问一答定
一图一境明
状态可交付
后续有根基

12、作业

12.1 理解题:Msg 与 AgentEvent 的职责边界在哪里?

参考思路:Msg 描述一次完整会话语义——谁、什么角色、说了什么,它的读者是模型,内容由 ContentBlock 组成,可以承载文本块、工具调用块、工具结果块和内部推理块。AgentEvent 描述执行过程中的增量事实,它的读者是宿主程序,负责“此刻发生了什么”。边界在于粒度与消费者:Msg 是轮次级的、面向模型的、被折叠过的;Event 是瞬时级的、面向宿主的、未被折叠的。把二者混用,会同时损失时序和语义。

12.2 实战题:给运行页加一个“工具调用耗时”

参考思路:在映射层外面再包一层状态机,记录 tool.call 到达的时间戳,在 tool.result 到达时算出差值,作为公开事件的一个字段推给前端。注意不要在 AgentEvent 里改数据,映射层应该保持无状态、纯函数;耗时统计属于展示层状态,放在 Controller 或者一个独立的 EventTimeline 组件里更合适。

12.3 排障题:前端只收到 text.delta,其它事件一个都没有

参考思路:先证伪“框架没产出”。在 Flux 上加 doOnNext 打印事件类名,确认工具事件是否到达消费侧。如果到了,问题在白名单漏分支或 filter 写在了 map 之前;如果没到,再检查 Agent 的工具是否真的被注册、是否真的被模型调用。注意不要一上来就去官方仓库翻 Issues——按本章证据链一的路径,先打日志。可以顺带打开 v2.0.3 的 AgentEvent.java,把该版本实际产出的事件子类列一遍,作为白名单的清单来源,而不是凭印象写。

12.4 架构判断题:白名单为什么必须放在宿主侧?

参考思路:放在 Agent 侧,框架就要理解业务对“公开”的定义,框架被业务污染,升级和复用都会受影响;放在前端侧,等于把不该出门的数据先送到了浏览器,泄露已经发生,前端过滤只是遮眼睛。宿主侧映射层是唯一既能拦住数据、又不污染框架的位置。更深一层:白名单要写成“默认拒绝 + 显式枚举”,不能写成“过滤黑名单”,因为框架新增事件类型时,黑名单不会自动更新,而枚举会强迫你重新做一次决定。

纸上得来浅
源码见真章
四题皆可做
一链更须长

13、思考

这一章的核心冲突其实只有一句话:推理的结果是一个对象,推理的过程是一串事实,它们是两种东西。

想通这一点,很多问题会自动归位。call() 返回 Mono,因为它承诺一个结果;streamEvents() 返回 Flux,因为它承诺一串事实。把结果切成小块播放,是在做动画;把事实按序播出去,才是在做流式。前者省事,后者要付出白名单、背压、连接生命周期的代价。

更值得记住的是那个反直觉判断:流式的难点不在分片,在分级。分片是代码问题,分级是产品问题——你到底承诺给用户看多少过程?这个承诺一旦做出,白名单就要跟着它走,而不是跟着技术方便走。

会用框架,是把 call() 换成 streamEvents(),让页面动起来。能参与框架演进,是先搞清楚 Msg 和 AgentEvent 各自对谁负责,再判断眼前这个问题到底该在自己的白名单里解决,还是真的属于上游。前者三个月就能学会,后者决定了你在社区里是提 Issue 的人,还是被提问的人。

一念辨语义
一证定根因
一改知取舍
一役见真章

posted @ 2026-10-11 11:17  李福春  阅读(3)  评论(0)    收藏  举报