AI 对话选 SSE原因解析(服务器发送事件原理与对比)

SSE(Server-Sent Events,服务器发送事件)是一条由服务器控制生命周期的持久化 HTTP 长连接,服务端把数据以事件流形式分段推给客户端,浏览器用原生 EventSource 即可接收,无需任何第三方库。AI 对话场景选 SSE 而不是 WebSocket,核心原因是对话响应是”服务器单向推送、客户端只发一次请求”的天然单向模式,而 OpenAI、Anthropic 等厂商的流式接口本身就是 SSE 格式,前后端复用同一套协议,链路最短。

一、SSE 的工作原理

1.1 一条请求,多段响应

普通 HTTP 请求是”请求一次、响应一次、连接关闭”。SSE 走的是同一套流程,区别在于服务端收到请求后不立刻关闭连接,而是持续往这条连接写数据,直到内容推完。数据遵循固定文本格式,每段以 data: 开头、空行结束:

data: 你好

data: 这是一段流式文本

data: [DONE]

事件还可以携带类型、编号与重试时间等字段,event: 声明事件名、id: 标记序号、retry: 告诉浏览器重连间隔,这些字段组合起来足够表达 AI 生成中的阶段变化、进度与错误状态。

1.2 完整交互流程

  1. 客户端发起 GET 请求,请求头声明接受事件流;
  2. 服务端返回 200 响应,Content-Type 设为 text/event-stream,保持连接打开;
  3. 服务端每生成一段数据就写一段事件,客户端逐段解析;
  4. 数据推完,服务端关闭连接或发送结束标记;
  5. 连接意外断开时,EventSource 自动重连,并通过 Last-Event-ID 头带上最后收到的消息编号,服务端可据此补发。

大模型逐 token 生成与这套机制天然契合:模型每吐出一个词元,后端就封装成一个事件推出去,前端收到即追加渲染,用户看到的就是逐字出现的打字机效果。

二、为什么 AI 对话选择 SSE

2.1 四种方案横向对比

维度 轮询 长轮询 WebSocket SSE
通信方向 单向 单向 双向全双工 单向
连接方式 每次新建 反复挂起重连 协议升级为独立 socket 一条 HTTP 长连接
断线重连 天然轮询 需自建 需自研 浏览器内置
实现成本 低 中 高 低
AI 流式匹配度 差 较差 过度设计 完全匹配

轮询在高延迟的 AI 生成场景会造成大量无效请求;WebSocket 双向能力在”客户端只发一条消息、服务端持续回推”的对话里用不上,还要自己处理握手、心跳与重连。长轮询虽然减少了请求次数,但每次推送都要重建连接,且服务端要维护挂起连接的状态,复杂度并不比 SSE 低。相比下来,SSE 在实现成本、连接开销与协议匹配度三个维度都占优,这也是多数 AI 产品把它作为默认流式通道的原因。

2.2 SSE 与 WebSocket 的关键差异

WebSocket 需要一次协议升级握手,之后变成完全独立的双向通道,适合语音通话、协同编辑这类客户端也在持续上行的场景。SSE 则建立在普通 HTTP 之上,天生对防火墙、代理、负载均衡友好,普通 HTTP 的负载策略直接可用,不需要为连接状态做粘性会话。浏览器对 EventSource 的自动重连和事件编号补发,省掉了一大段自研代码。上游大模型的流式接口也以 SSE 格式下发,后端做转发网关时透传即可,格式统一、不需要做双向协议转换。

还有一层体验因素。生成是渐进过程,用户看到文字逐字出现会产生”模型在思考”的感知,这比干等几十秒再一次性看到全文要自然得多。首字延迟从生成完成缩短到首 token 到达,聊天的真实感大幅提升。这套体验正是靠长连接把生成时延”摊薄”进用户视野。

三、在 AI 爆款文章创作器中的落地

3.1 后端用 Spring Boot 起一条流

创作器的多阶段生成(选题、大纲、正文、标题润色)会连续产出不同类型的数据,我按事件类型区分推送:

@PostMapping(value = "/api/gen/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestBody GenRequest request) {
    SseEmitter emitter = new SseEmitter(0L); // 不设超时
    executor.execute(() -> {
        try {
            for (String stage : stages) {
                emitter.send(SseEmitter.event()
                    .name("stage")
                    .data(Map.of("type", stage)));
                String chunk = llmService.generateChunk(stage, request);
                emitter.send(SseEmitter.event().name("content").data(chunk));
            }
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });
    return emitter;
}

阶段事件让前端知道当前进度,内容事件承载实际文本,二者用事件名区分,Vue 侧按名监听即可。多阶段生成的编排放在异步线程里,SseEmitter 实例由控制器返回给 Spring MVC,之后线程继续生产数据,互不阻塞。生成结束时必须显式调用 complete,否则连接会一直挂着,前端永远等不到结束信号。

3.2 前端逐段渲染

浏览器用 fetch 的 reader 逐块读取,比原生 EventSource 更灵活,因为创作器需要 POST 携带参数:

const res = await fetch('/api/gen/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ topic: this.topic })
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  const text = decoder.decode(value)
  this.article += text.replace(/^data: /gm, '')
}

四、SSE 落地的三个边界

  1. 代理缓冲:Nginx 等代理默认会缓冲响应,需要关闭缓冲并调长 read 超时,否则 token 会攒在代理层,流式失效;
  2. 心跳保活:连接空闲太久会被中间设备掐断,服务端定期推一行注释充当心跳,维持连接存活;
  3. 连接数量:浏览器对同一域名的 HTTP/1.1 连接数有限制,长连接占用的场景要留意;文本流之外,SSE 也不适合直接推二进制内容。

这三条在实践中逐一踩过,写下来能帮后来者少走弯路。再补一个细节:生成中途模型报错时,前文已经渲染给用户了,这时不要静默丢弃流,应推一个 error 事件说明原因,前端保留已展示的部分内容并提示重试,用户的体验损失远小于整段清空。

常见问题(FAQ)

Q1:SSE 和 WebSocket 该按什么标准选?

看方向性。服务器单向推数据选 SSE,客户端需要持续上行双向交互才选 WebSocket。

Q2:EventSource 支持 POST 请求吗?

EventSource 原生只支持 GET,POST 要改用 fetch 解析流。

Q3:流式输出怎么处理断线重连?

EventSource 自带重连,fetch 方案要自己加退避重试,配合服务端断点续传。

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

相关推荐

返回顶部