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 写入,页面卸载时中止流式请求并清理监听。