“边吃边吐”才是正经事?揭秘 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),用于读写这块内存
但在真实流式场景中,你不需要手动拷贝!因为:
fetch的response.body返回的是ReadableStream<Uint8Array>- 每次
read()得到的就是一个Uint8Arraychunk - 直接传给
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: true,decode()会认为E4是非法字节,直接丢弃或报错
✅
{ stream: true }告诉解码器:“我还没发完,先缓存未完成的字节,等下一块来了再拼”
四、延伸思考:流式输出的完整技术栈
| 层级 | 技术点 | 说明 |
|---|---|---|
| 后端 | SSE / WebSocket / HTTP Chunked | 推荐 SSE(Server-Sent Events),简单、基于 HTTP、自动重连 |
| 网络 | Transfer-Encoding: chunked | HTTP/1.1 分块传输编码,无需 Content-Length |
| 前端 | ReadableStream + TextDecoder | 浏览器原生支持流式读取 |
| 渲染 | requestAnimationFrame / 虚拟滚动 | 避免频繁 DOM 操作导致卡顿 |
| 错误处理 | AbortController + retry 机制 | 用户可能中途取消,需优雅终止 |
💡 大厂真题:
“如何实现一个支持中断、重试、防抖的流式聊天组件?”
—— 这题考察你对 Promise、Stream、状态管理、副作用清理 的综合掌控。
五、高频面试题关联
- Q:TextEncoder 和 Buffer 有什么区别?
A:TextEncoder是 Web API,专用于文本编码;Node.js 的Buffer是通用二进制容器。浏览器中应优先用TextEncoder。 - Q:为什么不用 JSON.parse 逐行解析流式 JSON?
A:因为 JSON 不是自同步格式!必须用专门的流式解析器(如 Oboe.js)或约定分隔符(如\n\n)。 - Q:流式输出会影响 SEO 吗?
A:会!搜索引擎爬虫通常不执行 JS。重要内容需 SSR 或 fallback 静态内容。
六、结语:流式不是炫技,而是工程权衡
流式输出看似只是“让字动起来”,实则是一场从前端到后端、从编码到渲染的全链路优化。
掌握它,不仅能让你做出“丝滑如德芙”的 AI 产品,更能向面试官证明:
你不仅会写代码,更懂用户、懂网络、懂性能。
下次当有人问:“你们 AI 为啥这么快?”
你可以微微一笑:“因为我们,边吃边吐。”