做 AI 聊天、Agent 对话或长任务反馈时,用户最先感知到的,通常不是模型能力,而是结果返回的方式。
一次性整段返回,体验像"卡住";边生成边返回、边解析边更新,体验才更接近实时交互。
前端实现这类能力时,绕不开三个概念:
SSEEventSourcefetch + ReadableStream
这三个词经常被混用,但它们不在同一层。真正需要想清楚的是:
- 前端怎么发请求
- 后端怎么持续返回
- 前端怎么持续读取和更新页面
- 用户点击"停止"时,哪一层需要停
- 网络断开后,是否重试、如何续接
这篇文章就按这条主线来讲。
阅读导图
先看整篇文章的结构:
text
基础概念
-> SSE 是什么
-> EventSource 是什么
-> fetch + ReadableStream 是什么
请求与返回
-> 前端怎么发
-> 后端怎么持续回
-> 前端怎么持续读
控制能力
-> 停止生成
-> 断线重试
-> 断点续接
选型建议
-> 什么时候选 EventSource
-> 什么时候选 fetch + ReadableStream
一、先把三个概念拆开
先说结论:
SSE是一种事件流格式EventSource是浏览器原生的 SSE 客户端 APIfetch + ReadableStream是更底层的流式请求与读取方式
一句话理解:
SSE 是"怎么返回",EventSource 是"怎么接 SSE",fetch + ReadableStream 是"怎么自己掌控整条流式链路"。
再看一张关系图:
text
+----------------------+
| SSE |
| 一种事件流格式 |
+----------+-----------+
|
+------------+------------+
| |
v v
+------------------------+ +-----------------------------+
| EventSource | | fetch + ReadableStream |
| 浏览器原生 SSE 客户端 | | 通用流式请求与读取方式 |
+------------------------+ +-----------------------------+
结论:
EventSource 常用于"直接接 SSE"
fetch + ReadableStream 常用于"自己发请求 + 自己读流 + 自己控流程"
1. SSE 是什么
定义
SSE,全称 Server-Sent Events。可以把它理解成一种持续返回的 HTTP 响应:
- 客户端发起请求
- 服务端不立即关闭连接
- 服务端持续推送文本事件
它本质上是服务端单向推送,很适合文本流式输出。
格式特点
一个典型的 SSE 数据格式如下:
text
data: {"type":"delta","content":"你"}
data: {"type":"delta","content":"好"}
data: {"type":"done"}
关键点只有两个:
- 每条事件通常以
data:开头 - 事件之间用空行分隔,也就是
\n\n
所以,SSE 解决的是"服务端如何分段返回事件"。
2. EventSource 是什么
它解决什么问题
EventSource 不是 SSE 本身,而是浏览器提供的 SSE 客户端 API。
它的优点很明显:
- 原生支持
- 不用自己写 SSE 解析逻辑
- 浏览器会处理基础连接和部分断线重连
它的边界
它也有明确限制:
- 只支持
GET - 不能带请求体
- 不能自由设置自定义请求头
- 不适合复杂鉴权和上下文传递
最小示例
js
const es = new EventSource('/api/stream')
es.onmessage = (event) => {
document.getElementById('output').innerText += event.data
}
es.onerror = (err) => {
console.error('连接异常', err)
}
更适合什么场景
如果只是订阅一个现成事件流,EventSource 很方便。
如果要发起一条复杂的 AI 聊天请求,再接收流式响应,它通常不够灵活。
3. fetch + ReadableStream 是什么
它的本质
如果说 EventSource 是"浏览器帮你接好了 SSE",那 fetch + ReadableStream 更像是:
- 你自己发请求
- 自己拿流
- 自己解析
- 自己更新 UI
为什么它更常用于 AI 聊天
它的优势就在控制力:
- 支持
GET - 也支持
POST - 可以带复杂
body - 可以带聊天上下文
- 可以加自定义
headers - 更适合 AI 聊天这种"复杂请求 + 流式响应"场景
代价也很明确:
- 你要自己读流
- 自己做协议解析
- 自己处理
done / error / abort - 自己处理重试和恢复
复杂项目里,fetch + ReadableStream 往往更常见,原因也在这里。
4. 三者关系,一张表看懂
| 概念 | 它是什么 | 能不能发复杂请求 | 要不要自己解析流 | 更适合什么场景 |
|---|---|---|---|---|
SSE |
服务端事件流格式 | 取决于客户端实现 | 取决于客户端实现 | 标准化文本事件流 |
EventSource |
浏览器原生 SSE 客户端 | 不适合复杂请求 | 通常不用 | 简单订阅、日志流、状态推送 |
fetch + ReadableStream |
通用流式请求与读取方式 | 可以 | 需要 | AI 聊天、复杂流式交互 |
二、为什么 AI 聊天更常用 fetch,而不是 EventSource
这个问题不用从规范出发,从接口设计出发就够了。
请求为什么会变复杂
一条真实的 AI 聊天请求,通常不只是一句文本,还会带上:
- 当前用户输入
- 历史对话
- 会话 ID
- 模型参数
- 鉴权信息
- 业务开关
- 租户信息
为什么 EventSource 不够自然
这时候如果用 EventSource,会比较别扭。因为它更像"订阅一个 GET 流",而不是"提交一份复杂请求体,再接收增量结果"。
为什么 fetch 更贴近业务
fetch 天然适合这种模式:
js
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: inputValue,
sessionId,
history,
}),
})
所以更准确的结论不是"EventSource 不好",而是:
- 简单 SSE 订阅,用
EventSource更省事 - AI 聊天流式交互,用
fetch + ReadableStream更实用
三、一条真实的 AI 流式交互链路是怎样的
先看主链路
text
用户输入问题
-> 前端发起 POST /api/chat/stream
-> 服务端调用大模型流式接口
-> 服务端边读上游结果边写回响应
-> 前端持续读取 response.body
-> 前端解析 chunk / event
-> 前端更新同一条 assistant 消息
-> 页面持续显示增量结果
如果从角色分工来理解,可以看成这样:
text
[User]
|
v
[Frontend]
- 发送请求
- 读取响应流
- 维护消息状态
|
v
[Backend]
- 接收聊天参数
- 调用上游模型
- 转发增量事件
|
v
[LLM Provider]
- 持续产出 delta
真正关键不在"流",而在"同一条消息"
流式体验的关键,不只是"持续返回",而是同一条 AI 消息被持续追加,而不是每收到一段就新建一条消息。
一个典型消息结构如下:
ts
type ChatMessage = {
id: string
role: 'user' | 'assistant'
content: string
status?: 'streaming' | 'done' | 'error'
}
用户发送后,前端通常会先做两件事:
- 插入用户消息
- 插入一条
content: ''的 assistant 占位消息
后面每收到一个增量片段,就把它拼到这条 assistant 消息上。
四、后端如何返回流:Node.js 里的最小可用做法
如果后端用 Node.js / Express,常见做法是返回 text/event-stream,然后持续 res.write()。
最小代码示例
js
app.post('/api/chat/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')
const userMessage = req.body.message
const modelStream = await callLLMStream(userMessage)
for await (const chunk of modelStream) {
const text = chunk.delta || ''
if (!text) continue
res.write(`data: ${JSON.stringify({
type: 'delta',
content: text,
})}\n\n`)
}
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
res.end()
})
这段代码在做什么
callLLMStream(userMessage):调用上游模型流for await ... of modelStream:持续读取增量res.write(...):立即转发给前端done:告诉前端流结束了
如果只想把"后端如何流式返回"讲清楚,这段代码已经够用。
五、前端如何解析并更新页面:不要把"流"当成一次性响应
前端调用 fetch 后,不能直接 await response.json(),而是要持续读取 response.body。
这也是最容易踩的坑:请求发出去了,但响应不是一次性到达的。
一个典型解析过程
ts
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() || ''
for (const part of parts) {
if (!part.startsWith('data: ')) continue
const jsonStr = part.slice(6)
const data = JSON.parse(jsonStr)
if (data.type === 'delta') {
setMessages(prev =>
prev.map(msg =>
msg.id === assistantMsg.id
? { ...msg, content: msg.content + data.content }
: msg
)
)
}
if (data.type === 'done') {
setMessages(prev =>
prev.map(msg =>
msg.id === assistantMsg.id
? { ...msg, status: 'done' }
: msg
)
)
}
}
}
这段逻辑的三个关键动作
-
持续读
reader.read()会不断拿到新的字节块。 -
维护
buffer一次
read()不一定刚好读到完整事件,所以必须做拼接。 -
更新同一条消息
delta是追加,不是新增消息。
为什么一定要有 buffer
text
收到字节块
-> TextDecoder 解码
-> 追加到 buffer
-> 按 \n\n 切分完整事件
-> 解析 data: 后面的 JSON
-> 判断是 delta / done / error
-> 更新同一条 assistant 消息
六、除了 SSE,流式接口还常见哪些格式
很多人会把"流式输出"等同于 SSE,其实不完全准确。SSE 只是最常见的一种。
常见的三种格式
1. SSE
text
data: {"type":"delta","content":"你"}
data: {"type":"delta","content":"好"}
优点是标准化、可扩展,适合事件驱动的流式响应。
2. NDJSON
text
{"text":"你"}
{"text":"好"}
一行一个 JSON,通常按 \n 切分。实现简单,也适合日志流和结构化输出。
3. 纯文本分块
服务端不封装 JSON,也不封装事件,只是不断返回文本片段。
这种方式最简单,但扩展性最差。因为一旦要加入 done、error、eventId、cursor 等信息,协议很快就会混乱。
如果是 AI 聊天页面,通常更建议:
- 用 SSE 风格 JSON 事件
- 或者用 NDJSON
七、停止生成怎么做:前端停接收,不等于后端停任务
流式链路跑起来后,第二个必须解决的问题就是"停止生成"。
先区分两层停止
"停止"实际上分成两层:
- 前端不再接收
- 后端不再继续生成
很多实现只做了第一层,所以页面停了,服务端还在跑。
为什么这个问题容易被忽略
text
用户点击"停止"
|
+-> 前端层
| -> 中止 fetch / 关闭 EventSource
| -> 停止继续读取响应
|
+-> 后端层
-> 取消当前任务
-> 停止调用或转发上游模型结果
1. fetch + ReadableStream 的打断
常用方案
在 fetch 这条线上,主方案是 AbortController。
js
let controller = null
let reader = null
async function startStream() {
controller = new AbortController()
try {
const res = await fetch('/api/chat/stream', {
signal: controller.signal,
})
reader = res.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) break
// 解析 value 并更新页面
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err)
}
} finally {
reader = null
controller = null
}
}
async function stopStream() {
controller?.abort()
await reader?.cancel('user stopped')
}
怎么理解这两个动作
controller.abort():从请求层终止reader.cancel():从读取层停止消费
大多数聊天页面里,前者已经够用,后者通常是补充。
2. EventSource 的打断
为什么通常要拆接口
EventSource 没有 AbortController,它的停止方式是 close()。
但如果既想让前端停接收,又想让后端停任务,通常要拆成三步:
POST /api/chat/create:创建任务,返回taskIdGET /api/chat/stream?taskId=xxx:用EventSource订阅任务流POST /api/chat/cancel:带上taskId通知后端取消
一个典型调用过程
js
const createResp = await fetch('/api/chat/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: '解释一下 SSE 的打断机制' }),
})
const { taskId } = await createResp.json()
const es = new EventSource(`/api/chat/stream?taskId=${taskId}`)
es.addEventListener('message', (e) => {
const data = JSON.parse(e.data)
console.log('delta:', data.delta)
})
async function stop() {
es.close()
await fetch('/api/chat/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId }),
})
}
这个设计的关键,是把"前端断开连接"和"后端取消任务"拆开。
八、断线重试:EventSource 和 fetch 不是一个量级的工作量
1. EventSource 的重连
浏览器会帮你做一部分
EventSource 的断线重连,浏览器会帮你处理一部分。
onerror 会触发,但它通常不是手动重连的主要入口。很多时候浏览器已经在自动重连,业务层更多是在做兜底。
2. fetch + ReadableStream 的重连
为什么这部分工作量更大
fetch 没有内建断线重连,所以这些事都要自己做:
- 重试逻辑
- 退避策略
- 错误分类
- 断点续接
- 用户主动停止时的终止控制
比较实用的做法,是把"是否重试"和"如何重试"拆开。
先看重试决策
text
请求失败
-> 用户是不是主动停止的?
-> 是:不重试
-> 否:继续判断
-> 是否超过最大重试次数?
-> 是:不重试
-> 否:继续判断
-> 是否属于可重试错误?
-> 否:结束
-> 是:退避等待后重发请求
shouldRetry:决定要不要重试
js
function shouldRetry({ error, status, attempt, maxRetries, signal }) {
if (signal?.aborted) return false
if (error?.name === 'AbortError') return false
if (attempt >= maxRetries) return false
if (typeof status === 'number') {
if ([400, 401, 403, 404, 422].includes(status)) return false
if ([408, 429, 500, 502, 503, 504].includes(status)) return true
}
return true
}
streamWithRetry:负责真正重发
js
async function streamWithRetry({
url,
method = 'POST',
headers = { 'Content-Type': 'application/json' },
buildBody,
parseChunk,
onDone,
onRetry,
onOpen,
signal,
maxRetries = 5,
}) {
let attempt = 0
let finished = false
const state = {
cursor: 0,
buffer: '',
}
while (!finished) {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError')
}
let response
let status
try {
response = await fetch(url, {
method,
headers,
body: buildBody ? JSON.stringify(buildBody(state)) : undefined,
signal,
})
status = response.status
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
onOpen?.({ attempt, state })
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) {
finished = true
onDone?.(state)
break
}
state.buffer += decoder.decode(value, { stream: true })
parseChunk?.(state.buffer, {
state,
setBuffer(next) {
state.buffer = next
},
markDone() {
finished = true
},
})
if (finished) {
onDone?.(state)
break
}
}
} catch (error) {
const retry = shouldRetry({
error,
status,
attempt,
maxRetries,
signal,
})
if (!retry) {
throw error
}
attempt += 1
const delay = Math.min(1000 * 2 ** (attempt - 1), 10000)
onRetry?.({
attempt,
delay,
error,
status,
state,
})
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
这套结构的重点不是代码多复杂,而是职责清楚:
shouldRetry:决定是否重试streamWithRetry:负责重发请求state.cursor:记录续接位置signal:保证用户停止时能直接终止
九、如果要做断点续接,前后端要怎么配合
只重试、不续接,意义有限。
为什么一定要做续接
流式输出最怕两件事:
- 已显示的内容又重复一次
- 中途断开的那一小段内容彻底丢失
所以更完整的做法是,前后端一起维护"已经处理到哪里"。
前后端各自负责什么
text
前端保存进度
-> cursor / chunkIndex / eventId
-> 重试时把这些信息带给服务端
服务端理解进度
-> 知道客户端已经收到哪里
-> 跳过已发送片段
-> 从下一个片段继续推送
前端可以保存:
cursorchunkIndexeventId
服务端则需要支持:
- 根据断点恢复流
- 跳过已经发送过的片段
- 从下一个片段继续发送
cursor 和 buffer 不是一回事
这也是为什么前面的 streamWithRetry 里要维护:
js
const state = {
cursor: 0,
buffer: '',
}
其中:
buffer处理半包拼接cursor处理业务续接
如果已经做到"断线重连",那就不要只盯着前端。真正决定体验的,往往是服务端有没有恢复能力。
十、这类实现最常见的几个坑
先看一眼常见误区
1. 把每个增量都渲染成一条消息
错误结果会像这样:
text
AI:你
AI:好
AI:我
AI:来
AI:解
AI:释
正确做法是:始终更新同一条 assistant 消息。
2. 忽略半包和粘包
一次 reader.read() 拿到的,不一定就是一整条事件。
所以 buffer 不是装饰,而是必需品。
3. 前端停了,但后端还在跑
无论是 AbortController.abort(),还是 EventSource.close(),前端停止接收都不等于后端任务真的结束。
如果服务端调用上游模型是长耗时操作,最好把取消信号继续向上传递。
4. 只做重试,不做恢复
没有 cursor / eventId / chunkIndex 的重试,通常只能"重新开始",不能"从断点继续"。
十一、最终建议:到底该怎么选
先按场景划分
如果你的场景是简单的服务端单向通知,比如:
- 日志推送
- 状态推送
- 轻量消息订阅
那么 EventSource 很省事。
如果你的场景是 AI 聊天流式交互,而且满足这些条件:
- 需要
POST - 需要复杂请求体
- 需要自定义
headers - 需要处理上下文
- 需要打断
- 需要重试
- 需要断点续接
那更建议从一开始就走 fetch + ReadableStream。
原因不是它更"高级",而是它更接近真实业务的控制面。
最后用一张图快速判断
text
需求是否只是简单订阅一个现成事件流?
-> 是:优先考虑 EventSource
-> 否:继续判断
是否需要 POST、请求体、鉴权头、停止、重试、续接?
-> 是:优先考虑 fetch + ReadableStream
-> 否:两者都可,但 fetch 通常更通用
结语
回到最开始的问题:AI 聊天里的流式交互,到底该怎么理解和实现?
可以把答案压缩成一句话:
后端按增量持续返回,前端持续读取、解析、更新状态,再把停止、重试和续接补齐。
在这条链路里:
SSE解决"如何以事件流形式返回"EventSource解决"如何简单接收 SSE"fetch + ReadableStream解决"业务变复杂后,如何掌控整条流式链路"
如果目标是做一个真正可用、可打断、可重试、可恢复的 AI 聊天页面,fetch + ReadableStream 通常会是更稳的路线。