AI 聊天流式交互基础:SSE、EventSource 与 ReadableStream

做 AI 聊天、Agent 对话或长任务反馈时,用户最先感知到的,通常不是模型能力,而是结果返回的方式。

一次性整段返回,体验像"卡住";边生成边返回、边解析边更新,体验才更接近实时交互。

前端实现这类能力时,绕不开三个概念:

  • SSE
  • EventSource
  • fetch + ReadableStream

这三个词经常被混用,但它们不在同一层。真正需要想清楚的是:

  • 前端怎么发请求
  • 后端怎么持续返回
  • 前端怎么持续读取和更新页面
  • 用户点击"停止"时,哪一层需要停
  • 网络断开后,是否重试、如何续接

这篇文章就按这条主线来讲。

阅读导图

先看整篇文章的结构:

text 复制代码
基础概念
  -> SSE 是什么
  -> EventSource 是什么
  -> fetch + ReadableStream 是什么

请求与返回
  -> 前端怎么发
  -> 后端怎么持续回
  -> 前端怎么持续读

控制能力
  -> 停止生成
  -> 断线重试
  -> 断点续接

选型建议
  -> 什么时候选 EventSource
  -> 什么时候选 fetch + ReadableStream

一、先把三个概念拆开

先说结论:

  • SSE 是一种事件流格式
  • EventSource 是浏览器原生的 SSE 客户端 API
  • fetch + 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'
}

用户发送后,前端通常会先做两件事:

  1. 插入用户消息
  2. 插入一条 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
        )
      )
    }
  }
}

这段逻辑的三个关键动作

  1. 持续读
    reader.read() 会不断拿到新的字节块。

  2. 维护 buffer

    一次 read() 不一定刚好读到完整事件,所以必须做拼接。

  3. 更新同一条消息
    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,也不封装事件,只是不断返回文本片段。

这种方式最简单,但扩展性最差。因为一旦要加入 doneerroreventIdcursor 等信息,协议很快就会混乱。

如果是 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()

但如果既想让前端停接收,又想让后端停任务,通常要拆成三步:

  1. POST /api/chat/create:创建任务,返回 taskId
  2. GET /api/chat/stream?taskId=xxx:用 EventSource 订阅任务流
  3. 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
  -> 重试时把这些信息带给服务端

服务端理解进度
  -> 知道客户端已经收到哪里
  -> 跳过已发送片段
  -> 从下一个片段继续推送

前端可以保存:

  • cursor
  • chunkIndex
  • eventId

服务端则需要支持:

  • 根据断点恢复流
  • 跳过已经发送过的片段
  • 从下一个片段继续发送

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 通常会是更稳的路线。

相关推荐
啦啦啦!2 小时前
项目环境的搭建,项目的初步使用和deepseek的初步认识
开发语言·c++·人工智能·算法
Westward-sun.2 小时前
OpenCV实战:摄像头实时文档扫描与透视矫正
人工智能·opencv·计算机视觉
V搜xhliang02462 小时前
生成式人工智能、大语言模型在医学教育教学中的前沿探讨
人工智能
枫叶林FYL2 小时前
【自然语言处理 NLP】7.1 机制可解释性(Mechanistic Interpretability)
人工智能·自然语言处理
任小栗2 小时前
【实战干货】Vue3 + WebRTC + SIP + AI 实现全自动语音接警系统(远程流获取+实时ASR+TTS回播)
人工智能·webrtc
qq_348231852 小时前
OpenClaw 完整安装教程
人工智能
杨浦老苏2 小时前
轻量级RSS源处理中间件FeedCraft
人工智能·docker·ai·群晖·rss
平安的平安2 小时前
Python 实现 AI 图像生成:调用 Stable Diffusion API 完整教程
人工智能·python·stable diffusion
IT观测2 小时前
# 聚焦AI驱动数据分析:2026年智能BI工具市场的深度调研与趋势展望报告
人工智能·数据挖掘·数据分析