Skip to content

前端 AI 场景流式传输:深度技术架构与实现 #133

Description

@peng-yin

导言:为什么流式传输是 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 启动请求。

  • 核心技术点: 必须将 AbortControllersignal 绑定到 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 作为定界符进行解析。

  1. 拼接: 将新收到的 chunk 与上次未处理完的 buffer 拼接。
  2. 分割:\n\n 分割整个缓冲区。
  3. 循环处理: 遍历除了最后一段外的所有片段(最后一段可能是未接收完的,需存入 buffer 等待下次接收)。
  4. 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);
            // 忽略解析失败的块
        }
    }
}

关键点:

  1. 缓冲区 (Buffer): 必须使用一个变量 (buffer) 来拼接和管理跨越数据块边界的片段。
  2. 消息完整性: 通过检查特定的分隔符(如 SSE 的 \n\n 或 JSONL 的 \n)来确定何时提取一个完整的消息。
  3. 渲染优化: 在这个阶段,通常会引入批处理机制(如 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 的流数据后,必须执行以下关键步骤:

  1. 解析上游流: BFF 必须读取并解析 LLM Provider 返回的原始流(例如,OpenAI 格式)。
  2. 提取 Token: 从解析结果中提取实际的文本片段(Token)。
  3. 格式化下游流: 将提取到的 Token 封装成 严格符合 SSE 规范 的格式(data: {...}\n\n)。
  4. 实时推送: 将封装后的 SSE 块实时写入到响应体中,推送到前端。

![BFF 流转发示意图](

Image

)

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、流式交互整体架构图

![流式交互整体架构图](

Image

)

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 DecodingFlashAttention 等技术,可以直接在推理侧降低首个 Token 的生成时间。

3. 核心指标与监控:以 TTFT 为中心

在流式场景中,以下指标是衡量用户体验和性能的关键:

  • TTFT (Time to First Token)最关键指标。它与 Web Vitals 中的 LCP (Largest Contentful Paint) 高度相关。用户看到第一个字的时间越短,感知速度越快。
  • TLLT (Time to Last Token):衡量整个推理过程的效率。
  • Token/s (吞吐量):衡量流速。低吞吐量会导致流式输出断断续续,影响阅读流畅度。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions