返回 AICAP-180
B8 · Day 72真 API + 流式 + Docker

SSE 流式协议

昨天(Day 71)把 agent runner 的网络边界写成了契约:RPC 风格 POST /chat + 4 类 typed error code。但契约里的「响应」还是一次性 body——用户要盯着空白屏等整段生成完。

阶段: B8 · 真 API + 流式 + Docker(Day 71-80) 标签: #sse #streaming #streamable-http #mcp

今日导引(由浅入深)

昨天(Day 71)把 agent runner 的网络边界写成了契约:RPC 风格 POST /chat + 4 类 typed error code。但契约里的「响应」还是一次性 body——用户要盯着空白屏等整段生成完。

今天在 B1→B18 曲线上前进一格:把响应从「一次性返回」改成「token 增量推送」。这是任何 agent 产品「感觉快」的物理基础。今天先用纯协议把管子打通(硬编码 3 帧),明天(Day 73)再灌真模型的 token。

最小可判定产出:一个返回 text/event-stream 的 route,curl / 浏览器能抓到 3 个 SSE 帧(2 帧 token delta + 1 帧 [DONE])。

1. 机理精读

1.1 SSE 是单向 server→client 的 text/event-stream

它建立在普通 HTTP 之上:客户端发一个普通 GET/POST,服务端不关闭连接、持续往里写文本帧。相比 WebSocket:

  • 不需要协议升级握手(Upgrade: websocket)。
  • 不需要双向帧管理。
  • 天然走 HTTP 基础设施(代理、CDN、HTTP/2 多路复用)。

对「server 持续吐、client 只读」的 token 流式场景,SSE 是恰到好处的最小工具;WebSocket 的双向能力在这里是冗余的复杂度。一句话判断:单向推送选 SSE,需要双向实时(聊天室、协同编辑)才选 WebSocket。

1.2 SSE 的代价与补偿

代价

  • SSE 单向意味着客户端无法在同一连接上回传(要回传得另开请求)。
  • 浏览器对同域 SSE 连接数有上限(HTTP/1.1 下约 6 个),HTTP/2 多路复用缓解了这点。

补偿——「重连友好」:

  • SSE 协议内建 Last-Event-ID 头与 retry: 字段。
  • 浏览器原生 EventSource 断线会自动带上最后 ID 重连,服务端可据此续传。

对长生成的 agent 流,这种内建重连是 WebSocket 要手写一堆代码才有的能力。(注意:模型生成本身通常不可重放,所以「续传」对 LLM 流多是「干净重开」而非「续上断点」——见 Day 74 的流中断处理。)

1.3 帧格式:event: + data: 行,空行分隔,哨兵帧终止

一帧 SSE 由若干 field: value 行组成,常用字段:

  • event::事件类型,缺省为 message
  • data::载荷,可多行(多个 data: 行会被换行拼接)。
  • id::事件 ID(配合 Last-Event-ID 续传)。
  • retry::重连毫秒。

一帧以一个空行结束——这是解析的关键边界。流没有内建「结束」信号,所以约定一个哨兵帧(OpenAI 风格的 data: [DONE])告诉客户端「生成完了,可以关连接」。本日就硬编码这种结构:2 帧 data: 装 token,1 帧 data: [DONE] 收尾。

1.4 对照 MCP 2026-07-28 spec 的 Streamable HTTP transport

旧的 MCP transport 是「HTTP + SSE 双端点」设计——一个端点收请求、另一个 SSE 端点推事件,两条连接要对齐很麻烦。Streamable HTTP 把它统一成单个 HTTP 端点

  • 客户端 POST 一个 JSON-RPC 请求。
  • 服务端可以选择用 text/event-stream同一个响应里流式回多帧。

本仓的 src/agent/mcp/server.ts 走的就是这个新范式——startHttpServer()/mcp 上用 StreamableHTTPServerTransport,stateless 风格每请求新建 server+transport。本日硬编码的 SSE route 要对齐这个帧边界思路,而不是去实现废弃的双端点旧 transport。

2. 推导 / 手算 / 代码走读

2.1 手算一个最小合法 SSE 响应体

\n 代表换行,空行是帧分隔符)

data: Hello\n
\n
data:  world\n
\n
data: [DONE]\n
\n

逐步解析(站在客户端 EventSource / curl 视角):

  1. 读到 data: Hello,遇到空行 → 触发一个 message 事件,payload=Hello
  2. 读到 data: world(注意 data: 后第一个空格是分隔符、会被去掉,所以 payload 是 world 带一个前导空格)→ 第二个事件。
  3. 读到 data: [DONE] → 第三个事件,客户端识别哨兵、关闭连接。

2.2 走读 src/agent/mcp/server.ts 对齐真实帧边界

本日 route 要模仿其传输层意图:

  • startHttpServer(port)createServer 里只处理 req.url.startsWith('/mcp'),否则 res.writeHead(404).end('not found')——本日 route 是 /api/chat,是独立的硬编码验证端点,不复用此路径。
  • 它把 body 累积到 chunksreq.on('data', ...)),req.on('end') 后整体 parse——MCP 是「一次请求、流式响应」,请求体非流式、响应体才流式,本日 SSE 帧对应的正是「响应体流式」那一侧。
  • new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
    • sessionIdGenerator: undefined 即 stateless(每请求新 transport),是 2026-07-28 风格。
    • enableJsonResponse: true 让简单调用走 JSON 而非强制 SSE——说明 SSE 是「需要流式时才升级」的可选模式,不是所有响应都得 SSE。
  • res.on('close', () => { void transport.close(); void server.close() }):连接关闭时清理 transport + server,对应 stateless「用完即弃」。

结论:本仓真实流式由 MCP server 承载;本日 /api/chat 是一个硬编码 3 帧的验证步骤,用来在产品 route 层把 SSE 帧格式跑通,不是常驻服务。

3. 今日实战

  1. 在 Next.js 起一个 route /api/chat(POST),响应头设 Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
  2. ReadableStream 硬编码写 3 帧:两帧 data: <token>\n\n 的 token delta + 一帧 data: [DONE]\n\n 哨兵。
  3. 用 curl 验证:curl -N -X POST localhost:3000/api/chat-N 关闭缓冲),确认能逐帧抓到 3 个 SSE 帧。
  4. 帧边界思路对齐 src/agent/mcp/server.ts 的 Streamable HTTP 处理,不实现废弃的双端点 transport。
  5. 把 curl 抓到的 transcript 存成文件归档(产出物,非常驻服务)。

4. 今日实测 / 产出

  • 待跑(需起 route)——curl 抓到 3 个 SSE 帧的 transcript。
  • 诚实标注:standalone POST /chat SSE app route 在本仓库未单独构建,真实流式能力由 MCP server(src/agent/mcp/server.ts)承载;本日为硬编码验证步骤,产出 transcript 文件而非常驻服务。

5. 常见误区 / 陷阱

  • 忘了空行分帧:SSE 一帧必须以空行(\n\n)结束,少一个换行客户端会把多帧粘成一帧或永远不触发事件。
  • 没关响应缓冲就 curl:不加 -N(或代理 / Nginx 开了 buffering)会让所有帧攒到最后一次性吐出,看起来「没流式」其实是被缓冲了。
  • 重新实现废弃的 HTTP+SSE 双端点:MCP 已统一到单端点 Streamable HTTP,照旧设计就是给自己挖坑。
  • 以为 SSE 能双向:SSE 只能 server→client,要客户端回传得另开请求或换 WebSocket,别在 SSE 连接上找回传通道。

6. 学习资源(每条带 YYYY-MM)

  • MDN, "Server-sent events / Using server-sent events"(持续更新,2026 现行;SSE 帧格式与 EventSource 权威说明)。
  • WHATWG HTML Living Standard, "Server-Sent Events" 章节(持续更新;text/event-stream 规范来源)。
  • Model Context Protocol, "Streamable HTTP transport" 规范(MCP spec,2026-07-28 定稿前为草案,须当周复验)。
  • 本仓代码:src/agent/mcp/server.ts(B6 真实 MCP server,Streamable HTTP stateless 实现)。

SOTA检查 (2026-06 更新)

  • 当前主流:SSE 仍是 LLM token 流式的主力传输(OpenAI / Anthropic / DeepSeek 流式 API 均走 text/event-stream + 哨兵帧)。MCP Streamable HTTP 是当前传输方案。
  • 是否仍 SOTA:是,但有版本前提——MCP Streamable HTTP 的最终规范 2026-07-28 才定稿,server 构建应排在其后,当周重验帧约定。
  • 过时黑名单
    • 避免实现已废弃的「HTTP+SSE 双端点」旧 transport。
    • 勿假设 SSE 双向。
  • 下次复查点:2026-07-28 MCP 最终规范发布后,复验 Streamable HTTP 帧约定与 stateless 风格是否有改动;Day 73 接真模型流式时复验 DeepSeek SSE delta 帧结构。

衔接

  • 昨天:Day 71 — HTTP API 边界设计(RPC 风格 POST /chat + 4 类 typed error 契约)。
  • 今天:用最轻量的 SSE 协议把「响应流」管子打通,硬编码 3 帧验证 text/event-stream 可读。
  • 明天:Day 73 — 模型流式接入(把硬编码的 token 帧换成 DeepSeek 真流式,测 TTFT)。