LLM 流式输出实战精讲:从 while 循环到 try-catch,每一行代码都算数
一、快速复习(5 分钟找回上下文)
流式输出的核心机制,说白了就是 LLM 逐个生成 token 并实时推送,而不是等待全部生成完毕后再一次性返回。服务器每生成一个 token 就立即传输,客户端接收到后立刻拼接展示——效果类似打字机逐字输出。与传统“等待若干秒,然后一次性呈现”的模式相比,用户感知的等待时间几乎为零,体验更流畅。

在 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 通过 v-model 绑定:
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 协议的行分隔符,也是判断一行是否完整的关键——以 n 结尾表示完整一行,否则表示被截断。
为什么一个 chunk 可能包含多行?LLM 生成 token 的速度并不固定。快速生成时,多个 token 可能被封装在同一个网络包中。因此解码后的 chunk 可能呈现为:
data: {...你好...}nn
data: {...!...}nn
:oknn
data: {...有...}nn
这就是为什么必须先 split 再逐行处理,不能假设一个 chunk 就是一行。
.filter(line => line.startsWith('data: ')):filter 是数组方法,遍历每个元素,保留满足条件的行,丢弃不满足的。不改变原数组,返回新数组。这里仅保留以 data: 开头的行,空行(SSE 消息分隔符)和 :ok(心跳保活)全部过滤掉:
切完:["data: {...}", "", "data: {...}", ":ok", ""]
过滤后:["data: {...}", "data: {...}"]
filter 只检查行开头是否 data:,不关心行内部内容,后续处理留给其他步骤。
第 4 层:for 循环 — 逐行剥皮
for (const line of lines) {
const incoming = line.slice(6)
}
slice(6) 移除前 6 个字符。"data: " 正好 6 个字符(d-a-t-a-:-空格),因此 slice(6) 从第 7 个字符开始截取,剩余部分即为纯 JSON 内容:
line = 'data: {"choices":[{"delta":{"content":"你好"}}]}'
incoming = '{"choices":[{"delta":{"content":"你好"}}]}' ← slice(6) 之后
注意 slice(6) 不修改原字符串,返回新字符串。原 line 保持不变。
第 5 层:两件事 — [DONE] 检查 + JSON 解析
第一件:遇到 [DONE] 立即停止
if (incoming === '[DONE]') {
done = true
break
}
[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 有两种结束方式:
reader.read()返回done: true——水龙头物理关闭- 数据中包含
data: [DONE]文本——水中飘来一张纸条
你的代码同时覆盖了两种场景,确保无论哪种情况都能正常退出。
第二件:JSON.parse 提取 delta.content
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 对象:
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 使用 += 的原因:流式输出是逐步拼接的过程:
"" + "你好" = "你好"
"你好" + "!" = "你好!"
"你好!" + "有" = "你好!有"
如果使用 =,每次都会覆盖之前的内容,屏幕上永远只显示最后一个字,前面所有内容都会丢失。
为什么必须使用 .value:content 是 ref 对象,真正的字符串值包裹在 .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.parse → SyntaxError: Unexpected end of JSON input → 进入 catch。
catch 中做什么?将不完整的数据片段存回 buffer:
buffer = `data: ${incoming}`
// ^^^^^^^ ^^^^^^^^
// 补前缀 不完整 JSON
为什么必须补回 data: 前缀?incoming 是 slice(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 推送,每次只返回新增的 delta。delta 意为“变化量、偏移量”——每次仅包含新增的几个字,不重复发送已有内容,节省带宽。
六、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.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。
