揭秘ChatGPT“打字机”效果:深入理解SSE流式传输技术

大家在使用chatgpt、deepseek时,我们都知道这些大语言模型需要处理大量的自然语言数据,这无疑需要大量的计算资源和时间。相较于普通的数据库读取操作,其响应速度自然会慢许多。对于这种可能需要长时间等待响应的对话场景,若是等待模型处理完再返回,用户必定会长时间的loading,体验极差。ChatGPT采用了一种巧妙的策略:它会将已经计算出的数据"推送"给用户,并利用SSE技术在计算过程中持续返回数据。这样做可以避免用户因等待时间过长而选择关闭页面。从而实现了一个"打字机"的效果,用户能一边看一边理解,等模型输出完毕后,已经理解了大部分内容。理论上这种使用流式数据返回的情况,可以借助全双工通信的Websocket进行实现,或者基于EventStream的事件流机制。那么实现这个效果的SSE究竟是什么样的原理呢?和Websocket又有什么区别呢?技术又如何选型呢?下面带大家一起探讨一下SSE,接开底层的面纱,及和Websocket的对比,一些选型建议。

何为SSE

​ SSE全称为Server-Sent Events,是服务器主动向客户端推送事件或者说数据流。但它并非一个全新的的协议,是构建在标准的HTTP/1.1协议上的。一个简单的流程图如下

  • 客户端使用特定的Content-type: text/event-stream 向server发起一个http的get请求,服务器确认后会保持连接的开放

  • 而后服务器会使用协议好的纯文本格式发送数据块,并且每个消息的结束会有两个换行符

  • 客户端的eventsource API接收到数据块后,会触发对应的事件,比如内置的onmessage或者自定义的事件,这样前端就能处理推送来的数据

在该技术出现之前,要实现服务器对客户端进行实时数据推送,依赖以下几种技术

  • 短轮询

    • 客户端以一个固定的间隔频繁向服务器发送请求,询问是否有新的数据。缺点也很明显,很多时候的请求是无效的,并没有新的数据产生,会浪费服务器带宽网络资源
  • 长轮询

    • 客户端发起一个请求,服务器端保持连接打开,即维护一个长连接,当客户端收到响应会立即发起下一个请求。这个连接会保持直到有新数据或者达到超时时间为止,相较于短轮询减少了大量的无效请求,但是相应的服务器端要连接大量挂起的连接。

    • 其实Adobe Flash也提供过socket功能来实现全双工的通信,但是随着技术发展已经被淘汰,且效率低下,依赖插件

  • Websocket

    • 它在客户端和服务器间建立一条全双工的tcp长连接的通道,可以随时互相推送数据。需要进行一次额外的协议升级握手,但是在服务器->客户端单向推送的场景下会有点过"重"了。
  • SSE

    • 标准化,高效,现代浏览器原生支持的从服务器到客户端单向的通信机制,成为W3C的标准,内置连接失败重试的机制
    • 简单的API,基于HTTP而带来良好的兼容性,在大模型爆发的时代,这些生成式的AI需要逐个token的打字机输出,与SSE天然契合,它能直接承载JSON LINES或者是MD的片段。OpenAI、Google等均把text/event-stream作为流式输出的协议。

​ 打字机的输出模拟人打字或者思考的过程,服务器不是等待LLM生成答案后一次性发出来,而是每生成一个token就发送。比如提问锄禾日当午的下一句,AI生成第一个token是"汗",则立刻通过SSE发送data:"汗",客户端接收到后显示出来,接着发送data:"滴",依次追加。效果展现就是文字挨个打出来在屏幕上。能带给用户更好的使用体验,而不是等待长时间的loading,可以边阅读输出边理解,节省总体的认知时间。

SSE的使用

客户端

客户端可直接借助浏览器原生提供的EventSource对象来处理,现代浏览器基本都支持,当然也可以事先检测一下

javascript 复制代码
if ('EventSource' in window) {
  console.log('浏览器支持SSE');
} else {
  console.log('浏览器不支持SSE');
}
  • 连接创建

    javascript 复制代码
    // 传入服务器的SSE接口地址即可
    var source = new EventSource(url)
  • 连接状态

    • EventSource实例里的readyState用于描述当前连接状态,0 -> 未建立连接或正在重连;1 -> 已建立连接,可接收数据;2 -> 连接关闭而且不会自动重连

下面是一个简单的流程图展示客户端的使用流程

  • 连接建立:触发open事件

    javascript 复制代码
    // 使用addEventListener,可以添加多个回调
    source.addEventListener('open', function (event) {
      console.log('连接已建立');
    }, false);
  • 数据接收

    当收到服务器推送的数据,默认情况下会触发message事件,用于处理未指定类型的消息

    javascript 复制代码
    source.addEventListener('message', function (event) {
      var data = event.data;
      console.log('收到数据:', data);
      // data始终是字符串的类型,如果服务器发送的是JSON lines,用JSON.parse(data)进行解析即可
    }, false);
  • 连接错误:接收到error事件

    当网络中断,服务器出错时,会触发error事件

    javascript 复制代码
    source.addEventListener('error', function (event) {
      // 错误处理逻辑
    }, false);
  • 连接关闭:主动调用close方法

    javascript 复制代码
    source.close();
  • 自定义事件

    在实际运用中,我们需要区分不同类型的消息,可以使用自定义事件,客户端监听这些事件触发不同的处理逻辑,自定义事件并不会触发默认的message事件,只能被对应的listener捕获。

    javascript 复制代码
    // 监听名为"load"的自定义事件
    source.addEventListener('load', function (event) {
      var loadData = event.data;
      console.log('收到load数据:', orderData);
      // 处理相关逻辑
    }, false);
    
    // 再监听一个名为"notice"的自定义事件
    source.addEventListener('notice', function (event) {
      var noticeData = event.data;
      console.log('收到公告:', noticeData);
      // 处理公告相关逻辑
    }, false);

服务器端

服务器端需要按照数据的格式和规则进行发送数据,首先响应头需要如下设置,缺少其一都可能导致连接失败或者数据异常

http 复制代码
Content-Type: text/event-stream  // 必须,指定为事件流类型
Cache-Control: no-cache          // 必须,禁止缓存,确保数据实时性
Connection: keep-alive           // 必须,保持长连接
  • 数据格式和字段

    SSE消息支持data、event、id、retry四个核心字段和注释行,一个示例消息如下

    csharp 复制代码
    : 发送消息,这是注释行
    
    retry: 10000    // 重连时间为10s
    id: 1           // 消息id
    event: notice  	// 事件名
    data: {"time": "2025-09-30 12:30:00","value":"公告:现在是十二点半干饭时间"}  // 消息数据

一段简单的实例代码,向客户端每秒推送一次服务器的时间

go 复制代码
package main

import (
    "fmt"
    "net/http"
    "time"
)

func main() {
    // 静态文件服务(前端)
    fs := http.FileServer(http.Dir("./static"))
    http.Handle("/", fs)

    // SSE 处理
    http.HandleFunc("/events", sseHandler)

    fmt.Println("服务器已启动: http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

func sseHandler(w http.ResponseWriter, r *http.Request) {
    // 设置 SSE 响应头
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    w.Header().Set("Access-Control-Allow-Origin", "*") // 可选,允许跨域

    // 获取 writer 的 Flush 接口
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
        return
    }

    // 持续发送事件
    for i := 0; ; i++ {
        msg := fmt.Sprintf("data: 服务器时间:%s\n\n", time.Now().Format(time.RFC1123))
        _, err := fmt.Fprintf(w, msg)
        if err != nil {
            return // 客户端断开连接
        }

        flusher.Flush()
        time.Sleep(1 * time.Second)
    }
}

服务端发送SSE数据的完整流程如下

SSE和Websocket如何选型

对比

特性 SSE WebSocket
通信方向 服务器 -> 客户端 服务器 <-> 客户端
协议 基于HTTP 独立的WS/WSS协议
数据格式 通常为UTF-8的文本 文本或二进制
自动重连 原生支持 需要手动实现
兼容性 几乎支持现代浏览器 更优秀的兼容性包括IE10+
实现复杂度 双端都易于实现 相对更为复杂

下面是一个直观的系列图对比

选型建议

  • 只需服务器到客户端单向推流,SSE 更轻量便捷;需要全双工通信,选择Websocket
  • 视频音频等二进制数据的传输,选择Websocket
  • 交互性强,低延迟场景,比如多人在线聊天/游戏等,选择Websocket
  • 新闻推送,AI处理进度或者结果流式输出,选择SSE更好

不过有趣的一点,原生的EventSource是只支持GET 请求 + text/event-stream响应。而GPT的API请求实际上使用的是POST 请求 + text/event-stream 响应。这里微软官方也提供了Feach API发起SSE请求的库,只要服务端返回的数据格式是符合的,那么浏览器就能正常理解,因而达到了post请求实现伪SSE的效果。

相关推荐
摆烂工程师7 小时前
今天 Cloudflare 全球事故,连 GPT 和你的网站都一起“掉线”了
前端·后端·程序员
llilian_167 小时前
智能数字式毫秒计在实际生活场景中的应用 数字式毫秒计 智能毫秒计
大数据·网络·人工智能
追逐时光者8 小时前
快速构建一个基础、现代化的 WinForm 管理系统
后端·.net
打码人的日常分享8 小时前
基于信创体系政务服务信息化建设方案(PPT)
大数据·服务器·人工智能·信息可视化·架构·政务
硬汉嵌入式8 小时前
专为 MATLAB 优化的 AI 助手MATLAB Copilot
人工智能·matlab·copilot
北京盛世宏博8 小时前
如何利用技术手段来甄选一套档案馆库房安全温湿度监控系统
服务器·网络·人工智能·选择·档案温湿度
搞科研的小刘选手8 小时前
【EI稳定】检索第六届大数据经济与信息化管理国际学术会议(BDEIM 2025)
大数据·人工智能·经济
半吊子全栈工匠8 小时前
软件产品的10个UI设计技巧及AI 辅助
人工智能·ui
机器之心9 小时前
真机RL!最强VLA模型π*0.6来了,机器人在办公室开起咖啡厅
人工智能·openai
机器之心9 小时前
马斯克Grok 4.1低调发布!通用能力碾压其他一切模型
人工智能·openai