AI应用开发 | 手写流式输出:把打字机效果背后的数据流拆开看

2 阅读10分钟

不管你用的是 ChatGPT、豆包还是其他 AI 对话工具,都见过这个效果:你发一句话,回答不是一次性弹出来的,而是一个字一个字蹦出来,像有人在实时打字

但底层到底是怎么运转的?从服务端到浏览器,这条数据链路长什么样?

这些问题,就是 AI 应用开发 这个系列想和你一起搞明白的。从流式对话RAGAgent,每篇一个知识点,都带可运行的 demo。

a-series-map.png

我们在系列开篇中 👉从 NotebookLM 说起,一文拆透 AI 应用的完整链路 拆解了 AI 应用的完整链路,今天就从第一个节点开始:

数据怎么一块块到前端的,手写一遍 ReadableStream 的完整链路

a-module-nav.png

先划重点

AI 逐字输出不是前端动画,是服务端边生成边往前端推的流式数据

服务端用 ReadableStream 三步实现流式响应:创建流、放数据、标记回答结束

前端用 while(true) + await 逐块读流,不会卡死浏览器

手写一遍的价值:知道框架底下跑的是什么,出了问题知道往哪查

为什么 AI 对话需要流式输出?

ai-typing-effect.gif

你可能也跟我一样好奇过:这个动图里面的打字机效果,能不能等 AI 回答完再一次性返回?

技术上当然可以,不过你体验一下就知道为什么没人这么做了。

LLM 生成一段回答需要时间,短则几秒,长则十几秒

想象一下:你在对话框里按了回车,然后盯着屏幕,什么都没有。

等了八秒,回答「啪」地一下全冲出来,页面突然被撑高

这个体验很差。

流式输出反过来:AI 每写出一小段文字,就立刻往前端推一段。用户边等边看到内容在增长,感觉像有人在陪你聊天。

即使总时间没变短,感知上完全不一样。

stream-vs-onetime.png

那数据到底是怎么一块一块过来的

既然是边生成边发,那发出来的数据长什么样?

AI 不是像人一样一个字一个字地写,它的最小生成单位叫 token,你可以把它理解成模型内部处理文字的片段

不过你不用管 token 具体怎么分,关键是 AI 每生成出一段,就能立刻发出去,不用等全部写完。

服务端会把若干 token 攒在一起,打包成一个 chunk 发出去。

前端每次收到的就是这样一个 chunk。

我们来看一下完整链路:

chunk-flow.png

打字机效果里每次蹦出来的一小段,就是一个 chunk。

你打开浏览器 Network 面板就能看到: 响应不是一次性到的,chunk 是一条条冒出来的

network-chunks.gif

服务端怎么做到「边生成边发」的?

你可能以为 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 给了浏览器在每块数据之间渲染的机会,这才是打字机效果能跑起来的原因。

await-event-loop.png

接到页面上,打字机效果就出来了

读流的逻辑有了,接到 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>
	);
}

demo-streaming.gif

实际项目也要手写这些吗?

走到这里你可能在想:真实项目里也要自己写 ReadableStream、自己读流、自己解码?

不用。有现成的框架可以一行代码搞定这些事。

比如 Vercel AI SDK,前端用 useChat 就能接管整个流式读取过程:

const { messages, input, handleSubmit } = useChat();

你在这篇里手写的那些(创建流、读流、解码),框架全帮你包好了。我们后面的文章会深入聊它的用法

但手写一遍的价值是:你知道框架底下跑的是什么。下一次流式输出出了问题,你能打开 Network 面板看 chunk,而不是对着框架文档发愣。

现在试着回答以下问题,加深理解:

Q1:回想你平时用 res.json() 拿数据的场景,和这篇手写读流的方式相比,本质区别在哪?

💡 想想「等全部到了再处理」和「来一块处理一块」的区别,以及谁帮你做了解码。

Q2:如果你在项目里发现流式输出偶尔出现中文乱码,你会先检查什么?

💡 文章里有一处「放在循环外」的细节,想想为什么。

Q3:文章里说「即使总时间没变短,感知上完全不一样」。除了逐字输出,你还能想到什么场景可以用类似的思路改善用户体验?

💡 想想进度条、骨架屏,它们和流式输出的共同点是什么。


如果这篇帮你理清了流式输出的底层原理,后续我会继续拆解 AI 应用开发中的其他环节。感兴趣可以关注,不错过更新。

分享底图_压缩.png