🧠 第三篇:核心机制

第7章 SSE 流式协议:meta / chunk / step_break / done

从后端事件到前端气泡——一次流式对话的完整旅程

7.1 本章导读

前面几章都在后端打转。本章我们把视角拉到浏览器:模型逐字输出的文字、工具调用的卡片、最终的完整消息——它们如何通过 SSE(Server-Sent Events)从 ReactLoopAgent 一路流到你的眼前?这个协议看似简单,但本项目在"多步工具调用"场景下踩过三个连环 Bug(文本乱码、工具卡不更新、气泡覆盖),本章会完整复盘。

7.2 为什么是 SSE 而不是 WebSocket

对比项SSEWebSocket
方向单向(服务端 → 客户端)双向
协议纯 HTTP,天然过代理/防火墙需协议升级(ws://)
重连浏览器自动重连(EventSource)需自己实现
本场景适配✅ 对话流就是单向推送双向能力用不上

Agent 对话是典型的"用户发一次、服务端推一串"——单向推送,SSE 是最小可用方案。Spring 端用 SseEmitter,前端用原生 EventSource 或 fetch 流。

7.3 八种事件的完整序列

POST /api/agent/stream 返回的事件流:

事件后端来源前端动作
meta请求开始时推送一帧显示会话、模型和起始时间
reasoning模型推理文本增量在独立推理区域或折叠面板展示
chunkReactLoopAgent.streamDeltaSink 每个 TextDelta追加到当前 streaming 气泡
step_breakstreamToolCallSink(工具调用时)完成当前气泡 → 插入工具卡片 → 开新气泡 → 重置 fullContent
tool_result工具执行完成帧更新工具卡片的结果、耗时和成功/失败状态
finish本轮 Agent 运行结束信号结束当前运行态,等待 done 落定
doneAgentCollectNode 投影的全量消息重建消息列表,恢复工具轨迹与最终状态
error任何环节异常展示错误并结束流

7.4 step_break:为什么必须是独立事件

这是本协议最关键的设计决策,来自真实踩坑。最初的想法是"在 chunk 文本里混入特殊字符串标记工具边界",结果在多步工具调用时出了乱码。

🐛 Bug 6:多 step 文本拼接乱码

多 step(多个工具调用)场景下,所有 step 的 TextDelta 通过同一个 deltaSink 推送,前端 fullContent += 把不同 step 的文本拼接到同一个气泡里——第一段话、工具结果说明、第二段话全混在一起,呈现为乱码。

修复方案是协议化:把"工具边界"从文本流中抽出来,变成独立的 SSE 事件。完整链路:

🐛 连环 Bug 10 & 11:step_break 引入后的两个坑

独立事件本身对了,但前端实现还有两个细节差点翻车:①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起始帧,非领域事件
AssistantChunkchunk文本增量
ToolCallstep_break工具调用边界(协议化产物)
全部事件的投影doneAgentCollectNode 从 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_readshell_execute 这些工具怎么注册?统一执行器怎么做 Hook 和守卫?第8章讲工具系统。