当前位置: 首页
AI教程
像水流一般:前端流式输出完整指南(三)

像水流一般:前端流式输出完整指南(三)

热心网友 时间:2026-07-21
转载

LLM 流式输出实战精讲:从 while 循环到 try-catch,每一行代码都算数 一、快速复习(5 分钟找回上下文) 流式输出的核心机制,说白了就是 LLM 逐个生成 token 并实时推送,而不是等待全部生成完毕后再一次性返回。服务器每生成一个 token 就立即传输,客户端接收到后立刻拼接

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

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

流式输出的核心机制,说白了就是 LLM 逐个生成 token 并实时推送,而不是等待全部生成完毕后再一次性返回。服务器每生成一个 token 就立即传输,客户端接收到后立刻拼接展示——效果类似打字机逐字输出。与传统“等待若干秒,然后一次性呈现”的模式相比,用户感知的等待时间几乎为零,体验更流畅。

像水流一样——前端流式输出完整指南(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 通过 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 有两种结束方式:

  1. reader.read() 返回 done: true——水龙头物理关闭
  2. 数据中包含 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 使用 += 的原因:流式输出是逐步拼接的过程:

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

如果使用 =,每次都会覆盖之前的内容,屏幕上永远只显示最后一个字,前面所有内容都会丢失。

为什么必须使用 .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。

来源:https://juejin.cn/post/7663405446762512410

游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

同类文章
更多
TalkVisions实时视频翻译应用,消除语言障碍

TalkVisions实时视频翻译应用,消除语言障碍

TalkVisions是一款实时视频翻译应用,能将视频中的口语实时转录为文本并翻译成用户所选语言,以字幕形式叠加在画面上,支持多语言、低延迟,还可保存录制视频,有效消除跨语言沟通障碍。

时间:2026-07-25 22:26
AI驱动的日历管理工具Ipso

AI驱动的日历管理工具Ipso

IpsoAI是一款专为专业人士及助手打造的AI日历管理工具,能够自动协调多方日程、智能草拟邮件,并通过快速安排会议、提供智能建议及自动化工作流程,显著减少琐碎操作,帮助用户高效管理时间、提升工作效率。

时间:2026-07-25 22:25
Spectate企业级专业高效监控与事故管理一体化平台

Spectate企业级专业高效监控与事故管理一体化平台

Spectate是一款高效监控和事故管理工具,能在30秒内检测故障并推送告警。它支持Slack、PagerDuty等主流集成,提供自定义状态页面和全球性能监控。系统自动更新状态并推送修复建议,帮助团队减少沟通成本,快速解决问题。

时间:2026-07-25 22:25
阿里云通义千问2.5大模型发布 多项能力赶超GPT-4

阿里云通义千问2.5大模型发布 多项能力赶超GPT-4

通义千问2 5大模型发布,多项能力宣称赶超GPT-4,中文语境下文本理解、生成、知识问答等表现优异。相比2 1版本,理解提升9%、逻辑推理提升16%、指令遵循提升19%。开源1100亿参数模型超越Llama-3-70B,获评开源最强。已服务超9万家企业,与小米、微博等达成合作。

时间:2026-07-25 22:25
万知个人AI工作站:一站式智能阅读创作分享平台

万知个人AI工作站:一站式智能阅读创作分享平台

万知是集成多种AI能力的个人工作站,支持自然语言交互、文档快速阅读与摘要生成、PPT自动设计与优化,覆盖学术研究、商务报告、写作辅助及日常问答等场景,全方位提升工作效率。

时间:2026-07-25 22:25
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 热门数据榜