“边吃边吐”才是正经事?揭秘 LLM 流式输出背后的二进制魔法

63 阅读5分钟

“边吃边吐”才是正经事?揭秘 LLM 流式输出背后的二进制魔法

从 TextEncoder 到流式响应,一线大厂面试官最爱问的前端性能优化题,今天一次讲透!


为什么你的 AI 聊天像在“憋大招”?

你有没有遇到过这样的场景?

用户点击“发送”,然后——
屏幕一片空白……
等待 3 秒……
突然“唰”一下弹出整段回答。

这体验,就像等外卖小哥爬 20 楼送餐——不是不能到,就是太煎熬

而优秀的 AI 应用(比如你正在用的这个)却能做到:字一个一个蹦出来,仿佛真人打字。这种“边生成边返回”的能力,就是我们今天要深挖的——流式输出(Streaming)

但别急!流式不只是“用户体验爽”那么简单。它背后牵扯到网络协议、内存管理、编码解码、事件循环等多个前端核心知识点。更关键的是——大厂面试高频考点

今天,我们就以一段看似简单的 HTML5 Buffer 操作代码为引子,层层剥开流式输出的技术洋葱。


一、为什么需要流式输出?

1.1 用户体验 vs 系统资源

  • 非流式(传统) :后端生成完整响应 → 一次性返回 → 前端渲染
    ✅ 实现简单
    ❌ 首字节延迟高(TTFB 长),用户焦虑感强
  • 流式(Streaming) :后端每生成一点内容 → 立即推送 → 前端实时渲染
    ✅ 首字节快,感知流畅
    ❌ 实现复杂,需处理分块、编码、错误恢复等

💡 面试加分点:流式不仅提升 UX,还能降低服务器内存占用(无需缓存完整响应体)。

1.2 流式的本质:分块传输 + 实时解码

而这一切的起点,往往是一个被低估的 Web API:TextEncoder / TextDecoder


二、核心概念:TextEncoder 是什么?为什么它和流式有关?

你可能以为 TextEncoder 只是个“字符串转 Uint8Array”的工具。但它的真正价值在于:

它是浏览器中实现“文本 ↔ 二进制”高效转换的标准桥梁

而在流式通信中,网络传输的永远是二进制(bytes) ,但 LLM 生成的是文本(string)。如何高效、安全地在这两者间切换?TextEncoder/Decoder 就是答案。


三、源码逐行解析:从“你好 HTML5”看透 Buffer 操作

我们来看这段代码:

<script>
const encoder = new TextEncoder();
const myBuffer = encoder.encode("你好 HTML5");
console.log(myBuffer); // Uint8Array(12) [228, 189, 160, 229, 165, 189, 32, 72, 84, 77, 76, 53]
</script>

3.1 为什么是 12 字节?

  • “你” → UTF-8 编码:E4 BD A0(3 字节)
  • “好” → E5 A5 BD(3 字节)
  • 空格 → 20(1 字节)
  • “HTML5” → ASCII,各 1 字节 × 5 = 5 字节
    ✅ 总计:3+3+1+5 = 12 字节

📌 关键点TextEncoder 默认使用 UTF-8 编码,这是 Web 标准,也是 HTTP 协议推荐的编码方式。

3.2 为什么要手动拷贝到 ArrayBuffer?

const buffer = new ArrayBuffer(12);
const view = new Uint8Array(buffer);
for (let i = 0; i < myBuffer.length; i++) {
    view[i] = myBuffer[i];
}

这段代码其实在模拟网络接收过程

  • myBuffer 是我们要发送的数据(Uint8Array)
  • ArrayBuffer 是底层内存块(不可直接操作)
  • Uint8Array 是视图(View),用于读写这块内存

但在真实流式场景中,你不需要手动拷贝!因为:

  • fetchresponse.body 返回的是 ReadableStream<Uint8Array>
  • 每次 read() 得到的就是一个 Uint8Array chunk
  • 直接传给 TextDecoder.decode(chunk, { stream: true }) 即可!

🔥 更好的写法(真实流式场景)

const decoder = new TextDecoder();
const reader = response.body.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = decoder.decode(value, { stream: true }); // 注意 stream: true!
  appendToDOM(text); // 实时渲染
}

3.3 为什么 TextDecoder.decode() 要加 { stream: true }

这是流式解码的核心秘密

  • UTF-8 是变长编码(1~4 字节)

  • 一个字符可能被拆到两个 chunk 中

    • 例如:“你”的第一个字节 E4 在 chunk1,后两个 BD A0 在 chunk2
  • 如果不启用 stream: truedecode() 会认为 E4 是非法字节,直接丢弃或报错

{ stream: true } 告诉解码器:“我还没发完,先缓存未完成的字节,等下一块来了再拼”


四、延伸思考:流式输出的完整技术栈

层级技术点说明
后端SSE / WebSocket / HTTP Chunked推荐 SSE(Server-Sent Events),简单、基于 HTTP、自动重连
网络Transfer-Encoding: chunkedHTTP/1.1 分块传输编码,无需 Content-Length
前端ReadableStream + TextDecoder浏览器原生支持流式读取
渲染requestAnimationFrame / 虚拟滚动避免频繁 DOM 操作导致卡顿
错误处理AbortController + retry 机制用户可能中途取消,需优雅终止

💡 大厂真题
“如何实现一个支持中断、重试、防抖的流式聊天组件?”
—— 这题考察你对 Promise、Stream、状态管理、副作用清理 的综合掌控。


五、高频面试题关联

  1. Q:TextEncoder 和 Buffer 有什么区别?
    A:TextEncoder 是 Web API,专用于文本编码;Node.js 的 Buffer 是通用二进制容器。浏览器中应优先用 TextEncoder
  2. Q:为什么不用 JSON.parse 逐行解析流式 JSON?
    A:因为 JSON 不是自同步格式!必须用专门的流式解析器(如 Oboe.js)或约定分隔符(如 \n\n)。
  3. Q:流式输出会影响 SEO 吗?
    A:会!搜索引擎爬虫通常不执行 JS。重要内容需 SSR 或 fallback 静态内容。

六、结语:流式不是炫技,而是工程权衡

流式输出看似只是“让字动起来”,实则是一场从前端到后端、从编码到渲染的全链路优化

掌握它,不仅能让你做出“丝滑如德芙”的 AI 产品,更能向面试官证明:

你不仅会写代码,更懂用户、懂网络、懂性能。

下次当有人问:“你们 AI 为啥这么快?”
你可以微微一笑:“因为我们,边吃边吐。”