导言:为什么流式传输是 AI 交互的必然选择?
大模型(LLMs)的响应时间(Latency)是其最大的用户体验瓶颈。推理时间动辄数秒到数十秒。为了将总响应时间(Time to Last Token, TLLT)转化为优质的用户体验,我们必须优化首个 Token 响应时间(Time to First Token, TTFT),它直接决定了用户对应用响应速度的第一印象。
流式传输的本质:它利用了人类视觉的感知延迟,将总等待时间(TLLT)分散成一系列可接受的、连续的小块更新,从而在用户心理上消除了卡顿感,将低效的等待转化为高效的阅读。因此,我们必须采用流式传输。
一、流式传输的技术实现与技术对比
在 Web 领域实现从后端到前端的实时或半实时数据流,主要有以下几种技术方案:
| 技术方案 |
原理 |
延迟与性能 |
应用场景 |
| Server-Sent Events (SSE) |
基于 HTTP 协议,通过一个持久的 HTTP 连接,服务器单向向客户端推送数据。 |
低延迟,仅支持单向推送。 |
AI 聊天机器人、实时状态更新、通知、仪表盘数据。(仅适用于 GET 请求的 AI 场景) |
| WebSocket |
基于 TCP 的全双工通信协议,服务器和客户端可以双向发送数据。 |
极低延迟,支持双向通信。 |
实时多人协作、在线游戏、高频交易、需要前端主动发送心跳的场景。 |
| 长轮询 (Long Polling) |
客户端发起请求,服务器保持连接直到有新数据或超时,客户端再立即发起新请求。 |
较高延迟,占用连接资源多。 |
传统聊天室、不要求高实时的传统 Web 应用。(基本被淘汰) |
| Fetch ReadableStream |
使用标准的FetchAPI,通过响应体的ReadableStream逐块读取数据。 |
低延迟,适用于大型文件下载、或作为 SSE 的替代方案。 |
AI 场景首选:提供了对底层数据的完全控制权,便于粘包解析与请求中断。 |
二、 协议核心:SSE 与 Fetch ReadableStream 的技术选择
在前端实现流式接收时,我们重点比较 EventSource(原生 SSE)和 Fetch + ReadableStream 这两种现代方案。
一、 Server-Sent Events (SSE) - 原生 API 视角
SSE 协议通过浏览器内置的 EventSource 接口实现,它基于 HTTP/1.1 保持持久连接,专门用于服务器向客户端推送事件。主要代码:
// 前端代码示例 (JavaScript/TypeScript)
const url = '/api/stream/chat';
const eventSource = new EventSource(url);
// 设置一个全局ID,用于重连时告知服务器
let lastEventId = null;
// 2. 监听事件
eventSource.onmessage = (event) => {
// 默认的 `message` 事件,处理后端发送的 data
const chunk = event.data;
lastEventId = event.lastEventId; // 存储最新的 ID
// 更新 UI,将 chunk 文本追加到聊天窗口
updateChatUI(chunk);
};
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
// 浏览器会自动尝试重连,但可以根据需要手动关闭
// eventSource.close();
};
eventSource.addEventListener('end', (event) => {
// 监听后端自定义的 'end' 事件,表示内容生成完成
console.log('Stream finished.');
eventSource.close();
});
优点(Pros)
| 特性 |
描述 |
| 原生支持 |
浏览器内置EventSource类,API 接口简单易用。 |
| 自动重连 |
原生支持断线重连机制。当连接中断时,浏览器会自动尝试重新连接,并在请求头中携带Last-Event-ID,方便服务器从上次中断的位置恢复数据流。 |
| 心跳机制 |
规范允许服务器发送空数据或注释行 (: comment) 作为心跳包,以保持连接活跃,防止连接超时。 |
| 内置解析 |
EventSource会自动解析 SSE 标准格式(data:,event:,id:),用户可以直接监听命名事件。 |
缺点(Cons)- 致命缺陷
| 缺陷 |
影响 |
| 仅支持 GET 请求 |
这是其在 AI 场景中的致命缺陷。LLM 交互必须使用POST请求来发送大型的 JSON Payload,包括:prompt(用户输入)、history(对话历史上下文)、system instruction(系统指令) 等。GET 请求无法承载复杂的或敏感的 Body 数据。 |
| 缺乏中断控制 |
原生EventSource接口没有提供直接的机制来取消正在进行的流。需要手动管理和关闭连接。 |
| 并发限制 |
在 HTTP/1.1 下,浏览器通常对同一域名的并发连接数有限制(通常为 6 个)。如果同时打开多个 SSE 连接,可能会阻塞页面上的其他资源加载。 |
二、 Fetch API + ReadableStream - 现代 Web 标准视角
Fetch API 是现代 Web 标准中用于网络请求的接口。通过访问 fetch 返回的 response.body 属性,我们可以获得一个 ReadableStream 对象,从而开启低级别的流式处理。这是当前最推荐的 LLM 流式传输方案,以下是伪代码级的核心实现:
// 以伪代码展示前端核心逻辑
async function processStream(url, requestBody) {
const controller = new AbortController(); // 用于中断请求
let reader = null; // 声明在外部以便在 finally 中访问
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal // 绑定中断信号
});
if (!response.body) return;
reader = response.body.getReader(); // 获取 Reader
const decoder = new TextDecoder('utf-8');
let accumulatedText = '';
let buffer = '';
// 渲染批处理计时器
let updateTimer = null;
let pendingChunk = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
clearTimeout(updateTimer);
// 确保最后一次更新执行
updateUI(accumulatedText + pendingChunk);
break;
}
// 1. 数据解码:{ stream: true } 确保多字节字符不会被截断
buffer += decoder.decode(value, { stream: true });
// 2. 数据解析(假设后端返回 SSE 格式)
const lines = buffer.split('\n');
buffer = lines.pop(); // 最后一个可能不完整,保留到 buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6).trim(); // trim 去除可能的空白
if (data === '[DONE]') {
// 结束标志
break;
}
try {
const json = JSON.parse(data);
// 3. 提取实际文本
const content = json.choices?.[0]?.delta?.content || '';
// 4. 批处理:积累待更新的文本
pendingChunk += content;
if (!updateTimer) {
// 启动定时器,进行批处理更新
updateTimer = setTimeout(() => {
accumulatedText += pendingChunk;
updateUI(accumulatedText); // 更新 UI
pendingChunk = ''; // 清空待更新文本
updateTimer = null;
}, 50); // 50ms 批处理间隔
}
} catch (e) {
console.error('JSON解析错误', e);
// 忽略解析失败的块
}
}
}
}
} catch (error) {
// 如果是 AbortController 取消,可以在这里捕获
if (error.name === 'AbortError') {
console.log("Stream successfully cancelled.");
} else {
console.error('流式传输失败:', error);
}
} finally {
// 【关键】确保 reader 锁被释放,以便流资源可以被回收
if (reader) {
reader.releaseLock();
}
}
}
// 假设的 UI 更新函数
function updateUI(text) {
// 实际 DOM 或状态更新逻辑
document.getElementById('ai-output').innerText = text;
}
// 中断操作示例:controller.abort();
优点(Pros)- AI 场景的首选
| 特性 |
描述 |
| 支持 POST 请求 |
**完美的解决了 AI 场景对上下文传输的需求。**可以在请求 Body 中发送任意复杂的 JSON 数据结构。 |
| 低级控制 |
开发者完全控制数据的读取、解码和解析过程。这使得我们可以实现更健壮的 JSON粘包/半包处理,并在后端数据格式不完全遵循 SSE 规范时进行灵活适配。 |
| 中断与控制 |
完美集成AbortController。通过调用controller.abort(),可以干净、明确地取消正在进行的流式请求。 |
| 适应性强 |
ReadableStream是 Web Streams API 的一部分,可以与其他流(如TransformStream)组合,用于在客户端进行数据的预处理或转换。 |
缺点(Cons)
| 缺陷 |
影响 |
| 需要手动实现 SSE 逻辑 |
开发者必须手动处理 SSE 数据的解码 (TextDecoder) 和解析(查找data: 前缀和\n\n分隔符)。 |
| 无原生重连 |
不支持自动断线重连。需要通过封装或使用外部库来实现断连后的重试逻辑。 |
| 复杂度较高 |
相比于简单的EventSource.onmessage,使用getReader()和while(true) { await reader.read() }循环读取流,代码复杂度更高。 |
3. SSE 格式详解
无论后端使用的是何种语言或框架,向前端推送的数据块必须严格遵循 SSE 规范:
数据必须遵循特定的文本格式:字段名: 值\n,且每个事件块必须以两个换行符(\n\n)结束。
| 字段名称 |
作用描述 |
示例 |
data |
**必需。**包含事件的实际数据负载。如果数据跨多行发送,客户端会自动将所有data行的内容连接起来,用换行符\n分隔。 |
data: Part 1\ndata: Part 2\n\n客户端收到的数据是:Part 1\nPart 2 |
event |
**可选。**用于给事件命名。客户端可以使用EventSource.addEventListener()监听特定的事件类型。如果省略,默认使用message。 |
event: chat_response\ndata: User A just joined.\n\n |
id |
**可选。**用于设置事件的唯一 ID。客户端在断线重连时,浏览器自动携带Last-Event-ID。 |
id: 12345\ndata: next segment.\n\n |
retry |
**可选。**用于指定客户端重连的毫秒数。 |
retry: 5000\n\n |
示例 SSE 响应体:
data: {"id": "chatcmpl-1", "delta": {"content": "A"}}
data: {"id": "chatcmpl-2", "delta": {"content": "I"}}
data: [DONE]
二、 前端实现:ReadableStream 管道的四个阶段
前端的核心任务是搭建一个稳健的管道,将后端的二进制流转化为可渲染的文本。这四个阶段负责流的生命周期管理、数据块的获取、解码以及最终的消费。
阶段 1: 请求发起与控制
使用 fetch 结合 AbortController 启动请求。
- 核心技术点: 必须将
AbortController 的 signal 绑定到 fetch 选项中,才能实现请求的中断。
- 关键代码
const controller = new AbortController();
fetch('/api/chat', {
method: 'POST',
signal: controller.signal, // 绑定中断信号
// ... headers and body
});
// 用户点击停止时: controller.abort();
阶段 2: 二进制解码 (Decoding)
ReadableStream 每次读取到的是一个原始的 Uint8Array 二进制数据块。需要使用 TextDecoder 将其转化为字符串。
- 核心技术点:
- 使用
new TextDecoder('utf-8')。
- 在解码时,必须传入
{ stream: true } 选项。这确保了如果一个多字节字符(如中文)被分割在两个连续的 Uint8Array 块中,TextDecoder 能够正确地在内存中缓冲并重组它,防止乱码。
- 关键代码
// 获取一个默认的阅读器 (Default Reader)
const reader = stream.getReader();
// 针对文本流,需要一个解码器将 Uint8Array 转换为字符串
const decoder = new TextDecoder('utf-8');
- Reader 的作用: Reader 是控制流速和访问数据块的接口。一旦一个流被
getReader() 锁定,直到 Reader 被释放(通过 releaseLock()),其他代码都不能再从该流中获取 Reader。
阶段 3: 数据读取与循环 (Reading Loop)
这是数据流动的核心阶段,通过循环调用 reader.read() 来获取数据块,并进行解码。
- 核心操作: 使用
while (true) 循环不断拉取数据块,直到流结束,并确保在 finally 块中调用 reader.releaseLock() 释放资源。
- 关键代码
const reader = response.body.getReader();
const decoder = new TextDecoder();
// ... buffer and state management
let buffer = ''; // 用于积累不完整的数据块(如 JSONL 或 SSE 边界)
try {
while (true) {
// 1. 读取操作:返回一个 Promise,包含 done 和 value
const { done, value } = await reader.read();
// 2. 判断流是否结束
if (done) {
console.log('Stream finished.');
break;
}
// 3. 数据解码:将 Uint8Array 转换为字符串
// { stream: true } 确保多字节字符不会在块边界被截断
buffer += decoder.decode(value, { stream: true });
// 4. 数据解析与处理(进入阶段四)
// ... processStreamChunk(buffer) ...
}
} catch (error) {
// 如果是 AbortController 取消,可以在这里捕获
if (error.name === 'AbortError') {
console.log('Stream successfully cancelled.');
} else {
console.error('流式传输失败:', error);
}
} finally {
// 【关键】确保 reader 锁被释放,以便流资源可以被回收
if (reader) {
reader.releaseLock();
}
}
- 数据块 (Value):
value 是一个 Uint8Array 类型的数组,它代表了当前读取到的原始二进制数据块。
阶段 4: 数据解析、处理与渲染(最关键的细节)
网络传输中存在"粘包"(多个事件块连在一起)和"半包"(一个事件块被截断)问题。前端收到的一个 chunk 可能包含多个完整的 SSE 事件,或一个被截断的 SSE 事件。因此,需要专门的逻辑来稳定地解析出完整的、可用的 AI 消息。
【核心概念】JSONL over SSE: 尽管后端采用 SSE 协议(data: ...\n\n)传输,但数据负载(Payload)本身通常是 JSON Lines (JSONL) 格式 的消息流,即每行都是一个独立的 JSON 对象。
解决方案: 维护一个持续的文本缓冲区 (buffer),并以 \n\n 作为定界符进行解析。
- 拼接: 将新收到的
chunk 与上次未处理完的 buffer 拼接。
- 分割: 以
\n\n 分割整个缓冲区。
- 循环处理: 遍历除了最后一段外的所有片段(最后一段可能是未接收完的,需存入
buffer 等待下次接收)。
- JSON 健壮性: 在解析
data: {...} 中的 JSON 时,必须使用 try...catch 块。如果 JSON 解析失败(例如 data 字段被截断或损坏),则将该行文本重新放回 buffer,等待下一个数据块的补全。
- 核心操作: 缓冲区管理、完整消息提取、格式化和 UI 渲染。
- 关键实现细节 (以 AI SSE 格式为例):
// 承接阶段三的 buffer
const lines = buffer.split('\n');
buffer = lines.pop(); // 将最后可能不完整的行保留在 buffer 中
for (const line of lines) {
// 5. 格式化解析(例如,提取 SSE 格式中的 data: 内容)
if (line.startsWith('data: ')) {
const data = line.substring(6).trim();
if (data === '[DONE]') {
// LLM 流式输出的结束标记
continue;
}
try {
// 6. 提取核心内容
const message = JSON.parse(data);
const content = message.content || message.choices?.[0]?.delta?.content || '';
// 7. 渲染与批处理
// updateUI(content); // 将内容更新到 DOM,通常需要批处理优化
handleStreamingUpdate(content);
} catch (e) {
console.error("Failed to parse JSON in stream chunk:", e);
// 忽略解析失败的块
}
}
}
关键点:
- 缓冲区 (Buffer): 必须使用一个变量 (
buffer) 来拼接和管理跨越数据块边界的片段。
- 消息完整性: 通过检查特定的分隔符(如 SSE 的
\n\n 或 JSONL 的 \n)来确定何时提取一个完整的消息。
- 渲染优化: 在这个阶段,通常会引入批处理机制(如
setTimeout)来避免高频次的 DOM 写入,从而提升前端性能。
三、 后端(BFF)架构与流转发
后端 BFF(Backend For Frontend)作为 LLM API 的代理,其核心功能是流转发(Stream Piping)。
1. 代理与安全性
- 安全:API Key 永远不暴露给前端。BFF 负责鉴权、调用 LLM 并注入 Key。
- 上下文:BFF 负责将会话历史(Messages Context)封装成 LLM 要求的格式。
2. BFF 流转发的核心职责:双重解析与格式转换
BFF 接收到 LLM Provider 的流数据后,必须执行以下关键步骤:
- 解析上游流: BFF 必须读取并解析 LLM Provider 返回的原始流(例如,OpenAI 格式)。
- 提取 Token: 从解析结果中提取实际的文本片段(Token)。
- 格式化下游流: 将提取到的 Token 封装成 严格符合 SSE 规范 的格式(
data: {...}\n\n)。
- 实时推送: 将封装后的 SSE 块实时写入到响应体中,推送到前端。

3. BFF 流转发的 Header 细节
后端 BFF 向前端传输 SSE 格式流时,BFF 必须设置以下 Headers,以确保浏览器识别为流,并防止中间件(如 Nginx)进行数据缓冲:
Content-Type: text/event-stream(必需)
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no (在 Nginx 或类似代理下至关重要,它能关闭服务端缓冲,保证数据实时推送到前端。)
4. 生产级错误信号处理
当 LLM API 返回 4xx/5xx 错误(如限速 429、Token 过期)时,BFF 不能简单地关闭连接,而应该向前端发送一个结构化的错误事件。
// 在后端 BFF 中
if (error.statusCode === 429) {
// 设置 event: error 字段,让前端捕获
res.write('event: error\n');
res.write('data: {"code": 429, "message": "Rate limit exceeded"}\n\n');
res.end();
}
前端则需要监听和解析这个特定的 event: error 字段。
四、架构设计
1、流式交互整体架构图

)
2、架构关键环节解析
| 序号 |
环节 |
技术职能与关键点 |
| Frontend |
浏览器 / React App |
使用fetchAPI,而非传统EventSource(因为需要 POST)。核心在于通过ReadableStream循环读取、使用TextDecoder解码,并实现健壮的 SSE数据解析和粘包处理。 |
| BFF |
后端代理 / BFF |
**核心枢纽。**负责安全(隐藏 API Key)、业务逻辑(上下文管理)和流式转发(Piping)。必须在接收到 LLM 的数据后,立刻设置Content-Type: text/event-streamHeaders,并将 LLM 的原始流实时写入前端 Response。 |
| LLM Provider |
模型服务商 |
接收请求,并将推理结果以分块(Chunk)形式,通过网络协议流式返回给 BFF。 |
| Piping (4-6) |
流式转发 |
BFF 收到数据块后,不进行缓冲,实时将其封装成data: {...}\n\n格式推送到前端。这是确保低延迟(TTFT)的关键。 |
| Stream End (8) |
结束信号 |
传输结束时,后端必须发送一个明确的结束标志(如[DONE]),前端收到后才能清理状态(如隐藏加载动画或光标)。 |
五、 用户体验与性能优化
1. 渲染优化:避免 UI 卡顿 (Jank)
频繁的 DOM 写入会导致浏览器高频重绘,可能引发 UI 卡顿。
- 批处理 (Throttling):通过
setTimeout 等机制,将 50-100ms 内收到的 Token 累积起来,进行一次 DOM 更新,减少重绘次数。
- React 优化:使用
useTransition (React 18+):对于 LLM 文本这种非紧急的 UI 更新,可以将状态更新包裹在 startTransition 中。这允许 React 在不阻塞用户交互(如输入、按钮点击)的情况下进行文本渲染。
- CSS
white-space: pre-wrap:对于显示 LLM 输出的长文本区域,使用此 CSS 属性可以保留 LLM 输出中的换行和空白,避免 Markdown 格式错乱。
2. 降低 TTFT 的架构选择
- 边缘计算 (Edge Functions/CDN):将 BFF 接口部署在距离用户最近的边缘节点(如 Vercel Edge, Cloudflare Workers)。这显著降低了网络传输路径,是降低 TTFT 的最有效手段。
- Keep-Alive (长连接):确保 BFF 和 LLM Provider 之间的连接使用 HTTP 持久连接。这避免了为每一个请求重建 TCP/TLS 连接的开销,从而显著减少了网络延迟。
- LLM 模型的 TTFT 优化:在模型侧,通过使用 Speculative Decoding 或 FlashAttention 等技术,可以直接在推理侧降低首个 Token 的生成时间。
3. 核心指标与监控:以 TTFT 为中心
在流式场景中,以下指标是衡量用户体验和性能的关键:
- TTFT (Time to First Token):最关键指标。它与 Web Vitals 中的 LCP (Largest Contentful Paint) 高度相关。用户看到第一个字的时间越短,感知速度越快。
- TLLT (Time to Last Token):衡量整个推理过程的效率。
- Token/s (吞吐量):衡量流速。低吞吐量会导致流式输出断断续续,影响阅读流畅度。
导言:为什么流式传输是 AI 交互的必然选择?
大模型(LLMs)的响应时间(Latency)是其最大的用户体验瓶颈。推理时间动辄数秒到数十秒。为了将总响应时间(Time to Last Token, TLLT)转化为优质的用户体验,我们必须优化首个 Token 响应时间(Time to First Token, TTFT),它直接决定了用户对应用响应速度的第一印象。
流式传输的本质:它利用了人类视觉的感知延迟,将总等待时间(TLLT)分散成一系列可接受的、连续的小块更新,从而在用户心理上消除了卡顿感,将低效的等待转化为高效的阅读。因此,我们必须采用流式传输。
一、流式传输的技术实现与技术对比
在 Web 领域实现从后端到前端的实时或半实时数据流,主要有以下几种技术方案:
FetchAPI,通过响应体的ReadableStream逐块读取数据。二、 协议核心:SSE 与 Fetch ReadableStream 的技术选择
在前端实现流式接收时,我们重点比较
EventSource(原生 SSE)和Fetch + ReadableStream这两种现代方案。一、 Server-Sent Events (SSE) - 原生 API 视角
SSE 协议通过浏览器内置的
EventSource接口实现,它基于 HTTP/1.1 保持持久连接,专门用于服务器向客户端推送事件。主要代码:优点(Pros)
EventSource类,API 接口简单易用。Last-Event-ID,方便服务器从上次中断的位置恢复数据流。: comment) 作为心跳包,以保持连接活跃,防止连接超时。EventSource会自动解析 SSE 标准格式(data:,event:,id:),用户可以直接监听命名事件。缺点(Cons)- 致命缺陷
prompt(用户输入)、history(对话历史上下文)、system instruction(系统指令) 等。GET 请求无法承载复杂的或敏感的 Body 数据。EventSource接口没有提供直接的机制来取消正在进行的流。需要手动管理和关闭连接。二、 Fetch API + ReadableStream - 现代 Web 标准视角
Fetch API 是现代 Web 标准中用于网络请求的接口。通过访问
fetch返回的response.body属性,我们可以获得一个ReadableStream对象,从而开启低级别的流式处理。这是当前最推荐的 LLM 流式传输方案,以下是伪代码级的核心实现:优点(Pros)- AI 场景的首选
AbortController。通过调用controller.abort(),可以干净、明确地取消正在进行的流式请求。ReadableStream是 Web Streams API 的一部分,可以与其他流(如TransformStream)组合,用于在客户端进行数据的预处理或转换。缺点(Cons)
TextDecoder) 和解析(查找data:前缀和\n\n分隔符)。EventSource.onmessage,使用getReader()和while(true) { await reader.read() }循环读取流,代码复杂度更高。3. SSE 格式详解
无论后端使用的是何种语言或框架,向前端推送的数据块必须严格遵循 SSE 规范:
数据必须遵循特定的文本格式:
字段名: 值\n,且每个事件块必须以两个换行符(\n\n)结束。datadata行的内容连接起来,用换行符\n分隔。data: Part 1\ndata: Part 2\n\n客户端收到的数据是:Part 1\nPart 2eventEventSource.addEventListener()监听特定的事件类型。如果省略,默认使用message。event: chat_response\ndata: User A just joined.\n\nidLast-Event-ID。id: 12345\ndata: next segment.\n\nretryretry: 5000\n\n示例 SSE 响应体:
二、 前端实现:ReadableStream 管道的四个阶段
前端的核心任务是搭建一个稳健的管道,将后端的二进制流转化为可渲染的文本。这四个阶段负责流的生命周期管理、数据块的获取、解码以及最终的消费。
阶段 1: 请求发起与控制
使用
fetch结合AbortController启动请求。AbortController的signal绑定到fetch选项中,才能实现请求的中断。阶段 2: 二进制解码 (Decoding)
ReadableStream每次读取到的是一个原始的Uint8Array二进制数据块。需要使用TextDecoder将其转化为字符串。new TextDecoder('utf-8')。{ stream: true }选项。这确保了如果一个多字节字符(如中文)被分割在两个连续的Uint8Array块中,TextDecoder能够正确地在内存中缓冲并重组它,防止乱码。getReader()锁定,直到 Reader 被释放(通过releaseLock()),其他代码都不能再从该流中获取 Reader。阶段 3: 数据读取与循环 (Reading Loop)
这是数据流动的核心阶段,通过循环调用
reader.read()来获取数据块,并进行解码。while (true)循环不断拉取数据块,直到流结束,并确保在finally块中调用reader.releaseLock()释放资源。value是一个Uint8Array类型的数组,它代表了当前读取到的原始二进制数据块。阶段 4: 数据解析、处理与渲染(最关键的细节)
网络传输中存在"粘包"(多个事件块连在一起)和"半包"(一个事件块被截断)问题。前端收到的一个
chunk可能包含多个完整的 SSE 事件,或一个被截断的 SSE 事件。因此,需要专门的逻辑来稳定地解析出完整的、可用的 AI 消息。【核心概念】JSONL over SSE: 尽管后端采用 SSE 协议(
data: ...\n\n)传输,但数据负载(Payload)本身通常是 JSON Lines (JSONL) 格式 的消息流,即每行都是一个独立的 JSON 对象。解决方案: 维护一个持续的文本缓冲区 (
buffer),并以\n\n作为定界符进行解析。chunk与上次未处理完的buffer拼接。\n\n分割整个缓冲区。buffer等待下次接收)。data: {...}中的 JSON 时,必须使用try...catch块。如果 JSON 解析失败(例如data字段被截断或损坏),则将该行文本重新放回buffer,等待下一个数据块的补全。关键点:
buffer) 来拼接和管理跨越数据块边界的片段。\n\n或 JSONL 的\n)来确定何时提取一个完整的消息。setTimeout)来避免高频次的 DOM 写入,从而提升前端性能。三、 后端(BFF)架构与流转发
后端 BFF(Backend For Frontend)作为 LLM API 的代理,其核心功能是流转发(Stream Piping)。
1. 代理与安全性
2. BFF 流转发的核心职责:双重解析与格式转换
BFF 接收到 LLM Provider 的流数据后,必须执行以下关键步骤:
data: {...}\n\n)。
3. BFF 流转发的 Header 细节
后端 BFF 向前端传输 SSE 格式流时,BFF 必须设置以下 Headers,以确保浏览器识别为流,并防止中间件(如 Nginx)进行数据缓冲:
Content-Type: text/event-stream(必需)Cache-Control: no-cache, no-transformConnection: keep-aliveX-Accel-Buffering: no(在 Nginx 或类似代理下至关重要,它能关闭服务端缓冲,保证数据实时推送到前端。)4. 生产级错误信号处理
当 LLM API 返回 4xx/5xx 错误(如限速 429、Token 过期)时,BFF 不能简单地关闭连接,而应该向前端发送一个结构化的错误事件。
前端则需要监听和解析这个特定的
event: error字段。四、架构设计
1、流式交互整体架构图

2、架构关键环节解析
fetchAPI,而非传统EventSource(因为需要 POST)。核心在于通过ReadableStream循环读取、使用TextDecoder解码,并实现健壮的 SSE数据解析和粘包处理。Content-Type: text/event-streamHeaders,并将 LLM 的原始流实时写入前端 Response。data: {...}\n\n格式推送到前端。这是确保低延迟(TTFT)的关键。[DONE]),前端收到后才能清理状态(如隐藏加载动画或光标)。五、 用户体验与性能优化
1. 渲染优化:避免 UI 卡顿 (Jank)
频繁的 DOM 写入会导致浏览器高频重绘,可能引发 UI 卡顿。
setTimeout等机制,将 50-100ms 内收到的 Token 累积起来,进行一次 DOM 更新,减少重绘次数。useTransition(React 18+):对于 LLM 文本这种非紧急的 UI 更新,可以将状态更新包裹在startTransition中。这允许 React 在不阻塞用户交互(如输入、按钮点击)的情况下进行文本渲染。white-space: pre-wrap:对于显示 LLM 输出的长文本区域,使用此 CSS 属性可以保留 LLM 输出中的换行和空白,避免 Markdown 格式错乱。2. 降低 TTFT 的架构选择
3. 核心指标与监控:以 TTFT 为中心
在流式场景中,以下指标是衡量用户体验和性能的关键: