导读
核心目标:理解大模型流式回答的底层机制,掌握 SSE 协议规范、前端分包/粘包解析、Vue 响应式打字机渲染及生产落地避坑。
涉及技术 :
Server-Sent Events (SSE)、Fetch & ReadableStream、Vue 3、FastAPI、AbortController。
现在打开一个产品,越来越容易碰到一个 AI 聊天框。做前端的看着看着,需求就来了:能不能也做一个这样的助手?
看到 AI 助手一个字一个字往外吐,很容易冒出一个前端直觉:这题我会,setInterval 嘛。
先别急着写定时器 😂。页面看起来都在"打字",但答案是早就拿全了,还是后端正在一段段发过来,这是两回事。

在 AI 应用中,前端的核心任务并非训练模型,而是把模型的流式输出、推理状态、工具调用过程与用户操作连接起来。
用户看到的往往是一段段实时到达的文字:

观察回答如何逐段出现,点击动图查看原图。
看起来像"一个字一个字蹦出来",实际每次收到的可能是一个字、几个字,也可能是一小段文字。传输片段不固定,页面更新也不必与每个字一一对应。
这就引出了一个前端必须吃透的核心问题:
在大模型还没生成完完整答案时,后端如何实时推送内容?
前端又是如何稳定接收并逐段渲染到页面上的?
理解 SSE(Server-Sent Events),是前端进入 AI 应用开发的基础起点。
核心结论先行:
一轮提问通常发起一个 HTTP 请求;后端在该响应流中分段推送事件(Event);前端读取文字片段并追加到响应式状态中。
SSE 负责管道传输,Agent 负责决定生成内容与调用工具。
流式回答的完整全景流程
先把整条链路串起来,后面看代码就不容易迷路:
text
用户输入问题并点击发送
│
▼
前端发起 POST 请求:/api/ai/demo-stream
请求体:{"message": "..."}
│
▼
后端建立持久响应流(Content-Type: text/event-stream)
│
├─ start:本轮开始
├─ delta:第一段文字片段
├─ delta:第二段文字片段
└─ done:本轮回答结束
│
▼
前端循环读取数据流,解码并按空行拆解出完整 SSE 事件
│
▼
将 delta.text 追加到响应式变量
│
▼
Vue / React 自动触发差量渲染,呈现打字机效果
核心关系: 一轮提问对应一个 HTTP 请求,响应体内包含多条 SSE 事件。 对话历史(Context)与单次请求的流式连接是相互独立的。
方案对比:AI 对话为什么先考虑 SSE?
在实现服务端向前端实时推数据时,常见的三种方案对比如下:
| 维度 | HTTP 轮询 (Polling) | WebSocket | Server-Sent Events (SSE) |
|---|---|---|---|
| 通信机制 | 客户端定时循环发请求 | 协议升级,全双工长连接 | 基于标准 HTTP 的单向流 |
| 通信方向 | 客户端主动拉取 | 双向互发 | 服务端向客户端单向推送 |
| 协议复杂度 | 简单(多次独立请求) | 较高(需握手升级与帧管理) | 极简(纯 UTF-8 文本流) |
| 断线重连 | 不适用 | 需客户端手动编写重连逻辑 | 浏览器原生支持(针对 EventSource) |
| 网关与代理 | 完全兼容 | 需代理服务器配置 WebSocket 穿透 | 基于标准 HTTP,仍需检查代理缓冲与超时配置 |
| AI 对话适用度 | 延迟高、开销大 | 偏重(客户端提问后主要等待输出) | 适合以服务端持续输出为主的场景 |
深度解析:为什么不是 WebSocket?
AI 问答的核心交互是:客户端先提问一次,随后大模型持续向前端输出。
此时通信主要是服务端往前端单向发数据,客户端基本不需要反向发包。采用 WebSocket 不仅要处理握手升级、心跳保活、鉴权传递等复杂逻辑,还会给反向代理(如 Nginx、API 网关)带来连接状态维持的额外负担。
因此,基于标准 HTTP 的 SSE 常用于这类流式对话。
深入 SSE 底层报文格式
服务端通过以下响应头建立 SSE 连接:
http
Content-Type: text/event-stream
Cache-Control: no-cache
在响应体中,数据以 UTF-8 文本流形式传输。每条消息由若干字段行组成,以空行作为消息结束边界。常见写法是 \n\n;规范也支持 \r\n 和单独的 \r 换行:
text
event: delta
id: 1024
retry: 5000
data: {"text":"你好"}
字段说明:
-
data(用于承载消息):消息内容,可以是纯文本或 JSON 字符串。
-
event(可选):自定义事件类型,默认为
message。浏览器EventSource可通过addEventListener按事件名监听。 -
id(可选):事件编号。客户端重连时会自动携带
Last-Event-ID请求头,便于后端补发遗漏数据。 -
retry(可选):指定客户端重连间隔(毫秒)。
-
:(冒号开头):注释行,常用于发送心跳保持连接活性。
-
\n\n(空行):使用 LF 换行时的消息结束边界,前端据此切分数据帧。
常见写法约定:
实际开发中,除了使用 SSE 的
event字段外,很多团队直接把类型放进 JSON 中发送:
data: {"type": "delta", "text": "你好"}\n\n这种方式更便于前端使用统一的 JSON 解析器处理不同事件类型。
前端实战:数据流接收与打字机渲染
在现代浏览器中,fetch() 配合 ReadableStream 可以在收到响应头后立即读取数据流,无需等待整个响应完成。
核心接收与解析代码
示例消费本文约定的 JSON data 事件(start、delta、done),不实现 EventSource 的 id、retry 和自动重连。
ts
import { reactive, ref } from 'vue'
interface ChatTurn {
id: number
question: string
text: string
status: 'streaming' | 'done' | 'error'
}
const turns = ref<ChatTurn[]>([])
/**
* 发送提问并流式接收模型回答
*/
async function askQuestion(message: string) {
const currentTurn = reactive<ChatTurn>({
id: turns.value.length + 1,
question: message,
text: '',
status: 'streaming'
})
turns.value.push(currentTurn)
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
try {
const response = await fetch('/api/ai/demo-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
})
if (!response.ok) throw new Error(`请求失败:${response.status}`)
if (!response.body) throw new Error('当前环境不支持流式读取')
reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let previousWasCR = false
while (true) {
const { value, done } = await reader.read()
const text = done ? decoder.decode() : decoder.decode(value, { stream: true })
// CR 立即转为 LF;跨 chunk 时仍记住它,跳过紧随其后的 LF。
for (const char of text) {
if (char !== '\n' || !previousWasCR) {
buffer += char === '\r' ? '\n' : char
}
previousWasCR = char === '\r'
}
// 按 \n\n 拆解出完整消息帧
let boundary = buffer.indexOf('\n\n')
while (boundary !== -1) {
const frame = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const dataPayload = frame
.split('\n')
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).replace(/^ /, ''))
.join('\n')
if (dataPayload && currentTurn.status === 'streaming') {
const event = JSON.parse(dataPayload)
if (event.type === 'delta' && typeof event.text === 'string') {
currentTurn.text += event.text
} else if (event.type === 'done') {
currentTurn.status = 'done'
}
}
boundary = buffer.indexOf('\n\n')
}
if (done) break // HTTP 响应体流结束;未闭合的残余帧不派发
}
if (currentTurn.status !== 'done') throw new Error('响应提前结束,未收到 done 事件')
} catch (error) {
console.error('流式读取异常:', error)
currentTurn.status = 'error'
} finally {
await reader?.cancel().catch(() => {}) // 错误时停止继续接收
reader?.releaseLock()
}
}
为什么必须使用 buffer 缓冲区?(分包与粘包)
这里最容易顺手写出一句 JSON.parse(chunk),然后喜提报错。问题在于:网络没有义务按我们写好的 JSON 边界送货 🫠。
reader.read() 读到的是一块字节,不保证刚好对应一条完整的 SSE 消息。常见情况有两种:
-
分包(半包):由于网络切片或延迟,一条完整的 JSON 消息被拆成两次读取(例如前半段拿到
{"type":"delta","te,后半段才拿到xt":"你好"}\n\n)。若直接JSON.parse会立即抛出SyntaxError异常! -
粘包:网络单次传输中合并了多条 SSE 事件(一个网络 chunk 里包含了 2~3 条完整消息)。
关键设计:
代码必须先将收到的数据追加到
buffer,统一换行符后再通过indexOf('\n\n')严格检测完整性:只解析完整消息帧;未闭合的残余文本留在 buffer 中,等待下一个 chunk 到达后继续拼接。
换句话说,buffer 就是临时收件区:半件先放着,凑齐再拆;一次来了好几件,就逐个拆开。
Vue 响应式更新与打字机效果
在 Vue 模板中直接绑定状态:
vue
<template>
<div class="chat-container">
<div v-for="turn in turns" :key="turn.id" class="turn-item">
<div class="user-bubble">{{ turn.question }}</div>
<div class="ai-bubble">
<span>{{ turn.text }}</span>
<span v-if="turn.status === 'streaming'" class="cursor-blink">|</span>
</div>
</div>
</div>
</template>
示例用 reactive() 创建当前轮次,确保修改的是响应式代理;只把普通对象放入 ref 数组后继续修改原对象,无法触发相同的更新。
当 currentTurn.text += event.text 累加时,Vue 响应式系统会自动触发细粒度 DOM 更新。每次网络数据到达,文字便随之增长,形成自然的打字机效果,基础流式展示不依赖定时器。
如果希望吐字速度更均匀,可以先缓冲收到的内容,再按节奏显示。定时器控制的是显示速度;SSE 让我们在完整答案生成前就能接收内容,两者可以结合。
选型对比:EventSource vs Fetch
浏览器提供了原生 EventSource API。下面是独立的 GET 通知示例,服务端使用 event: delta 和 event: done 命名事件,对应客户端的同名监听:
js
const source = new EventSource('/api/notifications')
source.onmessage = event => {
console.log('收到消息:', event.data)
}
source.addEventListener('delta', event => {
console.log('增量内容:', event.data)
})
source.onerror = error => {
console.error('连接异常:', error)
}
source.addEventListener('done', () => {
source.close()
})
注意:前面的 Fetch 示例将类型放在 JSON 的 type 字段中。若服务端只发送 data: {"type":"delta", ...},EventSource 会将其作为默认的 message 事件;需要在 onmessage 中解析 JSON,addEventListener('delta') 不会因此触发。
既然 EventSource 自带事件监听和断线重连,为什么大多数 AI 对话场景仍选择 fetch + reader?
| 对比维度 | 原生 EventSource | fetch + ReadableStream |
|---|---|---|
| HTTP 方法 | 仅支持 GET | 支持 POST、GET 等方法 |
| 请求体 (Body) | 不支持 | 支持传递复杂 JSON、对话历史与参数 |
| 请求头 (Headers) | 无法自定义请求头,可结合 Cookie 鉴权 | 可设置 Authorization 等非受限请求头 |
| 主动取消 | source.close() |
通过 AbortController 取消请求及响应读取 |
| 数据解析 | 浏览器内置解析 | 需前端维护 buffer 拆包 |
| 适用场景 | 通知推送、实时看板、单向大盘 | AI 对话、Agent 交互、大模型接口 |
本质区分:
SSE 是服务端的数据响应协议规范;
EventSource 是浏览器内置的专职 GET 客户端;
fetch 则是通用的网络请求接口。
如果不想手写 buffer 拆包逻辑,在工程化项目中推荐使用微软官方的开源库
@microsoft/fetch-event-source,兼具 POST 支持与自动重连能力。
后端实现参考(FastAPI)
这里用固定文字和延时模拟逐段输出,没有调用真实大模型。先看清 SSE 的收发链路,再把模拟输出换成模型的流式结果。
以 Python FastAPI 为例,后端通过 StreamingResponse 返回一个异步生成器:
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI()
async def mock_stream():
yield 'data: {"type": "start"}\n\n'
await asyncio.sleep(0.2)
words = ["推荐你", "在换季时", "选择这件", "防风外套。"]
for word in words:
yield f'data: {{"type": "delta", "text": "{word}"}}\n\n'
await asyncio.sleep(0.15)
yield 'data: {"type": "done"}\n\n'
@app.post("/api/ai/demo-stream")
async def chat_stream():
return StreamingResponse(
mock_stream(),
media_type="text/event-stream"
)
在接入真实大模型或 Agent 时,后端接到模型产生的 token 后即时包装成 delta 事件推给前端;若触发工具调用(如查订单、搜知识库),也可推送自定义状态事件供前端展示对应的加载组件。
联调验证:网络抓包与控制台日志对照
DevTools Network 抓包
光看页面在动,还不能证明流式接收真的跑通了。这个时候,Network 比"我感觉应该没问题"靠谱多了。
在 Chrome DevTools 的 Network 面板中,选中流式请求并切换至 EventStream 标签:

观察同一次请求内持续到达的事件,点击动图查看调试细节。
每一轮提问对应一条单独的 HTTP 请求,在 EventStream 标签页下可以直观查看按时间到达的各条事件帧。
控制台读取日志
同一个请求被 reader.read() 反复读取:
text
[SSE 第 1 轮 · read 1] 解码文本: data: {"type":"start","request_id":"745f8ea96bc8","seq":0}
[SSE 第 1 轮] 解析事件: start
[SSE 第 1 轮 · read 2] 解码文本: data: {"type":"delta","request_id":"745f8ea96bc8","seq":1,"text":"你问的是:"AI"}
[SSE 第 1 轮] 当前文本: 你问的是:"AI
[SSE 第 1 轮 · read 3] 解码文本: data: {"type":"delta","request_id":"745f8ea96bc8","seq":2,"text":" 助手是怎样一边"}
[SSE 第 1 轮] 当前文本: 你问的是:"AI 助手是怎样一边
...
[SSE 第 1 轮 · read 25] 解码文本:
data: {"type":"delta","request_id":"745f8ea96bc8","seq":24,"text":"示这段对话过程。"}
data: {"type":"done","request_id":"745f8ea96bc8","seq":25}
[SSE 第 1 轮] 解析事件: done
[SSE 第 1 轮 · read 26] read() 返回 { done: true, value: undefined }
[SSE 第 1 轮 · read 26] 底层响应流结束
日志验证了前面提到的机制:
-
read 1到read 26在同一次 HTTP 响应的读取过程中依次执行; -
read 25中同时读到了最后一个delta与done两条事件(粘包现象),通过 buffer 机制被正确切分; -
业务完成通过
{"type": "done"}标识,而底层的{ done: true }表示响应体流已结束,底层连接仍可能被复用。
生产落地避坑指南
在实际业务部署中,有以下几个高频避坑点:
高频坑:Nginx 缓冲导致流式打字机失效
本地一个字一个字出,上线憋半天,最后整段弹出来------熟悉的"我本地明明是好的"又来了 😅。先别急着调动画速度,看看是不是代理把响应攒住了。
-
现象:本地开发流式正常,部署上线后文字卡顿数秒,随后一次性全部返回。
-
原因 :Nginx 等反向代理默认开启了
proxy_buffering,会在数据达到一定字节数后才转发给客户端。 -
解法:
-
后端响应头增加:
X-Accel-Buffering: no; -
Nginx 配置中针对流式路径关闭缓冲:
nginxlocation /api/ai/ { proxy_pass http://ai_backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding on; }
-
取消请求后,后端也要停止生成
停止按钮也不能只负责让页面看起来停了。用户已经不看了,后台还在认真生成,这份"敬业"是要算 Token 的。
用户点击"停止生成"或离开页面时,前端通过 AbortController 取消请求和响应读取,但这不保证模型推理自动停止。
要让后台任务也停下来,后端需感知客户端断开,并将取消传递给模型调用或生成任务。具体能否及时停止,还取决于模型服务和 SDK 的取消支持。下面先展示前端的取消入口:
ts
const controller = new AbortController()
fetch('/api/ai/demo-stream', {
signal: controller.signal,
method: 'POST',
// ...
})
// 用户主动中止
function stopGenerating() {
controller.abort()
}
日志脱敏与内容安全
-
对话内容与 Prompt 通常包含用户敏感信息,避免在生产环境长期输出到
console.log。 -
流式输出速度较快,建议结合流式敏感词过滤机制进行实时阻断。
总结与展望
能把"发问题 → 收事件 → 解析片段 → 追加状态 → 渲染回答"这条链路跑通,那个看起来挺神秘的打字机效果,也就有了可以逐步排查的代码路径。
接下来再加工具调用状态和富文本展示。先把这一条流接稳,别一上来就给自己叠满需求 🙂。