像水流一样——前端流式输出完整指南(3)

LLM 流式输出实战精讲:从 while 循环到 try-catch,每一行代码都算数

一、快速复习(5 分钟找回上下文)

流式输出 :LLM 一个 token 一个 token 生成,不是一次性全给。服务器生成一个发一个,客户端逐字拼接显示------像打字机一样。和传统"等 8 秒啪一下全出来"的区别就是感知等待接近零

Vue3 Composition APIref() 创建响应式变量,script 里 .value 读写,template 里自动拆包。v-model 做双向绑定。核心思想:把同一功能的数据和方法放在一起,而不是按类型分散。

二进制编解码 :网络只认 0-255 的字节。TextEncoder 文字→字节,TextDecoder 字节→文字。一个中文字符 UTF-8 占 3 字节。

水管系统

scss 复制代码
response.body(水管)→ getReader()(水龙头)→ reader.read()(嘬一口)→ 屏幕

二、三个响应式状态,三个页面元素

csharp 复制代码
const question = ref('讲一个中国龙的故事')   // 输入框的值
const content = ref('')                      // LLM 的回复
const stream = ref(true)                     // 流式/非流式开关

stream.value 怎么来的?页面上那个 Streaming checkbox:

ini 复制代码
<input type="checkbox" v-model="stream" />

勾上 = true = 走 while 循环逐字蹦。不勾 = false = 走 response.json() 一把拿完。


三、代码执行全景

scss 复制代码
页面加载 → 初始化 ref,声明 update → 等待用户点击

用户点击"提交"
  ↓
update()
  ├─ 空值检查
  ├─ content = '思考中...'    给用户即时反馈
  ├─ fetch POST              发请求到 DeepSeek
  ├─ 拿到 response
  │
  ├─ stream === true ──────────────┐
  │  while(!done) {                │
  │    reader.read()    嘬一口     │
  │    decoder.decode()  解码      │
  │    buffer + 文本     拼残留    │
  │    split + filter    切行过滤  │
  │    for each line {             │
  │      slice(6)        去前缀    │
  │      [DONE]? → break           │
  │      JSON.parse      解析      │
  │      取 delta.content 取值     │
  │      content +=       拼屏幕   │
  │      catch → buffer   存残留   │
  │    }                           │
  │  }                             │
  │                                │
  └─ stream === false ────────────┘
       response.json()  一把拿完
       content = message.content   一次赋值

四、核心管道:while 循环逐层拆解

这是整个应用的心脏。从二进制到屏幕上的字,一共五层转换。

第 0 层:stream.value 怎么确定的

stream.value 来自 checkbox 的 v-model。它决定两件事:

css 复制代码
// ① 告诉 DeepSeek:请按什么方式返回
body: JSON.stringify({ stream: stream.value })

// ② 本地判断:进入哪个分支
if (stream.value) { 流式 } else { 非流式 }

勾上 = true,流式。不勾 = false,一把拿完。

第 1 层:reader.read() --- 嘬一口

bash 复制代码
const {value, done: doneReading} = await reader?.read()
done = doneReading

reader.read() 调用一次取一块数据。返回的是 Promise:

  • 数据到了 → resolve → {value: Uint8Array[...], done: false}
  • 数据还没到 → pending → await 站着等,不卡页面
  • 流干了 → resolve → {value: undefined, done: true},不是报错

为什么 done 要重命名为 doneReading?因为外面已有 let done = false 控制 while 循环,名字冲突。解构后 done = doneReading 同步退出标志。

value 是一块原始二进制数据。一块里面可能包含 0 行、1 行或多行 SSE 数据,甚至半行:

css 复制代码
第1口: "data: {"cho"                               ← 半行
第2口: "ices":[{"delta":{"content":"你好"}}]}\n\n   ← 1 行完整
第3口: "data: {"delta":{"content":"!"}}]}\n\ndata: [DONE]\n\n"  ← 2 行

切割完全取决于网络包到达时机,和 SSE 的 \n 边界无关。所以需要后面的 split + filter + buffer 三层兜底。

第 2 层:decoder.decode() --- 二进制 → 文本

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

decoder.decode(value) 把 Uint8Array 翻译成文本字符串。{stream: true} 参数告诉解码器"多字节字符可能跨块",内部会缓存不完整字节等你。

buffer 变量的作用 :暂存上一轮 JSON 解析失败的不完整行。大多数时候 buffer 是空字符串 '',只有 try-catch 兜到截断数据时才存值:

ini 复制代码
// 正常情况
chunkValue = '' + 'data: {...完整行...}\n\n'  ← buffer 为空,不影响

// 截断情况
chunkValue = 'data: {"cho' + 'ices":[...]}\n\n'  ← 上一轮残留 + 本轮新数据 = 完整!

buffer 在 while 循环外初始化为 let buffer = '',所以第一轮就是空字符串,不影响拼接。

第 3 层:split + filter --- 切行 + 过滤

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

split('\n') :按换行符切开。\n 是 SSE 协议的行分隔符,也是 buffer 判断完整性的标准------\n 结尾 = 完整一行,没有 = 被截断。

为什么一个 chunk 会有多行? LLM 生成 token 的速度是不确定的。快的时候一口气生成好几个 token,它们被包装进同一个网络包里。所以一个 chunk 解码后可能是:

css 复制代码
data: {...你好...}\n\ndata: {...!...}\n\n:ok\n\ndata: {...有...}\n\n

这就是为什么必须先 split 再逐行处理,不能假设一个 chunk 就是一行。

.filter(line => line.startsWith('data: '))filter 是数组方法,遍历每个元素,条件为 true 的留下 ,false 的丢掉 。不改变原数组,返回新数组。这里只留 data: 开头的行,空行(SSE 消息分隔符)和 :ok(心跳保活)全滤掉:

css 复制代码
切完:["data: {...}", "", "data: {...}", ":ok", ""]
过滤后:["data: {...}", "data: {...}"]

filter 不看行里面的内容,只看行开头是不是 data:。它不碰 JSON 里面的东西,那些留给后面处理。

第 4 层:for 循环 --- 逐行剥皮

arduino 复制代码
for (const line of lines) {
  const incoming = line.slice(6)

slice(6) 砍掉前 6 个字符。"data: " 刚好 6 个字符(d-a-t-a-:-空格),所以 slice(6) 从第 7 个字符开始取,剩下的就是纯内容:

css 复制代码
line     = 'data: {"choices":[{"delta":{"content":"你好"}}]}'
incoming = '{"choices":[{"delta":{"content":"你好"}}]}'    ← slice(6) 之后

注意 slice(6) 不动原字符串 ,返回新字符串。原 line 不受影响。

第 5 层:两件事 --- DONE 检查 + JSON 解析

第一件:遇到 DONE 就停

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

[DONE] 是 DeepSeek 自己定义的结束标志,就是一个纯文本字符串(不是 JSON,没有花括号)。它以一个独立的 SSE 行出现:

ini 复制代码
data: [DONE]

slice(6)incoming 就是完整的 '[DONE]',用 === 精确匹配。

为什么还要设 done = true 因为 [DONE] 只是一个文本文本,不是 TCP 层面的流关闭。reader.read() 返回的 doneReading 可能还是 false------水管没关,只是水里夹了张纸条说"没了"。你需要手动设 done = true 让 while 退出。

break 只跳出 for,不跳出 while 。所以要 done = true + break 配合:done = true 负责让 while 下一轮判断时退出,break 负责立刻跳出当前 for 循环(这个 chunk 后面的行不用看了)。

DeepSeek 有两种结束方式

  1. reader.read() 返回 done: true------水龙头物理关了
  2. 数据里夹 data: [DONE] 文本------水里飘来一张纸条

你的代码两种都兜了,不管哪种都能正常退出。

第二件:JSON.parse 取 delta.content

kotlin 复制代码
try {
  const data = JSON.parse(incoming)
  const delta = data.choices[0].delta.content
  if (data && delta) {
    content.value += delta
  }
} catch(err) {
  buffer = `data: ${incoming}`
}

JSON.parse(incoming) 把 JSON 字符串变成真正可操作的 JS 对象:

css 复制代码
incoming = '{"choices":[{"delta":{"content":"你好!"}}]}'
              ↓ JSON.parse
data     = { choices: [{ delta: { content: "你好!" } }] }
              ↓ 一路点进去
data.choices[0].delta.content → "你好!"

if (data && delta) 防御检查data 确保 JSON 解析成功(虽然 try-catch 已经兜了),delta 确保 content 字段不为空。有些 chunk 的 delta 里没有 content(比如只含 finish_reason: "stop" 的结束帧),不检查会 content.value += undefined,显示 "undefined" 在屏幕上。

content.value += delta 为什么用 +=:流式是逐步拼接的:

arduino 复制代码
"" + "你好" = "你好"
"你好" + "!" = "你好!"
"你好!" + "有" = "你好!有"

= 每次都会覆盖掉之前的内容,屏幕上永远只有一个字。前面全丢了。

为什么必须 .valuecontentref 对象,真正的字符串值包在 .value 里。script 中改值必须 .value,template 中 Vue 自动拆包。

第 6 层:catch --- JSON 不完整的兜底

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

JSON 为什么会被截断?

网络包有大小限制(MTU ~1500 字节)。一个 JSON 行如果超过包大小就会被切成两半:

css 复制代码
完整: data: {"choices":[{"delta":{"content":"你好"}}]}\n

包1: data: {"cho        ← JSON 不完整,有 { 开头但没有 } 结尾
包2: ices":[...]}\n     ← 剩下一半

包1 到了去 JSON.parseSyntaxError: Unexpected end of JSON input → 进入 catch。

catch 里做什么? 把不完整的这截数据塞回 buffer:

javascript 复制代码
buffer = `data: ${incoming}`
//       ^^^^^^^   ^^^^^^^^
//       补前缀    不完整 JSON

为什么必须补 data: 前缀? incomingslice(6) 之后的东西,data: 已经砍掉了。下一轮拼的时候数据是带前缀的 SSE 格式------格式不一致就拼不上。所以必须补回来。

ini 复制代码
// 不补前缀:
buffer = '{"choices":[...'     ← 没有 data:
// 下一轮:
chunkValue = '{"choices":[...' + 'data: {...}'  ← 前半段不是 data: 开头 → filter 筛掉 → 永久丢失!

// 补了前缀:
buffer = 'data: {"choices":[...'  ← 带 data:
// 下一轮:
chunkValue = 'data: {"choices":[...' + 'ices":[...]}\n\n'  ← 完整一行 → filter 保留

"出错不能扔掉" 是核心原则:这截数据只是暂时不完整,不是垃圾。扔了就永久丢失,屏幕上永远少字。try-catch 的意义不是"容错",是"暂存等下一轮"。


五、非流式分支:简单但体验差

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

和流式的两个关键差异:

流式 非流式
取数据 reader.read() 循环嘬 response.json() 一把拿
字段 delta.content(增量,+= 拼) message.content(全量,= 赋)
while 需要 不需要
buffer/try-catch 需要 不需要

为什么字段不同?非流式全部生成完才返回,所以返回的是完整消息 message。流式是一个 token 一个 token 推,所以每次只返回新增的 deltadelta 就是"变化量、偏移量"------每次只有新增的几个字,不从头重复。省带宽。


六、CSS 文档流

css 复制代码
.container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  font-size: 0.85rem;  /* 移动端适配 */
}
  • 文档流:浏览器默认布局规则------块级从上到下,行内从左到右
  • display: flex 开启新的格式化上下文,flex-direction: column 纵向排列
  • rem:相对于 html 根元素字号的比例单位,移动端等比缩放的核心手段

七、完整数据形态变化(用户输入"你好"全链路)

css 复制代码
"你好"(文本,输入框)
  ↓ JSON.stringify + UTF-8 编码
Uint8Array [...](二进制,请求体)
  ↓ POST 到 DeepSeek
═══════ 服务器推理 ═══════
  ↓
'data: {"choices":[{"delta":{"content":"你好!"}}]}\n\n'  (SSE 格式文本)
  ↓ 网络传输编码
Uint8Array [100,97,116,97,58,32,...]  (二进制字节流)
  ↓ decoder.decode(value)
'data: {"choices":[{"delta":{"content":"你好!"}}]}'  (文本)
  ↓ split('\n') 
["data: {...你好!...}", "", ""]
  ↓ filter(startsWith('data: '))
["data: {...你好!...}"]
  ↓ slice(6)
'{"choices":[{"delta":{"content":"你好!"}}]}'
  ↓ JSON.parse
{choices: [{delta: {content: "你好!"}}]}
  ↓ .choices[0].delta.content
"你好!"
  ↓ content.value +=
屏幕显示:"你好!"
  ↓ 下一轮
"有" → content += → 屏幕:"你好!有"
  ↓ 再下一轮
"什么可以帮你的" → 屏幕:"你好!有什么可以帮你的"

八、核心概念速查表

概念 一句话
response.body ReadableStream 水管,数据容器,不能直接取数据
getReader() 装水龙头,锁定水管(locked: true),独占读取
reader.read() 嘬一口,返回 {value: Uint8Array, done: boolean}
await 等 Promise resolve------数据到了 = 嘬到了
{stream: true} 传给解码器,多字节字符跨块不乱码
buffer 暂存上轮不完整的 JSON,下轮拼上再解析
\n SSE 行分隔符,也是判断一行是否完整的标记
split('\n') 按换行切开文本,一个 chunk 里可能有 1 行或多行
filter(startsWith('data: ')) 只要 data 行,空行和 :ok 心跳全丢掉。只判开头,不动内容
slice(6) 砍掉 "data: "(正好 6 个字符),剩下纯 JSON
[DONE] DeepSeek 自定义的流结束标志,纯文本不是 JSON
delta 增量,每次只返回新增的几个字
message 全量,非流式一次返回全部内容
content.value += 追加拼接,流式逐字拼
content.value = 直接赋值,非流式一把覆盖
try-catch + buffer JSON 截断不扔,暂存等下一轮拼完整
if (data && delta) 防御检查:JSON 解析成功且 content 有值才拼
ref() Vue3 响应式,script 里 .value,template 自动拆包
v-model 双向绑定,checkbox/input 和变量永远同步

九、一句话总结

流式输出的本质是一条五层数据转换流水线------嘬一口二进 → 解码文本 → 切行过滤 → 剥 JSON 皮 → 拼到屏幕 ------每层都有兜底,buffer 防截断,try-catch 兜 JSON,两处赋值(+= vs =)区分流式和非流式。把这些搞清楚了,任何一个 LLM API 你都能接上。

相关推荐
不要葱花1 小时前
接下来我将复现 10 篇强化学习算法:第 3 篇,一杯喜茶,搞定 Search-R1
算法·面试
weedsfly2 小时前
从表单校验到订单流转,彻底搞懂前端开发中的策略模式与状态模式
前端·javascript·面试
帅次2 小时前
Android 高级工程师面试:Flutter 路由导航 近1年高频追问 18 题
android·flutter·面试
zzz_23682 小时前
【Java实习面试算法冲刺】总复盘
java·算法·面试
半夜修仙3 小时前
Spring集成邮箱功能
java·开发语言·spring boot·spring·rabbitmq·github·maven
Esaka_Forever3 小时前
Prompting Techniques提示词工程核心知识梳理
人工智能·github
胡萝卜术12 小时前
深度重构:Agent Skills——从 Prompt 工程到能力工程
javascript·架构·github
csdn_aspnet16 小时前
GitHub Actions自动化运维实战,用CI/CD流水线实现测试、部署、安全扫描一体化
运维·安全·ci/cd·自动化·github
BerrySen17817 小时前
我的AI辅助开发工具链2026版
开源·github