AI打字机的秘密:一个 buffer 如何让机器学会“慢慢说话”
当AI像打字机一样说话:揭秘流式输出背后的魔法
你有没有过这样的体验:和ChatGPT对话时,它不是突然蹦出整段文字,而是像真人一样,一个字一个字地敲出来?这种"打字机效果"不仅让交互更自然,还大大提升了用户体验——你不再需要焦虑地等待,而是能实时感受到AI的"思考过程"。
话不多说,先奉上代码效果
![]()
今天,我们将一起探索这个神奇效果背后的原理,并亲手实现一个Vue 3 + DeepSeek API的流式对话界面。在开始代码之前,先让我们理解一个关键概念:缓冲区(Buffer) 。
🌊 为什么需要"缓冲区"?—— 流式输出的基础
LLM 流式接口返回的是 Server-Sent Events (SSE) 格式的数据流,例如
但底层传输使用的是 HTTP/1.1 Chunked Transfer Encoding 或 HTTP/2 流,数据被切成任意大小的 二进制块(chunks) 发送。
所以每一行的数据可能是不完整的,这就有可能造成数据的丢失从而无法解析完整的数据
这时,缓冲区(Buffer) 就登场了!它像一个临时存储区,把不完整的数据先存起来,等到拼出完整的一行(如data: {"choices":[{"delta":{"content":"好"}}]})再处理。
✨ Buffer的核心作用:解决网络分包导致的JSON解析失败问题,确保每个字都能正确显示。
HTML5中的Buffer实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 Buffer</title>
</head>
<body>
<h1>HTML5 Buffer</h1>
<div id="output"></div>
<script>
//JS 二进制、数组缓存
//html5 编码对象
const encoder = new TextEncoder();
console.log(encoder);
const myBuffer =encoder.encode("你好 HTML5");
console.log(myBuffer);
// 数组缓存 12 字节
// 创建一个缓冲区
const buffer = new ArrayBuffer(12);
// 创建一个视图(View)来操作这个缓冲区
const view = new Uint8Array(buffer);
for(let i=0;i<myBuffer.length;i++){
// console.log(myBuffer[i]);
view[i] = myBuffer[i];
}
const decoder = new TextDecoder();
const originalText = decoder.decode(buffer);
console.log(originalText);
const outputDiv = document.getElementById("output");
outputDiv.innerHTML =`
完整数据:[${view}]<br>
第一个字节:${view[0]}<br>
缓冲区的字节长度${buffer.byteLength}<br>
原始文本:${originalText}<br>
`
</script>
</html>
请看这样一张图
我们输入的文本是你好 HTML5但通过二进制传输就变成了这样的Uint8Array —— 无符号 8 位整数数组
实现的原理?
关键点解析:
- TextEncoder:文本->字节的转换器通过调用encode方法将文本(字符串)编码成计算机能读懂的二进制字节序列(Uint8Array),这就是网络传输中的原始数据
- TextDecoder:字节->文本,他是encode的逆向过程,把二进制的数据解读为文本
- Uint8Array的底层内存块:ArrayBuffer:-
ArrayBuffer是底层的内存区域,存储实际的二进制数据,而你可以认为Uint8Array是对ArrayBuffer一种解读方式(UTF-8)
🌐 为什么这对流式输出很重要?
当你调用 LLM 接口时:
- 服务器发送的是 二进制流(chunked transfer encoding)
- 浏览器收到的是
Uint8Array形式的 chunk - 你需要用
TextDecoder将其解码为字符串 - 再用
buffer拼接不完整的行(如data: {"delta":...})
🚨 所以:
TextEncoder/TextDecoder是连接“文本世界”和“字节世界”的桥梁。
现在你可以明白:当 AI 一个字一个字地输出时,背后正是这些 Uint8Array 和 TextDecoder 在默默工作。
🧩 现在,让我们用代码实现这个"打字机"效果
下面,我们将从零开始构建一个Vue 3应用,实现LLM流式输出。
1. 响应式数据定义:Vue 3的"心脏"
<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,200字')
const stream = ref(true) // 默认开启流式
const content = ref("") // 用于显示模型回答
</script>
关键点解析:使用ref响应式数据,能够更方便的快速绑定数据,当变量改变时能够实时更新页面内容,这也是我们选择vue框架的原因
2. 调用LLM的核心函数:askLLM
const askLLM = async () => {
if (!question.value) {
console.log('question 不能为空');
return
}
content.value = '思考中...'; // 提前反馈
// 构造API请求
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
messages: [{ role: 'user', content: question.value }]
})
})
关键点解析:
- 使用.env文件存储apikey
- 当调用大模型时,初始化content的值为“思考中”,优化用户体验
- 使用stream控制大模型的流式输出
- 通过messsges给出大模型清晰的上下文
3. 非流式模式:简单但不够"丝滑"
if (!stream.value) {
const data = await response.json();
content.value = data.choices[0].message.content;
}
产品设计的理念:
非流式模型的实现很简单,等待大模型完成所有的输出后,一次性将其输出到页面,但是这对用户来说是一个糟糕的体验,对于一个产品来说,能更快的显示出页面数据,减少用户的等待时间就能留住更多的用户,没有人喜欢看不见进度条的一直等待!!!
4. 流式模式:核心魔法所在(重点!)
if (stream.value) {
content.value = ""; // 清空上一次的输出
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
while (!done) {
const { value, done: doneReading } = await reader?.read();
done = doneReading;
if (!value) continue;
// 关键:用buffer拼接不完整的JSON行
const chunkValue = buffer + decoder.decode(value);
buffer = '';
// 按行分割,只处理有效的data:行
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '));
for (const line of lines) {
const incoming = line.slice(6); // 移除"data: "
if (incoming === '[DONE]') {
done = true;
break;
}
try {
// 解析JSON,获取新增的文本片段(delta)
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta; // 响应式更新!
}
} catch (err) {
// JSON解析失败?存入buffer等待下一次拼接
buffer += `data: ${incoming}`;
}
}
}
}
关键点解析:
- reder-->读取二进制流,decoder将二进制块解码为字符串
-
const reader = response.body?.getReader();这是一条可选链。如果body不为空,则调用getReader(); - 这个 reader 有一个关键方法:
read(),它返回一个 Promise,解析为{ value: Uint8Array, done: boolean }:我们通过const { value, done: doneReading } = await reader?.read();将value和done解构出来,同时为了避免done和我们上面定义的done冲突,我们采用重命名的方式解构,将他重命名为doneReading - 什么是chunk?在计算机网络中,数据不是一次性全部发送的,而是被切成一个个小段,逐个发送。这些小段就叫 chunks(数据块) 。而在浏览器的fetchAPI中,chunk就是通过reader.read()读取到的一个一个对象中的value
-
数据过滤,通过filter和startWith筛选出以
data:开头的有效数据,然后通过slice()方法,将所有筛选出的数据切割掉data:部分,方便后续解析JSON - 使用try/catch防止丢字:因为大模型一次给出的token是不确定的,而我们的data{}一次能不能传完也不确定,所以一行data{}可能会被拆成两部分,这就会导致这一段解析失败,那么解析失败的数据并不是我们不需要的,只是它不小心被分成了两部分,所以我们需要对它进行存储,你能想到什么?没错就是buffer,我们将能够解析的部分先拼接到content中显示到页面,解析失败的我们则倒退的到它的“初态”,将data:拼接回去,然后存入bufer,在读取下一行时,把它拼接到最前面,与自己丢失的部分匹配,然后进行新一轮的流式处理,当我们完成拼接后,需要把buffer清空,不然会影响到下一次的拼接
- 流式输出结束的标志
[DONE]:当我们对字符串进行处理时,如果剩余部分是[DONE]则代表所有内容已经输出完毕,我们就设置done为true来结束读取;
![]()
5. 模板与交互:让UI活起来
<template>
<div class="container">
<div>
<label>输入:</label>
<input v-model="question" />
<button @click="askLLM">提交</button>
</div>
<div class="output">
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div>{{ content }}</div>
</div>
</div>
</template>
![]()
关键点解析:
v-model:双向绑定表单数据,无论我们修改表单数据还是直接修改变量,另外一边也能同时进行更新@click ="":vue中不再需要像JS中一样机械的流程式去监听DOM元素,我们直接可以为DOM元素绑定事件,当触发事件时自动调用方法
✨ 为什么这个"打字机"效果如此重要?
| 传统模式 | 流式模式 |
|---|---|
| 等待完整回答(2-5秒) | 逐字显示(0.1-0.5秒/字) |
| 用户焦虑等待 | 实时反馈,感觉AI在"思考" |
| 体验生硬 | 交互自然,像真人对话 |
💡 关键洞察:流式输出不是技术炫技,而是用户心理的深度优化——它让AI从"工具"变成了"对话伙伴"。 代码虽短,但蕴含了现代AI交互的核心思想。真正的技术不是写代码,而是理解用户在等待时的心理
✨ 最后的思考:当AI能"打字"时,它不再是冰冷的机器,而成了你对话中的伙伴。而你,已经掌握了创造这种对话的魔法。