🔥前端也能搞懂流式输出:从 SSE 到打字机效果

27 阅读18分钟

不调框架、不用 EventSource,只用原生 fetch + ReadableStream,把"AI 一个字一个字往外蹦"这件事从原理到代码亲手实现一遍。 全程浏览器原生 API,一个大模型 API Key 就能复现。

🎯 本文目标

读完你应该能:用自己的话讲清"为什么 AI 的回答是逐字出现的";说清楚 SSE 和 WebSocket 该怎么选、为什么 AI 流式偏偏不用 EventSource;手写一条 fetch → ReadableStream → SSE 解析 → 打字机渲染 的完整链路;并且知道粘包、中止、兼容这几个坑该怎么填。


写在前面:这篇想帮你解决什么

用过 ChatGPT、DeepSeek 的人都见过那个效果:回答不是"啪"地整段出现,而是像有人在打字一样,一个字一个字往外冒。

很多前端同学以为这只是个"动画效果",前端拿到完整结果后自己做了个逐字显示的定时器。这是最常见的误解。 真到面试被追问"流式是怎么实现的""为什么用 SSE 不用 WebSocket""fetch 怎么读流",就答不上来了——因为没亲手拆过。

这篇文章带你把它拆开:先搞懂字为什么是"蹦"出来的,再一行行手写前端怎么接、怎么渲染,最后填掉三个真实会踩的坑。每一章只解决一个问题。


⚡ 第 1 章:先搞懂——为什么 AI 的字是"蹦"出来的

🎯 本章目标

建立"流式不是前端的动画,而是模型生成方式的直接暴露"这个直觉。

关键前提:模型本来就是逐字生成的

大模型生成文本,是自回归的——算出第一个字,把它接回输入,再算第二个字,如此循环,直到结束。它天生就是"一个 token 一个 token"往外吐的,根本不存在"一次性想好整段话"这回事。

如果你读过我那篇《模型缓存策略详解》,这就是里面讲的 Decode 阶段:每一步只生成一个新 token,再追加进上下文继续算下一个。

既然模型本来就是逐字产生结果的,那就有两种把结果给到前端的方式:

方式服务端行为用户感受
非流式等模型把整段话全部生成完,再一次性返回盯着转圈等好几秒,然后整段蹦出来
流式模型每生成一个片段,就立刻推给前端首字很快出现,边生成边看

所以流式输出不是什么额外的高级技术,它只是"把模型本来就在逐字产生的结果,逐字转发给前端"而已。 前端要做的,也不是自己造打字动画,而是如实地把陆续到达的片段接住、拼起来、显示出来

为什么值得做:TTFT 才是体验的命门

衡量流式体验有个关键指标叫 TTFT(Time To First Token,首字延迟)——从用户点下按钮,到屏幕上出现第一个字,花了多久。

  • 非流式:TTFT = 整段生成时间,可能 5~10 秒,这期间用户只能干等。
  • 流式:TTFT 常常只有几百毫秒,第一个字很快出来,用户立刻知道"它开始干活了"。

总耗时可能一样,但体感天差地别。 让用户"早点看到东西在动",是流式输出真正的价值。

🧭 承上启下

既然要"服务器一有片段就推给前端",那就需要一种"服务器主动往客户端推数据"的通道。这种通道有好几种,为什么 AI 场景几乎清一色选了 SSE?下一章说清楚。


📡 第 2 章:SSE 是什么,为什么不是 WebSocket

🎯 本章目标

搞懂 SSE 的定位,以及"单向推送"为什么刚好是 AI 流式的最优解。

SSE:服务器单向推送的"够用就好"方案

SSE(Server-Sent Events,服务器发送事件) 是一种服务器向客户端单向、持续推送数据的技术。它就架在普通 HTTP 之上,没有另起炉灶。

拿它和大家更熟的 WebSocket 对比一下:

SSEWebSocket
方向单向(服务器 → 客户端)双向
底层就是一条 HTTP 响应独立协议(需要握手升级)
复杂度低,普通接口就能返回高,要专门的服务和连接管理
适合场景AI 流式、消息通知、进度推送聊天室、协同编辑、游戏对战

AI 流式输出的数据流向是什么? 用户发一次问题(一次普通请求),然后服务器源源不断把生成的字推回来。这是彻头彻尾的单向场景——前端在生成过程中并不需要再往服务器插话。

WebSocket 的双向能力在这里完全用不上,白白背上"额外协议、连接管理、心跳保活"的复杂度。SSE 用一条 HTTP 响应就把这事办了,够用、且更简单。 选型的第一原则永远是"匹配场景",不是"谁更高级"。

SSE 的数据长什么样

大模型 API(OpenAI、DeepSeek、通义等大多兼容这套格式)开启流式后,返回的响应体是这样一行一行的纯文本:

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

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

data: {"choices":[{"delta":{"content":"输出"}}]}

data: [DONE]

三个约定记住就行:

  1. 每条消息以 data: 开头,后面跟一段 JSON。
  2. 消息之间用空行(\n\n)分隔。
  3. 最后用一条 data: [DONE] 表示流结束。

注意那个 JSON 里是 delta(增量)而不是 message——流式返回的每一片只包含这一次新增的那几个字,不是累计的全文。要完整内容,得前端自己把这些 delta 一路拼起来。这点后面写代码时是核心。

🧭 承上启下

数据格式清楚了。但真到前端接收,第一个反直觉的问题就冒出来了:浏览器明明内置了一个专门收 SSE 的 EventSource API,为什么做 AI 流式几乎没人用它,反而要用 fetch 手动读流?


🔌 第 3 章:前端怎么收——为什么是 fetch 而不是 EventSource

🎯 本章目标

理解 EventSource 的致命短板,以及 fetch + ReadableStream 手动读流的每一步在干什么。

先解决那个"为什么不用 EventSource"

浏览器确实内置了接收 SSE 的原生 API EventSource,几行就能用。但它有一个在 AI 场景里致命的限制:只能发 GET 请求,且不能自定义请求头和请求体。

而调用大模型 API 的请求长什么样?

  • 得用 POST(要把 messages、模型名、stream: true 放在请求体里);
  • 得带 Authorization 请求头(放 API Key)。

这两条 EventSource 一条都满足不了。所以社区的通用做法是:fetch 发起 POST 请求,再手动读取它返回的 ReadableStream,自己解析 SSE。 这不是炫技,是被 EventSource 的能力边界逼出来的唯一选择。

📎 顺便一提:EventSource 本来怎么用?

它其实比 fetch 手动读流省事得多——浏览器把"读流、解码、按行拆、认 data: 前缀"全内置了,你只管拿 event.data

const es = new EventSource('/api/notify')   // 传个地址就开始收(只能 GET)

es.onmessage = (event) => {                 // 每收到一条 data: 就触发一次
  if (event.data === '[DONE]') { es.close(); return }
  console.log('收到:', event.data)
}

es.onerror = (err) => console.error('出错(它还会自动重连)', err)

它甚至连"断线自动重连"都替你做了。可它偏偏用不了——看它的完整构造签名就明白卡在哪:

new EventSource(url, { withCredentials: true })
//              ↑ 只有 URL      ↑ 配置项里只有 withCredentials,没别的

没有 method 参数 → 只能 GET,塞不下 messagesstream: true 这些请求体;没有 headers 参数 → 加不了 Authorization,API Key 只能拼到 URL 上明文暴露。功能更强的它,就这样被两条硬伤锁死在 AI 场景之外。

反过来,如果是你自己的后端、GET 够用、不需要鉴权头的 SSE(站内通知、部署进度推送),EventSource 才是更省事的正解。

💡 这是个高频考点

"为什么 AI 流式不用 EventSource 而用 fetch" —— 能答出"EventSource 只支持 GET、不能带 header 和 body,而大模型请求必须 POST + 鉴权头",比只会背"用 fetch 读流"深一个层次。

手动读流三件套:getReader + TextDecoder + 解析

fetch 的响应体 response.body 是一个 ReadableStream(可读流)。读它需要三样东西配合:

  1. response.body.getReader() —— 拿到流的"读取器",之后反复 read() 就能一块块把数据取出来。
  2. TextDecoder —— 流里出来的是二进制 Uint8Array,用它解码成字符串。
  3. 手动解析 —— 把解码出的文本按 SSE 格式(data: 开头、[DONE] 结束)拆出真正的内容。

先别急着封装,在浏览器 F12 控制台里跑通一遍,亲眼看到字是怎么一块块到的:

// 直接贴进浏览器控制台运行(把 API_KEY 换成你自己的)
const API_KEY = '你的 API Key'

async function testStream() {
  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',                              // ① 必须 POST
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`       // ② 必须带鉴权头(EventSource 做不到)
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages: [{ role: 'user', content: '用一句话介绍你自己' }],
      stream: true                               // ③ 关键开关:开启流式
    })
  })

  const reader = response.body.getReader()       // 拿到读取器
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()  // 一块块读
    if (done) break                              // 流读完了

    const chunk = decoder.decode(value, { stream: true })  // 二进制 → 字符串

    // 按 SSE 格式解析:只要 data: 开头的行
    const lines = chunk.split('\n').filter(line => line.startsWith('data: '))
    for (const line of lines) {
      const data = line.replace('data: ', '')
      if (data === '[DONE]') { console.log('✅ 流结束'); continue }

      const json = JSON.parse(data)
      const content = json.choices[0]?.delta?.content   // 取增量内容
      if (content) console.log('收到:', content)
    }
  }
}

testStream()

跑一下,控制台会一条条打印出"收到:流""收到:式""收到:输出"——这就是那个"打字"效果的数据来源。到这一步,流式的本质你已经亲眼见到了:没有魔法,就是一个 while 循环在不停地读、拼、显示。

⚠️ decoder.decode(value, { stream: true }) 里的 { stream: true } 不是可有可无

一个多字节字符(比如一个中文字)在传输时可能被拆到两个 chunk 里。加了 { stream: true }TextDecoder 会记住上一块没解码完的半个字,等下一块补齐——不加就可能解出乱码。这是第 6 章"粘包"问题的第一道防线。

🧭 承上启下

控制台里能跑通了,但把逻辑摊在业务代码里显然不行。下一步把它收拢成一个"只管吐字、不管 UI"的可复用函数。


🧩 第 4 章:封装成一个"只吐字"的函数

🎯 本章目标

用一个回调把"数据接收"和"界面渲染"彻底解耦。

好的封装只做一件事:它负责把字一个个解析出来,至于这些字怎么显示,交给外面。 做法是给它一个 onChunk 回调——每解析出一片新内容,就调一次,把"新增的这片"和"到目前为止的全文"都交出去。

// stream.js —— 只负责接收和解析 SSE,不碰任何 UI(纯 JS,用不用框架都能跑)
const API_URL = 'https://api.deepseek.com/chat/completions'
const API_KEY = 'YOUR_API_KEY'   // ⚠️ 仅演示用;真实项目里 Key 要放后端,别硬编码在前端

/**
 * @param {string}      prompt   发给模型的问题
 * @param {function}    onChunk  每收到一片就回调 (新增内容, 累计全文)
 * @param {AbortSignal} signal   用于中止请求(见第 6 章)
 * @returns {Promise<string>}    最终完整内容
 */
async function streamChat(prompt, onChunk, signal) {
  const response = await fetch(API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      messages: [{ role: 'user', content: prompt }],
      stream: true
    }),
    signal   // 把中止信号透传给 fetch
  })

  if (!response.ok) throw new Error(`API 错误:${response.status}`)

  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let fullContent = ''

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    // 解码这一块,按行拆,只留 data: 开头的行(和第 3 章控制台版一样)
    const chunk = decoder.decode(value, { stream: true })
    const lines = chunk.split('\n').filter(line => line.startsWith('data: '))

    for (const line of lines) {
      const data = line.slice(6)   // 去掉 'data: '
      if (data === '[DONE]') continue

      try {
        const content = JSON.parse(data).choices[0]?.delta?.content
        if (content) {
          fullContent += content
          onChunk(content, fullContent)   // 回调:把字交出去
        }
      } catch {
        // 解析失败先跳过(这里藏着一个坑,第 6 章专门修)
      }
    }
  }
  return fullContent
}

这个函数跟任何框架都没关系——它只认一个 onChunk 回调。纯 HTML 里 onChunk 就直接改 DOM,React 里就 setState,Vue 里就更新 ref,换任何框架、甚至不用框架,这段核心逻辑一行都不用动。把"接数据"和"画界面"拆开,是这段封装最值钱的地方。

⚠️ 这个版本"能用",但埋了个偶尔丢字的隐患

上面 chunk.split('\n') 直接处理,默认了"每次拿到的 chunk 都是完整的一行行"。但真实网络不保证这点——一行 data: 可能被切成两半分两次到达,那半行就会 JSON.parse 失败被 catch 跳过,这个字就悄悄丢了。现在先不管它,第 6 章坑 1 会专门修这个问题,到时候你会看到那个 catch 为什么还不够。

🧭 承上启下

函数会吐字了,接下来就是"画"——把陆续到达的字变成带闪烁光标的打字机效果。


⌨️ 第 5 章:打字机效果——渲染 + 光标

🎯 本章目标

理解"打字机"效果其实不需要定时器,数据驱动天然就是打字机。

这里要破除另一个误解:很多人以为打字机效果要写一个 setInterval,每隔几十毫秒显示一个字。完全不用。

因为字本来就是陆续到达的——onChunk 每次带来一小片,你只要把累计的全文写进页面,屏幕上自然就逐字长出来了。数据的节奏就是打字的节奏。

纯 HTML + JS 写一个最小页面(不依赖任何框架,存成 .html 双击就能开):

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <style>
    /* 输出区:保留 AI 返回里的换行和空格 */
    #output {
      white-space: pre-wrap;
      line-height: 1.7;
      min-height: 60px;
    }
    /* 闪烁光标 */
    .cursor {
      animation: blink 0.8s infinite;
      color: #3451b2;
    }
    @keyframes blink {
      0%, 50%   { opacity: 1; }
      51%, 100% { opacity: 0; }
    }
  </style>
</head>
<body>
  <button id="askBtn">🤖 提问</button>
  <!-- 输出区 + 一个默认隐藏的光标 -->
  <div id="output"></div>
  <span id="cursor" class="cursor" style="display: none"></span>

  <script type="module">
    import { streamChat } from './stream.js'   // 第 4 章那个函数

    const output = document.getElementById('output')
    const cursor = document.getElementById('cursor')
    const askBtn = document.getElementById('askBtn')

    askBtn.addEventListener('click', async () => {
      output.textContent = ''                 // 清空上一次
      cursor.style.display = 'inline'          // 显示光标 = 开始输出

      try {
        await streamChat('用一句话介绍你自己', (_chunk, full) => {
          output.textContent = full            // ← 每来一片就写进 DOM,屏幕逐字生长
        })
      } finally {
        cursor.style.display = 'none'          // ← 无论成功失败都收起光标
      }
    })
  </script>
</body>
</html>

整个"打字机"就靠回调里那一行 output.textContent = full数据每到一片就把最新全文写进 <div>,浏览器重绘,字就一个个冒出来了。 没有 setInterval、没有框架、没有响应式——你能清楚看到"数据到达"和"界面更新"是直接对应的。

两个容易被忽略的小细节,恰恰是"精致感"的来源:

  • white-space: pre-wrap:模型返回的内容里有换行、缩进、列表。不加这行,所有换行会被浏览器吞掉,排版全乱。
  • 光标用一个独立的 <span>,靠显隐控制:开始输出时 display: inline,结束时 display: none——流一结束就撤掉光标,用户能清楚感知"它说完了"。

💡 换成 Vue / React 也是同一回事

框架无非把"手动 output.textContent = full"换成"更新一个状态变量、由框架重渲染":Vue 里是 answer.value = full、React 里是 setAnswer(full),光标的显隐则绑到一个 isStreaming 状态上。核心那句"每来一片就更新界面"一模一样,变的只是"谁负责把变量刷进 DOM"。所以这篇用最朴素的原生 DOM 演示,你看懂了,迁到任何框架都是顺的。

🧭 承上启下

Demo 顺风顺水,真实网络却不讲道理:数据包会被拆散、用户会中途反悔、Safari 会闹脾气。最后一章专门填这三个坑。


🕳️ 第 6 章:三个真实会踩的坑(这节最能体现工程成熟度)

🎯 本章目标

把"Demo 能跑"补齐到"线上能用",每个坑都对应一段面试能讲的细节。

坑 1:粘包 / 半包——一片 data: 可能是半截的(最重要)

现象:解析时偶尔报错,打印出来一看,data: {"choices":[{"delta":{"conte ——JSON 只有半截。

原因:网络是按数据包传输的,它根本不管你的 SSE 消息边界。一次 read() 拿到的 chunk,可能正好切在一条 data: 消息的中间;也可能一次带来两三条完整消息 + 半条。这就是"粘包/半包"。第 4 章那个简单版正是栽在这——半行 JSON.parse 失败被 catch 跳过,字就悄悄丢了。

解法:加一个跨轮次的缓冲区 buffer。核心思路是——读到的内容先塞进缓冲区,按 \n 切行,最后一行大概率不完整,先留着不处理,等下一轮 read() 把它补全再说。 相比第 4 章,改动只有加粗的三处:

const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''          // ★ 改动1:新增一个跨轮次的缓冲区
let fullContent = ''

while (true) {
  const { done, value } = await reader.read()
  if (done) break

  buffer += decoder.decode(value, { stream: true })  // ★ 改动2:累加进 buffer,不是每轮从零开始
  const lines = buffer.split('\n')
  buffer = lines.pop()   // ★ 改动3:最后一行可能是半截的,抠出来留到下一轮再拼

  for (const line of lines) {
    // 现在处理的都是完整的行了
    if (!line.startsWith('data: ')) continue
    const data = line.slice(6)
    if (data === '[DONE]') continue
    try {
      const content = JSON.parse(data).choices[0]?.delta?.content
      if (content) { fullContent += content; onChunk(content, fullContent) }
    } catch { /* 兜底,见下 */ }
  }
}

📎 每次 value 到底是什么?分 3 次到达走一遍

每次 reader.read() 拿到的 value 是一段二进制字节(Uint8Array),decode 后变成字符串。但网络在哪里切这段字节是随机的,它根本不管你 SSE 一条消息的边界——可能切在消息中间,也可能一次给你两条半。

假设服务端要发这 3 条:

data: {"delta":"你"}
data: {"delta":"好"}
data: {"delta":"呀"}

网络很可能这样乱切成 3 次到达(注意断点):

1次 value → 'data: {"delta":"你"}\ndata: {"del'      ← 结尾是半条
第2次 value → 'ta":"好"}\ndata: {"delta'              ← 开头是上条的后半截
第3次 value → '":"呀"}\n'

buffer 怎么逐轮把它们接起来:

// 第1轮:buffer = 'data: {"delta":"你"}\ndata: {"del'
//   split('\n') → ['data: {"delta":"你"}',  'data: {"del']
//                   └─ 完整 ✅              └─ 半条 ❌
//   pop() 把半条 'data: {"del' 存回 buffer,循环只处理那条完整的 → 解析出「你」

// 第2轮:buffer = 'data: {"del' + 'ta":"好"}\ndata: {"delta'
//               = 'data: {"delta":"好"}\ndata: {"delta'   ← 半条被补全了!
//   split('\n') → ['data: {"delta":"好"}',  'data: {"delta']
//   pop() 又把新半条 'data: {"delta' 存回 buffer → 解析出「好」

// 第3轮:buffer = 'data: {"delta' + '":"呀"}\n'
//               = 'data: {"delta":"呀"}\n'              ← 又补全了
//   split('\n') → ['data: {"delta":"呀"}',  '']
//   pop() 把末尾空串存回 buffer(收尾干净)→ 解析出「呀」

为什么"看起来没手动动 buffer"它也在滚动? 因为每轮 buffer += value 是累加、buffer = lines.pop() 又把没处理完的尾巴存回同一个 buffer——这一加一存,buffer 自己就在跨轮次接力。核心就一句:split('\n') 后除了最后一段,前面每段后面都跟过 \n、保证是完整行;唯独最后一段悬着(还没等到 \n),所以 pop 出来留到下一轮补全。 万一某条消息特别长、跨了三四轮,它就一直躺在 buffer 里被接力,直到那个 \n 到了才被切出来处理。

再叠加 JSON.parse 外面的 try-catch 兜底,双保险:结构上用 buffer 保证"只处理完整的行",运行时用 try-catch 兜住"万一还是解析失败就跳过"。 这个坑几乎每个手写流式的人都会踩,能讲清楚"为什么会半包、buffer 怎么解决",是这篇最硬的面试素材。

📎 还有个收尾边界:循环结束后,buffer 里可能还剩最后一条(面试加分点)

buffer 的机制是"最后一段先留着,等下一轮补全"。但最后一轮之后没有"下一轮"了——如果服务端最末一条 data: 后面没有换行 \n(有些服务端会这样),那这条就会一直躺在 buffer 里,while 循环 done 退出后它再也没机会被处理,等于丢了最后一条

严谨的写法是在循环结束后,把 buffer 里的残余再 flush 一次:

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  // …前面的 buffer 逐行处理…
}

// ★ 循环结束后:buffer 里可能还剩没处理的最后一行,补处理一次
const last = buffer.trim()
if (last.startsWith('data: ')) {
  const data = last.slice(6)
  if (data !== '[DONE]') {
    try {
      const content = JSON.parse(data).choices[0]?.delta?.content
      if (content) { fullContent += content; onChunk(content, fullContent) }
    } catch { /* 残缺就算了 */ }
  }
}

实践中大模型的 SSE 基本都以 data: [DONE] 结尾、且带换行,所以正文那版没写这步也能跑。但你能主动说出"循环结束后应该 flush 一次残余 buffer,否则末条没换行就会丢",在面试里是个能体现严谨度的细节。

坑 2:用户中途反悔——用 AbortController 真正掐断请求

生成到一半,用户不想看了,或者要换个问题重问。这时候不能只是把界面藏起来——底层的 fetch 还在偷偷下载、还在烧 token

正确做法是用 AbortController 从根上掐断:

let controller = null

async function ask(question) {
  controller = new AbortController()
  cursor.style.display = 'inline'
  try {
    await streamChat(question, (_c, full) => { output.textContent = full }, controller.signal)
  } catch (err) {
    if (err.name === 'AbortError') {
      // 用户主动停止,这不是错误,静默处理即可
    } else {
      output.textContent = '出错了:' + err.message
    }
  } finally {
    cursor.style.display = 'none'
  }
}

function stop() {
  controller?.abort()   // 真正中止 fetch,连接断开,不再烧 token
}

两个要点:signal 必须一路透传给 fetch(第 4 章封装里已经留好了这个参数);中止会让 fetch 抛出一个 nameAbortError 的错误,要在 catch 里把它和真正的网络错误区分开——用户主动停止不是 bug,不该弹报错。

最后把 ask / stop 接回第 5 章那个页面——加一个「停止」按钮,stop 就有地方触发了:

<button id="askBtn">🤖 提问</button>
<button id="stopBtn">⏹ 停止</button>   <!-- 新增:停止按钮 -->
askBtn.addEventListener('click', () => ask('用一句话介绍你自己'))
stopBtn.addEventListener('click', stop)   // ← stop 就绑在这里

链路这就闭合了:点「提问」→ ask 发起流式、记下 controller → 生成途中点「停止」→ stopcontroller.abort()fetch 中断 → ask 的 catch 收到 AbortError,静默收尾。

坑 3:Safari 上流式卡住不动

现象:Chrome 一切正常,Safari(尤其 iOS)上流式经常卡住、憋到最后才一次性蹦出来。

原因:Safari 对 ReadableStream 的增量刷新有自己的边界行为。好在坑 1 里那套 buffer 逐行处理的写法,本身就规避了大部分问题——如果你已经按坑 1 正确处理了缓冲,Safari 的多数卡顿也就跟着解决了。 这也说明健壮的写法往往能一次性覆盖好几个边界情况。

别忘了:错误发生在"已经吐了一半"的时候

流式有个非流式没有的尴尬:报错时,屏幕上可能已经显示了半段内容。 网络断在中途、模型触发限流……这时候不能粗暴清空,建议在已有内容后面追加一句提示,让用户知道"这是中断,不是全部":

catch (err) {
  if (err.name !== 'AbortError') {
    output.textContent += '\n\n⚠️(回答意外中断,请重试)'
  }
}

🎁 结语:流式输出没有魔法

💎 只记一句话也够

流式输出不是前端造的打字动画,而是"模型本来就逐字生成,前端如实地逐字接住并显示"——难的从来不是让字动起来,而是让它在真实网络里稳稳地动。

回头把这条链路串一遍,其实特别短:

  1. 为什么能流式:模型自回归、逐 token 生成,本就是一个字一个字产出的。
  2. 用什么传:SSE,单向推送、架在 HTTP 上,刚好匹配"一次提问、持续接收"的场景。
  3. 前端怎么收fetch 发 POST(EventSource 带不了鉴权头),getReader() 读流,TextDecoder 解码,手动解析 data:
  4. 怎么显示:把累计全文绑定到界面,数据到达的节奏天然就是打字节奏,末尾加个闪烁光标。
  5. 怎么变稳buffer 治粘包、AbortController 管中止、区分 AbortError 和真错误。

代码加起来也就百来行,没有一处是黑魔法。真正拉开差距的,是能讲清每一步"为什么这么做"——为什么不用 EventSource、为什么会半包、为什么错误要区分类型。这些细节,才是面试官眼里你和"用过某个流式 SDK"的人之间的区别。

🚀 下一步可以接什么

这套流式链路是很多 AI 功能的地基:接上多轮对话历史,就是完整的聊天;把 onChunk 里的文本边收边喂给 Markdown 渲染器,就是 ChatGPT 那种实时格式化效果;再往上,Agent 的"思考过程可视化"也是同一套流在支撑。


💡 原创声明

本文首发于我的个人博客 rjy92.github.io。如需转载请注明出处。 如果这篇文章帮到了你,欢迎在以下平台关注、交流: