Skip to content
SSE 与 ReadableStream:LLM 流式对话的浏览器端实现
从一次性 JSON 到流式响应
调用 LLM 对话接口时,默认的请求方式是发送一个普通 HTTP POST,等待模型推理完成,服务端返回一个完整 JSON。回答文本位于 choices[0].message.content。这种方式实现简单,但用户需要等待整个回答生成完毕后才能看到内容。回答越长,等待时间越长,对用户体验的影响也越大。
流式模式通过请求体中的 stream: true 开启。服务端在模型生成过程中,将增量文本分多次写入 HTTP 响应体,连接始终保持打开。浏览器每收到一段数据,就立即渲染到页面,用户看到的是逐字输出。
这两种模式在响应体层面的差异很直观。非流式响应是一个完整 JSON:
json
{
"choices": [{
"message": {
"role": "assistant",
"content": "你好。\n\n这是完整回答。"
}
}]
}流式响应则是一串数据行,每行携带一小段增量文本:
data: {"choices":[{"delta":{"role":"assistant","content":"你好"}}]}
data: {"choices":[{"delta":{"content":"。"}}]}
data: {"choices":[{"delta":{"content":"\n\n这是完整回答。"}}]}
data: [DONE]用户看到“你好。”时,服务端可能才刚刚生成下一句。
整个链路分成两层:HTTP 传输层使用分块传输(Transfer-Encoding: chunked)把数据分多次送到浏览器;数据组织层采用 SSE 格式定义事件边界。浏览器端拿到的是 ReadableStream 实例。ReadableStream 是 Web Streams 标准定义的可读流接口,在 Fetch API 中通过 Response.body 暴露响应体的字节内容 [4]。后续工作就是从这个流中逐块读取、解码和解析。
SSE 协议:事件流格式与 EventSource 的边界
SSE(Server-Sent Events)是 W3C 定义的服务器推送方案,让服务器通过 HTTP 向页面持续发送事件。事件流资源的 MIME 类型是 text/event-stream [1]。
事件流格式
SSE 数据由文本行组成,事件之间以空行分隔。每个事件由若干字段行构成:
data:消息内容。一个事件可以出现多行data,浏览器会把它们合并为一条消息,以换行符连接。event:事件类型。默认是message,也可以声明自定义类型。id:事件 ID。浏览器会记录最后一条事件的 ID,断线后通过Last-Event-ID请求头发送给服务端 [1]。retry:重连间隔,单位毫秒。
一个包含多种字段的事件流:
data: 第一行
data: 第一行
data: 第二行
event: ping
data: {"type":"ping"}
id: 100
data: 你好第一条事件只有一行 data。第二条事件的两行 data 会合并为字符串 第一行\n第二行。第三条事件类型为 ping。第四条事件带有 ID。
EventSource API
浏览器原生提供了 EventSource 来消费 SSE [1]:
js
const source = new EventSource('/api/events');
source.onmessage = (event) => {
console.log(event.data);
};
source.addEventListener('ping', (event) => {
console.log(event.data);
});EventSource 会自动处理连接建立、断线重连和 Last-Event-ID 发送。对于纯文本推送场景,这种开箱即用的行为较为方便。
EventSource 的限制
EventSource 有两个限制,使它不适用于 LLM 对话:
- 请求方式只能是 GET。OpenAI-compatible 的
chat/completions接口要求 POST [6]。 - 标准
EventSource无法添加Authorization头或任何自定义请求头 [3]。
密钥因此无法通过请求头传递。LLM 流式对话需要改用 fetch 请求接口,读取响应体中的 ReadableStream,自行解析 SSE 格式。
使用 Fetch 读取 ReadableStream
fetch 返回的 Response 对象有一个 body 属性,类型是 ReadableStream,代表响应体的字节流 [4]。
读取流需要先取得读取器:
js
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const reader = response.body.getReader();reader.read() 返回一个 Promise,resolve 为 { value, done }:
value是Uint8Array,代表一段字节。done为true表示流结束。
用循环逐块读取:
js
const decoder = new TextDecoder();
let text = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}这里有一个关键点:value 是字节块,不是字符串,必须用 TextDecoder 解码。TextDecoder.decode() 的第二个参数 { stream: true } 表示当前传入的是流中间的一段数据。当 UTF-8 多字节字符恰好被拆到两个响应块时,TextDecoder 会缓存第一个块中不完整的字节,等下一个块到达后拼出完整字符 [4]。
如果不用 stream: true,跨块的多字节字符会被替换为 U+FFFD,也就是常见的乱码。
response.body 是字节流,而 SSE 事件是以行为单位的字符串流。字节块的边界与事件边界并不对齐:一个 chunk 可能包含多条完整的 data: 行,也可能只包含 data: 行的前半部分。因此自定义解析器需要维护字符串缓冲区,按行切分,并把最后一段不完整的行留到下一轮。
Cloudflare 的示例使用 TextDecoderStream 来简化字节到字符串的转换 [5]:
js
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();这样 reader.read() 返回的 value 直接是字符串。但流的块边界问题依然存在,按行解析仍需要缓冲区。
解析 OpenAI 兼容的 LLM 流式响应
流式响应格式
OpenAI-compatible 接口在请求体加入 stream: true 后,返回的响应体是 SSE 事件流 [6][7]。每个事件一行 data:,内容是一个 JSON 字符串。该 JSON 是 ChatCompletionChunk 的一部分,增量文本位于 choices[0].delta.content。
一个典型事件序列 [8]:
data: {"id":"chatcmpl-xxx","choices":[{"index":0,"delta":{"role":"assistant","content":"你好"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","choices":[{"index":0,"delta":{"content":","},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","choices":[{"index":0,"delta":{"content":"世界"},"finish_reason":null}]}
data: [DONE]第一个 chunk 的 delta 中带有 role,之后只有 content。当服务端开启 stream_options.include_usage 时,最后一个事件是包含 token 用量的块,choices 为空数组,之后才是 data: [DONE] [7]。
解析器设计
解析器的输入是 ReadableStream,输出是文本增量回调。处理流程:
- 从
response.body获取reader。 - 用
TextDecoder解码字节,追加到缓冲区。 - 按换行符切分缓冲区,末尾不完整的一行留在缓冲区。
- 对每一行剥掉
data:前缀,解析 JSON。 - 取出
delta.content,交给回调。 - 遇到
data: [DONE]结束。 - 流结束后,处理缓冲区中剩余的最后一行。
实现:
js
async function readChatCompletionStream(response, handlers) {
const { onDelta, onDone } = handlers;
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function handleLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) return;
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') {
onDone?.();
return true;
}
let json;
try {
json = JSON.parse(payload);
} catch {
return;
}
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
onDelta?.(delta);
}
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (handleLine(line)) return;
}
}
if (buffer) {
handleLine(buffer);
}
} finally {
reader.releaseLock();
}
}阅读器用完后调用 releaseLock() 释放锁。若中途通过 AbortController 取消请求,reader.read() 会抛出异常,由调用方捕获。
流式对话的请求与 UI 更新
发起请求:POST + stream: true
实际对话请求需要携带消息历史和 stream: true:
js
const controller = new AbortController();
async function sendMessage(messages, onDelta) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Authorization 头的具体格式按服务商要求填写
},
body: JSON.stringify({
model: 'model-id',
messages,
stream: true,
}),
signal: controller.signal,
});
await readChatCompletionStream(response, {
onDelta,
onDone: () => console.log('完成'),
});
}这段代码中的请求地址可以是服务商直接地址,也可以是本地代理。由于 EventSource 不能设置 POST 和自定义请求头 [3],这里必须使用 fetch。
取消请求与服务端协作
浏览器端取消请求通过 AbortController:
js
controller.abort();这会中断 fetch,连接关闭,reader.read() 抛出一个 AbortError。
连接关闭后,服务端是否立即停止生成,取决于服务端实现。如果服务端持续向响应流写入数据而客户端已经断开,写操作会失败。一个能感知断开的服务端实现会监听响应对象的 close 事件。以 Node.js 为例:
js
res.writeHead(200, {
'Content-Type': 'text/event-stream',
});
res.on('close', () => {
if (!res.writableEnded) {
// 客户端断开,停止生成并释放资源
}
});因此取消是一个双向协作过程:浏览器负责关闭连接,服务端负责监听关闭并中止计算 [5]。
如果已经拿到 reader,也可以直接调用 reader.cancel() 终止读取。这会触发连接关闭,服务端同样会感知到流被取消 [4][5]。controller.abort() 和 reader.cancel() 两种方式选其一即可。
打字机效果与渲染性能
流式对话界面通常把收到的增量文本追加到页面。最简单的写法:
js
output.textContent += delta;这个写法可用,但每次增量到达都会同步触发 DOM 写入和布局计算。当服务端推送频率很高时,界面会出现卡顿。requestAnimationFrame 可以把同一帧内的多次增量合并为一次 DOM 更新:
js
let pendingText = '';
let scheduled = false;
function appendDelta(delta) {
pendingText += delta;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
output.textContent += pendingText;
pendingText = '';
scheduled = false;
});
}每段增量到达后,只做一次字符串累加,然后请求一个动画帧。多个增量发生在同一帧时,textContent 只被写入一次。
组件卸载与状态管理
流式请求是异步操作。组件卸载后,如果回调继续执行 DOM 写入或调用框架的状态更新函数,可能触发警告或无效更新。因此需要做两件事:
- 调用
AbortController.abort()终止请求。 - 设置
disposed标志,防止回调继续更新 UI。
下面示例中 render 由界面层提供,负责根据 phase 显示加载提示、错误信息和重试按钮。
js
function createChatSession(messages) {
const controller = new AbortController();
let disposed = false;
let phase = 'idle';
function setPhase(nextPhase, error) {
phase = nextPhase;
render({ phase, error });
}
async function run() {
setPhase('loading');
try {
await sendMessage(messages, (delta) => {
if (disposed) return;
if (phase === 'loading') setPhase('streaming');
appendDelta(delta);
});
if (!disposed) setPhase('done');
} catch (err) {
if (disposed) return;
if (err.name === 'AbortError') return;
setPhase('error', err);
}
}
function dispose() {
disposed = true;
controller.abort();
}
return { run, dispose };
}phase 的取值包括 loading、streaming、done、error。收到第一段增量时从 loading 进入 streaming。AbortError 是取消操作触发的正常异常,不需要作为错误处理。
工程注意点:代理缓冲、断线重试与乱码
SSE 响应头
SSE 响应至少需要 Content-Type: text/event-stream [1]。事件流是动态数据,规范要求不能缓存事件流,因此服务端通常还需要发送 Cache-Control: no-cache,指导浏览器和代理不缓存响应。对于长连接,Connection: keep-alive 也是常见的配合。
一个 Node.js 服务端的 SSE 响应头示例:
js
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});代理缓冲
代理缓冲的典型现象是:请求已发出,服务端日志显示正在生成,但浏览器端长时间没有任何输出。反向代理默认开启缓冲时,会把小块数据攒到一定量才转发,导致第一个字迟迟不出现。排查方向是确认代理层是否关闭了对 text/event-stream 的缓冲,具体配置方式需要查阅所用代理软件的文档。
断线重试
EventSource 内置断线重连,并通过 Last-Event-ID 告知服务端重连位置 [2]。但自定义 fetch 流没有这套机制。断线后最直接的做法是让用户点击重新发送。
如果需要自动重试,可以按指数退避重新发起请求。此时需要注意:
- LLM 生成结果无法从中间续传,重新请求不会得到与之前完全相同的结果。
- 重试前应清除界面中已显示的部分回答,或提示用户“生成中断,正在重试”。
- 用户点击停止后,不应再触发自动重发,避免两个请求同时运行。
一个保守的策略是:只在尚未收到任何内容的首个连接超时情况下自动重试;一旦已经开始输出文本,就交由用户决定是否重新生成。
乱码与字符边界
乱码多数来自两个环节。
第一个环节是字节解码。UTF-8 字符被拆到两个响应块时,直接调用 decode(value) 会丢失字节,产生 U+FFFD。始终使用 TextDecoder 的 stream: true 参数,或使用 TextDecoderStream,可以避免这个问题 [4][5]。
第二个环节是行拆分。JSON 数据被拆到两个 chunk 时,按行拆分需要把缓冲区末尾不完整的一行保留到下一轮循环。CRLF 换行同理,先对行内容做 trim(),再判断前缀。
SSE 规范允许多行 data 合并 [1],但 OpenAI-compatible 接口的每个事件都只发送一行 data,因此解析器不需要处理多行 data 的拼接逻辑。
SSE 与 WebSocket 的选型边界
SSE 和 WebSocket 都能做实时推送,但适用场景不同。
SSE 是基于 HTTP 的单向服务器推送协议。客户端通过 EventSource 或 fetch 读取 text/event-stream。优点是实现简单、基于标准 HTTP、自动重连;缺点是标准 EventSource 只能 GET 且不能自定义请求头 [3],数据方向是单向的。
WebSocket 是全双工协议,客户端和服务端都可以随时发送消息。它需要单独的握手和升级过程,消息格式是二进制或文本,没有内置的重连机制。
LLM 对话的典型流程是客户端提交一次消息,服务端持续推回结果。数据方向主要是服务器到客户端,使用 SSE 足以覆盖。如果应用需要客户端频繁发送消息、服务器同时主动推送其他事件,例如协同编辑或多人游戏,WebSocket 更合适。
接口提供方也是一个决定因素:服务商提供的接口类型决定了前端只能用对应的客户端。OpenAI-compatible 的 HTTP 接口在 stream: true 时返回 text/event-stream,使用 fetch 读取即可,不需要升级到 WebSocket [6][7]。
参考实现
如果不希望维护自己的解析器,可以参考以下封装。它们没有改变底层的 HTTP 和 SSE 协议,只是把重复的拆帧逻辑内置化。
- OpenAI Node SDK:创建
stream: true的请求后返回异步可迭代对象,调用方通过for await遍历ChatCompletionChunk。SDK 内部完成 SSE 拆帧。 - Vercel AI SDK:服务端负责把模型输出序列化为 SSE 响应,浏览器端通过
useChat管理消息列表与流式状态。前端的网络层依然是fetch+ReadableStream。 - ChatGPT-Next-Web 等开源聊天前端项目:包含完整的浏览器端流式请求实现,可以直接阅读其请求模块作为工程参考。
这些封装没有改变传输协议。理解底层的 ReadableStream 与 SSE 行解析方式后,即使不依赖封装,也可以独立实现同一套流程。
小结:从字节流到实时对话的完整链路
LLM 流式对话的数据链路可以概括为:
- 服务端将模型生成的增量文本封装为 SSE 事件,写入 HTTP 响应体。
- 浏览器通过
fetch发起 POST 请求,获得ReadableStream。 - 逐块读取字节流,用
TextDecoder(stream: true)解码为字符串。 - 按行拆帧,去掉
data:前缀,解析 JSON。 - 提取
choices[0].delta.content,作为文本增量交给 UI 层。 - UI 层通过
requestAnimationFrame合并增量,以打字机效果更新界面。 - 用户停止或组件卸载时,
AbortController中断 fetch,服务端感知连接关闭后停止生成。
每个环节都有各自的边界问题:字节流中的字符边界、块与行的不对齐、代理缓冲、断线语义、取消协作。理解这些边界后,前端消费 LLM 流式接口就不需要依赖第三方封装,直接用标准 API 就能实现。
参考链接
- [1] https://www.w3.org/TR/2012/WD-eventsource-20120426
- [3] https://gist.github.com/bayotop/a8c503348fdcf12200257384809b1b61
- [4] https://github.com/whatwg/html/issues/2177
- [5] https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/cancel
- [6] https://developers.cloudflare.com/durable-objects/examples/readable-stream
- [7] https://docs.anyone.ai/zh/api-reference/chat-completions
- [8] https://api-docs.deepseek.com/zh-cn/api/create-chat-completion
