
前言
近期一段时间,ChatGPT十分火热,而在我们使用官方提供的网页版ChatGPT时,不难发现,其中有一个很关键的特征:机器人的回复不是一下子全部显示在对话记录中,而是一个字一个字的“刷新”在页面之上。这样“流返回”的好处是:用户在发出信息后,立即就能感受到机器人的响应,而无需等待模型生成文字,极大提升了用户体验。
由于OpenAI官方提供了ChatGPT的接口,使得我们也有了搭建自己的“ChatGPT网站”的可能性。然而尝试过的朋友可能会发现:官方提供的使用教程往往都是采用了“块返回”的模式,即一次性返还所有模型回复,而非“流返回”,这就导致了如果模型回复过长,用户侧会有种“卡死”的感觉,十分不理想。
因此,本文分享一种基于ASP.NET Core框架、调用OpenAI服务、并可以中途取消的、返回文本流的接口的开发细节(这句话确实写的太难读了 :(
技术框架选取
首先要说明的是,无论选取什么技术栈都是可以的,毕竟OpenAI提供的接口走的是HTTP协议,所以选取的技术栈只要实现了HTTP通信即可。而OpenAi官方提供了两种语言的向其发送HTTP请求的封装包,分别是Python和JavaScript,然而这两种对我来说都有缺点:对于Python来说,虽然当前有如FastAPI一类的搭建后端接口的类库,然而Python毕竟存在GIL,本质是个单线程语言,用来搭建接口感觉不太舒适;而JavaScript就更不必说了,语言设计的太丑陋,再加上OpenAI提供的文档也没介绍清楚使用方法,用起来也很不顺手。
而对于其余的语言,OpenAI都没有提供官方的接口库,都需要自己封装或使用第三方包。
笔者比较熟悉的是Java、C#、Ruby,这其中都有第三方提供了调OpenAI的类库,权衡过后,本人选取了C#进行开发,更确切来说是使用C#的ASP.NET Core框架进行开发。毕竟从语言的整洁度、优雅度以及开发效率来说,C#要更好些。
实现细节
控制器最小单元
为了方便理解,我们先看一下ASP.NET Core框架中接口的最小单元:控制器的组成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| namespace NekoNetGPT.API.Controllers;
[Route("api/[controller]")] [ApiController] public class ConversationsController : ControllerBase { [HttpGet("hello")] public async Task<IActionResult> HelloWorld() { await DoSomethingAsync() { } return Ok("Hello"); } }
|
在上述代码中,当用户以Get请求访问“api/Conversation/hello”地址时,服务器就会返还一个200状态码的响应,响应内容为“Hello”。当然,没用过这一套框架的人可能会觉得上述代码并没有处理很多细节,尤其是路由映射相关的内容;而事实上,这其中有许多依靠“约定”的编程,导致代码看着有些奇怪,这里我们就不细展开了,只要知道大致是这么用的即可。
注册OpenAI服务至依赖注入容器
为了便于开发,我们就不自己封装对于OpenAI的HTTP请求了,而是利用一个第三方的nuget包:betalgo/openai
利用下面的命令来安装:
1
| Install-Package Betalgo.OpenAI
|
当然,实际用Visual Studio的nuget包管理器UI来做会更直观。
这之后,只需要在Program.cs中利用下述代码将其注册至容器:
1 2 3 4 5
| builder.Services.AddOpenAIService(settings => { settings.ApiKey = builder.Configuration["ApiKey"] ?? throw new Exception(); settings.Organization = Environment.GetEnvironmentVariable("MY_OPEN_AI_ORG_KEY") ?? null; });
|
你可能需要在创建项目时就建立一个ASP.NET Core API项目才能看懂上述代码在干什么,其涉及到了框架的依赖注入的使用,这里篇幅所限,不展开了。
在控制器中获取OpenAI Service
将服务注册完毕后,我们就可以在控制器中拿到这一服务。方法是采用构造器注入:不难发现,控制器类上有一个[ApiController]
的特性,这一特性就代表了此类会由框架进行实例化并管理,因此,我们只需要在其构造器中要求传入一个OpenAIService,框架就会知道,在控制器创建之前,要先在容器里先找到OpenAIService,再利用它进行控制器的创建。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| namespace NekoNetGPT.API.Controllers;
[Route("api/[controller]")] [ApiController] public class ConversationsController : ControllerBase { private readonly IOpenAIService openAIService; public ConversationsController(IOpenAIService openAIService) { this.openAIService = openAIService; }
}
|
添加取消功能
作为流式返回的接口,要面临的一个问题是:如果用户在中途关闭或停止了请求,那么与之相对的,服务器也应该停止处理,否则会浪费无意义的资源。
这一功能具体实现很复杂,然而好在C#有很灵性的语言特性,已经将其封装好了,我们只要使用即可,具体来说,就是使用“CancellationToken”这一功能。只要在接口中传入这一参数即可。
1 2 3 4 5
| [HttpPost("{sessionUid}")] public async Task<IActionResult> PostNewMessage(CancellationToken cancellationToken) { }
|
当然,前端也要对CancellationToken进行发送并处理,每个框架的实现不一样,但我们这里就不过多解释了,假定前端已经完成了功能开发。
发送请求并使用流响应
接下来,我们便可以调用OpenAI服务,从配置上来说,只需将请求参数中的stream设为true即可,难点在于将响应写回我们的响应体。主要用到了C#的“可等待枚举类”(await foreach),是一个很好的语言特性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| [HttpPost("{sessionUid}")] public async Task<IActionResult> PostNewMessage([FromRoute] Guid sessionUid, [FromBody] PostNewMessageRequestDto postNewMessageRequestDto, CancellationToken cancellationToken) { var requestMessages =
Response.Headers.Add("Content-Type", "text/event-stream");
StringBuilder persistSb = new(string.Empty);
try { var completionResult = openAIService.ChatCompletion.CreateCompletionAsStream(new ChatCompletionCreateRequest { Messages = requestMessages, Model = OpenAI.ObjectModels.Models.Gpt_3_5_Turbo_16k, }, cancellationToken: cancellationToken);
await foreach (var completion in completionResult) { if (completion.Successful) { var responseContent = completion.Choices.First().Message.Content;
persistSb.Append(responseContent);
var json = JsonSerializer.Serialize(new { id = completion.Id, @object = completion.ObjectTypeName, created = completion.CreatedAt, model = completion.Model, choices = completion.Choices, });
await Response.WriteAsync($"data: {json}\n", cancellationToken: CancellationToken.None); await Response.Body.FlushAsync(cancellationToken: CancellationToken.None); }
else { if (completion.Error == null) { throw new Exception("Unknown Error"); }
Console.WriteLine($"{completion.Error.Code}: {completion.Error.Message}"); return NotFound($"{completion.Error.Code}: {completion.Error.Message}"); } } } catch (OperationCanceledException ex) { await Console.Out.WriteLineAsync($"\n{ex.Message}"); } catch (Exception ex) { await Console.Out.WriteLineAsync($"\n{ex.Message}"); } finally { await Response.WriteAsync("data: [DONE]", cancellationToken: CancellationToken.None);
}
return new EmptyResult(); }
|