第7章 SSE 流式协议:meta / chunk / step_break / done
从后端事件到前端气泡——一次流式对话的完整旅程
7.1 本章导读
前面几章都在后端打转。本章我们把视角拉到浏览器:模型逐字输出的文字、工具调用的卡片、最终的完整消息——它们如何通过 SSE(Server-Sent Events)从 ReactLoopAgent 一路流到你的眼前?这个协议看似简单,但本项目在"多步工具调用"场景下踩过三个连环 Bug(文本乱码、工具卡不更新、气泡覆盖),本章会完整复盘。
7.2 为什么是 SSE 而不是 WebSocket
| 对比项 | SSE | WebSocket |
|---|---|---|
| 方向 | 单向(服务端 → 客户端) | 双向 |
| 协议 | 纯 HTTP,天然过代理/防火墙 | 需协议升级(ws://) |
| 重连 | 浏览器自动重连(EventSource) | 需自己实现 |
| 本场景适配 | ✅ 对话流就是单向推送 | 双向能力用不上 |
Agent 对话是典型的"用户发一次、服务端推一串"——单向推送,SSE 是最小可用方案。Spring 端用 SseEmitter,前端用原生 EventSource 或 fetch 流。
7.3 八种事件的完整序列
POST /api/agent/stream 返回的事件流:
| 事件 | 后端来源 | 前端动作 |
|---|---|---|
meta | 请求开始时推送一帧 | 显示会话、模型和起始时间 |
reasoning | 模型推理文本增量 | 在独立推理区域或折叠面板展示 |
chunk | ReactLoopAgent.streamDeltaSink 每个 TextDelta | 追加到当前 streaming 气泡 |
step_break | streamToolCallSink(工具调用时) | 完成当前气泡 → 插入工具卡片 → 开新气泡 → 重置 fullContent |
tool_result | 工具执行完成帧 | 更新工具卡片的结果、耗时和成功/失败状态 |
finish | 本轮 Agent 运行结束信号 | 结束当前运行态,等待 done 落定 |
done | AgentCollectNode 投影的全量消息 | 重建消息列表,恢复工具轨迹与最终状态 |
error | 任何环节异常 | 展示错误并结束流 |
7.4 step_break:为什么必须是独立事件
这是本协议最关键的设计决策,来自真实踩坑。最初的想法是"在 chunk 文本里混入特殊字符串标记工具边界",结果在多步工具调用时出了乱码。
多 step(多个工具调用)场景下,所有 step 的 TextDelta 通过同一个 deltaSink 推送,前端 fullContent += 把不同 step 的文本拼接到同一个气泡里——第一段话、工具结果说明、第二段话全混在一起,呈现为乱码。
修复方案是协议化:把"工具边界"从文本流中抽出来,变成独立的 SSE 事件。完整链路:
独立事件本身对了,但前端实现还有两个细节差点翻车:①step_break 分支创建新 streaming 消息后忘了 sess.messages.push(assistantMsg),导致 render() 不为第二轮文本创建气泡,后续 chunk 覆盖到第一轮气泡里;②updateStreamingMessage 的 60ms 节流缓存(_lastHtml)在 render() 重建 DOM 后没重置,缓存与 DOM 状态不一致。修复分别是在 render() 前补 push 和重置缓存。教训:状态管理的每一步(数据、DOM、缓存)必须在协议切换点同步重置。
7.5 done 事件:全量消息的必要性
为什么流式推送了 chunk,最后还要在 done 里带全量 messages?因为流式过程只覆盖"文本增量",工具卡片的最终状态(成功/失败、完整结果)需要从事件日志重建:
// done 事件携带的 messages 结构(简化)
{
"messages": [
{ "role": "user", "content": "列出目录文件" },
{ "role": "assistant", "content": "我先查看一下目录。" },
{ "role": "tool", "callId": "call_1", "name": "shell_execute",
"status": "success", "result": "..." }, // ← 流式阶段只有 status=running
{ "role": "assistant", "content": "目录下有 3 个文件..." }
]
}前端收到 done 后用这份全量消息替换本地状态——这就是第6章说的 AgentCollectNode 必须投影 ToolCall/ToolResult 的原因:没有它们,done 里就没有 tool 角色消息,前端会把 step_break 阶段创建的工具卡片清掉,卡片永远停在 running。
7.6 后端事件到 SSE 的映射总览
| 领域事件(SessionEvent) | SSE 事件 | 说明 |
|---|---|---|
| —(请求开始) | meta | 起始帧,非领域事件 |
AssistantChunk | chunk | 文本增量 |
ToolCall | step_break | 工具调用边界(协议化产物) |
| 全部事件的投影 | done | AgentCollectNode 从 SessionLog 重建 |
TurnEnd(Error) | error | 错误终态 |
注意 chunk 和 step_break 是"加速通道"(实时体验),done 才是"对账时刻"(以 SessionLog 投影为准)。即使加速通道丢了几个 chunk,done 的全量消息也能把 UI 拉回正确状态。这就是第5章事件溯源的价值在协议层的体现。
7.7 小结与下一章预告
本章要点:SSE 单向推送适配对话流;八事件序列 meta→(reasoning|chunk)*→(step_break→tool_result)*→finish→done;step_break 和 tool_result 必须是独立事件而非文本标记(多步工具乱码的教训);done 携带全量消息用于工具轨迹终态恢复;加速通道 + 对账时刻的双层设计。
下一章:协议通了,接下来看 Agent 的"手"——工具系统。fs_read、shell_execute 这些工具怎么注册?统一执行器怎么做 Hook 和守卫?第8章讲工具系统。