【.Net/C#之ChatGPT开发系列】二、C#异步流+SSE通信实现ChatGPT流式响应并实现打字机效果...

【.Net/C#之ChatGPT开发系列】一、开发准备及实现与ChatGPT的初次对话

前面我们利用了ChatGPT提供的聊天API接口,实现了一个简单的聊天应用,可以与ChatGPT进行基本的对话交互,今天我们继续,还请大家点个关注。

👇

当你使用ChatGPT官网提供的聊天工具时,你会发现,ChatGPT的内容是一个字一个字输出的,而我们实现的却是整句的输入,这是如何实现的呢,这样做又是为什么?(公众号没有评论功能,我自问自答吧98058b4e9f027d17f1ff01bd11c3aed7.png

先说一下为什么,我们先看一下ChatGPT4.0的回答:

d413471e021ccc8ce968967d699d8c73.png

我选择NewBing的更有创造力模式,它是基于ChatGPT4.0的模型来生成回答。我的理解是:

当ChatGPT接收到用户的输入后,它将该输入作为上下文信息,通过处理,预测下一个可能出现的词语,并输出。然后再将新生成的词语与之间的输出内容结合,形成新的上下文信息,再预测下一个可能出现的词语并输出。过程一直持续下去,直到ChatGPT生成完整内容。同时也解决了此过程中长时间等待的问题,增强了体验。

开始之前,我们先来了解两个知识点:C#异步流和SSE技术。

1、C#异步流

异步流是一种可以使用 async foreach 语法来处理的数据流,它返回一个 IAsyncEnumerable<T> 类型,该类型支持异步迭代。异步流可以在不阻塞主线程的情况下,等待数据的异步生成和消费,是C#8.0新增功能。

//要创建一个异步流,你需要定义一个返回 IAsyncEnumerable<T> 的 async 方法,并在方法体中使用 yield return 语句来返回数据12。例如:
public static async IAsyncEnumerable<int> GetData()
{
    for (int i = 0; i < 3; i++)
    {
        await Task.Delay(1000); // 模拟异步操作
        yield return i; // 返回数据
    }
}
//要使用一个异步流,你需要在 foreach 循环前加上 await 关键字,并等待每个元素的生成。例如:
await foreach (int number in GetData())
{
    Console.WriteLine(number); // 处理数据
}

2、SSE通信

SSE全称Server-Sent Events, 属于 HTML 5 规范中的一个组成部分,它是一种从服务端实时推送数据到浏览器端的技术。SSE 通信的优点是使用简单,不需要额外的组件,只需要基于HTTP协议即可。缺点是只能实现单向的数据传输,即从服务端到客户端,如果需要双向的数据通讯,可以考虑使用 WebSocket 技术。

客户端需要使用 EventSource 对象来监听服务端的接口,并注册相应的事件处理方法来接收和处理数据。例如:

var source = new EventSource("/subscribe");
source.onmessage = function(event) {
    // 处理数据
    console.log(event.data);
};
source.onerror = function(event) {
    // 处理错误
    console.log(event);
};

我们利用C#异步流和SSE通信来构建ChatGPT对话,再合适不过。为了方便查阅代码区别,我将新建一个项目,命名为:ChatGPT.Demo2,和上一章的ChatGPT.Demo1一样,使用相同的配方。

一、 服务端接口调整

打开Controllers/ChatController.cs文件,将原来的Input方法替换为以下内容:

[HttpPost]
public async Task Input([FromForm] string message)
{
    Response.Headers.Add("Content-Type", "text/event-stream");
    Response.Headers.Add("Cache-Control", "no-cache");
    var completionResult = _openAiService.ChatCompletion.CreateCompletionAsStream(
        new ChatCompletionCreateRequest
        {
            Messages = new List<ChatMessage>
            {
                ChatMessage.FromUser(message)
            },
            Stream = true,
            MaxTokens = 500,
            Model = OpenAI.ObjectModels.Models.ChatGpt3_5Turbo,
        });


    await foreach (var completion in completionResult)
    {
        if (completion.Successful)
        {
            await Response.WriteAsync($"ChatGPT:{completion.Choices.FirstOrDefault()?.Message.Content}");
            await Response.Body.FlushAsync();
        }
        else
        {
            if (completion.Error == null)
                throw new Exception("Unknown Error");


            await Response.WriteAsync($"{completion.Error.Code}: {completion.Error.Message}");
            await Response.Body.FlushAsync();
        }
    }
}

我们通过设置Content-Type为text/event-stream,允许服务器向客户端推送数据。通过设置Cache-Control为no-cache,告诉客户端或代理不要缓存响应,而应该每次都从服务器重新获取。

接着把上一节中的ChatCompletion.CreateCompletion改为ChatCompletion.CreateCompletionAsStream,该方法返回IAsyncEnumerable异步迭器:

1404d9da13b415079329c11e1c8f6fe5.png

然后我们可以使用await foreach语法糖,用来异步迭代一个IAsyncEnumerable序列,它类似于foreach,但是它会等待序列中的每个元素异步生成,而不是同步获取。这样可以在等待下一个元素时不阻塞当前线程,提高性能和响应性。

我们使用Response.WriteAsync来异步向HTTP响应的输出流中写入字符串,使用Response.Body.FlushAsync将HTTP响应的输出流中的缓冲数据发送到客户端,所有方法全部使用异步方法,提高性能和响应。

82b2978a0fdaa62f934d0b76ef9ffd7f.png

二、Web端js脚本调整

打开Views/Home/Index.cshtml文件,将页面中的js脚本替换为以下内容:

<script type="text/javascript">
    //内容显示框
    var messagesList = document.getElementById("messagesList")
    //消息输入框
    var messageInput = document.getElementById("messageInput");
    //发送按钮
    var sendButton = document.getElementById("sendButton");
    // 定义一个XMLHttpRequest对象,用于发送请求和接收响应
    var httpRequest = new XMLHttpRequest();


    //发送按钮绑定click事件
    sendButton.addEventListener("click", function (event) {
        var message = messageInput.value;
        if (message.length == 0) {
            alert('请输入聊天内容');
            return;
        }
        send(message);
        event.preventDefault();
    });


    function send(message) {
        //修改按钮状态
        sendButton.disabled = true;


        //向内容显示框中追加发送的内容
        var div = document.createElement("div");
        div.className = "alert alert-secondary";
        div.textContent = `Me:${message}`;
        messageInput.value = '';
        messagesList.appendChild(div);


        //向内容显示框中追加ChatGpt返回的内容
        div = document.createElement("div");
        div.className = "alert alert-primary";
        messagesList.appendChild(div);
        div.textContent = "……";


        //创建FormData格式消息
        var formData = new FormData();
        formData.set('message', message);


        httpRequest.onprogress = function (progressEvent) {
            //处理响应数据
            div.textContent = `GPT:${progressEvent.target.responseText}`;
        };
        //请求是否成功都会执行
        httpRequest.onloadend = function (progressEvent) {
            //恢复按钮状态
            sendButton.disabled = false;
        };


        //打开请求,设置请求方法和地址,并设置异步为true
        httpRequest.open("POST", "api/chat", true);
        httpRequest.responseType = "text";// 设置请求头为text格式
        httpRequest.send(formData);//发送请求
    }
</script>

这里我继续使用XMLHttpReques来实现SSE通信,并没有使用EventSource实现,主要是考虑它的浏览器支持更广泛一些,以下是两者的主要区别:

a9aea594f8fcaa2094243d4c8ee29fc6.png

为了实现打字机效果,我们需要在请求过程中逐步接收数据,而不是等到请求完成时一次性接收所有数据。因此,我们在send方法里使用了httpRequest.onprogress事件,而不是httpRequest.onload事件。

6cab01934e43b26b57d413325aa86e82.png

同时,为了防止用户重复点击发送按钮,我们在send方法里对发送按钮进行了锁定。并使用httpRequest.onloadend对按钮解出状态,onloadend事件无论请求是成功还是失败,在请求结束时都会触发。

F5启动项目看一下效果:

c668f5b00db9ec9822f2056f5c497e6b.gif

在和ChatGPT对话过程中,有时候它的回答并不符合我们的期望,或者我们的输入可能有误,想让它停止回答,然后重新调整对话内容,此时增加一个停止响应的功能很实用,我们来实现一下。

三、服务端优化

为了优化服务端的性能,我们需要对Controllers/ChatController.cs中的Input方法添加一个CancellationToken类型的参数cancellationToken,用于检测HTTP请求是否被客户端或服务器中止了。如果是,cancellationToken的IsCancellationRequested属性将变为true,我们便可以取消正在运行的任务,避免浪费资源。所以我们要把cancellationToken传递给所有异步方法,让它们能够响应取消信号。

34256e512601c120dfeaa7dc4bc37327.png

四、Web端再次调整

打开Views/Home/Index.cshtml文件,在发送按钮后边增加一个停止响应按钮:

<input type="button" id="stopButton" value="停止响应" class="btn btn-warning" disabled />

73ec6e6a4387500fa8128995700ef372.png

找到js脚本中sendButton的定义,增加停止响应按钮代码和事件:

//停止响应按钮
var stopButton = document.getElementById("stopButton");
//停止响应信号
var stopRequest = false;
//停止响应按钮绑定click事件
stopButton.addEventListener("click", function (event) {
    stopRequest = true;
    event.preventDefault();
});

ddd4656572c765c1f54110a32f5c484c.png

在这里我们定义了一个stopRequest变量来接收停止信号,当触发停止响应事件时,stopRequest被设置为true,当请求结束时,stopRequest会重置为false。

在send方法中新增XMLHttpRequest的onreadystatechange事件调用,代码如下:

//监听请求状态
httpRequest.onreadystatechange = function () {
  if (stopRequest) {
     httpRequest.abort();
     stopRequest = false;
  }
};

onreadystatechange事件是一个属性,它存储了一个函数(或函数名),每当XMLHttpRequest的readyState属性改变时,就会调用该函数,readyState的值有4个,0表示请求未初始化;1表示服务器连接已建立;2表示请求已接收;3表示请求处理中;4表示请求已完成,且响应已就绪。因此我们可以根据它来执行stopRequest的重置任务。

httpRequest.abort方法用于取消已经发送的请求。当一个请求被取消时,readyState属性会被设置为0。

同时增加对停止响应按钮的状态控制,当处理SSE通信时,按钮变为可用,当请求结束时,状态变为不可用。

f79670d0aa6c4b3de28eb0a419d229b4.png

F5启动项目看一下效果:

331161c8bd023a52c638f598789d40c6.gif

今天就先到这里,下节我们继续探索如何实现上下文聊天功能和会话管理功能,请大家多多关注。

//源码地址
https://github.com/ynanech/ChatGPT.Demo

👇感谢阅读,点赞+分享+收藏+关注👇c993477dd23144a70314cbda52b32a39.png

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值