添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

WebFlux实现ChatGPT的打字机效果

打字机效果

当我们在使用ChatGPT时,ChatGPT的回答是逐字输出的,那它是怎么实现的呢?在ChatGPT官方文档中 [1] ,有一个参数可以让它实现流式输出。

stream   boolean   Optional   Defaults to false
Whether to stream back partial progress. If set, tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: [DONE] message.

Server-Sent Events(SSE)

本质

严格的将,HTTP协议无法做到服务器主动推送信息。有一种变通的方法,就是服务器向客户端说明要发送的是流信息,也就是说,发送的不是一次性的数据包,而是一个数据流会连续不断的发送过来,这时,客户端不会关闭连接,会一直等着服务器发过来的新的数据流,SSE [2] 就是利用这种机制,使用流信息向浏览器推送信息。它基于 HTTP/1.1 协议。

  • 使用HTTP协议,现有的服务器软件都支持。
  • 轻量级,HTML5原生支持,使用简单。WebSocket协议相对复杂。
  • 默认支持断线重连,而WebSocket需要自己实现。
  • 一般只用于传输文本,二进制数据需要编码后传送。
  • 支持发送自定义消息类型。
  • 协议

    服务器向浏览器发送的 SSE 数据,必须是 UTF-8 编码的文本,具有如下的 HTTP 头信息。 Content-Type 必须指定 MIME 类型为 text/event-stream

    Content-Type: text/event-stream;charset=UTF-8
    Cache-Control: no-cache
    Connection: keep-alive

    SSE数据由若干个 message 组成,每个 message 之间用 \n\n 分割,每个 message 内部由若干行组成,每行用 \n (LF)分割,每行的格式如下。

    [field]: value\n

    上边 field 有四种类型:

  • event:事件类型,默认为 message
  • data:消息的数据字段。如果该条消息包含多个data字段,则客户端会用换行符( \n )把它们连接成一个字符串来作为字段值。
  • id:事件 ID,会成为当前EventSource对象的内部属性 lastEventId 的属性值。
  • retry:一个整数值,指定了重新连接的时间(单位为毫秒),如果该字段值不是整数,则会被忽略。
  • 除了上面规定的字段名,其他所有的字段名都会被忽略。

    此外,还有一种冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保存链接不中断。

    : 这是一行注释
    : 这是一行注释\n\n
    event: message\n
    data: test\n
    id: 1\n
    retry: 10000\n\n
    data: two message\n\n

    data字段

    数据内容用 data 字段表示。

    data: message\n\n

    如果数据很长,可以分成多行。

    data: one message\n
    data: two message\n
    data: three message\n\n

    id字段

    数据标识符用 id 字段表示,相当于每一条数据的编号。

    id: 1\n
    data: message\n\n

    浏览器用 lastEventId 属性来读取这个值。一旦连接断线,浏览器会发送一个HTTP头,里边包含一个特殊的 Last-Event-ID 请求头,用来帮助服务器端重建连接。因此,这个请求头可以被视为一种同步机制。

    event字段

    event 字段表示自定义的事件类型,默认是 message 事件。浏览器可以用 addEventListener() 来监听该事件。

    event: complete\n
    data: send complete\n\n

    retry字段

    服务器可以用 retry 字段,指定浏览器重新发起连接的时间间隔。

    data: message\n
    retry: 10000\n\n

    两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。

    服务端实例

    package show.lmm.server.sent.events.controller;
    import org.springframework.http.MediaType;
    import org.springframework.http.codec.ServerSentEvent;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    import reactor.core.publisher.Flux;
    import java.util.ArrayList;
    import java.util.List;
    @RestController
    @RequestMapping()
    public class IndexController {
        private List<String> contents = new ArrayList<>() {{
            add("春天是一年中最美丽的季节之一,因为它代表着新生和希望。当树木重新变绿、花朵绽放,大地开始焕发生机和活力,我们可以感受到春天的到来。");
            add("在春天,天气温暖,阳光明媚,这是一个非常适合户外活动的季节。人们可以到公园里散步,跑步或骑自行车,享受大自然的美丽和清新的空气。在这个季节里,野生动物也开始出现,你可以看到小鸟在树枝上唱歌,蝴蝶在花朵间飞舞。");
            add("春天也是一个种植和园艺的好季节。在春天,大多数植物和花卉都开始生长,人们可以开始在花园里播种,培育他们的爱好,享受与大自然亲密接触的乐趣。在城市里,许多人也会在家里或阳台上种植花卉,让自己的居住环境变得更加美丽和舒适。");
            add("春天也是一个新的开始,很多人会在这个季节里寻找新的机会,开始新的计划和冒险。学生们会迎来新的学期,企业也会制定新的发展计划。这个季节的气氛充满了希望和激情,让人们感到鼓舞和振奋。");
            add("最重要的是,春天也是一个感恩的季节。春天的到来意味着过去的冬天已经过去,我们可以迎接新的开始和新的机会。我们应该珍惜这个季节,感谢我们所拥有的一切,享受生活的美好和快乐。");
            add("春天的气息是怎样的呢?当你走在街道上时,你会感受到空气中充满了新鲜和清新的味道,这是因为春天的雨水让空气中的尘土和杂质被清洗干净,让空气变得更加清新和宜人。此外,春天的天空也变得更加明亮和清澈,让人们的心情也跟着变得更加明朗和愉悦。");
            add("在春天,我们可以看到自然界的奇迹。树木重新长出新的叶子,花朵也开始绽放。光是看到这些美景,就能让我们的心情变得更加舒畅和愉悦。此外,在春天,我们也能看到很多动物和昆虫开始活跃,它们也在享受这个充满活力和生机的季节。");
            add("春天也是一个充满希望和梦想的季节。在这个季节里,我们可以看到很多人开始新的计划和冒险,他们都希望能够在这个充满生机和希望的季节里实现自己的梦想和计划。此外,在春天,很多人也会感到自己有更多的精力和动力去做一些事情,这也让人们感到充满了希望和信心。");
            add("最后,春天也是一个充满感恩和爱的季节。在这个季节里,我们应该珍惜自己所拥有的一切,感恩生命的美好和幸福。我们也应该关注周围的人和事,去帮助和关心那些需要我们的人,让我们的生活变得更加美好和有意义。");
            add("总之,春天是一个充满生机和希望的季节,它让我们看到自然界的奇妙和人类的希望。让我们珍惜这个季节,去享受它带来的美好和快乐。");
        }};
        @GetMapping(value = "/index", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
        public Flux<ServerSentEvent> serverSentEvents() {
            return Flux.create(sink -> {
                for (int i = 0, length = contents.size(); i < length; i++) {
                    sink.next(ServerSentEvent.builder("event").event("appendP").build());
                    for (int j = 0, jLength = contents.get(i).length(); j < jLength; j++) {
                        sink.
    
    
    
    
        
    next(ServerSentEvent.builder(String.valueOf(contents.get(i).charAt(j))).build());
                        try {
                            Thread.sleep(160);
                        } catch (InterruptedException e) {
                            sink.next(ServerSentEvent.builder("server error" + i).build());
                sink.next(ServerSentEvent.builder("complete").event("complete").build());
                sink.complete();
            });
    

    前端实例

    <!DOCTYPE html>
    <html lang="en">
        <meta charset="UTF-8">
        <title>Server Sent Events demo</title>
        <style>
                list-style-type: disc;
            li#tit{
                list-style-type: none;
            html{
                font-size: 16px;
            #crow {
                color: #000;
                font-family: consolas;
                font-weight: bold;
                font-size: 16px;
                animation: crow 0.6s linear 0s infinite;
            @keyframes crow {
                from {
                    opacity: 0;
                to {
                    opacity: 1;
        </style>
    </head>
    <ul id="status_msg">
        <li id="tit">状态:</li>
    <p>问题:写一篇描述春天的文章,原创,800字左右</p>
    <div id="content_msg">
        <p>答案:</p>
    </div>
    <script type="application/javascript">
        const errorMsgEl = document.getElementById("status_msg");
        const contentMsgEl = document.getElementById("content_msg");
        let crowEl;
        let spanElement;
        let text = "";
        run();
         * 运行 Server-Sent Events
        function run(){
            if(!window.EventSource){
                const el = document.createElement('dt');
                el.innerText = '你的浏览器不支持 Server-Sent Events'
                errorMsgEl.appendChild(el)
                return;
            const source = new EventSource('/index');
            // 监听连接成功事件
            source.addEventListener('open', function (event) {
                const el = document.createElement('dt');
                el.innerText = '已连接'
                errorMsgEl.appendChild(el)
            }, false);
            // 接收消息事件
            source.addEventListener('message', function (event) {
                text += event.data;
                spanElement.innerText = text
            }, false);
            // 异常事件
            source.addEventListener('error', function (event) {
                console.log(event)
                const el = document.createElement('dt');
                el.innerText = `发生错误:`+event
                errorMsgEl.appendChild(el)
            }, false);
            // 自定义 appendP 事件
            source.addEventListener('appendP', function (event) {
                if(crowEl){
                    crowEl.remove();
                pElement = document.createElement('p');
                crowEl = document.createElement("label");
                crowEl.id = 'crow';
                crowEl.innerText = '|';
                spanElement = document.createElement('span');
                pElement.appendChild(spanElement);
                pElement.appendChild(crowEl);
                text = "";
                contentMsgEl.appendChild(pElement)
            }, false);
            // 自定义完成事件
            source.addEventListener('complete', function (event) {
                if(crowEl){
                    crowEl.remove();
                source.close();
                const el = document.createElement('dt');
                el.innerText = '连接已关闭'
                errorMsgEl.appendChild(el)
            }, false);
    </script>
    </body>
    </html>

    示例

    https://gitee.com/luoye/examples/tree/main/server-sent-events


    1. 1.https://platform.openai.com/docs/api-reference/completions
    2. 2.https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events/Using_server-sent_events