模块 00 · 基础功底 · 第 0.4 课

0.4 流式协议:HTTP、SSE 与 WebSocket

第 0 部分 · 第 4 课 | 预计 4–5 小时 | 前置:0.1 Go、0.2 TypeScript

配套练习:projects/03-streaming/(TypeScript,零运行时依赖,npm test 13 个用例全部通过)。

读前说明

几乎所有模型 API 的流式输出都走 SSE,几乎所有实时语音 / 双向打断场景都走 WebSocket,而它们底下都是 HTTP。这一课把三者从“会用 SDK”讲到“自己能写解析器、能排查线上流式卡住”。

每段代码依旧按三步讲:要解决什么问题 → 代码 → 逐行解读 + 容易错的地方。

本课结束时你能:

  1. 用 curl 看懂一次流式请求在网络上到底长什么样;
  2. 手写一个符合规范、能扛住任意切块的 SSE 解析器;
  3. 用 Go 和 TS 各写一个正确的 SSE 服务端:响应头、flush、心跳、断开即停;
  4. 知道什么时候该换 WebSocket,以及它多出来的那些麻烦;
  5. 排查“本地好好的,上线后流式一下全吐出来”这类问题。

为什么 Agent 必须流式

一次模型调用生成 800 个 token,按 50 token/秒算要 16 秒。不流式,用户就盯着空白页 16 秒;流式的话,首 token 延迟(TTFT) 可能只有 0.5 秒,后面边生成边显示。

对 Agent 来说流式不只是体验问题:


第一部分:HTTP,一切的地基

1. 一次 HTTP 请求长什么样

要解决的问题:SDK 把细节全藏了,出问题时你得知道线上传的到底是什么。

curl -v https://httpbin.org/get

-v 会打印出原始报文,去掉 TLS 信息后大致是:

> GET /get HTTP/1.1            ← 请求行:方法 路径 协议版本
> Host: httpbin.org            ← 请求头,一行一个
> Accept: */*
>                              ← 空行:请求头结束
< HTTP/1.1 200 OK              ← 状态行
< Content-Type: application/json
< Content-Length: 256          ← 响应体有多少字节
<                              ← 空行:响应头结束
{ ...响应体... }

要记住的几个点

2. 流式的关键:不知道多长也能发

HTTP/1.1 的做法叫 分块传输(Transfer-Encoding: chunked):不写 Content-Length,响应体被切成一块块发,每块前面写这一块的长度,最后用长度为 0 的块表示结束。

HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked

b                       ← 十六进制 b = 11:下面这块 11 字节
data: 你\n\n            ("data: " 6 字节 + 中文 3 字节 + 两个换行)
b
data: 好\n\n
0                       ← 长度 0:结束

你几乎不用手写分块,Go 的 net/http 和 Node 的 http 模块在你没设 Content-Length 并多次写入时会自动用它。HTTP/2 和 HTTP/3 没有 chunked,它们本身就是按“帧”传的,天然支持流式。

真正需要你操心的是“flush”:你调用 write 时,数据可能先进了缓冲区,攒够一大块才发。对普通接口这是优化,对流式就是灾难:用户看到的是“卡半天然后一下全出来”。后面 Go 服务端会讲怎么强制刷出去。

3. 用 curl 看真实的流

curl -N https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"数到3"}]}'

-N 关掉 curl 自己的输出缓冲,你会看到一行行冒出来:

data: {"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant","content":""}}]}

data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"1"}}]}

data: {"id":"chatcmpl-1","choices":[{"delta":{"content":",2"}}]}

data: [DONE]

这就是 SSE。没有 API Key 的话,跑配套练习的 npm run dev,或者用 curl -N -X POST http://127.0.0.1:端口/chat 打本地服务,效果一样。


第二部分:SSE(Server-Sent Events)

4. SSE 格式:四个字段加一个空行

要解决的问题:在一个一直不结束的 HTTP 响应里,怎么分出“一条条消息”?

SSE 的规则很简单,整个规范的核心就这些:

event: token          ← 事件类型,可选,不写默认是 "message"
id: 42                ← 事件 id,可选,用于断线续传
retry: 3000           ← 告诉浏览器:断线后等 3000 毫秒再重连,可选
data: 第一行          ← 数据,可以有多行
data: 第二行          ←   多行 data 会用 "\n" 拼起来
                      ← 空行:这条事件结束,派发出去
: ping                ← 以冒号开头的是注释,解析器直接丢弃(常用作心跳)

逐条解读

各家模型 API 的用法:

厂商 怎么用 SSE
OpenAI Chat Completions 只用 data:,最后一条是 data: [DONE]
OpenAI Responses / Anthropic Messages 用 event: 区分类型,如 response.output_text.delta、content_block_delta、message_stop
多数国产模型 API 兼容 OpenAI 格式,同样以 [DONE] 结束

所以写 Agent 后端,一个正确的 SSE 解析器是必备件。

5. 手写 SSE 解析器:最容易错的是“切块”

要解决的问题:网络不会按“一条事件”给你数据。你可能一次收到半行,也可能一次收到三条半事件,甚至 \r\n 被从中间劈开,中文的 3 个字节被劈成 1 + 2。

先看一个错误写法,很多人第一次都这么写:

// ❌ 错误:假设每次 read 正好是完整的一行或几行
for await (const chunk of body) {
  for (const line of decoder.decode(chunk).split("\n")) {
    if (line.startsWith("data: ")) handle(line.slice(6));
  }
}

它有四个问题:

  1. 一行被切成两块时,前半行 data: {"te 被当成完整一行,JSON 解析直接报错;
  2. decoder.decode(chunk) 没加 { stream: true },中文字节被切开会变成乱码 �;
  3. 不认 \r\n,行尾会残留一个 \r;
  4. 不处理多行 data,也不按空行分事件。

本地测试大概率发现不了,因为本地网络太快,一块里常常就是完整事件;上线经过代理和公网,才开始随机报错。

正确思路:用一个缓冲区攒着,凑出完整一行才处理。

export class SSEParser {
  private buf = "";              // 还没凑成完整一行的残余文本
  private data: string[] = [];   // 当前事件攒的 data 行
  private eventType = "";
  private lastId: string | undefined;
  private pendingCR = false;     // 上一块以 \r 结尾

  feed(chunk: string): SSEEvent[] {
    if (chunk === "") return [];
    if (this.pendingCR && chunk.startsWith("\n")) chunk = chunk.slice(1); // ①
    this.pendingCR = false;
    this.buf += chunk;

    const out: SSEEvent[] = [];
    for (;;) {
      const m = /\r\n|\n|\r/.exec(this.buf);       // ② 找第一个行尾
      if (!m) break;                                 //    没有完整行:等下一块
      if (m[0] === "\r" && m.index === this.buf.length - 1) this.pendingCR = true;
      const line = this.buf.slice(0, m.index);
      this.buf = this.buf.slice(m.index + m[0].length); // ③ 剩下的留在缓冲区
      const ev = this.line(line);
      if (ev) out.push(ev);
    }
    return out;
  }

  private line(line: string): SSEEvent | undefined {
    if (line === "") return this.dispatch();         // ④ 空行:派发事件
    if (line.startsWith(":")) return undefined;      // 注释 / 心跳

    const i = line.indexOf(":");
    const field = i === -1 ? line : line.slice(0, i);
    let value = i === -1 ? "" : line.slice(i + 1);
    if (value.startsWith(" ")) value = value.slice(1); // 只去一个空格

    switch (field) {
      case "data":  this.data.push(value); break;
      case "event": this.eventType = value; break;
      case "id":    this.lastId = value; break;
      // retry 略,完整版见练习
    }
    return undefined;
  }

  private dispatch(): SSEEvent | undefined {
    if (this.data.length === 0) { this.eventType = ""; return undefined; }
    const ev = { event: this.eventType || "message", data: this.data.join("\n"), id: this.lastId };
    this.data = [];
    this.eventType = "";   // 注意:id 不清空,规范规定 lastEventId 会一直沿用
    return ev;
  }
}

逐行解读

字节到文本:

const decoder = new TextDecoder();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  parser.feed(decoder.decode(value, { stream: true })); // stream: true 是关键
}
parser.feed(decoder.decode()); // 最后冲刷一次,吐出解码器里剩的字节

"你" 在 UTF-8 里是 3 个字节 E4 BD A0。如果一块只到 E4 BD,不加 stream: true 的 decode 会直接输出 �。加了以后,解码器会把不完整的字节先留着,等下一块。Go 里不需要操心这个,因为 bufio.Scanner 按行切的是字节,切出来的一整行一定是完整的 UTF-8(0.1 课第 5 节就是这么写的)。

6. 客户端:为什么不用 EventSource

先说结论:后端用的是 SSE 格式,但前端通常不用浏览器原生的 EventSource,而是 fetch + ReadableStream 自己解析。 这不矛盾:SSE 是一种响应格式(text/event-stream),EventSource 只是浏览器提供的其中一种接收方式。

6.1 EventSource 擅长什么

const es = new EventSource("/events");
es.addEventListener("token", (e) => console.log(e.data));

两行代码,浏览器帮你做了一整套事情:

发起 GET 请求
   ↓
持续接收 text/event-stream
   ↓
解析 event / data / id
   ↓
连接断开 → 自动重连(带上 Last-Event-ID,见 9 节)

所以如果场景是“浏览器 GET /events,服务端单向推通知、进度、日志”,EventSource 非常合适,没必要自己造轮子。

6.2 模型聊天的请求不是这个形状

调模型(或者你自己的 Agent 后端)时,请求长这样:

POST /api/chat
Authorization: Bearer xxx
Content-Type: application/json
Accept: text/event-stream

{"model": "xxx", "messages": [{"role": "user", "content": "帮我解释一下 Transformer"}]}

EventSource 在这里有两个硬伤:

另外它也不能接 AbortController,只能 es.close()。

                    SSE(text/event-stream)
                            │
              ┌─────────────┴─────────────┐
              │                           │
         EventSource                    fetch
              │                           │
         只能 GET                    GET / POST / …
       不能自定义请求头               任意请求头
        不能带请求体                  可以带请求体
       自动重连、自动解析            自己解析、自己重连
              │                           │
    适合:通知、进度推送         适合:LLM Chat / Agent / 模型 API

所以 LLM / Agent 场景的标准链路是:

fetch(POST) → response.body(ReadableStream)→ 自己解析 SSE → token / tool / done

6.3 fetch 版客户端:三层检查

代码(练习 03-streaming 里的同款):

export async function* streamChat(url: string, body: unknown, opts: { signal?: AbortSignal } = {}) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
    body: JSON.stringify(body),
    signal: opts.signal,                 // 0.2 课的 AbortController:用户点停止就 abort
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);   // ① 先看状态码
  if (!res.headers.get("content-type")?.startsWith("text/event-stream"))
    throw new Error("not SSE");                          // ② 再看类型
  for await (const ev of parseSSE(res.body!, opts.signal)) {
    if (ev.event === "token") yield { type: "token", text: JSON.parse(ev.data).text };
    else if (ev.event === "done") { yield { type: "done" }; return; }
  }
  throw new Error("stream ended without done");          // ③
}

逐行解读

记住这句:

HTTP 流正常结束  ≠  LLM 任务正常完成

所以协议里必须有一个业务级的结束信号:OpenAI Chat Completions 的 data: [DONE]、Anthropic 的 message_stop、我们练习里的 event: done。只有收到它才算成功;流结束了却没收到,就交给上层重试或续传。

6.4 记成一个三层状态机

① HTTP 层
   ├── 2xx                 → 继续
   └── 401 / 429 / 5xx     → 失败(读 JSON 错误体,429 按 Retry-After 退避)

② SSE 协议层
   ├── text/event-stream   → 继续
   └── 其他 Content-Type   → 失败

③ 业务协议层
   ├── token               → 持续输出
   ├── tool                → 工具调用事件
   ├── done                → 成功结束
   └── 流 EOF 但没有 done  → 异常 / 未完成

这比“SSE 断了就是失败”更准确:判断成功的不是连接有没有断,而是业务层有没有收到明确的完成信号。

面试可以这样说:EventSource 适合简单的 GET SSE 场景;LLM / Agent 流式接口需要 POST 携带 messages,还要 Authorization、AbortController,所以前端用 fetch 拿到 ReadableStream 自己解析 SSE。并且不能把 HTTP 流结束等同于业务成功,必须以 done / [DONE] 这类业务级结束事件确认生成完整结束。

7. 服务端:Go 版

要解决的问题:写一个把模型输出转发给前端的 SSE 接口,要求每个 token 立即送达、客户端断开立即停。

func chatHandler(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher) // ① 断言:这个 ResponseWriter 支不支持 Flush
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }

    h := w.Header()                 // ② 响应头必须在第一次写 body 之前设好
    h.Set("Content-Type", "text/event-stream; charset=utf-8")
    h.Set("Cache-Control", "no-cache, no-transform")
    h.Set("X-Accel-Buffering", "no")
    w.WriteHeader(http.StatusOK)
    flusher.Flush()                 // 先把头发出去,客户端马上知道“连上了”

    ctx := r.Context()              // ③ 客户端断开时,这个 ctx 会被取消
    tokens := callLLM(ctx)          // 0.1 课写过的:返回 <-chan string,ctx 取消就停

    heartbeat := time.NewTicker(15 * time.Second)
    defer heartbeat.Stop()

    for {
        select {
        case <-ctx.Done():          // ④ 客户端走了:直接返回,callLLM 也因同一个 ctx 停下
            return
        case <-heartbeat.C:
            fmt.Fprint(w, ": ping\n\n") // ⑤ 注释行当心跳
            flusher.Flush()
        case tok, ok := <-tokens:
            if !ok {                // channel 关了:模型输出完毕
                fmt.Fprint(w, "event: done\ndata: [DONE]\n\n")
                flusher.Flush()
                return
            }
            b, _ := json.Marshal(map[string]string{"text": tok})
            fmt.Fprintf(w, "event: token\ndata: %s\n\n", b)
            flusher.Flush()         // ⑥ 每写一条就 Flush,否则会被缓冲
        }
    }
}

逐行解读

Cache-Control: no-transform 和 X-Accel-Buffering: no 是干什么的? 见第 10 节。

8. 服务端:TypeScript(Node)版

配套练习 src/server.ts 的核心部分:

res.writeHead(200, {
  "Content-Type": "text/event-stream; charset=utf-8",
  "Cache-Control": "no-cache, no-transform",
  "X-Accel-Buffering": "no",
});

const ac = new AbortController();
res.on("close", () => {                 // ① 连接关闭(不管谁关的)
  if (!res.writableFinished) ac.abort(); //    还没正常写完就关了 = 客户端断开
});

const hb = setInterval(() => res.write(": ping\n\n"), 15_000);
try {
  for (let i = start; i < tokens.length; i++) {
    await sleep(interval, ac.signal);    // ② 所有等待都挂在 signal 上
    res.write(encodeSSE({ event: "token", id: String(i), data: JSON.stringify({ text: tokens[i] }) }));
  }
  res.write(encodeSSE({ event: "done", data: "[DONE]" }));
  res.end();
} finally {
  clearInterval(hb);                     // ③ 无论怎么结束,定时器都要清
}

逐行解读

9. 断线续传:id 和 Last-Event-ID

要解决的问题:手机切网络、地铁进隧道,连接断了。重新请求要从头生成吗?

SSE 自带了续传机制:

  1. 服务端给每条事件带上 id:;
  2. 客户端记住最后收到的 id;
  3. 重连时带请求头 Last-Event-ID: 最后的 id;
  4. 服务端从这个 id 之后接着发。

EventSource 会自动做第 2、3 步,用 fetch 时自己做。但要说精确一点:

client ── Last-Event-ID: 123 ──▶ server ── 从 124 开始补发 ──▶ 124, 125, 126 …
                                    ↑
                         前提:事件按 id 存在某处

练习的服务端是这样处理的:

const lastId = Number(req.headers["last-event-id"] ?? -1);
const start = Number.isInteger(lastId) && lastId >= 0 ? lastId + 1 : 0;

但真实 Agent 服务里没这么简单:模型输出是一次性的,断了就没了,你不能“从第 42 个 token 重新生成”。生产做法是把生成和推送解耦:

模型调用 ──写入──▶ 消息缓冲(Redis Stream / 内存环形缓冲,按会话 id 存)
                         │
前端连接 ◀──按 id 读取───┘   断线重连时从 Last-Event-ID 之后读

这样即使前端断开,后台生成也可以继续(或者按策略在一段时间后取消),用户回来接着看。这是第 03 模块“长任务 Agent”和第 08 模块“断点恢复”的基础,这里先有个印象。

10. 线上最常见的坑:中间层缓冲

症状:本地流式好好的,部署到服务器后变成“等十几秒,一下全出来”。

原因:你和用户之间隔着一串中间层,任何一层在缓冲,流式就失效。

中间层 问题 解决
你的代码 没 Flush(Go)/ 压缩中间件攒数据(Node) 每条 Flush;流式路由不走压缩
Nginx 反向代理 默认 proxy_buffering on,攒满缓冲区才转发 响应头 X-Accel-Buffering: no,或配置 proxy_buffering off;
Nginx / 网关 开了 gzip,压缩需要攒数据 Cache-Control: no-transform,或对 text/event-stream 关 gzip
负载均衡 / CDN 空闲超时掐连接;部分 CDN 默认缓冲响应 心跳;调大 idle timeout;流式接口不过 CDN 缓存
Serverless 平台 部分平台默认整段返回响应 用平台专门的流式响应模式

Nginx 推荐配置:

location /api/chat {
    proxy_pass http://backend;
    proxy_http_version 1.1;          # 长连接需要 1.1
    proxy_set_header Connection "";
    proxy_buffering off;             # 关缓冲
    proxy_read_timeout 300s;         # 模型慢的时候别被 60 秒默认值掐掉
    gzip off;
}

排查顺序:先在服务器本机 curl -N localhost:端口 绕过所有中间层,流式正常说明是中间层问题;再逐层往外 curl,看从哪一层开始变成“一下全出来”。

另一个坑:HTTP/1.1 的连接数上限。浏览器对同一个域名最多同时开 6 个 HTTP/1.1 连接。每个 SSE 都占一个且一直不关,用户开 6 个标签页,第 7 个就卡住了,连普通接口都发不出去。HTTP/2 下多个流共用一个连接,没有这个问题,所以线上流式接口务必走 HTTP/2(一般在 Nginx / LB 上开启即可)。


第三部分:WebSocket

11. WebSocket 解决什么问题

SSE 是单向的:服务端推,客户端只能听。客户端想说话,只能另发一个 HTTP 请求。

大多数文字聊天这就够了:用户发消息是一个 POST,回复走 SSE。但有些场景需要双向、低延迟、持续的通道:

12. 握手与帧

WebSocket 以一个普通 HTTP 请求开头,然后“升级”成另一种协议:

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket                         ← 我想换协议
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols           ← 好,换了
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

101 之后,这条 TCP 连接就不再说 HTTP 了,双方互发帧。你需要知道的帧类型:

帧 用途
text UTF-8 文本,通常是 JSON
binary 二进制,如音频
ping / pong 心跳:一方发 ping,另一方必须回 pong
close 带状态码的关闭,如 1000 正常、1001 离开、1011 服务端出错

和 SSE 的关键区别:WebSocket 有消息边界。发一条就是一条,接收方不用像 SSE 那样自己按空行切。

13. Go 服务端

标准库不带 WebSocket,社区常用 github.com/coder/websocket(原 nhooyr.io/websocket,支持 context)和 github.com/gorilla/websocket。下面用前者:

import "github.com/coder/websocket"
import "github.com/coder/websocket/wsjson"

type ClientMsg struct {
    Type string `json:"type"` // "user_message" | "interrupt"
    Text string `json:"text,omitempty"`
}

func wsHandler(w http.ResponseWriter, r *http.Request) {
    c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
        OriginPatterns: []string{"app.example.com"}, // ① 只允许自己的前端连
    })
    if err != nil {
        return
    }
    defer c.CloseNow()

    ctx, cancel := context.WithCancel(r.Context())
    defer cancel()

    var (
        mu        sync.Mutex
        cancelGen context.CancelFunc = func() {}    // 当前这次生成的取消函数
    )

    for {
        var msg ClientMsg
        if err := wsjson.Read(ctx, c, &msg); err != nil { // ② 读循环:阻塞等客户端消息
            return                                        //    连接断开就退出
        }
        switch msg.Type {
        case "interrupt":
            mu.Lock()
            cancelGen()                                   // ③ 打断正在进行的生成
            mu.Unlock()
        case "user_message":
            mu.Lock()
            cancelGen()                                   // 新消息到来,先停掉旧的
            genCtx, genCancel := context.WithCancel(ctx)
            cancelGen = genCancel
            mu.Unlock()
            go generate(genCtx, c, msg.Text)              // ④ 生成放在另一个 goroutine
        }
    }
}

func generate(ctx context.Context, c *websocket.Conn, prompt string) {
    for tok := range callLLM(ctx, prompt) {
        if err := wsjson.Write(ctx, c, map[string]string{"type": "token", "text": tok}); err != nil {
            return
        }
    }
}

逐行解读

14. TS 客户端

浏览器和 Node 22+ 都有全局 WebSocket:

const ws = new WebSocket("wss://api.example.com/ws"); // 线上一定用 wss://(TLS)

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({ type: "user_message", text: "你好" }));
});

ws.addEventListener("message", (e) => {
  const msg = JSON.parse(e.data as string);
  if (msg.type === "token") render(msg.text);
});

ws.addEventListener("close", (e) => {
  console.log("closed", e.code, e.reason); // 1000 正常,1006 异常断开(没收到 close 帧)
  // ⚠️ WebSocket 不会自动重连,要自己做:指数退避 + 重新订阅会话
});

stopButton.onclick = () => ws.send(JSON.stringify({ type: "interrupt" }));

浏览器 WebSocket 不能自定义请求头,所以没法用 Authorization。常见做法:先调一个 HTTP 接口拿一个短期 token,再放在 URL 参数或第一条消息里;或者依赖同域 Cookie(这时一定要配合 Origin 检查)。

15. WebSocket 多出来的麻烦

选 WebSocket 之前,要知道你放弃了什么:

问题 SSE WebSocket
断线重连 EventSource 自动重连 + Last-Event-ID 全部自己写
心跳 注释行 ping/pong,或应用层心跳
鉴权 普通 HTTP 头 浏览器里不能带头,要绕
负载均衡 普通 HTTP,随便转 要支持 Upgrade,长连接粘在一台机器上
扩缩容 / 发版 请求结束连接就释放 连接常驻,发版要优雅地断开并让客户端重连
调试 curl 即可 要专门工具(浏览器 DevTools、websocat)
背压 TCP 自动处理 客户端收得慢时,服务端发送缓冲会堆积,要自己限

第四部分:怎么选

16. 决策表

场景 选 理由
调模型 API 的流式输出 SSE(fetch 解析) 厂商都是这么提供的
自己的 Agent 后端 → 网页聊天 SSE 简单、可 curl、好部署;用户发消息走 POST
长任务进度推送 SSE + 消息缓冲 支持续传,断线不丢
实时语音、需要随时打断 WebSocket 双向、低延迟、二进制
服务和服务之间流式调用 gRPC 流 / HTTP/2 有类型、有背压(后续模块讲)
MCP 远程传输 Streamable HTTP 就是 HTTP POST + 可选 SSE 响应,第 02 模块讲

经验法则:能用 SSE 就用 SSE。 等你确实需要“服务端正在说话时,客户端随时插话”,再上 WebSocket。


本课小结

概念 一句话
chunked / HTTP/2 帧 不知道响应多长也能边生成边发
Flush 不刷出去,流式就变成“攒一波”
SSE 格式 字段: 值 一行一个,空行结束一条事件,冒号开头是注释
解析器 缓冲区攒残余半行;TextDecoder 加 stream: true;三种行尾都认
结束标记 以 [DONE] / done 为准,连接断了不等于正常结束
fetch vs EventSource 要 POST、要带鉴权头,就用 fetch
断开即停 Go 用 r.Context(),Node 监听 res 的 close 再 abort
心跳 防止中间层因空闲掐连接
中间层缓冲 X-Accel-Buffering: no、no-transform、关 gzip、走 HTTP/2
WebSocket 双向有消息边界;重连、鉴权、扩缩容全要自己管

练习(今天做完)

在 projects/03-streaming/ 里:

  1. SSE 解析器(src/sse.ts):实现 SSEParser.feed,支持 data / event / id / retry、注释、三种行尾、多行 data。必须通过“任意两个位置切开都得到同样结果”的模糊测试。
  2. 字节流解析(parseSSE):每次只给 1 个字节,中文也不能乱码;signal 中止时释放底层连接。
  3. SSE 服务端(src/server.ts):POST /chat 逐个吐 token,正确的响应头、心跳、支持 Last-Event-ID 续传、客户端断开后停止生成(stats.aborted 加 1)。
  4. 客户端(src/client.ts):fetch 发 POST,先查状态码和 Content-Type,没收到 done 就断开要抛错。
cd projects/03-streaming
npm install
npm run typecheck && npm test   # 13 个用例全部通过
npm run dev                     # 看终端里逐字打印

选做:用 Go 写一个同样的 /chat SSE 服务(第 7 节代码),然后 curl -N 打它,再故意删掉 flusher.Flush() 看看区别。

自测题

  1. 下面这段 SSE 会派发几个事件?每个的 event 和 data 是什么?

    : hello
    
    event: a
    
    data: 1
    data: 2
    
    event: b
    data:3
    
  2. 为什么调模型 API 不用浏览器的 EventSource?

  3. 客户端的 reader.read() 返回了 done: true,可以认为模型已经输出完了吗?
  4. 本地流式正常,上线后变成一次性全出来。说出至少三个可能的原因。
  5. Go 里为什么要先 w.(http.Flusher) 断言?什么情况下会失败?
  6. Node 里判断客户端断开,为什么监听 res.on("close") 而不是 req.on("close")?
  7. 用户开了 7 个标签页,第 7 个页面的所有请求都卡住了。最可能的原因是什么?怎么解决?
  8. 做一个文字聊天 Agent,你会选 SSE 还是 WebSocket?做一个可以随时打断的语音 Agent 呢?

参考答案与详解

自测题答案

1. 两个。

2. EventSource 只能发 GET,不能带请求体,也不能自定义请求头(没法带 Authorization)。模型 API 需要 POST 一大段 JSON 并鉴权,只能用 fetch 自己读流、自己解析。

3. 不能。网关超时、服务重启、网络中断都可能让连接被正常关闭,read() 同样返回 done: true。要以收到协议里的结束标记(data: [DONE]、message_stop、done 事件)为准,没收到就当作失败处理:重试或用 Last-Event-ID 续传。

4. 任意三个即可: - Go 代码里没调 Flush,或者中间件包装了 ResponseWriter 导致 Flush 不生效; - Node 用了压缩中间件; - Nginx proxy_buffering 默认开启; - 网关或 Nginx 开了 gzip; - CDN 或 Serverless 平台默认缓冲整个响应。

排查方法:在服务器本机 curl -N 直连应用端口,正常则逐层往外查。

5. 流式需要在每条事件后主动刷出缓冲区,而 http.ResponseWriter 接口本身没有 Flush 方法,只能通过类型断言看具体实现是否支持。标准库的实现都支持;但如果套了自定义中间件包装器而包装器没实现 Flush(),断言就会失败。解决:包装器实现 Flush(以及 Unwrap),或者改用 http.NewResponseController(w).Flush(),它会通过 Unwrap 找到底层实现。

6. 在较新的 Node 版本里,req 的 close 表示“请求体读完了”,这时连接还在,用它判断会把正常请求当成断开。res 的 close 才表示底层连接关闭;再配合 res.writableFinished 判断是不是我们自己正常 end() 之后关闭的。

7. 浏览器对同一域名的 HTTP/1.1 连接数上限是 6。每个标签页的 SSE 占着一个连接不放,6 个占满后,第 7 个页面的任何请求都要排队。解决:让流式接口走 HTTP/2(同一连接多路复用);或者在多个标签页之间共享一条连接(SharedWorker / BroadcastChannel)。

8. 文字聊天选 SSE:用户发消息走 POST,回复走 SSE,简单、好调试、好部署,“停止生成”用 AbortController 断开即可。可打断的语音 Agent 选 WebSocket:需要持续双向传输二进制音频,并且在服务端说话时随时接收客户端的打断信号。

练习参考实现

完整代码在 projects/03-streaming/src/,下面是要点。

SSE 解析器的模糊测试:这是本课最值得带走的测试方法。

it("任意位置切块,结果都与整块一致", () => {
  const raw = 'event: token\r\nid: 1\r\ndata: {"text":"你好"}\r\n\r\n: ping\n\ndata: x\ndata: y\n\n';
  const whole = parseAll([raw]);
  for (let i = 0; i <= raw.length; i++) {
    for (let j = i; j <= raw.length; j++) {
      expect(parseAll([raw.slice(0, i), raw.slice(i, j), raw.slice(j)])).toEqual(whole);
    }
  }
});

双重循环把同一段数据在所有可能的两个位置切成三块,包括切出空块、切在 \r 和 \n 中间。第一版实现在空块时会重置 pendingCR,就是被这个测试抓出来的。手写用例很难想到“空块恰好落在 \r\n 中间”这种情况。

1 字节一块的中文测试:

const body = new ReadableStream<Uint8Array>({
  start(c) {
    for (const b of bytes) c.enqueue(new Uint8Array([b])); // 每次只给 1 个字节
    c.close();
  },
});

去掉 decoder.decode 的 { stream: true } 跑一下,这个测试会立刻失败,输出乱码。

断开即停的集成测试:

const ac = new AbortController();
for await (const ev of streamChat(url, {}, { signal: ac.signal })) {
  if (ev.type === "token") {
    got.push(ev.text);
    if (got.length === 2) ac.abort();   // 收到 2 个 token 就取消
  }
}
// 断言:服务端的 stats.aborted 变成 1,completed 仍为 0

这个测试同时验证了三件事:客户端能取消、取消会真的关掉 TCP 连接、服务端能感知断开并停止生成。生产里第三件最容易漏,漏了的代价是用户走了你还在为模型输出付费。

© 2026 duang777 · 课件源文件:notes/00-foundations/04-streaming.md