当AI像人一样"一个字一个字"地回复时,用户体验直接拉满。但背后的流式输出,你真的搞懂了吗?
写在前面
最近在做一个AI聊天应用,需求很明确:调用大模型接口时,要让回复内容像ChatGPT那样一个字一个字地"蹦"出来,而不是转圈圈等半天后一次性显示完整答案。
说实话,刚开始我以为这很简单------不就是stream: true嘛,配上前端EventSource就完事了。结果真正上手才发现,坑比想象中多得多 。尤其是JSON截断、双重退出逻辑 和协议解析这三个点,让我熬夜到凌晨三点。
这篇文章就把我从踩坑到填坑的全过程记录下来,包含最容易被忽略的底层原理,希望能帮你省下几个小时的调试时间。
一句话总结核心痛点:LLM返回的是二进制文本流,可能粘包、可能截断、有双重结束信号,你必须精准处理每一帧,否则UI必崩。
一、先看数据长啥样:服务器返回的原始流示例
在写代码之前,我们必须先搞懂服务器到底吐出来的是什么 。下面是一次真实请求中,response.body 流经 TextDecoder 解码后,按顺序收到的原始文本块(已格式化展示):
1.1 正常情况:一条完整的 data 行
css
data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1721088000,"model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"content":"从前"},"finish_reason":null}]}
这就是一条完整且合法 的JSON数据。注意它以
data:开头 ,结尾没有逗号,是一个独立的JSON对象。
1.2 粘包情况:一次收到多条 data 行
css
data: {"id":"1","choices":[{"index":0,"delta":{"content":"有座"}}]}
data: {"id":"2","choices":[{"index":0,"delta":{"content":"山,"}}]}
data: {"id":"3","choices":[{"index":0,"delta":{"content":"山上"}}]}
由于网络传输效率优化,服务器可能一次性把多条消息打包发送 。如果不用
\n切割,这三条会混成一个长字符串,直接JSON.parse必报错。
1.3 拆包情况(最坑):一条JSON被物理切成了两半
第一次 reader.read() 拿到:
css
data: {"id":"1","choices":[{"index":0,"delta":{"con
第二次 reader.read() 拿到:
css
tent":"有座庙"},"finish_reason":null}]}
这就是TCP拆包 的典型表现。因为网络MTU(最大传输单元)限制或服务端缓冲区刷新策略,一个完整的逻辑行被切成了两个物理块。直接按行分割的话,第一行
{"con解析必然失败。
1.4 结束信号:[DONE]
ini
data: [DONE]
这是业务层面的结束标记,表示LLM已经生成完所有内容。它不包含任何JSON数据,只是一个约定好的特殊字符串。
1.5 解析流程图解
scss
原始二进制流(Uint8Array)
↓ TextDecoder.decode()
文本字符串(可能包含多个 \n)
↓ .split('\n')
数组:["data: {...}", "data: {...}", ""]
↓ .filter(line => line.startsWith('data: '))
有效行:["data: {...}", "data: {...}"]
↓ .slice(6) 去掉前缀
裸JSON字符串:["{...}", "{...}"]
↓ JSON.parse()(可能失败,进buffer)
JavaScript对象 → 提取 delta.content
↓ 累加拼接
最终页面渲染内容
二、核心流程全貌(先看图,再读代码)
javascript
用户点击提交
↓
fetch发起POST请求(stream: true)
↓
服务器返回ReadableStream二进制流(TCP分片传输)
↓
getReader()获取读取器,进入while轮询
↓
await reader.read() 嘬一口数据(拿到上面示例中的某个chunk)
↓
TextDecoder解码二进制 → 文本字符串
↓
【关键】按 \n 分割成多行(解决TCP粘包问题)
↓
过滤出以 "data: " 开头的行
↓
去掉 "data: " 前缀 → 得到裸JSON字符串
↓
尝试 JSON.parse()
├─ 成功 → 提取 delta.content → 拼接到页面(增量渲染)
└─ 失败 → 存入buffer,等下一块拼上再解析(处理拆包截断)
↓
遇到 data: [DONE] → 主动退出循环(业务结束)
↓
reader.read() 返回 done: true → 被动退出循环(连接关闭)
三、代码实战:每一行都是知识点
3.1 基础配置与状态管理
csharp
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事');
const content = ref('');
const stream = ref(true); // 开关,方便对比流式/非流式
这里用的是Vue3的ref,但核心逻辑和框架无关,你用React的useState、原生JS都行。
3.2 发起请求:别忘了stream参数
php
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value // 关键!告诉服务器我要流式
})
});
关键点 :stream: true这个参数是告诉LLM服务端------"别等生成完再返回,生成多少给我多少"。
3.3 流式处理核心(带完整注释,对应上面数据示例的解析逻辑)
ini
if (stream.value) {
content.value = '';
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false; // 【退出条件1】开关变量
let buffer = ''; // 存放被截断的"半截JSON行"
while (!done) {
// 嘬一口,拿到 Uint8Array 二进制块
const { value, done: doneReading } = await reader.read();
done = doneReading; // 【退出条件2】流读取完毕(底层EOF)
// ★ 把上一轮没解析完的buffer拼到当前chunk最前面(解决拆包截断)
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // 拼完后立即清空,用完即弃
// ★★★ 灵魂操作:按 \n 分割,解决TCP粘包 ★★★
// 对应数据示例:将 "data: {...}\ndata: {...}" 切成 ["data: {...}", "data: {...}"]
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '));
for (const line of lines) {
// 切掉 "data: " 头,裸JSON登场
// 对应数据示例:"data: {"id":"1"...}" → "{"id":"1"...}"
const incoming = line.slice(6);
// ★★★ 退出条件1:服务端发送的显式结束信号 ★★★
if (incoming === '[DONE]') {
done = true;
break;
}
try {
// 解析裸JSON字符串 → JavaScript对象
const data = JSON.parse(incoming);
// 提取增量补丁 delta.content
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta; // 增量追加,逐字渲染
}
} catch (e) {
// JSON解析失败(被截断了,如上面示例的 "data: {"con")
// 存到buffer下轮再战,补上后续的 "tent":"有座庙"..."
buffer = `data: ${incoming}`;
}
}
}
}
四、三大灵魂拷问(理解这些,才算真正掌握)
4.1 为什么必须按 \n 分割?直接 JSON.parse 不行吗?
这是SSE协议规范 + TCP流特性的双重约束。
-
SSE协议硬规定 :大模型厂商都遵循Server-Sent Events规范,要求每条消息以
data:开头,行尾用\n(或\r\n)分隔。服务器发送的是"文本行流",而不是"JSON数组流"。 -
TCP粘包/拆包现实 :TCP是流式协议,没有"消息边界"。你调用一次
reader.read(),可能收到:- 拆包 :半条数据(
data: {"con)------如示例1.3 - 粘包 :三条数据挤在一起(
data: {A}\ndata: {B}\ndata: {C})------如示例1.2
如果不用
\n切分,你根本无法从data: {"con"}和tent":"hi"}}这种乱流中找回边界。split('\n')的本质,是把"不可靠的字节流"还原为"可靠的逻辑消息帧" 。 - 拆包 :半条数据(
\n是数据帧的"地桩",没有它,流式数据就是一盘散沙。
4.2 返回的 delta 到底是什么?为什么不是全量 content?
delta 是 "增量补丁" (Incremental Patch),而不是"完整快照"。
- 非流式 返回的
message.content是完整的、最终版的答案。 - 流式 返回的
delta.content是相对于上一条消息新增的那几个字符。
结合数据示例:
- 第1个chunk:
delta: "从前"→ 页面显示"从前" - 第2个chunk:
delta: "有座"→ 页面显示"从前有座" - 第3个chunk:
delta: "山"→ 页面显示"从前有座山"
设计成增量而非全量的原因:
- 节省带宽:重复传输完整的"从前"+"从前有座"是巨大的浪费。
- 降低延迟:增量包体积小,传输更快。
- 前端只需做字符串拼接 (
content.value += delta),逻辑极简。
4.3 为什么有两个退出条件(done 和 [DONE])?不重复吗?
这是底层协议(TCP/HTTP) 与应用层协议(SSE) 的双重保险,不仅不重复,而且缺一不可。
| 退出机制 | 来源 | 触发时机 | 对应数据示例 | 如果不处理会怎样 |
|---|---|---|---|---|
doneReading === true |
ReadableStream底层读取器(HTTP/TCP层) |
服务器主动关闭HTTP连接,或网络中断 | 无特定数据,突然读不到任何字节了 | 即使收到[DONE],若连接没关,循环会永远等待 reader.read(),页面卡死 |
incoming === '[DONE]' |
LLM服务端业务层(SSE协议) | 大模型生成完最后一段,主动发送结束标记 | 示例1.4:data: [DONE] |
若LLM生成完但没发[DONE](或延迟发送),即使连接没断,你也不知道回答完了,页面会一直转圈 |
执行顺序通常是 :LLM生成完毕 → 发送最后一条data: [DONE](业务结束)→ 约几百毫秒后,服务器关闭TCP连接(底层结束)。
你的代码必须同时处理这两种情况:
- 碰到
[DONE]立即跳出,提前渲染完成态(不用傻等TCP挥手)。 - 万一网络异常导致没收到
[DONE],连接断了,doneReading也能兜底退出,避免死循环。
五、Buffer截断处理详解(最容易被忽略的神器)
这是整个方案里最考验功底、最容易被忽略、但最关键的地方,也是上面示例1.3(拆包)的解决方案。
5.1 问题复现(对应示例1.3)
LLM服务端返回的数据长这样:
css
data: {"id":"xxx","choices":[{"index":0,"delta":{"content":"从前"}}]}
data: {"id":"xxx","choices":[{"index":0,"delta":{"content":"有座"}}]}
data: {"id":"xxx","choices":[{"index":0,"delta":{"content":"山"}}]}
data: [DONE]
每一行都是一个完整的JSON,直接JSON.parse()没问题。
但实际情况往往没这么理想 。由于网络传输、TCP分片、服务端缓冲区等因素,你收到的chunk可能长这样:
css
data: {"id":"xxx","choices":[{"index":0,"delta":{"con
tent":"从前有座山"}}]}
看到没?{"con 和 tent":"从前有座山"}}]} 被分到了两个chunk里。
如果直接按行分割并尝试JSON.parse(),第一行解析必然失败。
4.2 buffer的解决方案
buffer的核心逻辑就是 "这行解析不了,我先存着,等下一块数据来了拼在一起再试" 。
javascript
try {
const data = JSON.parse(incoming);
// 解析成功,正常处理
} catch (e) {
// 解析失败,把这一行原样存到buffer里
buffer = `data: ${incoming}`;
}
下一轮循环时:
arduino
const chunkValue = buffer + decoder.decode(value);
// 把上一轮没解析完的拼到新数据前面,再重新分割
说白了就是:解析失败不丢弃,而是作为"未完成的拼图碎片",等下一块数据来了一起拼。
4.3 为什么不能用简单的字符串拼接?
你可能会想:"那我直接把所有chunk拼成一个完整字符串,最后一次性解析不行吗?"
不行。原因有二:
- 内存问题:如果回复内容很长(几千字),全拼在内存里会占用大量资源
- 实时性问题:用户要的是"边生成边显示",不是"等全生成完再显示"
所以buffer方案的精髓是:只缓存"当前这一行"未完成的部分,不缓存整个响应。
金句:buffer不是"存更多",而是"存得巧"------只存当前解析失败的那一小块,用完即清。
六、避坑指南(我替你们踩过的雷)
\n不止一个 :服务器可能一次发多行(示例1.2),必须split('\n')后for循环逐行处理。[DONE]可能粘包 :它可能和上一条数据挤在同一个chunk里,比如data: {...}\ndata: [DONE],所以循环里判断incoming === '[DONE]'要及时break。decoder.decode(value)可能返回空 :流结束时value为undefined,稳妥写法是decoder.decode(value || new Uint8Array())。- buffer拼合后记得清空 :
buffer = ''必须放在拼合语句的下一行,否则会无限累积。 data:后面可能有空格 :有些服务端实现是data:不带空格,但主流都是data:(带一个空格),slice(6)稳妥。如果怕不兼容,可以用line.replace(/^data:\s*/, '')。
七、完整可运行代码(Vue3版)
ini
<script setup>
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事');
const content = ref('');
const stream = ref(true);
const update = async () => {
if (!question.value) return;
content.value = '思考中....';
const endpoint = 'https://api.deepseek.com/v1/chat/completions';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value
})
});
if (stream.value) {
content.value = '';
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = '';
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '));
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (delta) {
content.value += delta;
}
} catch (e) {
buffer = `data: ${incoming}`;
}
}
}
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
};
</script>
<template>
<div class="container">
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
</div>
<div>{{ content }}</div>
</div>
</div>
</template>
八、一图流总结
scss
┌─────────────────────────────────────────────────────────────────┐
│ 流式输出终极心法 │
├─────────────────────────────────────────────────────────────────┤
│ 📡 数据样本:data: {"delta":{"content":"你好"}} │
│ ✂️ 分割术:\n 是地桩,split 拆粘包,slice(6) 去头 │
│ 🧩 容错术:buffer 缓存截断行(如 {"con),拼合后再解析 │
│ 📦 数据层:delta 是增量补丁,前端只需累加拼接 │
│ 🛑 退出层:双重保险------业务 [DONE] + 底层 EOF(doneReading) │
│ ⚡ 体验层:边生成边渲染,首字延迟从秒级降至毫秒级 │
└─────────────────────────────────────────────────────────────────┘
最后一句 :流式输出不是炫技,而是让AI的"思考过程"变得可见、可感、可等待。搞懂了数据样本怎么拆、
\n怎么切、delta怎么拼、双重退出怎么兜底,你就稳稳掌握了AI对话交互的核心地基。