不管你用的是 ChatGPT、豆包还是其他 AI 对话工具,都见过这个效果:你发一句话,回答不是一次性弹出来的,而是一个字一个字蹦出来,像有人在实时打字。
但底层到底是怎么运转的?从服务端到浏览器,这条数据链路长什么样?
这些问题,就是 AI 应用开发 这个系列想和你一起搞明白的。从流式对话到 RAG 到 Agent,每篇一个知识点,都带可运行的 demo。
我们在系列开篇中 👉从 NotebookLM 说起,一文拆透 AI 应用的完整链路 拆解了 AI 应用的完整链路,今天就从第一个节点开始:
数据怎么一块块到前端的,手写一遍 ReadableStream 的完整链路。
先划重点
AI 逐字输出不是前端动画,是服务端边生成边往前端推的流式数据
服务端用 ReadableStream 三步实现流式响应:创建流、放数据、标记回答结束
前端用
while(true)+await逐块读流,不会卡死浏览器手写一遍的价值:知道框架底下跑的是什么,出了问题知道往哪查
为什么 AI 对话需要流式输出?
你可能也跟我一样好奇过:这个动图里面的打字机效果,能不能等 AI 回答完再一次性返回?
技术上当然可以,不过你体验一下就知道为什么没人这么做了。
LLM 生成一段回答需要时间,短则几秒,长则十几秒。
想象一下:你在对话框里按了回车,然后盯着屏幕,什么都没有。
等了八秒,回答「啪」地一下全冲出来,页面突然被撑高。
这个体验很差。
流式输出反过来:AI 每写出一小段文字,就立刻往前端推一段。用户边等边看到内容在增长,感觉像有人在陪你聊天。
即使总时间没变短,感知上完全不一样。
那数据到底是怎么一块一块过来的
既然是边生成边发,那发出来的数据长什么样?
AI 不是像人一样一个字一个字地写,它的最小生成单位叫 token,你可以把它理解成模型内部处理文字的片段。
不过你不用管 token 具体怎么分,关键是 AI 每生成出一段,就能立刻发出去,不用等全部写完。
服务端会把若干 token 攒在一起,打包成一个 chunk 发出去。
前端每次收到的就是这样一个 chunk。
我们来看一下完整链路:
打字机效果里每次蹦出来的一小段,就是一个 chunk。
你打开浏览器 Network 面板就能看到: 响应不是一次性到的,chunk 是一条条冒出来的。
服务端怎么做到「边生成边发」的?
你可能以为 HTTP 响应只能一次性返回——其实不是。
我们平时开发,经常会这样使用 fetch 处理接口响应:
const res = await fetch('/api/data');
const data = await res.json(); // 等全部到了,一次性拿到完整数据
这种请求是等服务端全准备好了,一次性打包发过来的。就像外卖打包,厨师做完所有菜才送到你门口。
但浏览器原生支持一种叫 ReadableStream 的对象,可以让响应「边生成边发」。做好一道就送一道,你先吃着,后面的还在做。
数据还没全到,传送带已经开始转了。
ReadableStream 的核心就是一条传送带,你要做三件事:启动它、往上面放数据、告诉它结束了。
对应到代码里就是:开流、塞数据、关流。
我们一起写一个最小的 demo 看看:
// 模拟 AI 逐块返回的文字
const chunks = ['今天', '天气', '不错,', '适合', '出门', '走走。'];
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
let i = 0;
// 每隔 300ms 往流里塞一块数据,模拟 AI 逐块生成
const timer = setInterval(() => {
if (i < chunks.length) {
controller.enqueue(encoder.encode(chunks[i])); // 把菜放上传送带
i++;
} else {
clearInterval(timer);
controller.close(); // 传送带停转
}
}, 300);
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
API 名字不用记,你只要看懂每一步在做什么就够了:
第一步:开传送带
new ReadableStream 创建一条流,start 回调里的代码会立刻执行,传送带从这里开始转。
第二步:往传送带上放数据
controller.enqueue() 往流里塞一块数据。
不过它只认字节,不认字符串,所以要先用 TextEncoder 把文字转成字节。
客户端收到后用 TextDecoder 解回来,两者对称。
第三步:关传送带
controller.close() 告诉客户端回答结束了。
这步不能漏。不调 close(),客户端会一直等下去,以为数据还没发完。
前端怎么一块一块读这条流?
服务端在往流里塞数据,前端怎么读?
你平时用 fetch 拿数据,直接 res.json() 就完事了,从来不会碰 res.body,因为没必要。
但在流式场景里,你不能等全部到了再解析,要一块一块读。
这时候 res.body 就派上用场了,它就是那条传送带。
拿到传送带之后,用 getReader() 站到末端准备接货,然后不断地 read(),每次取一块,直到传送带停了:
export async function readStream(
onChunk: (text: string) => void
): Promise<void> {
const res = await fetch('/api/stream');
const reader = res.body!.getReader(); // 站到传送带末端
const decoder = new TextDecoder(); // 准备解码器(在循环外,保持状态)
while (true) {
const { value, done } = await reader.read(); // 伸手等下一块
if (done) break; // 传送带停了
const text = decoder.decode(value); // Uint8Array → 字符串
onChunk(text); // 把这块文字交给调用方处理
}
}
代码不长,但你第一次看可能会觉得有几个地方不太好理解。
为什么要解码?
你已经知道平时 .json() 帮你把所有事都处理好了,其中就包括解码。
但在流式场景里,你直接读 res.body,拿到的不是字符串,是 Uint8Array。
Uint8Array 是什么?
首先,所有数据在网络上传输时,都会被拆成一个个字节。
你可以把字节理解成计算机最小的数据包裹单位。
而Uint8Array 就是 JavaScript 里装这些字节的容器。
你可能觉得 Uint8Array 很陌生,但其实 HTTP 传的一直都是字节,你平时感觉不到,只是因为 res.json() 和 res.text() 帮你做了解码。
比如「今天」这两个字,UTF-8 编码下就是六个字节。
以前框架帮你把这步藏起来了,现在自己读流,这步就要自己来。
TextDecoder 就干这件事:把字节还原回字符串。
TextDecoder 为什么放在 while 循环外?
你可能注意到了:TextDecoder 是在循环外创建的,不是在循环里每次 new。
举个例子:「今」这个字在 UTF-8 下要用 3 个字节来表示。
但网络传输不管字符边界,它只管按固定大小切块。
所以完全可能出现这种情况: 前 2 个字节跟着上一个 chunk 发走了,第 3 个字节在下一个 chunk 里才到。
我们结合下面的伪代码来直观地分析一下。
假设「今」的 3 个字节被网络拆成了两块:
「今」= [E4, BB, 8A](3 个字节)
chunk 1 到了:[..., E4, BB] ← 「今」的前 2 个字节混在这块里
chunk 2 到了:[8A, ...] ← 第 3 个字节在下一块开头
放在循环外,始终是同一个 TextDecoder 对象,内部有一块「暂存区」跨 chunk 保留:
const decoder = new TextDecoder(); // 循环外创建,只有一个对象
// chunk 1 到了
decoder.decode([..., E4, BB])
→ 暂存区:[E4, BB](凑不成完整字符,先存着)
→ 输出:前面完整的字符
// chunk 2 到了
decoder.decode([8A, ...])
→ 暂存区的 [E4, BB] + 新来的 [8A] → 拼成「今」 ✅
→ 输出:「今」+ 后续完整字符
放在循环内,每次都 new 一个全新的对象,上一个的暂存区直接丢了:
// chunk 1 到了
const decoder1 = new TextDecoder(); // 全新对象
decoder1.decode([..., E4, BB])
→ 暂存区:[E4, BB]
→ 输出:前面完整的字符
// chunk 2 到了
const decoder2 = new TextDecoder(); // 又一个全新对象,decoder1 的暂存区没了
decoder2.decode([8A, ...])
→ [8A] 单独凑不成任何字符 → 乱码 ❌
TextDecoder 放在循环外,是为了跨 chunk 拼接多字节字符,否则遇到中文会乱码。
while(true) 不会卡死浏览器吗
你看到 while (true) 可能会本能地紧张:这不会卡死吗?
关键在于循环里的 await。
每次 await reader.read() 等数据的时候,浏览器可以正常渲染。
所以每收到一块文字,setText 触发一次渲染,屏幕上就多出几个字。
一块一块地来,就是打字机效果。
没有 await的时候,循环一口气跑完,中间浏览器没有任何机会渲染。
setText 调了六次,但屏幕上一个字都没出来,直到循环结束才一次性全部显示。打字机效果消失。
await 给了浏览器在每块数据之间渲染的机会,这才是打字机效果能跑起来的原因。
接到页面上,打字机效果就出来了
读流的逻辑有了,接到 UI 上核心就一行:
setText(prev => prev + chunk);
每收到一块新文字,就拼到已有内容后面。
你看到的逐字蹦出效果,就是这行代码在驱动。
我们用一个最简单的 React 组件把它跑起来:
'use client';
import { useState } from 'react';
import { readStream } from '@/lib/chat/streaming/demo-stream';
export default function Page() {
const [text, setText] = useState('');
async function handleClick() {
setText('');
await readStream(chunk => {
setText(prev => prev + chunk); // 每来一块就追加
});
}
return (
<main className='p-8 max-w-xl'>
<button
onClick={handleClick}
className='mb-6 px-4 py-2 bg-black text-white rounded'
>
开始流式输出
</button>
<p className='text-lg leading-8 whitespace-pre-wrap'>{text}</p>
</main>
);
}
实际项目也要手写这些吗?
走到这里你可能在想:真实项目里也要自己写 ReadableStream、自己读流、自己解码?
不用。有现成的框架可以一行代码搞定这些事。
比如 Vercel AI SDK,前端用 useChat 就能接管整个流式读取过程:
const { messages, input, handleSubmit } = useChat();
你在这篇里手写的那些(创建流、读流、解码),框架全帮你包好了。我们后面的文章会深入聊它的用法。
但手写一遍的价值是:你知道框架底下跑的是什么。下一次流式输出出了问题,你能打开 Network 面板看 chunk,而不是对着框架文档发愣。
现在试着回答以下问题,加深理解:
Q1:回想你平时用 res.json() 拿数据的场景,和这篇手写读流的方式相比,本质区别在哪?
💡 想想「等全部到了再处理」和「来一块处理一块」的区别,以及谁帮你做了解码。
Q2:如果你在项目里发现流式输出偶尔出现中文乱码,你会先检查什么?
💡 文章里有一处「放在循环外」的细节,想想为什么。
Q3:文章里说「即使总时间没变短,感知上完全不一样」。除了逐字输出,你还能想到什么场景可以用类似的思路改善用户体验?
💡 想想进度条、骨架屏,它们和流式输出的共同点是什么。
如果这篇帮你理清了流式输出的底层原理,后续我会继续拆解 AI 应用开发中的其他环节。感兴趣可以关注,不错过更新。