揭秘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的效果。

相关推荐
编程小白_正在努力中3 小时前
大语言模型后训练:解锁潜能的关键路径
人工智能·大语言模型
一车小面包3 小时前
使用bert-base-chinese中文预训练模型,使用 lansinuote/ChnSentiCorp 中文网购评价数据集进行情感分类微调和训练。
人工智能·深度学习
自由的疯3 小时前
Java Jenkins、Dockers和Kubernetes有什么区别
java·后端·架构
aiopencode3 小时前
tcpdump 抓包内容分析实战,快速定位到结论的工程化套路(含真机抓包)
后端
用户673398017513 小时前
Docker部署单机版NacosV3.0版本并使用Nginx代理
后端
京东零售技术3 小时前
浅析cef在win和mac上的适配
后端
Java水解3 小时前
MySQL 中 ROW_NUMBER() 函数详解
后端·mysql
七夜zippoe3 小时前
大显存 AI 训练实战:PyTorch/TensorFlow 参数调试与多场景落地指南
人工智能·pytorch·深度学习
赋范大模型技术圈3 小时前
OpenAI Agent Kit 全网首发深度解读与上手指南
人工智能·openai