AI 回答为什么能一个字一个字蹦出来?前端搞懂 SSE 这一篇就够了

导读

  • 核心目标:理解大模型流式回答的底层机制,掌握 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 配置中针对流式路径关闭缓冲:

      nginx 复制代码
      location /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。

  • 流式输出速度较快,建议结合流式敏感词过滤机制进行实时阻断。


总结与展望

能把"发问题 → 收事件 → 解析片段 → 追加状态 → 渲染回答"这条链路跑通,那个看起来挺神秘的打字机效果,也就有了可以逐步排查的代码路径。

接下来再加工具调用状态和富文本展示。先把这一条流接稳,别一上来就给自己叠满需求 🙂。


参考资料

相关推荐
u0111026753 小时前
Vue 3 实现图片裁剪框:拖动、缩放与固定宽高比
前端·javascript·vue.js
小小善后师3 小时前
用 Canvas + AI 实现登录页的 Logo 粒子动画
前端·vue.js
web打印社区5 小时前
远程打印:WebSocket 与 HTTP 轮询怎么选
前端·vue.js·websocket·网络协议·http·electron·pdf
朱 欢 庆7 小时前
el-table-v2 虚拟表格组件
前端·javascript·vue.js
今年下半年19 小时前
【VUE】整合腾讯地图、自定义区域边界、村委名称及资产统计(放大显示资产点位)
前端·javascript·vue.js
今年下半年1 天前
【前端】ant-design-vue 表格「行、列都动态」的处理方案
前端·javascript·vue.js
志尊宝1 天前
Vue3 零基础每日笔记(054):Pinia 三件套详解——state / getters / actions 全搞懂
前端·javascript·vue.js·vue·前端开发
ym hyd 1111 天前
慢性病精细化管理平台源码 Java+SpringBoot+Vue3 前后分离
java·vue.js·spring boot·毕设
wangyadong3171 天前
# el-table 复选框无法选中问题记录
前端·javascript·vue.js