SSE 流式对话处理方法(前端打字机效果与断线重连)

AI 对话的打字机效果,本质是前端通过 SSE(Server-Sent Events)与后端维持一条 HTTP 长连接,把模型逐块吐出的 token 增量追加到页面。实现的关键就三点:后端按事件流协议输出数据、前端用 EventSource 或 fetch 流逐块消费、渲染层只追加不整块替换。我在 AI 爆款文章创作器和企业级 AI 网关两个项目里都用了这套链路,下面按通信协议、前端消费、渲染优化三段讲透。

一、为什么选 SSE 而不是 WebSocket

对话场景是”服务端单向推送给前端”,SSE 天然匹配。它基于普通 HTTP,协议简单,浏览器原生支持,断线还能自动重连。

对比项 SSE WebSocket
协议基础 HTTP,沿用现有网关与鉴权 独立 TCP 升级握手
通信方向 服务端单向推送 双向全双工
浏览器支持 EventSource 原生支持 原生支持
自动重连 内置 需自己实现
文本流式 天然按文本块传输 按帧,需自行组包

聊天只需要”用户发一条、服务端流式回一段”,用 WebSocket 双通道反而带来心跳、重连、序列化一组额外负担。网关侧做负载均衡时 SSE 还能走普通 HTTP 协议栈,运维成本低一截。

二、后端如何输出事件流

Spring AI 的 ChatClient 提供 stream 方法,返回响应式流,Controller 声明 text/event-stream 媒体类型即可让 Spring Boot 按 SSE 协议写出:

@GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(@RequestParam String message) {
    return chatClient.prompt()
            .user(message)
            .stream()
            .content();
}

网关需要更强的控制力,比如带工具调用时插入状态事件,改用 SseEmitter 手动推送,事件可以命名:

emitter.send(SseEmitter.event().name("token").data(chunk));
emitter.send(SseEmitter.event().name("done").data("complete"));

流协议的核心是每条事件用 data: 开头、空行结尾;命名事件则多一行 event: 名称。Spring 的 SseEmitter 与 Flux 都会按这个格式自动组包,前端按约定的事件名消费即可。

三、前端消费:EventSource 与 fetch 流两条路

3.1 EventSource:GET 场景的默认选择

EventSource 是浏览器内置对象,专为 SSE 而生,代码量最小:

const source = new EventSource(`/api/chat/stream?message=${encodeURIComponent(text)}`)
source.addEventListener('token', (e) => appendChunk(e.data))
source.addEventListener('done', () => source.close())
source.onerror = () => { /* 网络断开,浏览器自动重连 */ }

两个限制要提前知道:EventSource 只能发 GET 请求,参数塞进查询串,消息过长会撞 URL 上限;也没法带自定义请求头。网关若要求 Authorization 头,EventSource 就走不通。

3.2 fetch 流读取:POST 与带头的场景

网关的对话接口要传上下文、模型参数和鉴权头,我用 fetch 配合 ReadableStream 逐块解析:

const res = await fetch('/api/chat/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
  body: JSON.stringify({ messages, model })
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  appendChunk(decoder.decode(value, { stream: true }))
}

注意一点:TCP 分块不保证恰好是一个 SSE 事件,粘包半包都可能出现。要稳妥的话按 \n\n 拆事件、按 data: 前缀取值,再进缓冲队列统一处理。简单场景下直接把每个读到的文本块追加渲染,语义不会出错,只是粒度粗一些。

3.3 SSE 事件解析:缓冲队列解决粘包半包

流式传输按 TCP 包切分,一个事件可能跨两个包到达,一个包也可能塞进多个事件。项目里用了一个小缓冲器,按空行切分事件、按行解析字段:

class SseParser {
  constructor(onEvent) {
    this.buffer = ''
    this.onEvent = onEvent
  }
  push(chunk) {
    this.buffer += chunk
    let sep = this.buffer.indexOf('\n\n')
    while (sep !== -1) {
      const raw = this.buffer.slice(0, sep)
      this.buffer = this.buffer.slice(sep + 2)
      const event = { name: 'message', data: '' }
      raw.split('\n').forEach((line) => {
        if (line.startsWith('event:')) event.name = line.slice(6).trim()
        else if (line.startsWith('data:')) event.data += line.slice(5).trim()
      })
      this.onEvent(event)
      sep = this.buffer.indexOf('\n\n')
    }
  }
}

解析器与连接逻辑解耦后,无论底层是 EventSource 还是 fetch 流,上层都按命名事件消费,多路复用同一套渲染逻辑。

四、打字机效果的渲染实现

打字机效果的三个关键动作:增量追加、保持消息对象稳定、断开时保留已生成内容。

4.1 增量追加,不整块替换

每次收到数据块,只把文本拼到当前消息的 content 字段上,Vue 响应式系统会只更新变化部分。用 textContent 而非 innerHTML 拼接,既规避把模型输出当 HTML 解析的风险,也避免特殊字符渲染错乱——模型输出的内容包含尖括号、引号时,按文本插入才是安全且正确的。

4.2 光标与滚动跟随

生成中的消息尾部加一个闪烁光标,用 CSS 动画实现,数据到位就停掉。滚动跟随用”接近底部才自动滚到底”的策略:用户自己往上翻历史时不强拉滚动条,回到底部区域再恢复跟随。

4.3 中止与兜底

发送新消息或切换会话时,用 AbortController 中止未完成的流式请求,前端主动断连会触发后端的断开事件,网关借此清理订阅资源。页面卸载在 onUnmounted 里统一 abort,杜绝回调写已销毁组件。

4.4 Markdown 输出的渲染链路

文章创作器场景里,模型返回的是整篇 Markdown,直接显示源码没法看。渲染链路拆成两段:流式阶段用等宽字体显示”草稿态”,让用户先看到内容在生长;流结束后一次性交给 Markdown 渲染器转为富文本,代码块再做高亮。这里有个取舍:边流边渲染 Markdown 会频繁重排、光标闪烁、滚动跳动,卡顿感反而明显,分段渲染的体验更稳定。

五、超时、断线与重连的工程细节

模型响应慢不是 bug,但不设超时就是事故。网关端把 SseEmitter 超时设成模型平均响应时间的 2 至 3 倍,前端对”长时间无首包”单独计时,超时提示重试。EventSource 断线会自动重连,但会无限重连直到后端确认不可达,网关要主动返回 4xx/5xx 而非挂起连接。fetch 流则无自动重连,需要在 catch 分支判断错误类型,可重试的网络错误走退避重试,业务错误直接展示。

实测最有价值的指标是”首 token 时间”——用户感知的流畅度由它决定,而不是整段生成总耗时。监控面板把这两个指标分开统计,能直观看出慢是慢在网关路由还是模型首包。

常见问题(FAQ)

Q1:EventSource 能带 Authorization 头吗?

不能。EventSource 只支持 GET 且无法自定义请求头,需鉴权时改用 fetch 流读取。

Q2:SSE 数据块和事件不是一一对应怎么办?

按空行拆分事件再按 data 前缀取值,进缓冲队列统一解析,避免粘包错位。

Q3:打字机效果怎么避免越用越卡?

只追加不重建消息对象,文本用 textContent 写入,页面卸载时中止流式请求并清理监听。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部