详细流式输出学习

写在前面

最近在开发 AI 聊天应用时,发现很多同学对"流式输出"的理解停留在"调用 API,数据一点点回来"的层面。但当真正动手实现时,却会被 ReadableStreamUint8ArrayTextDecoderdata: [DONE] 这些概念搞得晕头转向。

本文会从一个完整的 Vue3 组件出发,逐行解析每个细节,让你不仅知道"怎么用",更理解"为什么这么用"。


一、先看完整代码(带着问题往下读)

vue

ini 复制代码
<script setup>
import { ref } from 'vue'

const question = ref('将一个中国龙的故事');
const content = ref('');
const stream = ref(true);

const update = async () => {
  if (!question.value) return;
  content.value = '思考中....';
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value
    })
  });

  if (stream.value) {
    content.value = '';
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';

    while (!done) {
      const { done: doneReading, value } = await reader?.read();
      done = doneReading;
      
      const chunkValue = buffer + decoder.decode(value);
      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 (error) {
          buffer = `data: ${incoming}`;
        }
      }
    }
  } else {
    const data = await response.json();
    content.value = data.choices[0].message.content;
  }
}

const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
  'Content-Type': 'application/json',
  Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
</script>

这段代码看起来不长,但每一行背后都藏着重要的知识点。接下来,我们以"水流"为比喻,逐步拆解。


二、整体架构:从"水龙头"到"水杯"

text

bash 复制代码
┌─────────────────────────────────────────────────────────────┐
│                        用户点击发送                        │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────────┐
│  1. 发送请求:fetch 带上 stream: true                     │
│     → 告诉服务器:"我要流式响应,别一次性全给我"           │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────────┐
│  2. 服务器响应:返回 ReadableStream 对象                   │
│     → 就像打开了一个"水龙头"                              │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────────┐
│  3. 获取读取器:response.body.getReader()                  │
│     → 拿到"水管子",准备接水                             │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────────┐
│  4. 循环读取:while (!done) { reader.read() }             │
│     → 一滴一滴(或一勺一勺)接水                         │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────────┐
│  5. 解析数据:TextDecoder 解码 + JSON.parse               │
│     → 把"水"变成能喝的内容                                │
└─────────────────────┬───────────────────────────────────────┘
                      ▼
┌─────────────────────────────────────────────────────────────┐
│  6. 渲染页面:content.value += delta                       │
│     → 一口一口喝掉,实时展示                              │
└─────────────────────────────────────────────────────────────┘

三、逐层深入:每个细节都不放过

3.1 为什么用 ref?Vue3 响应式原理

javascript

ini 复制代码
const question = ref('将一个中国龙的故事');
const content = ref('');
const stream = ref(true);

核心概念:响应式数据

ref 是 Vue3 Composition API 的核心,它返回一个 RefImpl 对象:

javascript

kotlin 复制代码
// 简化版 RefImpl 实现
class RefImpl {
  constructor(value) {
    this._value = value;
    this.dep = new Set(); // 依赖收集
  }
  
  get value() {
    track(this.dep); // 收集依赖(模板中的使用位置)
    return this._value;
  }
  
  set value(newValue) {
    this._value = newValue;
    trigger(this.dep); // 触发更新(重新渲染模板)
  }
}

为什么用 ref 而不是普通变量?

javascript

ini 复制代码
// ❌ 普通变量:改变后页面不会更新
let content = '';
content = '你好'; // 页面毫无反应

// ✅ ref:改变后页面自动刷新
const content = ref('');
content.value = '你好'; // 页面立刻显示"你好"

在流式输出中,每次收到新的 delta,我们都要执行 content.value += delta,这会触发 Vue 的响应式系统,实现"逐字打印"的效果。


3.2 stream: true 到底做了什么?

javascript

css 复制代码
body: JSON.stringify({
  model: 'deepseek-v4-flash',
  messages: [{ role: 'user', content: question.value }],
  stream: stream.value  // ← 就是这个参数!
})

这是关键!

stream: true 时,服务器会:

  1. 不等待完整生成:LLM 每生成一个 token(词元),就立即发送
  2. 使用 SSE 协议 :Server-Sent Events,数据格式为 data: {json}\n\n
  3. 响应头变化Content-Type: text/event-stream

对比一下:

模式 响应方式 首字节时间 用户体验
stream: false 等待全部生成完,一次性返回 可能 5-10 秒 白屏等待,焦虑
stream: true 边生成边返回 200-500ms 逐字显示,流畅

核心差异

javascript

javascript 复制代码
// stream: false - 等待全部
const data = await response.json(); // 阻塞等待 5 秒
console.log(data.choices[0].message.content); // 一次性全部显示

// stream: true - 流式返回
const reader = response.body.getReader(); // 立刻返回
while (true) {
  const { value } = await reader.read(); // 每次拿到一小块
  // 逐字显示...
}

3.3 response.body 是什么?为什么是二进制流?

javascript

vbscript 复制代码
console.log(response.body);
// 输出:ReadableStream { locked: false, ... }

深入理解 ReadableStream

response.body 是一个 ReadableStream 对象,它代表了 HTTP 响应体这个"数据源"。

想象一下:

  • 传统响应response.json()):就像快递员把整个包裹放在你家门口,你一次性拆开
  • 流式响应response.body):就像快递员打开包裹,一件一件递给你

为什么是二进制?

HTTP 协议传输的数据本质上是字节流(Binary Stream)。ReadableStream 读取到的数据是 Uint8Array(二进制数组),而不是字符串。

javascript

perl 复制代码
// 实际 value 的内容
console.log(value);
// Uint8Array(47) [
//   100, 97, 116, 97, 58, 32, 123, 34, 99, 104, 111, 105, 99, 101, 115, 34,
//   58, 91, 123, 34, 100, 101, 108, 116, 97, 34, 58, 123, 34, 99, 111, 110,
//   116, 101, 110, 116, 34, 58, 34, 228, 189, 160, 229, 165, 189, 34, 125, 125, 93, 125
// ]
// 这些数字是 "data: {"choices":[{"delta":{"content":"你好"}}]}" 的 UTF-8 编码

Uint8Array 是什么?

  • 一个数组,每个元素是 0-255 的整数
  • 表示二进制数据(字节)
  • 100 对应字符 'd'97 对应 'a',以此类推

3.4 getReader()read() 的原理

javascript

ini 复制代码
const reader = response.body?.getReader();
const { done: doneReading, value } = await reader?.read();

getReader() 的作用

  • 创建一个"读取器"(Reader),用于从 ReadableStream 中读取数据
  • 一个流只能有一个活跃的 reader(locked 状态)
  • 类似"打开水龙头,拿到水管"

read() 的返回值

typescript

typescript 复制代码
{
  done: boolean,  // true 表示流已结束,没有更多数据
  value: Uint8Array | undefined  // 读取到的数据块
}

read() 的工作机制

text

yaml 复制代码
┌────────────────────────────────────────────────────────┐
│  1. 调用 reader.read()                                │
│     → 检查内部缓冲区是否有数据                        │
├────────────────────────────────────────────────────────┤
│  2. 如果有数据:                                      │
│     → 立即返回 { done: false, value: 数据块 }        │
├────────────────────────────────────────────────────────┤
│  3. 如果没有数据,但流还在进行:                     │
│     → 等待(await),直到有数据到达或流结束          │
├────────────────────────────────────────────────────────┤
│  4. 如果流已结束:                                    │
│     → 返回 { done: true, value: undefined }           │
└────────────────────────────────────────────────────────┘

为什么用 while (!done)

因为每次 read() 只返回一个数据块(chunk),我们需要循环读取直到 done: true


3.5 TextDecoder:从二进制到文本

javascript

ini 复制代码
const decoder = new TextDecoder();
const chunkValue = buffer + decoder.decode(value);

为什么需要解码?

因为 valueUint8Array(二进制),而我们需要的是字符串(文本)。

text

css 复制代码
Uint8Array [100, 97, 116, 97, 58, 32, 123, ...]
    ↓ TextDecoder.decode()
"data: {"choices":[{"delta":{"content":"你好"}}]}"

TextDecoder 的原理

javascript

ini 复制代码
// 简化版实现
class TextDecoder {
  decode(bytes) {
    let result = '';
    let i = 0;
    while (i < bytes.length) {
      // 根据 UTF-8 编码规则解析
      if (bytes[i] < 0x80) {
        // 1 字节字符(ASCII)
        result += String.fromCharCode(bytes[i]);
        i++;
      } else if (bytes[i] < 0xE0) {
        // 2 字节字符(如部分拉丁文)
        const code = ((bytes[i] & 0x1F) << 6) | (bytes[i+1] & 0x3F);
        result += String.fromCharCode(code);
        i += 2;
      } else if (bytes[i] < 0xF0) {
        // 3 字节字符(中文、日文等)
        const code = ((bytes[i] & 0x0F) << 12) | 
                     ((bytes[i+1] & 0x3F) << 6) | 
                     (bytes[i+2] & 0x3F);
        result += String.fromCharCode(code);
        i += 3;
      }
    }
    return result;
  }
}

为什么要用 { stream: true } 参数?

javascript

php 复制代码
decoder.decode(value, { stream: true });
  • stream: true 表示"可能有后续数据",解码器会保留未完成的字符
  • 例如:中文字符"你"的 UTF-8 编码是 3 个字节 [228, 189, 160]
  • 如果第一个数据块只包含 [228, 189],解码器会保留这两个字节
  • 等第二个数据块送来 [160] 时,完整解码为"你"

3.6 buffer:处理不完整的 JSON

javascript

ini 复制代码
let buffer = '';
const chunkValue = buffer + decoder.decode(value);
buffer = '';
// ... 处理 lines ...
catch (error) {
  buffer = `data: ${incoming}`;
}

为什么需要 buffer

SSE 数据以 \n 分隔,但网络传输时:

text

scss 复制代码
第1次 read(): "data: {"choices":[{"delta":{"cont"
第2次 read(): "ent":"你好"}}]}\n\ndata: [DONE]"

第1次读取的数据不完整({"cont 被截断),无法 JSON.parse

buffer 的工作机制

text

rust 复制代码
┌─────────────────────────────────────────────────────────┐
│  第1次读取:buffer = ""                                │
│  chunkValue = "" + "data: {"choices":[{"delta":{"cont" │
│  按行分割 → 没有完整的行(没有 \n)                   │
│  全部进 catch → buffer = 'data: {"choices":[{"delta":{"cont' │
├─────────────────────────────────────────────────────────┤
│  第2次读取:buffer 有值                                │
│  chunkValue = 'data: {"choices":[{"delta":{"cont' +   │
│               'ent":"你好"}}]}\n\ndata: [DONE]'        │
│  现在有完整行:                                        │
│  → "data: {"choices":[{"delta":{"content":"你好"}}]}"  │
│  → "data: [DONE]"                                     │
│  可以正确解析了!                                      │
└─────────────────────────────────────────────────────────┘

注意 catch 中的处理

javascript

ini 复制代码
catch (error) {
  buffer = `data: ${incoming}`;
}

JSON.parse 失败时,我们把不完整的 JSON 存到 buffer 中,等待下一次 read() 补齐。

为什么要加 data: 前缀?

因为下一次循环时,chunkValue = buffer + decoder.decode(value) 直接拼接,我们需要保持 data: 前缀存在,这样 split('\n')startsWith('data:') 才能正确处理。


3.7 data: [DONE] 的作用

javascript

ini 复制代码
if (incoming === '[DONE]') {
  done = true;
  break;
}

[DONE] 是协议规定的终止信号

SSE 规范并没有硬性规定结束标志,但 OpenAI 和 DeepSeek 等 API 遵循了一个事实标准:

text

css 复制代码
data: {"id":"1","choices":[{"delta":{"content":"你"}}]}

data: {"id":"2","choices":[{"delta":{"content":"好"}}]}

data: [DONE]

为什么需要 [DONE]

  1. 明确结束reader.read()done 可能因为网络问题延迟,但 [DONE] 是业务层面的明确信号
  2. 双重保障 :即使 done: true 提前到来,[DONE] 也能确保我们正确处理了最后的数据
  3. 兼容性 :某些流实现可能在 done: true 前就发送了所有数据

两个 done 的区别

变量 来源 含义 类型
doneReading reader.read() 返回 流是否已经结束(无更多数据) 网络层
done(业务变量) 我们自己定义 是否应该停止循环 业务层

javascript

ini 复制代码
// 两个 done 配合使用
const { done: doneReading, value } = await reader?.read();
done = doneReading; // 网络层告诉业务层

// 业务层还可以通过 [DONE] 提前结束
if (incoming === '[DONE]') {
  done = true; // 业务层主动结束
  break;
}

3.8 JSON 解析与 content 拼接

javascript

ini 复制代码
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
  content.value += delta;
}

SSE 数据格式

服务器返回的每一行都是:

text

css 复制代码
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","created":1234567890,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}

解析路径

javascript

kotlin 复制代码
data.choices[0].delta.content
//     ↓       ↓      ↓
//   数组   第一个   增量对象  内容

delta 的含义

  • delta = 变化量、增量
  • 每次只返回新增的内容(一个 token),而不是完整文本
  • 前端需要不断累加:content.value += delta

为什么是 += 而不是 =

javascript

ini 复制代码
// 第1次:收到 "你"
content.value = '' + '你'  // "你"

// 第2次:收到 "好"
content.value = '你' + '好' // "你好"

// 第3次:收到 "吗"
content.value = '你好' + '吗' // "你好吗"

四、深度解析:split('\n')filter 的精妙设计

这是整个流式处理中最容易被忽视,却最考验功底的部分。

javascript

arduino 复制代码
const lines = chunkValue.split('\n')
  .filter((line) => line.startsWith('data:'));

4.1 为什么要用 split('\n') 而不是 split('\n\n')

SSE 协议规范

SSE 协议的标准格式:

text

css 复制代码
data: {"content":"你"}

data: {"content":"好"}

data: [DONE]
  • 每条消息以 data: 开头
  • 每条消息以 两个换行符 \n\n 结束(即一个空行)

如果用 split('\n\n') 会怎样?

javascript

css 复制代码
const chunkValue = 'data: {"content":"你"}\n\ndata: {"content":"好"}\n\ndata: [DONE]\n\n';
const messages = chunkValue.split('\n\n');
// 结果:[
//   'data: {"content":"你"}',
//   'data: {"content":"好"}',
//   'data: [DONE]',
//   ''  // 末尾的空字符串
// ]

看起来可以,但流式场景下会出问题:

javascript

rust 复制代码
// 第1次 read() 可能只拿到一半:
const chunkValue = 'data: {"content":"你"}\n\ndata: {"con';
const messages = chunkValue.split('\n\n');
// 结果:[
//   'data: {"content":"你"}',
//   'data: {"con'  // ← 不完整!无法解析!
// ]

结论 :用 split('\n\n') 在流式场景下会出错,因为数据块可能在任意位置被截断。

为什么用 split('\n') 更安全?

javascript

perl 复制代码
const chunkValue = 'data: {"content":"你"}\n\ndata: {"con';
const lines = chunkValue.split('\n');
// 结果:[
//   'data: {"content":"你"}',
//   '',  // 空行(原来的 \n\n)
//   'data: {"con'  // 不完整的行
// ]

即使数据被截断,我们仍然能:

  1. 识别出完整的 'data: {"content":"你"}' 并立即解析
  2. 把不完整的 'data: {"con' 存到 buffer 中等待补齐
  3. 空行 '' 被后续的 filter 过滤掉

核心思想split('\n') 保证了每行数据的独立性,即使数据被截断,也能最大程度地恢复有效数据。


4.2 filter((line) => line.startsWith('data:')) 的 4 层防护

第一层:过滤空行

javascript

css 复制代码
const chunkValue = 'data: {"content":"你"}\n\ndata: {"content":"好"}\n\n';
const lines = chunkValue.split('\n');
// ['data: {"content":"你"}', '', 'data: {"content":"好"}', '']

const filtered = lines.filter(line => line.startsWith('data:'));
// ['data: {"content":"你"}', 'data: {"content":"好"}']

没有 filter,空行会进入循环导致:

javascript

scss 复制代码
const incoming = line.slice(6); // 空行 slice(6) → ''
JSON.parse(''); // ❌ SyntaxError: Unexpected end of JSON input

第二层:过滤注释行

SSE 协议支持注释行(以 : 开头):

text

css 复制代码
: 这是一个注释
data: {"content":"你"}

: 另一个注释
data: {"content":"好"}

filter 自动过滤掉这些注释,避免解析错误。

第三层:过滤其他控制行

SSE 协议还有 event:id:retry: 等字段:

text

vbnet 复制代码
event: user
data: {"content":"你"}

id: 123
data: {"content":"好"}

retry: 10000
data: [DONE]

如果不过滤,line.slice(6) 会切出 user12310000 等无效内容,导致 JSON.parse 失败。

第四层:防御 data: 前缀变体

某些实现可能使用:

text

css 复制代码
data: {"content":"你"}  // 标准格式
data: {"content":"你"}  // 多个空格(已过滤)
data:  {"content":"你"} // 注意空格数量

startsWith('data:') 要求严格匹配 ,避免了 'data: ' 这种带多个空格的变体带来的问题。


4.3 为什么不用 trim()includes()

如果用 trim()

javascript

ini 复制代码
// ❌ 错误示范
const lines = chunkValue.split('\n')
  .map(line => line.trim())
  .filter(line => line.startsWith('data:'));

问题

  • trim() 会删除行首尾空格,但 SSE 要求 data: 后面有一个空格
  • 如果数据本身包含前导空格(如 Markdown 代码块),trim() 会破坏内容

javascript

rust 复制代码
// 原始数据
'data: {"content":"  const a = 1;  "}'

// trim() 后
'data: {"content":"const a = 1;"}'  // ❌ 丢失了缩进空格

如果用 includes('data:')

javascript

ini 复制代码
// ❌ 错误示范
const lines = chunkValue.split('\n')
  .filter(line => line.includes('data:'));

问题

  • includes 太宽松,会匹配到其他包含 data: 的字符串
  • 例如:"error: invalid data: field" 也会被错误匹配

正确做法:startsWith('data:')

javascript

arduino 复制代码
// ✅ 正确
.filter(line => line.startsWith('data:'))

优势

  • 严格匹配协议前缀
  • 避免误匹配
  • 性能更好(不需要扫描整个字符串)

4.4 实战:完整的行处理流程

场景 1:标准数据块

javascript

css 复制代码
// 输入
const chunkValue = 'data: {"content":"你"}\n\ndata: {"content":"好"}\n\ndata: [DONE]\n\n';

// split('\n')
const rawLines = [
  'data: {"content":"你"}',
  '',
  'data: {"content":"好"}',
  '',
  'data: [DONE]',
  ''
];

// filter(line => line.startsWith('data:'))
const validLines = [
  'data: {"content":"你"}',
  'data: {"content":"好"}',
  'data: [DONE]'
];

// 遍历处理
for (const line of validLines) {
  const incoming = line.slice(6); // 去掉 'data: '
  // incoming: '{"content":"你"}' → JSON.parse ✅
  // incoming: '{"content":"好"}' → JSON.parse ✅
  // incoming: '[DONE]' → 结束信号 ✅
}

场景 2:数据被截断

javascript

ini 复制代码
// 输入(第1次 read)
const chunkValue = 'data: {"content":"你"}\n\ndata: {"con';

const rawLines = [
  'data: {"content":"你"}',
  '',
  'data: {"con'
];

const validLines = [
  'data: {"content":"你"}',
  'data: {"con'  // 注意:它确实以 'data:' 开头!
];

for (const line of validLines) {
  // 第1条:正常解析 ✅
  const incoming1 = '{"content":"你"}';
  JSON.parse(incoming1); // ✅
  
  // 第2条:不完整
  const incoming2 = '{"con';
  try {
    JSON.parse(incoming2); // ❌ SyntaxError
  } catch (error) {
    buffer = `data: ${incoming2}`; // 存起来等下一次
  }
}

场景 3:多个空行

javascript

rust 复制代码
// 输入
const chunkValue = 'data: {"content":"你"}\n\n\n\ndata: {"content":"好"}\n\n';

const rawLines = [
  'data: {"content":"你"}',
  '',
  '',
  '',
  'data: {"content":"好"}',
  ''
];

const validLines = [
  'data: {"content":"你"}',
  'data: {"content":"好"}'
];
// 多个空行被优雅地过滤掉了 ✅

4.5 边界情况与防御性编程

Windows 换行符(\r\n

javascript

css 复制代码
// 某些服务器可能返回 Windows 风格的换行
const chunkValue = 'data: {"content":"你"}\r\n\r\ndata: {"content":"好"}\r\n\r\n';

// split('\n') 会留下 \r
const lines = chunkValue.split('\n');
// ['data: {"content":"你"}\r', '\r', 'data: {"content":"好"}\r', '\r']

// filter(line => line.startsWith('data:'))
// ['data: {"content":"你"}\r', 'data: {"content":"好"}\r']
// 仍然能工作,因为 \r 在末尾不影响 startsWith

// 但 slice(6) 后变成 '{"content":"你"}\r'
// JSON.parse('{"content":"你"}\r') ✅ 能正常工作(JSON 允许尾部空白)

建议:更健壮的写法

javascript

ini 复制代码
// 统一处理换行符
const lines = chunkValue.replace(/\r\n/g, '\n').split('\n')
  .filter(line => line.startsWith('data:'));

数据中包含换行符

javascript

css 复制代码
// 如果 content 本身包含换行符
const data = {
  content: '第一行\n第二行\n第三行'
};
// SSE 格式:
// data: {"content":"第一行\n第二行\n第三行"}

// 处理时:
const chunkValue = 'data: {"content":"第一行\n第二行\n第三行"}\n\n';
const lines = chunkValue.split('\n');
// ['data: {"content":"第一行', '第二行', '第三行"}', '']

// filter(line => line.startsWith('data:'))
// ['data: {"content":"第一行']  // ❌ 丢失了 '第二行' 和 '第三行"}'

解决方案:使用更智能的 SSE 解析器

javascript

ini 复制代码
// 更健壮的解析(生产级实现)
const parseSSEChunk = (chunkValue) => {
  const lines = [];
  let current = '';
  const parts = chunkValue.split('\n');
  
  for (const part of parts) {
    if (part.startsWith('data:')) {
      if (current) {
        lines.push(current);
        current = '';
      }
      current = part;
    } else if (part !== '' || current) {
      // 空行可能是数据的一部分(如 Markdown 空行)
      // 但 SSE 的空行是分隔符,需要区分
      // 这里简化处理:非 data: 开头的行都作为当前行的一部分
      current += '\n' + part;
    }
  }
  if (current) lines.push(current);
  
  return lines.filter(line => line.startsWith('data:'));
};

五、完整数据流追踪

让我们追踪一次完整的请求-响应过程:

text

css 复制代码
用户输入:将一个中国龙的故事
    ↓
点击发送 → update() 执行
    ↓
fetch POST https://api.deepseek.com/chat/completions
    headers: { Authorization: 'Bearer sk-xxx' }
    body: { model: 'deepseek-v4-flash', messages: [...], stream: true }
    ↓
服务器开始生成,返回 ReadableStream
    ↓
┌─────────────────────────────────────────────────────────┐
│  第1次 read()                                          │
│  value: Uint8Array(89) [100,97,116,97,58,32,123,...]  │
│  decoder.decode() → "data: {"choices":[{"delta":{"con" │
│  buffer = 'data: {"choices":[{"delta":{"con"'          │
│  (JSON 不完整,存入 buffer)                            │
├─────────────────────────────────────────────────────────┤
│  第2次 read()                                          │
│  value: Uint8Array(60) [116,101,110,116,34,58,34,...] │
│  chunkValue = buffer + decoder.decode(value)           │
│  → "data: {"choices":[{"delta":{"content":"从"}}]}"    │
│  JSON.parse → { choices: [{ delta: { content: '从' } }] } │
│  content.value = '' + '从' = '从'                      │
│  页面显示:从                                          │
├─────────────────────────────────────────────────────────┤
│  第3次 read()                                          │
│  value: Uint8Array(...) [228,184,128,...]             │
│  JSON.parse → { choices: [{ delta: { content: '前' } }] } │
│  content.value = '从' + '前' = '从前'                 │
│  页面显示:从前                                        │
├─────────────────────────────────────────────────────────┤
│  ... 重复多次,每次拼接一个 token ...                  │
├─────────────────────────────────────────────────────────┤
│  第N次 read()                                          │
│  value: Uint8Array(15) [100,97,116,97,58,32,91,68,...] │
│  decoder.decode() → "data: [DONE]"                     │
│  incoming === '[DONE]' → done = true; break           │
│  循环结束,输出完成                                    │
└─────────────────────────────────────────────────────────┘

六、非流式模式对比

javascript

ini 复制代码
} else {
  const data = await response.json();
  content.value = data.choices[0].message.content;
}

stream: false 时:

text

css 复制代码
发送请求
    ↓
等待... (可能 5-10 秒)
    ↓
收到完整响应 (一次性)
    ↓
{
  "choices": [{
    "message": {
      "content": "从前有一条中国龙..."
    }
  }]
}
    ↓
content.value = "从前有一条中国龙..."
    ↓
页面一次性显示全部内容

优缺点对比

特性 流式 (stream: true) 非流式 (stream: false)
首字节时间 200-500ms 5-10s
用户体验 逐字显示,流畅 白屏等待,焦虑
实现复杂度 复杂(需要解析流) 简单(直接 JSON.parse)
网络占用 持续小包传输 一次性大包传输
错误处理 部分数据可能丢失 要么成功要么失败

七、常见问题与优化

7.1 为什么不用 response.text()

javascript

ini 复制代码
// ❌ 这样会等待全部接收完成,失去流式意义
const text = await response.text();

// ✅ 应该用 response.body.getReader()
const reader = response.body.getReader();

7.2 如何处理网络中断?

javascript

csharp 复制代码
try {
  while (!done) {
    const { done: doneReading, value } = await reader?.read();
    // ...
  }
} catch (error) {
  // 网络中断或解析错误
  content.value = '❌ 连接中断,请重试';
}

7.3 如何优化性能?

javascript

ini 复制代码
// 使用 requestAnimationFrame 批量更新
let pendingContent = '';
let lastUpdate = performance.now();

while (!done) {
  // ...
  pendingContent += delta;
  
  // 每 16ms 刷新一次(60fps)
  if (performance.now() - lastUpdate > 16) {
    content.value += pendingContent;
    pendingContent = '';
    lastUpdate = performance.now();
  }
}

7.4 如何支持取消?

javascript

ini 复制代码
let abortController = new AbortController();

const update = async () => {
  abortController = new AbortController();
  
  const response = await fetch(endpoint, {
    signal: abortController.signal, // 传入取消信号
    // ...
  });
  // ...
}

const cancel = () => {
  abortController.abort(); // 取消请求
  content.value = '⏹️ 已停止生成';

7.5 如何处理超大 chunk?

javascript

ini 复制代码
// 如果一次 read() 返回了 1MB 数据
// split('\n') 会创建大量数组元素
// 更高效的做法:使用 for 循环逐个处理
const processChunkEfficient = (chunkValue) => {
  let start = 0;
  let end = chunkValue.indexOf('\n');
  const validLines = [];
  
  while (end !== -1) {
    const line = chunkValue.slice(start, end);
    if (line.startsWith('data:')) {
      validLines.push(line);
    }
    start = end + 1;
    end = chunkValue.indexOf('\n', start);
  }
  
  return validLines;
};
相关推荐
秃头披风侠_郑1 小时前
【uniapp】一文让你学会微信小程序+APP+H5全平台实战指南
前端·微信小程序·uni-app
嘟嘟07171 小时前
从零搞懂 RAG 文档处理:逐行拆解 Loader 和 Splitter,一行代码背后发生了什么
前端
come112341 小时前
Vue 3 与现代 JavaScript 常用语法指南
前端·javascript·vue.js
槑有老呆1 小时前
从零搭建 LLM 流式对话前端:一文读懂 SSE、二进制流与 Vue 3 响应式
前端·javascript
没落英雄1 小时前
7. 从零开始搭建一个 AI Agent —— UI 卡片渲染系统
前端·人工智能·架构
前端缘梦1 小时前
LangGraph 核心特性技术详解:流式输出、持久化、记忆体系与中断实战。
前端·人工智能·程序员
张龙6871 小时前
前端错误监控生产级方案:从 onerror 捕获到 Source Map 精准还原
前端·javascript
渣波1 小时前
从零手写 Vite Mock 流式 AI 对话,搞懂 LLM 的“打字机”特效
前端·javascript
温柔过期啦1 小时前
网站改了标题但是搜索引擎却没改?怎么样才能快速更新?
前端·搜索引擎·百度