---
url: /frontend-ai/streaming-sse.md
description: >-
  面向前端工程师的大模型流式输出科普：为什么模型逐 token 生成、SSE 的 data 行与空行分隔协议、为什么该用 fetch 而不是
  EventSource，以及断字处理、[DONE] 结束标记、用 AbortController 停止生成与断线降级。
---

# 大模型流式输出：SSE 在前端怎么接

这篇解决一个很具体的问题：模型在服务端一个字一个字地吐，浏览器这边怎么把它「边生成边显示」出来，而不是盯着转圈等到最后。读完你应该能自己写一个不依赖任何库的流式客户端，也知道它会死在哪儿。

## 为什么大模型天生适合流式

大模型（Large Language Model）不是算完一整段再返回的，它是自回归（autoregressive）逐 token（词元）生成的：每吐一个 token 要做一轮前向计算，而下一轮的输入里就包含上一轮的输出。也就是说服务端本来就是一拍一拍地拿到结果，攒齐了再发纯属人为延迟。首字时间（TTFT，Time To First Token）通常远小于整段生成时间，把这些增量立刻画到屏幕上，用户感知到的「快」提升非常明显，长回答尤其夸张。

## SSE 长什么样

SSE（Server-Sent Events，服务器推送事件）是跑在 HTTP 上的极简文本协议：响应头 `Content-Type: text/event-stream`，连接不关，body 就是一条条纯文本事件。每条事件由若干行 `data: ` 组成，以一个空行结尾——这个空行就是分隔符，少一个，后面的数据会一直黏在缓冲区里出不来。

```bash
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]
```

OpenAI 兼容接口（`stream: true`）就是这个形状：增量在 `choices[0].delta.content`，最后用一条 `data: [DONE]` 收尾。如果开了 `stream_options.include_usage`，`[DONE]` 之前还会多一条只带 usage（用量统计）的 chunk。

## 为什么前端用 fetch 而不是 EventSource

浏览器自带 `EventSource`，三行就能接上，但它有三个硬伤：只能发 GET，带不了请求体；不能加自定义请求头，`Authorization` 传不进去；断线后浏览器会自作主张地重连，聊天场景下会把同一句提示词反复重发。所以生产里基本都绕开它，改用 `fetch` 加 ReadableStream（可读流）自己解析。`response.body` 本身就是个 ReadableStream，`getReader()` 拿到读取器，`read()` 返回 `{ done, value }`，其中 `value` 是 Uint8Array。

## 最小可用的 TS 读流实现

```ts
export async function* streamChat(prompt: string, signal: AbortSignal) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt }),
    signal,
  });
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

  const reader = res.body.getReader();
  const decoder = new TextDecoder(); // 默认 utf-8
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    // stream: true —— 半个中文留在解码器里，等下一个 chunk 拼上
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? ''; // 最后一段可能不完整，留给下一轮
    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      const payload = line.slice(6).trim();
      if (payload === '[DONE]') return;
      yield JSON.parse(payload).choices[0]?.delta?.content ?? '';
    }
    if (done) return;
  }
}
```

关键就两处：`decoder.decode(value, { stream: true })` 处理跨 chunk 断字，一个中文占三字节，chunk 边界正好劈开它是常态，不传这个参数就等着页面蹦乱码；buffer 只切到最后一个换行，剩余部分留给下一轮，因为一行 JSON 也可能横跨两个 chunk。

## 停止生成、\[DONE] 与断线降级

「停止生成」别用 `reader.cancel()` 硬切，标准做法是 `AbortController`：把 `controller.signal` 传进 `fetch`，用户点停止就 `controller.abort()`，请求直接终止，`read()` 抛出 AbortError，记得 catch 掉、别当业务错误弹窗。已经渲染的文本留不留，是产品决策不是技术问题。

`[DONE]` 是 OpenAI 风格的结束标记，不属于 SSE 协议本身：服务端可能正常关流，也可能不发 `[DONE]` 就断掉，所以别把它当唯一结束条件，`done === true` 和 catch 分支同样要能收尾。

断线重连：SSE 有 `Last-Event-ID` 续传机制，但大模型接口基本不支持从中间续，实践中要么整轮重发，要么拿已生成的内容当上下文续写。更省事的兜底是降级——流式失败就自动补发一次非流式请求（`stream: false`），把完整答案填进气泡，用户至少拿到了结果。

## 流式对 UI 状态管理的影响

* 状态从「请求中 / 完成」两态，膨胀成「待发送 → 已发送 → 流式接收 → 完成 / 中断 / 出错」多态，停止按钮只在流式接收阶段可点。
* 高频 setState：token 级更新可能几十毫秒一次，别每个 chunk 都触发整棵消息列表重渲染，只动当前这一条。
* 消息对象要可变：同一条 assistant 消息的内容一直在长大，别用「先占位、后替换」的思路写。
* 首字之前要有骨架兜底，接口 200 但服务端迟迟不吐第一个 token 是常见现象。
* 副作用等流真正结束再做：入库、埋点、触发下一步，都得挂在结束回调上。

::: warning
别在不需要流式的地方硬上流式。三类场景建议直接非流式：输出很短（几十字以内，首字和末尾的差别肉眼看不出来）、结构化输出（JSON 要能整体 parse，流式收一半解析必然报错）、要过 CDN 或反向代理（Nginx 默认 `proxy_buffering on` 会把流缓冲住，看起来跟一次性返回没区别，得显式关掉）。另外前端解析一定要 try/catch：`JSON.parse` 遇到半个包会直接抛，一个坏 chunk 不该让整轮对话崩掉。
:::
