您的位置:首页 > 手游攻略 > 像水流一样——前端流式输出完整指南(第三篇)

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

作者:互联网  时间: 2026-07-22 08:49:55  

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

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

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

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

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

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

水管系统:

response.body(水管)→ getReader()(水龙头)→ reader.read()(嘬一口)→ 屏幕

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

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

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

<input type="checkbox" v-model="stream" />

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

三、代码执行全景

页面加载 → 初始化 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。它决定两件事:

// ① 告诉 DeepSeek:请按什么方式返回body: JSON.stringify({ stream: stream.value })// ② 本地判断:进入哪个分支if (stream.value) { 流式 } else { 非流式 }

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

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

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 数据,甚至半行:

1口: "data: {"cho" ← 半行第2口: "ices":[{"delta":{"content":"你好"}}]}nn ← 1 行完整第3口: "data: {"delta":{"content":"!"}}]}nndata: [DONE]nn"← 2 行

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

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

const chunkValue = buffer + decoder.decode(value)buffer = ''

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

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

// 正常情况chunkValue = '' + 'data: {...完整行...}nn'← buffer 为空,不影响// 截断情况chunkValue = 'data: {"cho' + 'ices":[...]}nn'← 上一轮残留 + 本轮新数据 = 完整!

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

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

const lines = chunkValue.split('n').filter((line) => line.startsWith('data: '))

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

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

data: {...你好...}nndata: {...!...}nn:oknndata: {...有...}nn

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

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

切完:["data: {...}", "", "data: {...}", ":ok", ""]过滤后:["data: {...}", "data: {...}"]

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

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

for (const line of lines) {const incoming = line.slice(6)

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

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

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

第 5 层:两件事 — [DONE] 检查 + JSON 解析

第一件:遇到 [DONE] 就停

if (incoming === '[DONE]') {done = truebreak}

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

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

try {const data = JSON.parse(incoming)const delta = data.choices[0].delta.contentif (data && delta) {content.value += delta}} catch(err) {buffer = `data: ${incoming}`}

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

incoming = '{"choices":[{"delta":{"content":"你好!"}}]}'↓ JSON.parsedata = { 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 为什么用 +=:流式是逐步拼接的:

"" + "你好" = "你好""你好" + "!" = "你好!""你好!" + "有" = "你好!有"

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

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

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

catch(err) {buffer = `data: ${incoming}`}

JSON 为什么会被截断?

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

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

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

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

buffer = `data: ${incoming}`// ^^^^^^^ ^^^^^^^^// 补前缀不完整 JSON

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

// 不补前缀:buffer = '{"choices":[...' ← 没有 data:// 下一轮:chunkValue = '{"choices":[...' + 'data: {...}'← 前半段不是 data: 开头 → filter 筛掉 → 永久丢失!// 补了前缀:buffer = 'data: {"choices":[...'← 带 data:// 下一轮:chunkValue = 'data: {"choices":[...' + 'ices":[...]}nn'← 完整一行 → filter 保留

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

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

} 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 文档流

.container {display: flex;flex-direction: column;height: 100vh;font-size: 0.85rem;/* 移动端适配 */}

  • 文档流:浏览器默认布局规则——块级从上到下,行内从左到右
  • display: flex开启新的格式化上下文,flex-direction: column 纵向排列
  • rem:相对于 html 根元素字号的比例单位,移动端等比缩放的核心手段

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

"你好"(文本,输入框)↓ JSON.stringify + UTF-8 编码Uint8Array [...](二进制,请求体)↓ POST 到 DeepSeek═══════ 服务器推理 ═══════↓'data: {"choices":[{"delta":{"content":"你好!"}}]}nn'(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.bodyReadableStream 水管,数据容器,不能直接取数据
getReader()装水龙头,锁定水管(locked: true),独占读取
reader.read()嘬一口,返回{value: Uint8Array, done: boolean}
await等 Promise resolve——数据到了 = 嘬到了
{stream: true}传给解码器,多字节字符跨块不乱码
buffer暂存上轮不完整的 JSON,下轮拼上再解析
nSSE 行分隔符,也是判断一行是否完整的标记
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+ bufferJSON 截断不扔,暂存等下一轮拼完整
if (data && delta)防御检查:JSON 解析成功且 content 有值才拼
ref()Vue3 响应式,script 里.value,template 自动拆包
v-model双向绑定,checkbox/input 和变量永远同步

九、一句话总结

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

最新游戏

更多

Copyright©2010-2019. All rights reserved | 波波三国游戏官网|[email protected]

备案编号:湘ICP备2022015115号-4