跳到主要内容

EventSource 是什么,为什么 Agent 流式接收最后都换成了 fetch + ReadableStream

· 阅读需 33 分钟

「Agent 为什么都用 fetch + ReadableStream 读流?EventSource 不是也能收 SSE 吗?」

这个问题问得对,但流传的答案「大厂从 SSE 换成了 ReadableStream」说得不准。真实情况是:

EventSource 是浏览器内置的 SSE 客户端,它在「打开一个 URL、持续收通知」这件事上是满分答案。但 Agent 场景需要的几件事——发 POST、带请求头、看状态码——它一件也做不到,重连语义也完全不由你控制。于是大家保留了 SSE 的帧格式,只把「读它的那只手」换成了 fetch + ReadableStream。

这篇按这条线讲透:① EventSource 到底是什么 → ② 为什么 Agent 场景要换掉它 → ③ 换成 fetch + ReadableStream 之后怎么写(含可跑的最小实现)→ ④ 流式端点的三个生产雷区 → ⑤ 这套也不够用时,什么时候必须上 WebSocket / WebRTC。

正文的实测都在本机 Node 上跑出来,脚本随仓库留档在 source/stream-lab/。

一、EventSource 是什么​

先把它钉死在一句话上:

EventSource 是浏览器内置的 SSE 客户端 API。 你给它一个 URL,它替你连上服务端的 text/event-stream 响应,然后逐帧解析、按事件名派发、断了自动重连。

这里有两个东西特别容易混,先把它们分开:

是什么决定什么
SSE(text/event-stream)一种帧格式:HTTP 响应体里一串 event: / data: / id: 文本行,空行分帧「字节怎么切成消息」
EventSource一种读这种帧的浏览器 API:new EventSource(url) + onmessage / addEventListener「你怎么把消息读出来」

帧格式本身在 SSE 是什么:HTTP 上的服务端单向推送 里已经拆透了(一帧长什么样、空行怎么分帧、服务端怎么写出这串文本),这里不重复。这篇要讲的是读它的那只手。

1.1 它替你代劳了什么​

手写一个 SSE 客户端,你要自己做五件事;EventSource 一行代码全包:

要做的事手写EventSource
连上并保持这条长响应fetch + 循环 read()new EventSource(url)
按空行切帧、解析 event: / data:自己写内置
按事件名分发回调自己写addEventListener('tick', …)
断线自动重连自己写退避策略内置
断点续传自己记 offset内置:重连时自动带 Last-Event-ID
const es = new EventSource('/events'); // GET,无请求体
es.onmessage = (e) => render(e.data); // 没写 event: 的帧
es.addEventListener('tick', (e) => render(e.data)); // 写了 event: tick 的帧
es.onerror = () => { /* 连接正常断开时它自动重连;非 200 则直接失败且不再重连(§1.2 ③) */ };
es.close(); // 不关的话,服务端 res.end() 后它还会连回来

就这一屏代码,做**「服务端单向推文本」这件事它是最优解**——所以 SSE 那篇 的案例、站内通知、日志 tail、进度条、以及很多「只推不收」的看板,用它都是对的。

1.2 但它的设计前提,正好卡在 Agent 的路上​

EventSource 的前提是**「浏览器打开一个 URL,持续收通知」**:只有 GET、没有请求体、不需要自定义头、你也不关心状态码。通知场景下这个前提完全成立。搬到 Agent 上,每一条前提都反过来了。

先把依据交代清楚:这四条限制,MDN 的 EventSource 页面上并没有被当作「限制」列出来——MDN 描述的是行为,不会告诉你「你做不到什么」。所以下面的依据是 WHATWG HTML 规范与 Fetch 规范的原文。另外 EventSource 是浏览器 API,Node 里 typeof EventSource === 'undefined',我在 Node 上跑不了它——这四条是规范事实,不是本机实测(见 §九 局限)。

① 只能 GET,不能带请求体。 规范里 EventSource 的请求是这样造出来的:

【官方原文】"Let request be the result of creating a potential-CORS request given urlRecord, the empty string, and corsAttributeState." —— WHATWG HTML · Server-sent events(抓取日期 2026-10-16,curl,HTTP 200)

那句里的空字符串就是方法;而这个算法造出的 request 不设 body,Fetch 规范又规定 request 的默认方法与默认 body 是:

【官方原文】"A request has an associated method (a method). Unless stated otherwise it is GET." … "A request has an associated body… Unless stated otherwise it is null." —— Fetch Standard §2.2.5 Requests(抓取日期 2026-10-16,curl,HTTP 200)

→ 在 Agent 场景:每轮要 POST 一整段对话历史(system prompt + 工具定义 + 多轮消息)。塞进 query string 不只长度受限,对话内容还会进 URL、进访问日志、进浏览器历史。

② 没有面向作者的请求头接口。 构造器只接受两个参数——url 和 options(options 里只有 withCredentials)——规范里没有任何地方让你塞自定义头;请求头是 UA 自己组的(规范只允许 UA "may set (Accept, text/event-stream)",重连时自己加 Last-Event-ID)。

→ 在 Agent 场景:带不了 Authorization: Bearer。只能退回 Cookie 鉴权(CSRF 面变大),或把 token 放 query(同样进日志)。

③ 拿不到状态码——而且非 200 会直接把这条流「判死」。 这一条比「拿不到」更狠:

【官方原文】"Otherwise, if res's status is not 200, or if res's Content-Type is not text/event-stream, then fail the connection." … "Once the user agent has failed the connection, it does not attempt to reconnect." —— WHATWG HTML · Server-sent events(同上)

失败时抛给脚本的只是一个 Event 类型的 error 事件,不携带状态码、不携带响应头(MDN 对它的描述原话就是 "A generic Event")。

→ 在 Agent 场景:401、429、500、502 在你眼里长得完全一样;而且——429 一来,这条流就彻底死了、再也不会重连。既不知道发生了什么,也没有自动恢复。这是四条里最容易被低估的一条。

④ 重连语义不由你控制。 规范规定连接一旦关闭就重连:

【官方原文】"Clients will reconnect if the connection is closed; a client can be told to stop reconnecting using the HTTP 204 No Content response code." —— WHATWG HTML · Server-sent events(同上)

也就是说服务端 res.end() 正常收尾,浏览器也当成掉线自己连回来。

→ 在 Agent 场景:业务上「这一轮答完了」在它看来是掉线,重连后还要自己判重。而 §3.4 会看到,续传这件事又得靠 Last-Event-ID 自己接——EventSource 白送了一半,另一半仍是你的。

这四条合起来,基本判了 EventSource 在 Agent 场景的死刑。这才是那些「从 SSE 换成了 ReadableStream」的说法真正想表达的东西。

二、为什么要换成 ReadableStream:换掉的是「读法」,不是「帧」​

2.1 先把三层拆开,否则这句话没法理解​

「fetch + ReadableStream」和「SSE」根本不在同一层:

层有哪些选择决定什么
① 帧格式(framing)SSE(event: / data: 文本行)、NDJSON(每行一个 JSON)、自定义二进制字节怎么切成消息
② 传输(transport)HTTP 长响应(一个不结束的 response body)、WebSocket(升级为全双工帧)、WebRTC(UDP)消息走哪条管道
③ 客户端消费 APIEventSource、fetch + ReadableStream、WebSocket你怎么把字节读出来

EventSource 在第 ③ 层,SSE 在第 ① 层。所以「用 ReadableStream + fetch」和「用 SSE」完全可以同时成立——主流 Agent 的真实组合就是三者同时在用:

POST /chat ──► 服务端返回 Content-Type: text/event-stream
body 是一串 SSE 帧,边算边写
◄── 客户端 fetch() 拿到 response.body(ReadableStream)
用 reader.read() 循环读,自己按 \n\n 切帧、解析 data:

一句话:换掉的是 ③ 里的 EventSource,不是 ① 里的 SSE 帧。 帧格式一个字没改。

2.2 实测:同一段读取骨架,换掉解析器就能吃两种帧​

这条如果只是嘴上说,很难服人。我用同一个读取骨架消费两种完全不同的帧,唯一的差别是「按什么切分」(实验脚本见 source/stream-lab/harness.mjs):

SSE 帧(text/event-stream) 收到 50 个 chunk ← 按 \n\n 切分
NDJSON 帧(application/x-ndjson) 收到 50 个 chunk ← 按 \n 切分
// 读取骨架:这一整段对两种帧完全一样,换的只是 splitter
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
const parts = splitter(buf); // ← SSE 按 '\n\n'、NDJSON 按 '\n'
buf = parts.rest; // 不完整的尾巴留到下一轮
for (const p of parts.items) handle(parse(p));
}

注意这段骨架里没有一个字跟 SSE 有关。 它只是「把一条字节流按分隔符切成消息」——SSE 也好、NDJSON 也好、AI SDK 自己的 data protocol 也好,都是同构的。

2.3 三条例证:主流确实收敛到「POST + SSE 帧 + fetch」​

1. 三大模型 API 都是 SSE over POST。 OpenAI Chat Completions、Anthropic Messages、Gemini(alt=sse)都返回 text/event-stream 帧、都用 POST 发请求——客户端只可能是 fetch + ReadableStream(或等价封装),EventSource 连请求都发不出去。

2. MCP 的传输演进直接否掉了「长连 SSE 通道」。 MCP 的 Streamable HTTP 取代了早期的 HTTP+SSE transport,规范原话:

【官方原文】"Streamable HTTP — This replaces the HTTP+SSE transport from protocol version 2024-11-05." —— MCP 规范 2025-03-26 · Transports(抓取日期 2026-10-16,curl,HTTP 200)

它的模型和 EventSource 正好相反——没有长连通道,每条消息都发一个新的 POST:客户端必须带 Accept: application/json, text/event-stream,服务端要么回一个 JSON、要么回一个 SSE 流,客户端两种都得支持。

更值得注意的是最新修订版的走向。当前规范版本是 2026-07-28,它对 Streamable HTTP 连着做了三次「减法」:

【官方原文】"Changes included: Removal of the GET stream endpoint. Removal of protocol-level sessions." —— MCP 规范 2026-07-28 · Streamable HTTP(抓取日期 2026-10-16,curl,HTTP 200)

连上一版(2025-11-25)里还存在的 SSE 续传能力也被删掉了:

【官方原文】"Resumable SSE streams via Last-Event-ID are not supported." … "A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request ID." —— MCP 规范 2026-07-28 · Streamable HTTP(同上)

这一条尤其值得记:连 MCP 都从「靠 Last-Event-ID 续传」退回到了「断了就整轮重发」——因为在「一条流 = 一个请求」的模型里,续传反而是多余的状态。也就是说:行业方向是「按需开流、用完就关」,而不是「维持一条常驻双向通道」。

3. Vercel AI SDK 默认用 HTTP POST 发每一轮,流本身走 SSE 格式。 官方给的理由原话是:

【官方原文】"The data stream protocol uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling." —— AI SDK · Stream Data Protocol(抓取日期 2026-10-16,curl,HTTP 200)

(留意措辞是 reconnect capabilities,不是常被转述成的「可续传 / resumability」——那是转述,不是原文。)默认走 POST 这一条在另一页:

【官方原文】"By default, useChat uses HTTP POST requests to send messages to /api/chat." —— AI SDK · Transport(同上)

而 WebSocket 之所以不是默认,文档没有明说。**【本人分析】**他们的主战场是 serverless/edge,而函数响应完就结束、没有常驻进程拿着 socket,常驻连接天然不合。所以传输被做成可插拔的 ChatTransport(文档里就有一个 WebSocketChatTransport),把「要不要用 WS」交给你或第三方会话层。

这里有个容易被忽略的诚实结论:平台适配(serverless/edge 更便宜)也是「POST + SSE」胜出的重要原因,不全是技术优越性。WS 的能力优势(显式取消、多端订阅、断线续传)是真实存在的——所以 AI SDK 才留了插槽,而不是宣布 WS 错。

三、怎么用:fetch + ReadableStream 读 SSE​

道理讲完了,落到代码。整节的可跑脚本在 source/stream-lab/client-from-scratch.mjs(零依赖,node 直接跑)。

3.1 最小骨架​

这就是全部核心,二十行不到:

const res = await fetch('/chat', {
method: 'POST', // ★ EventSource 做不到
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ messages }), // ★ EventSource 做不到
signal, // 取消用(见 3.4)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); // ★ EventSource 看不到

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
const parts = buf.split('\n\n'); // 空行分帧
buf = parts.pop(); // ★ 半帧留到下一轮
for (const part of parts) handle(parseFrame(part));
}

3.2 逐行拆解:每个细节都不能省​

① res.body 是什么? 是一条 ReadableStream<Uint8Array>——字节流。不是行、不是帧、不是字符串。所有「怎么切」的问题都得你自己回答。

② 为什么要 pipeThrough(new TextDecoderStream())? 因为它帮你处理跨 chunk 的半个字符。一个汉字是 3 个 UTF-8 字节,而 chunk 边界由网络决定,完全可能正好切在一个字中间。这时候如果对每个 chunk 单独 new TextDecoder().decode(chunk),那个字会变成 �(U+FFFD 替换字符)——而且它是永久损坏的,后面再拼也拼不回来。TextDecoderStream 会把不完整的字节序列留在内部,等下一块到了再吐。

③ 为什么要留 buf? 因为帧边界 ≠ chunk 边界。服务端一次 res.write('...\n\n') 是一个完整帧,但网络可能把它拆成两次到达,也可能把两帧并成一次。切早了拿到半帧、没切到两帧粘成一坨,两种都会让 JSON.parse 炸。 buf 就是那个「上一轮没切完的尾巴」。这一条和「多字节字符被截断」是流式解析最经典的两个坑,我在 流式 chunk 会乱序吗 里做过逐字节验证。

④ done 只说明字节流结束了,不说明业务结束。 循环退出时 buf 里可能还留着半帧——按规范应当丢弃(它不是一条完整消息,硬解析只会得到一个残缺对象)。真正的「答完了」要靠业务层的结束标记(OpenAI 的 [DONE]、Anthropic 的 message_stop、或自定义的 event: done)。

⑤ 背压是白送的。 reader.read() 是拉模型:你处理完一帧才去要下一帧。所以渲染慢的时候,背压会自然地沿着 ReadableStream 往上游传,不需要你写队列。

3.3 完整可跑:服务端 + 客户端 + 真实输出​

服务端要比「写个循环」多花点心思(三处 ★ 都是生产必需):

// server.mjs —— 流式端点
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
let raw = '';
for await (const chunk of req) raw += chunk; // POST 才有的:请求体

if (!req.headers.authorization) { // 自定义头也收得到
res.writeHead(401, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: 'missing bearer token' }));
}

const lastId = Number(req.headers['last-event-id'] ?? 0); // ★ 续传靠它

res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-accel-buffering': 'no', // ★ 让 nginx 别缓冲这条响应
});
res.flushHeaders(); // ★ 先把响应头吐出去

// 心跳:防止中间网关因空闲超时掐连接(注释行,客户端会忽略)
const hb = setInterval(() => res.write(': ping\n\n'), 1000);

// close 监听必须在开始写响应之前注册,否则被中断的请求根本记录不到
let aborted = false;
req.on('close', () => { aborted = true; clearInterval(hb); });

try {
for (let i = lastId; i < TOKENS.length; i++) {
if (aborted) return;
res.write(`event: token\nid: ${i + 1}\ndata: ${JSON.stringify({ i, text: TOKENS[i] })}\n\n`);
await sleep(120);
}
res.write(`event: done\ndata: ${JSON.stringify({ total: TOKENS.length })}\n\n`);
} finally {
clearInterval(hb);
res.end();
}
});

客户端的完整读取函数(骨架 + 状态码 + 续传记账):

async function readSSE(url, { headers = {}, body, signal, onFrame } = {}) {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});

// ★ ① 状态码与响应头都拿得到(EventSource 拿不到)
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.retryAfter = res.headers.get('retry-after');
err.detail = await res.json().catch(() => null);
throw err;
}

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = '';
let lastId = null; // ★ ② 记下来,重连时带回服务端
const frames = [];

while (true) {
const { value, done } = await reader.read();
if (done) break; // 剩下的 buf 是半帧,按规范丢弃
buf += value;
const parts = buf.split('\n\n');
buf = parts.pop();
for (const part of parts) {
const frame = { event: 'message', data: '', id: null };
for (const line of part.split('\n')) {
if (line.startsWith(':')) continue; // 注释行(心跳)
if (line.startsWith('event: ')) frame.event = line.slice(7);
else if (line.startsWith('data: ')) frame.data += line.slice(6);
else if (line.startsWith('id: ')) frame.id = line.slice(4);
}
if (frame.id !== null) lastId = frame.id;
if (frame.data === '') continue;
frames.push(frame);
onFrame?.(frame);
}
}
return { frames, lastId };
}

跑起来的真实输出(Node 24.19.0,本机回环):

1. EventSource 做不到的两件事:POST 请求体(对话历史)与 Authorization 头
[服务端] POST /chat authorization=Bearer demo-token body={"model":"demo","messages":[{"role":"user","content":"怎么读 SSE?"}]}
客户端逐帧渲染:流式接收的正确姿势是fetch。
首帧到达 35.7 ms,末帧到达 993 ms,共 8 帧

首帧 35.7 ms、末帧 993 ms——中间隔了将近 1 秒,说明这确实是一帧一帧到的,不是攒完一次性给你。请求体和 Authorization 头都原样到了服务端(见 [服务端] 那行),这两件事 EventSource 一件也做不到。

3.4 换成手写之后,三件事要自己处理​

① 状态码与错误分类 → 这是换来的新能力(实测输出):

未带 token → 捕获 HTTP 401,错误体 {"error":"missing bearer token"}
配额用尽 → 捕获 HTTP 429,Retry-After=3s,错误体 {"error":"rate limited","retryAfter":3}

401 该去登录、429 该等 3 秒重试、500 该报警——三种完全不同的处置。EventSource 下它们长得一模一样。

② 重连与续传 → 这是 EventSource 白送、手写必须自己补的。 补法是拆成两件事:

  • 重连:你自己的 try/catch + 退避重试循环(记得加 jitter,否则服务端一重启就是重连风暴)——EventSource 的内置重连就在这个位置;
  • 续传:仍然用 SSE 自己的机制——服务端每帧写 id:,客户端把最后一个 id 记下来,重连时带上 Last-Event-ID 头,服务端从那之后重放。实测:
第一段(掉线前):流式接收的
── 模拟重连,带上 Last-Event-ID: 3 ──
[服务端] 收到 Last-Event-ID: 3 → 从第 4 帧续推
第二段(续传后):正确姿势是fetch。
拼接结果:流式接收的正确姿势是fetch。 ← 不重不漏,用时 608 ms

「不重不漏」四个字是重点:续传做错了通常不是丢了、就是重了。前提是服务端能按 id 重放(保留一段可重放缓冲);做不到就只能退回「整轮重跑」,或者接受 半帧丢弃 的残文。

③ 取消 → 这是两边都要自己设计的,而且它有个 fetch 也解决不了的硬伤,单独放到 §五 讲。客户端这一侧很便宜:

const ac = new AbortController();
readSSE('/chat', { signal: ac.signal, onFrame: render });
// 用户点「停止生成」
ac.abort();
257 ms 时中止,已渲染:流式接收的
fetch 以 AbortError 结束;服务端那侧只会看到「连接没了」

注意最后半句——这就是 §五 要展开的问题。

3.5 服务端配套清单​

流式端点不是你写完 res.write 就完了。上面服务端代码里的三处 ★ 是下限,完整的配套是:

□ res.flushHeaders() 先吐响应头,客户端才能开始读(否则可能被攒着)
□ X-Accel-Buffering: no 告诉 nginx 别缓冲这条响应
□ 心跳(`: ping\n\n`) 防中间网关按空闲超时掐连接;客户端解析时跳过 `:` 开头的行
□ 关压缩 见 §四 4.1,这一条最容易翻车
□ close 监听写在写响应之前 否则被中断的请求根本记录不到

四、流式端点的三个生产雷区​

这些是必须知道的运维账,其中前三条我做了实测复现。它们的共同症状是「看起来是前端卡了,实际是流没流出来」。

4.1 ★ 开启响应压缩 → 流式直接没了​

实测(同样 50 块 × 5ms 节奏):

未压缩 收到 50 次数据,首次 0 ms,末次 267 ms,跨度 266 ms ← 正常逐块
开启 gzip 收到 1 次数据,首次 263 ms,末次 263 ms,跨度 0 ms ← 全被缓冲,一次性到达

原因:压缩器要攒够一块再输出,于是把「逐块 flush」吃掉了。用户在页面上看到的是「转圈 4 秒,然后整段字一次性蹦出来」。

处置:流式端点要么关压缩,要么用 zlib 的 flush(Z_SYNC_FLUSH)逐块冲。这一条几乎每个团队都踩过一次。

4.2 ★ 中间有会缓冲的代理 → 同样退化​

实测:在客户端与服务端之间放一个「读完整个上游响应再转发」的代理(行为等价于 nginx proxy_buffering on、部分 CDN 的默认行为):

直连 首次数据 ~5 ms
经过缓冲代理 首次数据 276 ms ← 和末块同时到达,差值 0

首块延迟被拉长到和末块一样,服务端再怎么 flush 也没用。

处置:nginx 要 proxy_buffering off(或 X-Accel-Buffering: no),CDN 要确认不缓冲 text/event-stream,网关要放宽空闲超时。排查「流式不流」的默认动作,就是从上到下逐跳确认有没有人在缓冲。

4.3 ★ 一条流占一个 HTTP 响应 → 连接账要算​

实测(HTTP/1.1、maxSockets=1):一个 250ms 的长流占住连接后,普通 GET /fast 等了 239 ms 才返回。

浏览器对同源 HTTP/1.1 有 6 连接上限,多个长流会互相挤占。处置:流式场景上 HTTP/2(多路复用,一条连接跑多个流)。

4.4 其他固有限制​

  • 纯文本:二进制要走 base64,体积膨胀约 33%;
  • 单向:客户端到服务端只能另开请求;
  • 多个逻辑流要多个响应:没有「一条连接上开多个频道」的标准做法(WS 有);
  • 与 serverless/edge 的调用时长天然冲突:长响应会撞上执行时长上限。

五、这套不够用时:WebSocket​

上面全是在说 fetch + ReadableStream 的好话。但它有一条自己解决不了的硬伤,还有几条代价,到了这里就该上 WebSocket 了。

5.1 先破一个迷思:WS 并不更快​

这是最该被数据终结的争论。同一服务端、同样 50 块 × 5ms 节奏(Node,回环):

指标HTTP 流式(POST + fetch + ReadableStream)WebSocket
首块到达1.7 ms8.1 ms(含 WS 握手)
末块到达254 ms270 ms
块与块的间隔均值5.14 ms5.34 ms

结论:服务端→客户端的 token 流式,两者没有速度差异。 块间隔几乎相同(都约等于服务端 5ms 的发送节奏),差异只体现在建连开销上——WS 首块更慢是因为要把握手算进去。

再看「每轮对话」的请求开销(把载荷压到最小:每轮 3 块、零间隔,跑 50 轮):

50 轮总耗时每轮
HTTP 流式49 ms0.99 ms(每轮一次 POST 往返)
WebSocket17 ms0.34 ms(仅首次含握手)

差 0.65 ms/轮。 这是回环下的数字,真实网络会放大——但它放大的是「每轮多一次请求」的成本,而不是「WS 天生快」。

结论:WS 的优势从来不是快,是语义。 如果你的选型理由是「WS 更快」,那这个理由是错的。

5.2 ★ 唯一的硬理由:显式取消(stop vs 断线)​

这是 HTTP 流式真正做不到的事。 在 HTTP 流式里,客户端唯一的「上行信号」就是把连接关掉:

── 场景 A:用户点了「停止生成」 ──
[服务端] label=用户点停止 连接关闭。服务端只能知道「连接没了」,拿不到任何意图信息
── 场景 B:网络断开/进程被杀 ──
[服务端] label=网络断开 连接关闭。服务端只能知道「连接没了」,拿不到任何意图信息

两次日志一字不差。 服务端分不清「用户主动停」和「掉线了」——而这两件事的处理完全不同(前者该停任务、算完成;后者该保留现场、可续传)。§3.4 里 AbortController 那一行的输出,就是场景 A 的样子。

想表达意图,只能再补一个请求并按 runId 关联:

额外 POST /cancel?runId=...
(多一次往返,多一套关联逻辑,还要处理「cancel 先到、流后断」的竞态)

而 WS 里它就是一条有语义的消息(实测输出):

{"cancelled":true,"reason":"user_clicked_stop","ackAt":1789785615257}

可带 reason、可回执、可写审计、可区分主动停与掉线(掉线不会发这条消息)。 对 Agent 来说这是刚需——「停止生成」是用户最常按的按钮之一。

这里有个很能说明问题的旁证:MCP 2026-07-28 干脆把「关闭 SSE 响应流」定义成了取消:

【官方原文】"Closing the SSE response stream MUST be treated by the server as cancellation of that request. Because each request has its own response stream, the transport-level disconnect is unambiguous." —— MCP 规范 2026-07-28 · Streamable HTTP(同上)

读仔细一点:MCP 说这个断开是 unambiguous 的——但它 unambiguous 的只是「哪个请求没了」,不是「为什么没了」。因为每条请求独占一条响应流,关掉它能唯一对应到某个请求;可服务端依然分不清这是用户按了停止,还是网线被拔了。规范只能强行规定「关流 = 取消」,于是一次意外断网会被当成用户主动取消,在途的工作被丢掉、且按 §2.3 不再续传。

**【本人推断】**这正是 HTTP 流式方案的共同软肋:它缺一个表达「意图」的正经通道,只有「连接在 / 连接没了」这一个比特。WS 补上的就是这条通道。

5.3 WS 的代价​

别只看收益,这些是你要付的账:

  • 脱离 HTTP 语义:没有状态码、没有缓存语义,基础设施的重试/幂等/路由都不适用——排查和治理手段少一大截;

  • 浏览器端同样设不了自定义请求头 → 又是 Authorization 的问题。这一条 AI SDK 在文档里写得很直白:

    【官方原文】"Browser WebSocket APIs cannot set arbitrary handshake headers. The headers option is included inside each application-level request frame." —— AI SDK · Transport(同上)

    要在 WS 上带鉴权信息,只能塞进应用层的消息体里(或 query / 子协议 / Cookie)。而且鉴权发生在握手时,连接期间的 token 过期要自己在应用层收尾;

  • 基础设施要专门配:LB/反代要支持 Upgrade、要处理长连接粘性、发布时要优雅排空连接;发布时全体客户端同时重连会造成重连风暴(要加 jitter 退避);

  • serverless/edge 通常撑不住(§2.3 里 Vercel 的理由);

  • 没有标准的续传:SSE 有 Last-Event-ID 现成的语义,WS 要自己设计 offset/replay;

  • 分帧、心跳、背压、僵尸连接清理全是你的活;

  • TCP 队头阻塞:实时语音场景一条丢包会卡住整条流——这也是为什么实时音频最终要落到 WebRTC(见 §六)。

5.4 什么时候必须用 WebSocket:四条判据​

命中任何一条就该上 WS:

判据为什么 HTTP 流式不行
用户要能明确「停止/打断」,且要区分主动停与掉线§5.2:关闭连接这个信号没有语义
服务端要能主动推(反问用户、HITL 审批、进度播报)HTTP 流式里服务端只能在你开的那个响应里说话
多端/多设备订阅同一会话同上,且要另建推送通道
一条连接跑多路逻辑流HTTP 流式要开多个长响应,撞上 §4.3 的连接账

(协作编辑、实时游戏、语音对话这类低延迟双向交互当然也在列,但它们的终点往往是 §六。)

六、实时语音:WS 往往还不够,要上 WebRTC​

这条单独说,因为很多团队选 WS 的动机是「做语音 Agent」。

WebSocketWebRTC
传输TCP(可靠)UDP(不可靠 + 抗丢包补偿)
延迟(同等工作负载,Azure 测量)约 200 ms约 100 ms
丢包表现队头阻塞:一个包丢了整条流都卡抖动缓冲掩盖,2% 丢包下 WS 会「像坏了」而 WebRTC 正常
复杂度简单、443 端口、易穿防火墙高(STUN/TURN/ICE)

事实分布:

  • OpenAI Realtime API 同时提供 WS 与 WebRTC,且明确不推荐用 WS 做实时音频(延迟高于 WebRTC);浏览器的推荐路径是 WebRTC + 服务端 sideband 控制面;
  • Gemini Live 是 WS 为中心(JSON 包 + base64 媒体分片),好处是后端可以终止连接、做鉴权、代理转发,并对采样率/分辨率有确定控制;
  • 服务端/电话桥接(Twilio 之类)用 WS 更多——因为此时音频采集播放本来就在你手里,且要绕开 WebRTC 的 STUN/TURN 复杂度。

判据:浏览器直连用户的实时语音 → WebRTC;服务端到服务端的媒体桥接 → WS 够用。

七、选型决策​

你的 Agent 主要是「发一轮、收一段流」
├─ 是 ──► POST + fetch + ReadableStream + SSE 帧(默认,也是大厂主流)
│ 需要「用户停止」?→ 额外设计 cancel 协议(带 runId 关联)
└─ 否,还需要下面任意一条
├─ 用户要能明确「停止/打断」,且要区分主动停与掉线 ──► WebSocket
├─ 服务端要能主动推(反问、HITL、进度播报) ──► WebSocket
├─ 多端/多设备订阅同一会话 ──► WebSocket
├─ 一条连接跑多路逻辑流 ──► WebSocket
└─ 浏览器实时语音 ──► WebRTC(+ 服务端控制面)
你的需求选择理由
绝大多数 Agent 对话POST + fetch + ReadableStream + SSE与模型 API、MCP 一致;serverless 友好;基础设施零改造
需要显式取消/打断语义WebSocket否则「停止」和「掉线」不可区分
服务端主动推送、多端WebSocketHTTP 流式只能在你开的响应里说话
浏览器实时语音WebRTCTCP 队头阻塞;延迟约差一倍
只要服务端推送、不需要上行纯 SSE 帧 + fetch 读别用 EventSource:四条硬伤(§1.2)照样成立

八、生产 checklist(流式端点)​

□ 协议:POST + fetch + ReadableStream + SSE 帧(别用 EventSource)
□ 压缩:流式端点关 gzip,或用 Z_SYNC_FLUSH 逐块冲(否则「假流式」)
□ 代理:nginx proxy_buffering off / X-Accel-Buffering: no;CDN 确认不缓冲 event-stream
□ 响应头:res.flushHeaders() 先吐出去
□ 协议:上 HTTP/2 多路复用,避免长流挤占同源连接
□ 超时:网关空闲超时放宽;服务端定期发注释行心跳(`: ping`)
□ 分帧:留 buf 处理半帧;多字节字符用 TextDecoderStream 跨块解码
□ 续传:用 SSE 的 Last-Event-ID + retry 字段;服务端保留可重放缓冲
□ 错误:用 res.status 区分 401 / 429 / 5xx,别都当「网络错误」
□ 取消:HTTP 方案要显式设计 cancel 协议(带 runId 关联,处理竞态);
WS 方案把 cancel 做成带 reason 的消息并回执
□ 断线:客户端区分「用户点的停止」与「网络断开」,两者处理不同
□ 部署:WS 要处理连接排空与重连风暴(加 jitter 退避)
□ 观测:流式端点单独统计「首块延迟」(TTFB)而不是只看总耗时

九、本文的局限​

  • 所有实测都在本机回环、单进程、Node 上做(harness.* 为 Node 24.14.1,client-from-scratch.* 为 Node 24.19.0),没有真实网络、没有 nginx/CDN、没有 serverless——数字只用于说明机制,不代表 WAN 表现;
  • 缓冲代理是行为等价的最小复现,不是 nginx 本身;
  • 块节奏是合成出来的(固定 5ms / 120ms),真实 token 间隔不均匀;
  • §1.2 的四条是规范事实(WHATWG HTML + Fetch 规范逐字原文,2026-10-16 抓取),不是本机实测——Node 没有全局 EventSource,我跑不了它;§1.1 的用法示例同理,来自规范与 MDN 的 API 描述;
  • §2.3 / §5.2 / §5.3 中标【官方原文】的段落均于 2026-10-16 用 curl 抓取原件逐字核对(HTTP 200),未使用搜索摘要;标【本人分析】/【本人推断】的是我的推断,不是官方立场——尤其「AI SDK 为什么不用 WS 做默认」这一条,文档并未给出理由;
  • §六的延迟与丢包数据引自公开资料,非本机实测。

关联​

自测​

  1. EventSource 和 SSE 分别在哪一层?「大厂从 SSE 换成 ReadableStream」这句话错在哪?
  2. 列出 EventSource 在 Agent 场景的四条硬伤,各对应 Agent 的哪个需求?
  3. 为什么 res.body 不能直接 decode()?TextDecoderStream 帮你处理了什么?
  4. 读循环里 buf 是干什么的?循环结束时 buf 里剩下的东西该怎么处理?
  5. EventSource 白送的两个能力是什么?换成手写后各自怎么补?
  6. 实测里 HTTP 流式与 WS 的块间隔分别是多少?由此能得出什么结论?
  7. 为什么开启 gzip 会让流式「变成一次性返回」?怎么修?
  8. 在 HTTP 流式里,服务端怎么区分「用户点了停止」和「网络断开」?代价是什么?
  9. 举出三条「必须用 WebSocket」的场景判据。做浏览器实时语音该用什么?

参考​