从前端视角理解 AI 流式输出:从二进制流到打字机效果

0 阅读6分钟

从前端视角理解 AI 流式输出:从二进制流到打字机效果

前言

最近在探索 AI Agent 开发时,有一个体验细节让我印象深刻——流式输出。当你向 ChatGPT、DeepSeek 等大模型提问时,回答不是瞬间出现的,而是像打字机一样逐字逐句地"流"出来。这个看似简单的效果,背后涉及二进制编解码、网络流(Stream)、浏览器 API 等一系列知识。本文将从零开始,带你一步步理解并实现它。


一、一切皆二进制:TextEncoder 与 TextDecoder

在理解"流"之前,先要理解计算机世界的一个基本事实:一切数据最终都是二进制

const encoder = new TextEncoder();
const bytes = encoder.encode("你好");
console.log(bytes); // Uint8Array [228, 189, 160, 229, 165, 189]

"你好"这两个汉字,在底层被编码为 6 个字节(每个汉字占 3 个 UTF-8 字节)——每个字节的值都在 0-255 之间,这就是我们所说的 Uint8Array(无符号 8 位整数数组)。

反过来,当我们收到二进制数据时,需要用解码器还原:

const decoder = new TextDecoder();
const str = decoder.decode(bytes);
console.log(str); // "你好"

为什么这很重要? 因为大模型的"流式响应",本质上就是服务器向浏览器持续发送的二进制流。要处理这些数据,我们必须掌握编解码。


二、流式输出 vs 非流式:两种应答模式

看下面这段调用 DeepSeek API 的代码,它展示了两种模式的区别:

const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: question.value }],
    stream: stream.value  // 关键参数:是否开启流式
  })
});

非流式模式 (stream: false)

用户提问 → 服务器思考 → 生成完整回答 → 一次性返回 JSON

就像等快递——你必须等整个包裹打包好才能收到:

const data = await response.json();
content.value = data.choices[0].message.content; // 一次性拿到全部内容

流式模式 (stream: true)

用户提问 → 服务器边思考边发 → 浏览器边收边显示 → 像打字机逐字输出

就像看直播——数据源源不断地"流"过来:

const reader = response.body?.getReader(); // 获取读取器
const decoder = new TextDecoder();

while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;
  // 逐步处理每一小块数据...
}

三、ReadableStream:浏览器里的"水管"

流式模式的核心是 response.body——它是一个 ReadableStream 对象,你可以把它想象成一根从服务器通向浏览器的"水管":

┌──────────┐                          ┌──────────┐
│  DeepSeek │ ──── 二进制流 ────────→ │  浏览器   │
│   服务器   │     (水管)              │          │
└──────────┘                          └──────────┘

我们从这根水管里"嘬"数据的核心 API:

const reader = response.body?.getReader(); // 获取读取器对象
const { value, done } = await reader.read(); // 读一口
  • getReader() :获取一个读取器,就像在水管口装了个"水龙头"
  • reader.read() :拧开水龙头,接一杯水。value 是本轮读到的二进制数据(Uint8Array),done 标识流是否结束
  • await:如果暂时没有新数据到达,就等着——等 LLM 算出了下一个 token

这种机制完美地适配了大模型的工作方式:计算一个 token,发送一个 token,而不是等全部算完再发送。


四、SSE 协议解析:从二进制到可读文本

拿到二进制数据后,真正的挑战是解析数据格式。流式响应遵循 SSE(Server-Sent Events) 规范,每行数据的格式是:

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

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

data: [DONE]

解析逻辑如下:

let buffer = ''; // 缓冲区——处理跨 chunk 的残片

while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;

  // 拼上上一轮没处理完的残片
  const chunkValue = buffer + decoder.decode(value);
  buffer = '';

  // 按行分割,筛选出 data: 开头的行
  const lines = chunkValue.split('\n')
    .filter((line) => line.startsWith('data: '));
  
  for (const line of lines) {
    const jsonStr = line.slice(6); // 去掉 "data: " 前缀
    if (jsonStr === '[DONE]') break; // 流结束信号
    
    const data = JSON.parse(jsonStr);
    const delta = data.choices[0].delta.content;
    if (delta) {
      content.value += delta; // 追加到页面,实现打字机效果
    }
  }
}

为什么需要 buffer

因为 TCP 是流式传输,一个 chunk 可能包含半行数据。比如:

Chunk 1: "data: {"choices":[{"delta":"
Chunk 2: "{"content":"你好"}}]}\n"

如果不缓冲,JSON.parse(chunk1) 就会报错。因此需要把每轮没处理完的残片存起来,跟下一轮的数据拼接——buffer 就像在拼拼图,等拼完整了再解析


五、为什么要用流式输出?

1. 用户体验

300ms 是用户感知"卡顿"的心理阈值。非流式模式下,生成 500 字回答可能需要 5-10 秒——用户只能干等。流式输出让用户在 几百毫秒内 就能看到第一个字,焦虑感大大降低。

2. 心智模型

这契合了"Agent 越来越像人"的趋势(就如 readme 中所写的)。人类对话时也是边说边想,而不是沉默 10 秒然后一口气说完。

3. 可中断性

流式输出天然支持"停止生成"——用户看到前几句不满意,可以直接中断,节省 token 成本。


六、从 Demo 到 Agent:AI 开发模式思考

readme.md 中提到一个有意思的观点:

Agent 越来越像人,走向 AGI 时代。如何将工作划分——将 AI 擅长的交给 Agent,我们审核;不擅长的我们接管。

这让我联想到 Vue 的热更新(HMR)流式输出 的相似之处:

场景热更新 (HMR)流式输出
谁主导Vite 监听到文件变更LLM 算出一个 token
传输什么变化的模块代码增量文本 (delta)
目标局部刷新,不丢页面状态逐字追加,不丢阅读上下文
本质增量更新增量更新

二者都在追求同一个目标:最小化用户感知的等待时间,保持交互的连续性

当我们构建 AI Agent 时,也应该遵循同样的思路——把任务拆成小块,流式地呈现进度,让用户在每一步都能参与决策(审核/接管),而不是等 Agent 把所有事情做完才给出结果。


七、完整流程总结

用户输入 "讲一个中国龙的故事"
        │
        ▼
┌─────────────────────┐
│  前端 fetch POST    │  stream: true
│  /chat/completions  │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  DeepSeek 服务器     │  逐 token 生成
│  SSE 格式响应        │
└────────┬────────────┘
         │  data: {"delta":{"content":"中"}}
         │  data: {"delta":{"content":"国"}}
         │  data: {"delta":{"content":"龙"}}
         │  ...
         │  data: [DONE]
         ▼
┌─────────────────────┐
│  response.body      │  ReadableStream (二进制流)
│  .getReader()       │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  TextDecoder        │  二进制 → 字符串
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  按 \n 分割行        │
│  过滤 "data:" 前缀   │
│  JSON.parse         │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  content.value +=   │  逐字追加
│  delta.content      │  Vue 响应式 → 页面打字机效果
└─────────────────────┘

写在最后

TextEncoderReadableStream,从 getReaderTextDecoder,再到 SSE 文本协议解析——流式输出串联起了前端开发中的多个核心概念。它不只是一个"炫技"的效果,而是 LLM 应用开发中的基础体验设施

当你下次看到 AI 回答像打字机一样逐字呈现时,应该能想到背后这根"水管"里流淌着的二进制数据,以及那些在 while (!done) 循环里辛勤工作的代码 😄。