让AI回答打印机般的浮现,再也不用“快速等待”了

56 阅读7分钟

学会打印机似的流式输出,再也不用“快速等待”了

什么是流式输出?来看看这个画面:

QQ2025127-235415.gif

这样逐字输出内容就是流式输出。

在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数据块:

  1. 网络层检查发现数据包(2000字节)> MTU(1500字节)

  2. 网络设备自动分片:

    • 第一片:1480字节(1500 - 20字节IP头)
    • 第二片:520字节(剩余数据)
  3. 两片数据被独立发送

实际传输场景

服务器发送: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内容处理结果
1data: {"choices": [{"delta": {"content": "喜"}}]''data: {"choices": [{"delta": {"content": "喜"}}]未完成JSON
2data: {"choices": [{"delta": {"content": "羊"}}]}data: {"choices": [{"delta": {"content": "喜"}}]data: {"choices": [{"delta": {"content": "喜"}}]data: {"choices": [{"delta": {"content": "羊"}}]拼接后为有效JSON
3}]}\ndata: {"choices": [{"delta": {"content": "喜"}}]data: {"choices": [{"delta": {"content": "羊"}}]data: {"choices": [{"delta": {"content": "喜"}}]data: {"choices": [{"delta": {"content": "羊"}}]}\n有效JSON片段

💡 关键洞察:没有buffer,代码会将"喜"和"羊"视为两个独立的无效JSON对象,导致内容丢失。

四、SSE数据处理的完整流程

让我们用生活化的比喻理解整个处理流程:

  1. 网络传输:就像快递员将大包裹拆分成小盒子寄送
  2. 数据接收:你收到的可能是"盒子1: 喜"和"盒子2: 羊"
  3. buffer:你把"盒子1"的内容暂存起来
  4. 数据拼接:当收到"盒子2",你把"喜"和"羊"拼在一起
  5. 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>

结果展示:

QQ2025128-01534.gif

七、为什么流式输出是AI应用的必备技术?

  1. 用户体验提升:等待时间从"数秒"缩短到"几毫秒"的逐字显示
  2. 资源效率:服务器无需等待完整生成,可提前开始发送内容
  3. 网络友好:小块数据传输比大块更可靠,错误率更低
  4. 内存优化:无需缓存完整响应,节省服务器和客户端内存

🌟 行业实践:所有主流AI平台(OpenAI、Anthropic、DeepSeek)均支持流式输出,这已成为AI交互的行业标准。

八、常见误区澄清

误区1buffer只是存储临时数据
事实buffer是解决网络分片问题的核心机制,没有它,流式输出将无法正常工作。

误区2slice(6)可以随意修改
事实data: 正好6个字符(d,a,t,a,:, ),这是SSE协议规定的,必须使用6。

误区3[DONE]是JSON格式
事实[DONE]是纯字符串,不是JSON,所以直接比较incoming === '[DONE]'

九、总结:流式输出的底层哲学

流式输出不仅是技术实现,更是用户体验设计的哲学

  1. 渐进式交付:不等待完整结果,而是逐步交付
  2. 网络意识设计:理解并适应网络传输的限制
  3. 用户信任建立:通过"思考中..."和逐字显示,建立用户对AI的期待

正如知识库中所述:"流式响应(stream: true)是分块传输的,就像你点外卖时,商家不是一次性把所有菜端上桌,而是分批送。"

十、实践建议

  1. 调试技巧:在content.value += delta前添加日志,观察逐字变化
  2. 错误处理:实现完善的buffer和异常处理,确保数据完整性
  3. UI优化:添加加载指示器,提升等待体验
  4. 性能监控:记录流式响应的延迟,优化网络和服务器配置

💡 终极建议:在你的AI应用中,永远优先使用流式输出。它不只是技术选择,更是对用户体验的尊重。


通过这篇文章,你已理解了流式输出的完整工作原理,特别是buffer在处理网络分片中的关键作用。这不仅是一个代码技巧,更是理解网络传输和用户体验设计的深度洞察。

当你的AI应用实现"打字机效果"时,用户会感受到科技的温度——不是等待,而是参与。这正是流式输出的真正价值所在。