学会打印机似的流式输出,再也不用“快速等待”了
什么是流式输出?来看看这个画面:
这样逐字输出内容就是流式输出。
在AI应用开发中,流式输出(Streaming)是提升用户体验的关键技术。与传统等待完整响应相比,流式输出实现了"边生成边返回"的体验,让用户感觉AI正在实时思考。今天,我将带你深入探索流式输出的底层原理,特别是那些让代码能正确处理网络分片的"魔法"——buffer和SSE(Server-Sent Events)协议。
一、流式输出 vs 非流式输出:用户体验的革命
非流式输出(stream: false)
// 完整响应后一次性返回
const data = await response.json();
content.value = data.choices[0].message.content;
用户体验:用户需要等待完整回答生成后才能看到内容,等待感明显。
流式输出(stream: true)
if (stream.value) {
content.value = ""; // 清空之前内容
const reader = response.body?.getReader();
// ...后续处理
}
用户体验:AI生成的内容逐字显示,如同在打字机上输出,等待时间大幅缩短,交互感更强。
📌 关键区别:流式输出不是简单的"分段返回",而是基于SSE协议的增量内容传输,核心在于
delta.content而非message.content。
二、SSE协议:流式输出的基石
流式输出依赖于SSE(Server-Sent Events)协议,这是浏览器原生支持的、单向的服务器到客户端的实时通信协议。SSE响应格式如下:
data: {"id": "chatcmpl-xxx", "choices": [{"delta": {"content": "喜"}}]}
data: {"id": "chatcmpl-xxx", "choices": [{"delta": {"content": "羊"}}]}
data: {"id": "chatcmpl-xxx", "choices": [{"delta": {"content": "和"}}]}
[data: [DONE]]
SSE关键特性:
- 每行以
data:开头 - 每个数据块包含AI生成的增量内容
- 以
[DONE]作为流结束标记
三、网络传输的真相:为什么需要buffer?
1. MTU(最大传输单元):网络世界的"餐桌大小"
网络传输有天然限制:MTU(Maximum Transmission Unit) ,即网络设备能接收的最大数据包大小。以太网默认MTU为1500字节。
🌐 知识库引用:"以太网MTU=1500字节,这是以太网接口对IP层的约束,如果IP层有>1500字节数据需要发送,需要分片才能完成发送。"
2. 为什么数据会被网络切分成多块?
假设服务器返回一个2000字节的SSE数据块:
-
网络层检查发现数据包(2000字节)> MTU(1500字节)
-
网络设备自动分片:
- 第一片:1480字节(1500 - 20字节IP头)
- 第二片:520字节(剩余数据)
-
两片数据被独立发送
实际传输场景:
服务器发送:data: {"choices": [{"delta": {"content": "喜"}}]}
服务器发送:data: {"choices": [{"delta": {"content": "羊"}}]}
但网络可能这样分割:
第一块:data: {"choices": [{"delta": {"content": "喜"}}]
第二块:data: {"choices": [{"delta": {"content": "羊"}}]}
3. buffer:数据分片的"拼图大师"
这就是为什么需要buffer:
let buffer = ''; // 临时存储不完整的行
while(!done) {
const { value, done: doneReading } = await reader?.read();
const chunkValue = buffer + decoder.decode(value);
buffer = '';
// ...后续处理
}
buffer的工作原理:
| 步骤 | 原始数据 | buffer状态 | chunkValue内容 | 处理结果 |
|---|---|---|---|---|
| 1 | data: {"choices": [{"delta": {"content": "喜"}}] | '' | data: {"choices": [{"delta": {"content": "喜"}}] | 未完成JSON |
| 2 | data: {"choices": [{"delta": {"content": "羊"}}]} | data: {"choices": [{"delta": {"content": "喜"}}] | data: {"choices": [{"delta": {"content": "喜"}}]data: {"choices": [{"delta": {"content": "羊"}}] | 拼接后为有效JSON |
| 3 | }]}\n | data: {"choices": [{"delta": {"content": "喜"}}]data: {"choices": [{"delta": {"content": "羊"}}] | data: {"choices": [{"delta": {"content": "喜"}}]data: {"choices": [{"delta": {"content": "羊"}}]}\n | 有效JSON片段 |
💡 关键洞察:没有
buffer,代码会将"喜"和"羊"视为两个独立的无效JSON对象,导致内容丢失。
四、SSE数据处理的完整流程
让我们用生活化的比喻理解整个处理流程:
- 网络传输:就像快递员将大包裹拆分成小盒子寄送
- 数据接收:你收到的可能是"盒子1: 喜"和"盒子2: 羊"
buffer:你把"盒子1"的内容暂存起来- 数据拼接:当收到"盒子2",你把"喜"和"羊"拼在一起
- JSON解析:将拼接后的完整内容解析为可读文本
五、代码深度解析:为什么这样写
1. 数据分块处理
const chunkValue = buffer + decoder.decode(value);
buffer = '';
decoder.decode(value):将二进制数据转为字符串buffer:存储未完成的行,解决网络分片问题
2. SSE行过滤
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '));
- 按换行符分割
- 过滤掉非SSE行(如空行、注释)
3. 数据解析
const incoming = line.slice(6); // 去掉"data: "前缀
if (incoming === '[DONE]') { done = true; break; }
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) { content.value += delta; }
slice(6):移除SSE协议要求的data:前缀delta.content:获取当前生成的增量文本content.value += delta:逐字追加到显示区域
4. 异常处理
catch(err) {
buffer += `data: ${incoming}`;
}
- 当JSON解析失败(数据不完整),将不完整内容存入
buffer等待下一块 - 这是处理网络分片的关键保护机制
六、实战应用
我们创建一个vue文件,编写如下代码:
<script setup>
// es6 解构
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,200字')
const stream = ref(true)
const content = ref("") // 单向绑定 主要的
// 调用LLM
const askLLM = async () => {
// question 可以省.value getter
if (!question.value) {
console.log('question 不能为空');
return
}
content.value = '思考中...';
// 请求行
// 请求头
// 请求体
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
}
]
})
})
if (stream.value) {
// 流式输出
content.value = ""; // 把上次的生成清空
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false; // 流是否结束
let buffer = '';
while(!done) { // 只要没有完成,就一直拼接buffer
// 解构的同时 重命名
const { value, done: doneReading } = await reader?.read()
console.log(value, doneReading);
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
console.log(chunkValue)
buffer = '';
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '))
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta;
}
} catch(err) {
// JSON.parse 解析失败
buffer += `data: ${incoming}`
}
}
}
} else {
const data = await response.json();
console.log(data);
content.value = data.choices[0].message.content;
}
}
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question"/>
<button @click="askLLM">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div>{{content}}</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
button {
padding: 0 10px;
margin-left: 6px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
</style>
结果展示:
七、为什么流式输出是AI应用的必备技术?
- 用户体验提升:等待时间从"数秒"缩短到"几毫秒"的逐字显示
- 资源效率:服务器无需等待完整生成,可提前开始发送内容
- 网络友好:小块数据传输比大块更可靠,错误率更低
- 内存优化:无需缓存完整响应,节省服务器和客户端内存
🌟 行业实践:所有主流AI平台(OpenAI、Anthropic、DeepSeek)均支持流式输出,这已成为AI交互的行业标准。
八、常见误区澄清
❌ 误区1:buffer只是存储临时数据
✅ 事实:buffer是解决网络分片问题的核心机制,没有它,流式输出将无法正常工作。
❌ 误区2:slice(6)可以随意修改
✅ 事实:data: 正好6个字符(d,a,t,a,:, ),这是SSE协议规定的,必须使用6。
❌ 误区3:[DONE]是JSON格式
✅ 事实:[DONE]是纯字符串,不是JSON,所以直接比较incoming === '[DONE]'。
九、总结:流式输出的底层哲学
流式输出不仅是技术实现,更是用户体验设计的哲学:
- 渐进式交付:不等待完整结果,而是逐步交付
- 网络意识设计:理解并适应网络传输的限制
- 用户信任建立:通过"思考中..."和逐字显示,建立用户对AI的期待
正如知识库中所述:"流式响应(stream: true)是分块传输的,就像你点外卖时,商家不是一次性把所有菜端上桌,而是分批送。"
十、实践建议
- 调试技巧:在
content.value += delta前添加日志,观察逐字变化 - 错误处理:实现完善的
buffer和异常处理,确保数据完整性 - UI优化:添加加载指示器,提升等待体验
- 性能监控:记录流式响应的延迟,优化网络和服务器配置
💡 终极建议:在你的AI应用中,永远优先使用流式输出。它不只是技术选择,更是对用户体验的尊重。
通过这篇文章,你已理解了流式输出的完整工作原理,特别是buffer在处理网络分片中的关键作用。这不仅是一个代码技巧,更是理解网络传输和用户体验设计的深度洞察。
当你的AI应用实现"打字机效果"时,用户会感受到科技的温度——不是等待,而是参与。这正是流式输出的真正价值所在。