如何利用 ASP.NET Core 创建调用OpenAI服务并返还文本流的接口

banner

前言

近期一段时间,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)
{
// 获取请求参数并制作向OpenAI发送的数据部分省略,根据你自己的业务来定
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();
}