在ChatGPT中,吐字那么酷炫的效果到底是怎么实现的?

部署运行你感兴趣的模型镜像

在这个 AI 日新月异的时代,AIGC(AI生成内容)已迅速席卷全球,甚至掀起了一场技术革命。然而,当我们谈论这些炫酷的大模型时,你是否思考过它们背后的秘密?是什么让这些开源模型如此强大?它们是如何被训练出来的,又如何能够在我们本地运行?更重要的是,这场技术浪潮已经涌来,我们要如何在这股洪流中找到自己的方向,不被时代所抛下?所以作者决定出一系列的文章来和大家一起探索一下AIGC的世界,专栏就叫《重生之我要学AIGC》,欢迎大家订阅!!!谢谢大家。
在这里插入图片描述

之前在写这篇的时候留了一个坑:国外的Spring出AI了?阿里:没关系,我会出手
我们这次来填这个模型吐字到前端的坑。在填这个坑之前不知道大家有没有想过。像gpt那种吐字的效果是怎么实现的呢,就像这样:

msedge_sTT7rPssh5

那么博主也写了一个demo给大家展示一下:

msedge_kMSH1ivRQ8

怎么样,是不是期待值拉满,那么接下来就来看看怎么实现这个操作的吧,在这之前我们需要介绍一项技术:SSE

Server-Sent Events (SSE) 是一种允许服务器向浏览器发送更新的技术,它使得服务器能够主动推送实时更新给客户端,而不需要客户端频繁地请求数据。这种机制非常适合于需要从服务器端实时获取更新的应用场景,例如股票价格更新、聊天应用的消息提示、gpt吐字等。

主要特点:

  • 单向通信:SSE 主要用于从服务器向客户端发送数据,而客户端通常通过其他机制(如 AJAX 请求)来发送数据给服务器。
  • 保持连接:一旦建立连接后,服务器可以持续地发送数据到客户端,直到连接被关闭。
  • 格式简单:SSE 使用简单的文本格式来传输数据,每条消息包含一些特殊的字段(如 data, event, id, retry),并以特定的格式结束。
  • 断线重连:如果连接中断,客户端可以根据 retry 字段指定的时间间隔尝试重新连接。

SSE 特别适用于那些不需要全双工通信的应用场景,因为它只支持单向数据流。对于需要双向通信的应用(如在线聊天),WebSocket 是更好的选择。和我们寻常的发送一个请求,服务器一次全部返回给你不同,SSE适用那些你首先发送一个请求到服务器,然后让服务器一直推送东西给你的场景。

SSE的demo

我们先写一个非常简单的SSE的demo出来

第一步,新建一个SpringBoot项目

SpringBoot入门:如何新建SpringBoot项目(保姆级教程)

第二步,导入对应的依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

第三步,新建一个controller类

写一个简单的demo方法

  @PostMapping(value = "/flux-demo", produces = "text/event-stream; charset=utf-8")
    public Flux<ServerSentEvent> hello(@RequestParam(name = "name", defaultValue = "unknown user") String name) {
        return Flux.interval(Duration.ofSeconds(1))  // 每隔一秒发送一个事件
                .take(10)  // 限制发送 10 次
                .map(seq -> {
                    String message = String.format("Hello %s, here is your message #%d at %s",
                            name, seq + 1, LocalTime.now());
                    return ServerSentEvent.<String>builder()
                            .id(String.valueOf(seq)) // 设置事件 ID
                            .event("chat-message")  // 自定义事件名称
                            .data(message)          // 传递的消息数据
                            .build();
                });
    }

大家可以注意到,这里和我们平时写的返回一个json不同,可以看到produces返回的是text/event-stream; charset=utf-8。produces和consumes属性用于指定HTTP请求和响应的内容类型。

  • consumes = "application/json" 表示该方法只接受 Content-Type 为 application/json 的请求。
  • produces = "application/json" 表示该方法返回的内容类型(Content-Type)为 application/json。

与此同时,我们的返回参数为 Flux<ServerSentEvent>

Flux 是Reactor库中的一个反应式流(Reactive Stream)类型,表示一个可以发出0到多个事件的异步序列。

ServerSentEvent 是Spring框架提供的一个类,用于构建服务器发送事件。

Flux 是Reactor库中的一个类型,用于表示一个异步的、多值的事件流,特别适用于服务器发送事件(Server-Sent Events, SSE)的场景。

说到Flux让我又想起了之前也写过一篇文章 : 要是你想使用异步队列的话,那就试试Reactor Flux吧! 也被面试官问过Flux除了能做异步队列还能做什么,所以这篇文章给出了这个答案。

第四步,工具调用查看结果

在我们写的方法中,这个方法会每一秒发送一个事件到调用方,一共发送十次,我们可以使用apifox等工具调用查看一下效果

Apifox_7bzTPtEu53

可以看到,我们的请求开始建立了一个连接,等后端发送完成之后就自动断开连接了

image-20241021104001740

而我们查看返回消息的头也可以看到返回的Content-Type就是我们PostMapping注解中produces属性写的值

image-20241021104329002

第五步,写一个前端页面

工具我们查看完了,现在我们来写一个前端html页面查看返回的数据怎么渲染上去,我们是springboot项目,所以我在resources文件夹下面新建一个static文件夹,创建一个叫index.html的文件,内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chat SSE Demo</title>
</head>
<body>

<h1>Server-Sent Events with POST</h1>

<div id="messages"></div>

<script>
    function startSSE() {
        const url = 'http://localhost:8088/ai/flux-demo'; // 后端服务器的完整 URL
        const name = 'John Doe'; // 假设前端需要传递一个 name 参数

        fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Accept': 'text/event-stream'
            },
            body: new URLSearchParams({
                'name': name
            })
        }).then(response => {
            if (!response.ok) {
                throw new Error('Network response was not ok');
            }

            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            let buffer = ''; // 用于缓存不完整的数据

            return new ReadableStream({
                start(controller) {
                    function read() {
                        reader.read().then(({ done, value }) => {
                            if (done) {
                                controller.close();
                                return;
                            }

                            buffer += decoder.decode(value, { stream: true });

                            // 检查是否有完整的事件块(每个事件块之间有两个换行符)
                            const eventBlocks = buffer.split("\n\n");

                            // 将最后一个块留在缓存里,可能是不完整的
                            buffer = eventBlocks.pop();

                            console.log("Parsed Event eventBlocks:", eventBlocks); // Debug: 查看解析后的完整数据

                            eventBlocks.forEach(eventBlock => {
                                const lines = eventBlock.split("\n");
                                let eventData = {};

                                lines.forEach(line => {
                                    if (line.startsWith("data:")) {
                                        eventData.data = line.substring(5); // 提取 "data: " 后的内容
                                    }
                                });

                                if (eventData.data) {
                                    displayMessage(eventData.data); // 显示消息
                                }

                                // console.log("Parsed Event Data:", eventData); // Debug: 查看解析后的完整数据
                            });

                            read();
                        });
                    }

                    read();
                }
            });
        }).catch(error => {
            console.error('There has been a problem with your fetch operation:', error);
        });

    }

    function displayMessage(message) {
        const messageDiv = document.getElementById('messages');
        const newMessage = document.createElement('p');
        newMessage.textContent = message;
        messageDiv.appendChild(newMessage);
    }

    // 开始 SSE 连接
    startSSE();
</script>

</body>
</html>

我们可以看到效果

msedge_m6WJeJVtJD

至此,我们已经通过一个简单的demo实现了一个吐字的效果,接下来我们来个更高级的操作:对接通义实现我们开头对话式的吐字效果

对话式吐字SSE

首先我们根据官方的文档对接通义模型的api,在java中为sdk形式的调用,阿里大模型服务平台百炼

看个文章这么麻烦,还要从0开始对阿里通义灵码(bushi)。但是如果你看过作者之前写的文章:国外的Spring出AI了?阿里:没关系,我会出手。那么我们就可以省略前面的所有步骤,直接使用SpringAI-AliBaba来演示对话式吐字SSE

第一步,对接通义

可以看作者的博客,直接使用SpringAI-AliBaba来秒上手国外的Spring出AI了?阿里:没关系,我会出手

第二步,导入依赖

这里和我们前面写的demo是一样的

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

第三步,创建controller方法

@PostMapping(value = "/flux", produces = "text/event-stream; charset=utf-8")
public Flux<ServerSentEvent> flux(@RequestParam(name = "message", defaultValue = "你好") String message) {
    return tongYiSimpleService.flux(message);
}

有了前面写demo的基础,我们的produces和方法返回类型都是一样的

第四步,编写service方法

@Override
public Flux<ServerSentEvent> flux(String message) {
    TongYiChatOptions chatOptions = new TongYiChatOptions();
    chatOptions.setIncrementalOutput(true);
    Flux<ServerSentEvent> streamEvents = streamingChatModel.stream(new Prompt(message, chatOptions))
            .map(outputContent -> {
                String content = outputContent.getResult().getOutput().getContent();
                System.out.println(content);
                return ServerSentEvent.builder().event("message").data(content).build();
            });
    // 返回 Flux<ServerSentEvent>
    return streamEvents;

}

我们直接使用 streamingChatModel.stream方法调用阿里封装好的方法。

最后通过 map方法把 streamingChatModel.stream方法返回的参数Flux<ChatResponse> 转换为 Flux<ServerSentEvent>

第五步,工具调用查看结果

http://localhost:8088/ai/flux?message=你好啊

Apifox_3LZqUi2hG8

可以看到,最终的效果就是他根据我们的问题回答的内容,返回的格式也是和我们前面写的demo是一样的。

第六步,写一个前端

我们在前面写的前端页面上改造一下:使用累加的方式加入事件流里面的消息以及获取地址url里面的参数

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chat SSE Demo</title>
</head>
<body>

<h1>Server-Sent Events with POST</h1>

<div id="messages"></div>

<script>
    // 获取 URL 中的查询参数
    function getQueryParam(param) {
        const urlParams = new URLSearchParams(window.location.search);
        return urlParams.get(param);
    }

    function startSSE() {
        const url = 'http://localhost:8088/ai/flux'; // 后端服务器的完整 URL
        const message = getQueryParam('message') || ''; // 获取查询参数中的 'message' 值

        if (!message) {
            displayMessage('No message provided in the URL');
            return;
        }

        fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Accept': 'text/event-stream' // 接受 SSE 类型的数据流
            },
            body: new URLSearchParams({
                'message': message
            })
        }).then(response => {
            if (!response.ok) {
                throw new Error('Network response was not ok');
            }

            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            let buffer = ''; // 用于缓存不完整的数据
            let accumulatedData = ''; // 用于累计所有 data 内容

            return new ReadableStream({
                start(controller) {
                    function read() {
                        reader.read().then(({ done, value }) => {
                            if (done) {
                                controller.close();
                                return;
                            }

                            buffer += decoder.decode(value, { stream: true });

                            // 检查是否有完整的事件块(每个事件块之间有两个换行符)
                            const eventBlocks = buffer.split("\n\n");

                            // 将最后一个块留在缓存里,可能是不完整的
                            buffer = eventBlocks.pop();

                            eventBlocks.forEach(eventBlock => {
                                const lines = eventBlock.split("\n");
                                let eventData = { data: '' };

                                lines.forEach(line => {
                                    if (line.startsWith("data:")) {
                                        // 累加所有的 data 行,不插入换行符
                                        eventData.data += line.substring(5).trim();
                                    }
                                });

                                // 仅当有数据时才显示
                                if (eventData.data) {
                                    accumulatedData += eventData.data; // 累加数据
                                    displayMessage(accumulatedData); // 显示完整数据
                                }
                            });

                            read();
                        });
                    }

                    read();
                }
            });
        }).catch(error => {
            console.error('There has been a problem with your fetch operation:', error);
        });
    }

    function displayMessage(message) {
        const messageDiv = document.getElementById('messages');
        messageDiv.innerHTML = ''; // 清空之前的内容
        const newMessage = document.createElement('p');
        newMessage.innerHTML = message; // 显示累计后的数据
        messageDiv.appendChild(newMessage);
    }

    // 开始 SSE 连接
    startSSE();
</script>

</body>
</html>

我们来看一下效果

msedge_FbZQA6iYMh

nice,成功!!

既然demo出来了,那么后面不管大家做什么样的业务都ok啦。好啦,我们这次就讲到这里,如果大家还对这种类似的文章感兴趣,欢迎在评论区留言或者催更!!!

在这里插入图片描述

您可能感兴趣的与本文相关的镜像

GPT-oss:20b

GPT-oss:20b

图文对话
Gpt-oss

GPT OSS 是OpenAI 推出的重量级开放模型,面向强推理、智能体任务以及多样化开发场景

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

掉头发的王富贵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值