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
它有三个层面同时失效。
业务层面:三秒空窗期。用户不知道它是在思考还是挂了,也不知道它到底做了没有。
技术层面:一次完整推理的所有中间状态,都被折叠进了一个最终 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
如果你的业务只需要最终答案,用 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
更值得记住的是那个反直觉判断:流式的难点不在分片,在分级。分片是代码问题,分级是产品问题——你到底承诺给用户看多少过程?这个承诺一旦做出,白名单就要跟着它走,而不是跟着技术方便走。
会用框架,是把 call() 换成 streamEvents(),让页面动起来。能参与框架演进,是先搞清楚 Msg 和 AgentEvent 各自对谁负责,再判断眼前这个问题到底该在自己的白名单里解决,还是真的属于上游。前者三个月就能学会,后者决定了你在社区里是提 Issue 的人,还是被提问的人。
一念辨语义
一证定根因
一改知取舍
一役见真章

浙公网安备 33010602011771号