跳到主要内容

02 · 事件流协议

这一章讲 AgentScope 的「输出长什么样」。别的框架 agent.run() 给你返回一个字符串;AgentScope 给你一个事件流。理解这套协议,你才知道怎么做边生成边渲染的前端、怎么接人机交互。

2.1 它要解决的小问题

一次 agent 回复内部发生很多事:模型先思考、再吐文本、中途要调三个工具、每个工具流式吐结果……如果只在最后返回一坨文本,前端只能干等几十秒再「啪」地全显示,而且没法在中途暂停问用户。AgentScope 的答案:把每件事都做成一个事件,边发生边推出去。

2.2 思路/直觉:start / delta / end 三段式

每一种「内容块」(文本、思考、工具调用、工具结果、二进制数据)的生命周期都被切成三段事件:

TextBlockStartEvent ← 「一个文本块开始了,id=abc」
TextBlockDeltaEvent ← 「abc 这个块,新增了这段字:'你好'」
TextBlockDeltaEvent ← 「abc 这个块,又新增:',世界'」
...
TextBlockEndEvent ← 「abc 这个块结束了」

前端收到 start 就建一个空气泡,收到 delta 就往里追加字,收到 end 就封口。block_id 是把碎片归位的钥匙——多个块可能交错(文本里夹着工具调用),靠 id 区分谁是谁。

2.3 全部事件类型一览

所有事件定义在 event/_event.py,共用基类 EventBase(带 id/created_at/metadata)。按用途分组:

事件含义
回复边界ReplyStartEvent / ReplyEndEvent一次 reply 的开始/结束
模型调用ModelCallStartEvent / ModelCallEndEvent一次模型调用,End 带 token 用量
文本TextBlock{Start,Delta,End}Event模型吐的正文
思考ThinkingBlock{Start,Delta,End}Event模型的思考链(reasoning content)
数据DataBlock{Start,Delta,End}Event流式二进制(如 omni 模型的音频 PCM)
提示HintBlockEvent一次性提示(团队消息、后台结果、用户打断),不流式
工具调用ToolCall{Start,Delta,End}Event模型要调工具,delta 累积 JSON 参数
工具结果ToolResult{Start, TextDelta, DataDelta, End}Event工具执行的流式结果,End 带最终 state
兜底ExceedMaxItersEvent撞上最大迭代
人机交互RequireUserConfirmEvent / RequireExternalExecutionEvent暂停:要用户确认 / 要外部执行
人机交互(回传)UserConfirmResultEvent / ExternalExecutionResultEvent续跑:用户答复 / 外部结果
扩展CustomEvent服务层自定义通知(任务进度、团队变更),前端遇未知 name 应静默跳过

AgentEvent 是这一大族的联合类型别名(event/_event.py:478),前端可以对它做穷尽 match

2.4 原理:模型响应怎么被翻译成事件

核心翻译器是 _convert_chat_response_to_event(agent/_agent.py:2426)。它接收一个 ChatResponse chunk 和一个跨 chunk 复用的 block_ids 字典(记录当前打开着的 text/thinking/tool/data 块 id),据此决定发哪些 start/delta/end。

关键技巧是何时关闭一个打开的文本块。看这段真实逻辑(agent/_agent.py:2463):

# 示意,非源码:文本块的开/续/关判断
if text_blocks: # 本 chunk 有文本
if not block_ids["text"]: # 还没开块 → 发 Start
block_ids["text"] = new_id(); yield TextBlockStartEvent(...)
yield TextBlockDeltaEvent(delta=...) # 发增量
elif block_ids["text"] and not data_blocks: # 本 chunk 既没文本也没数据
yield TextBlockEndEvent(...); block_ids["text"] = None # → 关块

为什么要 and not data_blocks? 源码注释(agent/_agent.py:2455)解释:一个只带数据(比如 omni 模型在两段文本之间插了一段音频 PCM)的 chunk 不能关掉文本流,否则前端会把一段连贯文字裂成好几个气泡。但带工具调用(且无文本/数据)的 chunk 关掉文本块,以此保证 文本 → 工具 → 文本 的渲染顺序正确。这种「为渲染体验服务」的细节,正是把输出做成协议才挖得出来的。

工具调用的累积也类似(agent/_agent.py:2511):同一个 tool_call.id 第一次出现发 ToolCallStartEvent,之后每次发 ToolCallDeltaEvent(delta 是 JSON 参数的片段,流式拼起来才是完整参数),在后续 chunk 里消失了就发 ToolCallEndEvent

2.5 原理:工具结果怎么流式化

工具执行产出的是 ToolChunk 流,_convert_tool_chunk_to_event(agent/_agent.py:2561)把它翻成 ToolResultTextDeltaEvent(文本)或 ToolResultDataDeltaEvent(二进制,base64 或 url)。结束时发 ToolResultEndEvent,带上最终 state——这是前端判断这次工具调用是成功/出错/被拒/被打断的依据(ToolResultState 枚举见 message/_block.py:161)。

2.6 关键细节 / 坑

  • reply_id 是聚合键。 几乎每个事件都带 reply_id,标识它属于哪一次回复;tool_call_id 把工具结果事件和对应的工具调用串起来。
  • reply() vs reply_stream() reply()(agent/_agent.py:216)内部消费同一个事件流,但只挑出最后那个 Msg 返回——给「不关心过程、只要结果」的调用方。两者共用 _reply,零重复逻辑。
  • HintBlockEvent 是一次性的。 它不走 start/delta/end,因为提示内容在创建时就完整(event/_event.py:239),适合团队消息、后台工具结果这类「整段塞进来」的内容。
  • use_enum_values=True 事件基类配了它(event/_event.py:66),所以序列化出去的 type/state 是字符串值而非枚举对象,前端拿到的是干净的 JSON。

2.7 小结

把输出做成「带 block_id 的 start/delta/end 事件流」是 AgentScope 区别于多数框架的设计选择。它换来了:边生成边渲染、内容块精确重组、以及——下一章的主题——在流中间干净地暂停以征求用户同意

→ 下一章:03 · 权限与人机交互