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

230 阅读13分钟

导读

  • 核心目标:理解大模型流式回答的底层机制,掌握 SSE 协议规范、前端分包/粘包解析、Vue 响应式打字机渲染及生产落地避坑。

  • 涉及技术:Server-Sent Events (SSE)、Fetch & ReadableStream、Vue 3、FastAPI、AbortController。


现在打开一个产品,越来越容易碰到一个 AI 聊天框。做前端的看着看着,需求就来了:能不能也做一个这样的助手?

看到 AI 助手一个字一个字往外吐,很容易冒出一个前端直觉:这题我会,setInterval 嘛。

先别急着写定时器 😂。页面看起来都在“打字”,但答案是早就拿全了,还是后端正在一段段发过来,这是两回事。

前端与 AI 交互

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

用户看到的往往是一段段实时到达的文字:

AI 助手两轮对话界面
观察回答如何逐段出现,点击动图查看原图。

看起来像“一个字一个字蹦出来”,实际每次收到的可能是一个字、几个字,也可能是一小段文字。传输片段不固定,页面更新也不必与每个字一一对应。

这就引出了一个前端必须吃透的核心问题:

在大模型还没生成完完整答案时,后端如何实时推送内容?

前端又是如何稳定接收并逐段渲染到页面上的?

理解 SSE(Server-Sent Events),是前端进入 AI 应用开发的基础起点。

核心结论先行:

一轮提问通常发起一个 HTTP 请求;后端在该响应流中分段推送事件(Event);前端读取文字片段并追加到响应式状态中。

SSE 负责管道传输,Agent 负责决定生成内容与调用工具。


流式回答的完整全景流程

先把整条链路串起来,后面看代码就不容易迷路:

用户输入问题并点击发送
          │
          ▼
前端发起 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)WebSocketServer-Sent Events (SSE)
通信机制客户端定时循环发请求协议升级,全双工长连接基于标准 HTTP 的单向流
通信方向客户端主动拉取双向互发服务端向客户端单向推送
协议复杂度简单(多次独立请求)较高(需握手升级与帧管理)极简(纯 UTF-8 文本流)
断线重连不适用需客户端手动编写重连逻辑浏览器原生支持(针对 EventSource)
网关与代理完全兼容需代理服务器配置 WebSocket 穿透基于标准 HTTP,仍需检查代理缓冲与超时配置
AI 对话适用度延迟高、开销大偏重(客户端提问后主要等待输出)适合以服务端持续输出为主的场景

深度解析:为什么不是 WebSocket?

AI 问答的核心交互是:客户端先提问一次,随后大模型持续向前端输出。

此时通信主要是服务端往前端单向发数据,客户端基本不需要反向发包。采用 WebSocket 不仅要处理握手升级、心跳保活、鉴权传递等复杂逻辑,还会给反向代理(如 Nginx、API 网关)带来连接状态维持的额外负担。

因此,基于标准 HTTP 的 SSE 常用于这类流式对话。


深入 SSE 底层报文格式

服务端通过以下响应头建立 SSE 连接:

Content-Type: text/event-stream
Cache-Control: no-cache

在响应体中,数据以 UTF-8 文本流形式传输。每条消息由若干字段行组成,以空行作为消息结束边界。常见写法是 \n\n;规范也支持 \r\n 和单独的 \r 换行:

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 和自动重连。

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 模板中直接绑定状态:

<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 命名事件,对应客户端的同名监听:

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?

对比维度原生 EventSourcefetch + 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 返回一个异步生成器:

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 标签:

Network 面板中的 SSE EventStream
观察同一次请求内持续到达的事件,点击动图查看调试细节。

每一轮提问对应一条单独的 HTTP 请求,在 EventStream 标签页下可以直观查看按时间到达的各条事件帧。

控制台读取日志

同一个请求被 reader.read() 反复读取:

[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 配置中针对流式路径关闭缓冲:
      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 的取消支持。下面先展示前端的取消入口:

const controller = new AbortController()

fetch('/api/ai/demo-stream', {
  signal: controller.signal,
  method: 'POST',
  // ...
})

// 用户主动中止
function stopGenerating() {
  controller.abort()
}

日志脱敏与内容安全

  • 对话内容与 Prompt 通常包含用户敏感信息,避免在生产环境长期输出到 console.log。

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


总结与展望

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

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


参考资料