从前端视角理解 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 响应式 → 页面打字机效果
└─────────────────────┘
写在最后
从 TextEncoder 到 ReadableStream,从 getReader 到 TextDecoder,再到 SSE 文本协议解析——流式输出串联起了前端开发中的多个核心概念。它不只是一个"炫技"的效果,而是 LLM 应用开发中的基础体验设施。
当你下次看到 AI 回答像打字机一样逐字呈现时,应该能想到背后这根"水管"里流淌着的二进制数据,以及那些在 while (!done) 循环里辛勤工作的代码 😄。