虽然现在聊 AI 流式传输有些晚了,但真正落地过 生产级工程 的人其实并没有想象中那么多。还没有尝试过? 这篇文章就是为 AI 流式传输 梳理一条清晰的思路,带你从选型到实践,少走弯路。
正文:
在构建大语言模型(LLM)对话交互(如 ChatGPT、DeepSeek、Gemini)时,打字机效果 已成为标配。这种实时逐字输出的视觉体验,其底层核心在于流式传输(Streaming) 。
本文采用渐进式展开策略来讲这个问题。
一、 业务选型:从协议原理到业务取舍
1.1 原理上:SSE 单向流与传统 HTTP 请求的底层差异
传统的 HTTP 请求采用的是 请求 - 响应 (Request - Response) 模式。客户端必须等待服务器将所有数据计算并准备完毕,一次性发送给客户端,随后连接立即关闭。在 LLM 生成数千字的回答时,这种模式会导致客户端出现长达数十秒的"白屏等待",用户体验极差。
Server-Sent Events (SSE) 是一种基于 HTTP 的轻量级单向通信标准。它允许服务器在响应头中设置 Content-Type: text/event-stream,并保持长连接。服务器在计算出部分结果后,无需等待后续内容,即可将其作为独立的"数据块(Chunks)"持续推送到客户端,直到数据发送完毕或客户端主动断开。
| 维度 | 传统 HTTP 请求 | Server-Sent Events (SSE) |
|---|---|---|
| 连接生命周期 | 短连接,响应完成后立即关闭 | 持久长连接,直到显式关闭或异常中断 |
| 数据传输方向 | 单向(仅客户端请求,服务器响应) | 单向(服务器持续向客户端推送数据) |
| 响应头声明 | Content-Type: application/json |
Content-Type: text/event-stream |
| 传输开销 | 每次通信都需要完整的 HTTP 握手及头部信息 | 一次建立连接,后续传输的数据帧头部开销极小 |
1.2 业务中:大模型场景下为什么必须放弃原生 EventSource
在浏览器中,原生的 EventSource API 提供了傻瓜式的 SSE 接入方式:
ini
const eventSource = new EventSource('/api/chat/stream');
eventSource.onmessage = (event) => {
console.log(event.data);
};
然而,在 AI 大模型(如 DeepSeek、OpenAI、Claude 等)的应用场景下,原生的 EventSource 是无法满足工程需要的。对比关系如下:
| 特性对比 | 原生 SSE (new EventSource()) |
Fetch API + ReadableStream |
|---|---|---|
| 网络定位 | 浏览器高级封装的专用协议 API | 底层通用的网络字节流 API |
| HTTP 方法 | 仅支持 GET |
支持 GET, POST , PUT 等所有请求 |
| 自定义 Header | 不支持(无法直接携带 Token 等鉴权 Headers) | 完全支持自定义控制 |
| 参数传递 | 只能通过 URL Query 参数传递,受浏览器长度限制 | 可以将复杂的参数和历史上下文放入 Request Body 中 |
| 断线重连 | 浏览器底层自带,自动重连 | 需要开发者在 JavaScript 中手动实现重连逻辑 |
为什么 AI 必须使用 Fetch + ReadableStream?
POST请求是绝对刚需 :大模型对话需要传递长达数千字的System Prompt(系统提示词)、多轮Messages历史对话数组,以及温度(temperature)等结构化参数。如果使用GET请求,这些数据根本塞不进受限的 URL 长度中(通常浏览器限制为 2KB - 8KB)。- 鉴权安全性要求高 :调用大模型接口必须携带 API Key,或在企业网关中携带
Authorization: Bearer <token>凭证。原生EventSource无法自定义 Header,导致凭证可能必须暴露在 URL 中,带来极大的安全隐患。
1.3 架构下:兼容 /v1/chat/completions 标准路由的意义
在企业架构设计中,接口协议的标准化决定了系统的生命周期。
无论是自建的大模型网关,还是前端直接对接的后端接口,强烈建议前端规范采用兼容 OpenAI V1 规格 的 /v1/chat/completions 路由定义。
- 前后端解耦:当底层大模型从 OpenAI 切换到 DeepSeek、Gemini 或本地私有化部署的 Llama 时,前端的数据解析器(Parser)无需修改一行代码,只需要修改 baseURL 和 API 密钥。
- 生态复用:很多成熟的开源 UI 库和 SDK(如 Vercel AI SDK)都完全基于该标准格式构建,兼容标准可以实现生态的无缝接入。
二、 数据处理:流式接收与容错解析
当使用 Fetch API 替代原生 EventSource 时,我们必须自己解决网络数据从原始字节一步步转换为应用层可用数据的全过程。
2.1 数据解码:Uint8Array 到文本的转换与 TextDecoder 的妙用
通过 response.body.getReader() 读取的数据是以字节数组(Uint8Array)的形式呈现的,我们需要使用 TextDecoder 将其还原为可读的 UTF-8 字符串。
javascript
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 注意:必须设置 stream: true,以保留多字节字符的解码状态
const textChunk = decoder.decode(value, { stream: true });
console.log(textChunk);
}
为什么要加
{ stream: true }?一个中文字符在 UTF-8 编码下占用 3 个字节。如果在网络包传输过程中,一个中文字符的字节恰好被切割在两个 TCP 包中(例如第一个包包含前 2 字节,第二个包包含第 3 字节),不加
{ stream: true }的TextDecoder会在解码第一个包时因字节不完整直接抛出乱码符号()。而加入该参数后,解码器会缓存这个未完成的字节,等到下一个包到来时合并解码,确保字符显示正常。
2.2 缓冲机制:网络粘包截断与 UTF-8 多字节字符截断(如中文乱码)
在弱网或高并发环境下,TCP 协议的滑动窗口机制以及网络分包会导致流式数据到达客户端时,其切片位置是随机的。这就造成了 "包截断" 现象:
javascript
期待接收到的标准数据流:
data: {"content": "我"} \n\n data: {"content": "是"} \n\n data: {"content": "AI"}
网络传输中实际收到的 Chunk 切片(可能由于截断被分为 2 个 chunk):
【Chunk 1】 "data: {"content": "我"}\n\ndata: {"content""
【Chunk 2】 ": "是"}\n\ndata: {"content": "AI"}\n\n"
如果直接尝试对 Chunk 1 结尾的 "data: {"content"" 进行 JSON.parse,或者直接按 \n\n 切分行,程序必然因为 JSON 结构残缺而抛出 SyntaxError 异常,导致渲染崩坏或应用崩溃。
2.3 容错解析:半截 JSON 的缓冲拼接与 try...catch 优雅降级
为了解决上述网络包截断问题,我们在前端必须引入 Buffer 缓冲区变量。
基本算法逻辑:
- 声明一个字符串变量
buffer,用于持久存储未解析完的数据。 - 每次接收到新的字节数据时,解码为字符串并追加到
buffer中。 - 在
buffer中循环寻找换行符\n。如果找到了,说明至少有一行完整的数据可以处理。 - 截取从开头到
\n的这一行数据进行 SSE 协议解析(过滤data:和[DONE]),并尝试JSON.parse。 - 更新
buffer,把已经解析过的部分删掉,剩下的"半截"数据(可能是空的,也可能是没有换行符的残余行)保留在buffer中,等待下一次read()到的数据与之拼接。
容错解析核心代码实现:
javascript
let buffer = ''; // 缓冲区
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) {
// 当流结束时,如果 buffer 中还有未处理的残余数据,做最后一次尝试解析
if (buffer.trim()) {
processLine(buffer);
}
break;
}
buffer += decoder.decode(value, { stream: true });
// 循环寻找换行符,将 buffer 中的完整行切出处理
let lineBreakIndex;
while ((lineBreakIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, lineBreakIndex).trim();
buffer = buffer.slice(lineBreakIndex + 1); // 截断已取出的行
if (line) {
processLine(line);
}
}
}
// 单行解析与容错器
function processLine(line) {
// 1. 过滤非标准 SSE 格式数据或空行
if (!line.startsWith('data: ')) return;
const jsonStr = line.slice(6).trim(); // 移去 "data: " 前缀
// 2. 检查流结束标识
if (jsonStr === '[DONE]') {
console.log('大模型输出结束标识 [DONE]');
return;
}
try {
const parsed = JSON.parse(jsonStr);
// 3. 执行数据提取(见下文)
handleStreamData(parsed);
} catch (err) {
// 容错降级:捕获不完整的 JSON 串,记录日志,但不中断程序
console.warn('捕获到未完成或畸形的 JSON 行,已跳过解析,等待下个数据包拼接:', jsonStr, err);
}
}
2.4 深度探索:面向"思考型"大模型的多路流式提取
当前主流推理模型在生成回答前会有一个"思考过程"。在接口设计中,思考内容与最终回答通常是通过不同的字段并行或串行输出的。
例如,标准的兼容 OpenAI 响应包 Delta 结构如下:
JavaScript
// 阶段一:思考中
{
"choices": [{
"delta": {
"reasoning_content": "用户问如何优化流式传输,我应该从..."
}
}]
}
// 阶段二:思考完毕,输出最终答案
{
"choices": [{
"delta": {
"content": "优化流式传输主要分为三个核心步骤..."
}
}]
}
要在前端实现像官方 ChatGPT 客户端那样------"思考过程展示在折叠框内,最终答案输出在外面",前端必须维护两个不同的状态,并进行多路流式提取与分发。
ini
let isThinking = false;
let accumulatedReasoning = '';
let accumulatedContent = '';
function handleStreamData(parsed) {
const delta = parsed.choices?.[0]?.delta;
if (!delta) return;
// 1. 提取思考内容
if (delta.reasoning_content) {
if (!isThinking) {
isThinking = true;
onThinkingStart(); // 触发 UI 切换,显示"思考中..."
}
accumulatedReasoning += delta.reasoning_content;
onThinkingUpdate(delta.reasoning_content); // 增量更新思考框 UI
}
// 2. 提取普通回答内容
else if (delta.content) {
if (isThinking) {
isThinking = false;
onThinkingEnd(); // 触发 UI 切换,结束并折叠思考框
}
accumulatedContent += delta.content;
onContentUpdate(delta.content); // 增量更新正文 UI
}
}
三、 视觉效果:从最小实现到生产可用
3.1 极简版验证:解决 DOM 追加与换行符失效问题
对于初级前端,当拿到 content 的增量文本后,最直观的写法是动态修改 DOM 的内容。但是如果直接使用 div.innerText += chunk,由于大模型输出的文本常常带有 \n 换行,而在 HTML 中默认的空白字符和换行符会被折叠,这会导致所有的段落和换行都变成了一行。
快速解决方法:
- 方案一 (最推荐) :在渲染容器的 CSS 中,增加
white-space: pre-wrap;。它能让浏览器完美保留文本中的空格 and 换行符。 - 方案二 :在追加内容时,手写转换,将
\n转换为<br>,并使用安全手段插入。
3.2 生产环境警报:Markdown 格式崩坏与 XSS 注入风险
当项目走向生产环境,简单的 pre-wrap 文本便无法支撑丰富的功能(如代码高亮、表格、数学公式等),我们必须使用 Markdown 解析器。然而,手写正则或粗暴的解析会引入两大高危痛点:
痛点一:Markdown 截断导致的"视觉崩坏"
大模型流式输出是以字为单位的。当它正在输出一个代码块时,流中会先出现 ```````javascript````。在闭合的另一个 ``````````` 到来之前,如果解析器直接把这段未闭合的 Markdown 丢去渲染,可能会把整个页面下方的布局都吸入到"代码块盒子"里,导致整个网页布局瞬间崩坏、闪烁或排版错乱。
痛点二:XSS 注入(跨站脚本攻击)
大模型的输出内容是动态的、不可信的(Untrusted Content)。如果攻击者通过 Prompt 诱导大模型输出包含恶意脚本的代码:
ini
<img src="x" onerror="alert('XSS 攻击!窃取你的 Token: ' + localStorage.getItem('token'))">
如果你直接使用 element.innerHTML = marked.parse(streamContent),浏览器会自动执行这段代码,导致用户的敏感数据(例如 API 密匙、登录会话)被窃取。
3.3 架构演进:数据与渲染解耦(Marked + DOMPurify 生产实践)
企业级渲染的标准架构应该是:数据接收与渲染逻辑解耦。
- 数据管道只管存数据 :不要每来一个字符就跑一次昂贵的 Markdown 渲染,可以利用
requestAnimationFrame或者设置一个几十毫秒的渲染节流阀(Throttle)来平滑渲染。 - 安全管道双层过滤 :使用成熟的解析库
marked结合 HTML 过滤库dompurify阻断 XSS 风险。
javascript
// 模拟生产环境的流式渲染流水线
import { marked } from 'marked';
import DOMPurify from 'dompurify';
// 配置 marked,使其支持在未完全闭合时的容错渲染
marked.setOptions({
breaks: true,
gfm: true
});
function renderMarkdown(rawMarkdown) {
// 1. 将 markdown 解析为原始 HTML 字符串
const rawHtml = marked.parse(rawMarkdown);
// 2. 利用 DOMPurify 过滤恶意 HTML 标签及危险属性,确保输出绝对安全
const cleanHtml = DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true } // 仅允许安全的 HTML 元素
});
return cleanHtml;
}
// 在 UI 更新时调用
function updateUI(text) {
const container = document.getElementById('chat-container');
container.innerHTML = renderMarkdown(text);
}
四、 实战演练:完整工程代码示例
4.1 示例一:基于 Google AI Studio 的 Gemini 流式测试(标准单路)
我选择使用 Google AI Studio 作为示例,是因为我们可以从'大善人'那里申请到免费的 API Key,对新人进行本地调试和学习非常友好(具体申请流程网上有非常多的教程,在此就不做展开了)。
以下是一个可直接双击运行的单 HTML 文件示例,使用 Fetch + ReadableStream 调用 Gemini 官方的流式接口:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Gemini 极简流式传输测试</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
#output { font-family: monospace; white-space: pre-wrap; padding: 20px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef; min-height: 100px; line-height: 1.6; }
button { padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
button:hover { background: #0056b3; }
</style>
</head>
<body>
<h2>Gemini Stream 测试</h2>
<button id="btn" onclick="startStream()">开始生成回答</button>
<p>输出结果:</p>
<div id="output">等待指令...</div>
<script>
// 请在此处替换为您的 Google AI Studio API Key
const API_KEY = 'YOUR_GEMINI_API_KEY';
const API_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:streamGenerateContent?alt=sse&key=${API_KEY}`;
async function startStream() {
const outputElement = document.getElementById('output');
const btn = document.getElementById('btn');
outputElement.textContent = '';
btn.disabled = true;
const requestBody = {
contents: [{ parts: [{ text: "请用大约100字描述一下未来的通用人工智能。" }] }]
};
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`HTTP Error:${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// 1. 解码传入的字节块
buffer += decoder.decode(value, { stream: true });
// 2. 按行切分并逐行处理
let lineBreakIndex;
while ((lineBreakIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, lineBreakIndex).trim();
buffer = buffer.slice(lineBreakIndex + 1);
if (line.startsWith('data: ')) {
const jsonString = line.substring(6).trim();
if (jsonString === '') continue;
try {
const dataObj = JSON.parse(jsonString);
// 根据 Gemini 的返回结构解析文本路径
const textPart = dataObj.candidates[0].content.parts[0].text;
outputElement.textContent += textPart;
} catch (e) {
// 略过不完整的 JSON 行
}
}
}
}
} catch (error) {
console.error('请求失败:', error);
outputElement.textContent = `请求失败:${error.message}`;
} finally {
btn.disabled = false;
}
}
</script>
</body>
</html>
4.2 示例二:企业级双路流式客户端(标准 OpenAI 兼容接口,含缓冲容错)
以下是使用原生 JavaScript 实现的标准 OpenAI 兼容流式请求客户端,包含缓冲区防截断拼接 、思考过程/普通回答双路分流以及相应的 DOM 渲染逻辑。
JavaScript
/**
* 启动 AI 兼容模型对话流
*@param{string} apiKey - 密钥
*@param{string} userPrompt - 用户输入的提示词
*/
async function startChatCompletionsStream(apiKey, userPrompt) {
const API_URL = '<https://api.deepseek.com/v1/chat/completions>'; // 可替换为任何兼容 OpenAI 路由的网关地址
const thinkingContainer = document.getElementById('thinking-box');
const contentContainer = document.getElementById('content-box');
// 初始化 UI
thinkingContainer.innerHTML = '';
contentContainer.innerHTML = '';
thinkingContainer.style.display = 'none';
let buffer = '';
let accumulatedThinking = '';
let accumulatedContent = '';
let isThinking = false;
const requestBody = {
model: "deepseek-reasoner", // 例如使用 deepseek 的推理模型
messages: [{ role: "user", content: userPrompt }],
stream: true
};
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer${apiKey}`
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
throw new Error(`HTTP 状态异常:${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) {
// 流结束,将最后未成行的数据解析
if (buffer.trim()) {
handleLine(buffer);
}
break;
}
buffer += decoder.decode(value, { stream: true });
// 利用循环处理 buffer 中因换行产生的所有完整行
let lineBreakIndex;
while ((lineBreakIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, lineBreakIndex).trim();
buffer = buffer.slice(lineBreakIndex + 1);
if (line) {
handleLine(line);
}
}
}
} catch (error) {
console.error('流式请求捕获到严重错误:', error);
}
// 单行处理与分流分发器
function handleLine(line) {
if (!line.startsWith('data: ')) return;
const rawJson = line.slice(6).trim();
if (rawJson === '[DONE]') {
console.log('--- 传输完毕 ---');
return;
}
try {
const parsed = JSON.parse(rawJson);
const delta = parsed.choices?.[0]?.delta;
if (!delta) return;
// 路由一路:思考过程数据
if (delta.reasoning_content) {
if (!isThinking) {
isThinking = true;
thinkingContainer.style.display = 'block';
thinkingContainer.innerHTML = '<strong>思考中...</strong><br>';
}
accumulatedThinking += delta.reasoning_content;
// 生产环境建议替换为 marked+DOMPurify 渲染,此处用安全追加演示
const textSpan = document.createElement('span');
textSpan.textContent = delta.reasoning_content;
thinkingContainer.appendChild(textSpan);
}
// 路由二路:正式回答内容数据
else if (delta.content) {
if (isThinking) {
isThinking = false;
// 思考结束,给思考框加个完结样式
const endTip = document.createElement('div');
endTip.innerHTML = '<em>(思考结束)</em><hr>';
thinkingContainer.appendChild(endTip);
}
accumulatedContent += delta.content;
// 追加正文文本
const textSpan = document.createElement('span');
textSpan.textContent = delta.content;
contentContainer.appendChild(textSpan);
}
} catch (e) {
// 此处吃掉因 JSON 截断导致的解析失败,等待下一轮 buffer 拼接后重试
}
}
}
五、 调试排查:如何监控 EventStream 面板
在开发和优化打字机效果时,浏览器开发者工具(DevTools)的 Network(网络) 面板是必不可少的武器。
sql
Chrome DevTools 监控面板结构示意:
+------------------------------------------------------------------------------------+
| Headers Payload Preview Response 【EventStream】 Initiator Timing |
+------------------------------------------------------------------------------------+
| Time | Event | ID | Data |
|------------|---------|------|------------------------------------------------------|
| 19:10:44.2 | message | 1 | {"choices":[{"delta":{"reasoning_content":"思考"}}]} |
| 19:10:44.5 | message | 2 | {"choices":[{"delta":{"content":"开始回答"}}]} |
+------------------------------------------------------------------------------------+
5.1 EventStream 面板的激活机制
- 当服务器响应头中明确声明
Content-Type: text/event-stream时,Chrome 等现代浏览器会自动激活 Network->对应请求详情中的 EventStream 专属调试面板。 - 普通的 Response 面板在连接未结束时通常是无法刷新内容的,而 EventStream 面板则会以结构化表格的形式,实时显示每一帧的接收时间、事件类型(Event)、消息 ID 和消息数据(Data),极大地降低了数据包断点调试的成本。
5.2 常见错误及排查方案
-
EventStream 面板为空,且请求状态标红:
- 原因:这通常表明 HTTP 握手阶段即告失败。可能是跨域请求限制(CORS)、鉴权 Headers(如 Bearer Token)错误、接口 404,或者网络链路被防火墙阻断。
- 排查 :切换到
Headers面板检查状态码与响应体。如果使用了第三方 API 网关,确认 API 凭证有效性以及请求 Body 是否符合大模型厂商格式定义。
-
打字机输出非常迟钝,没有逐字追加而是成块吐出:
- 原因 :网络通信中间件或服务器端开启了响应缓冲区(Buffering) 。例如,Nginx 默认会缓冲上游服务器的响应,集满一定字节数后一次性发给客户端。
- 排查 :在 Nginx 配置文件中,针对流式路由添加
proxy_buffering off;,并确保服务器端设置了X-Accel-Buffering: no响应头,以强制关闭网关侧的输出缓存。