LLM 流式输出实战精讲:从 while 循环到 try-catch,每一行代码都算数
一、快速复习(5 分钟找回上下文)
流式输出 :LLM 一个 token 一个 token 生成,不是一次性全给。服务器生成一个发一个,客户端逐字拼接显示------像打字机一样。和传统"等 8 秒啪一下全出来"的区别就是感知等待接近零。
Vue3 Composition API :ref() 创建响应式变量,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 有两种结束方式:
reader.read()返回done: true------水龙头物理关了- 数据里夹
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
"" + "你好" = "你好"
"你好" + "!" = "你好!"
"你好!" + "有" = "你好!有"
用 = 每次都会覆盖掉之前的内容,屏幕上永远只有一个字。前面全丢了。
为什么必须 .value :content 是 ref 对象,真正的字符串值包在 .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.parse → SyntaxError: Unexpected end of JSON input → 进入 catch。
catch 里做什么? 把不完整的这截数据塞回 buffer:
javascript
buffer = `data: ${incoming}`
// ^^^^^^^ ^^^^^^^^
// 补前缀 不完整 JSON
为什么必须补 data: 前缀? incoming 是 slice(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 推,所以每次只返回新增的 delta。delta 就是"变化量、偏移量"------每次只有新增的几个字,不从头重复。省带宽。
六、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 你都能接上。