Skip to content

大模型流式输出: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 是常见现象。
  • 副作用等流真正结束再做:入库、埋点、触发下一步,都得挂在结束回调上。

警告

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

最近更新