實現阿里雲模型服務靈積 DashScope 的 Semantic Kernel Connector

Semantic Kernel 內置的 IChatCompletionService 實現只支持 OpenAI 與 Azure OpenAI,而我卻打算結合 DashScope(阿里雲模型服務靈積) 學習 Semantic Kernel。

於是決定自己動手實現一個支持 DashScope 的 Semantic Kernel Connector —— DashScopeChatCompletionService,實現的過程也是學習 Semantic Kernel 源碼的過程,
而且藉助 Sdcb.DashScope,實現變得更容易了,詳見前一篇博文 藉助 .NET 開源庫 Sdcb.DashScope 調用阿里雲靈積通義千問 API

這裏只實現用於調用 chat completion 服務的 connector,所以只需實現 IChatCompletionService 接口,該接口繼承了 IAIService 接口,一共需要實現2個方法+1個屬性。

public sealed class DashScopeChatCompletionService : IChatCompletionService
{
    public IReadOnlyDictionary<string, object?> Attributes { get; }

    public Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
    {
        throw new NotImplementedException();
    }

    public IAsyncEnumerable<StreamingChatMessageContent> GetStreamingChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
    {
        throw new NotImplementedException();
    }
}

先實現 GetChatMessageContentsAsync 方法,調用 Kernel.InvokePromptAsync 方法時會用到這個方法。

實現起來比較簡單,就是轉手買賣:

  • 把 Semantic Kernel 的 ChatHistory 轉換爲 Sdcb.DashScope 的 IReadOnlyList<ChatMessage>
  • 把 Semantic Kernel 的 PromptExecutionSettings 轉換爲 Sdcb.DashScope 的 ChatParameters
  • 把 Sdcb.DashScope 的 ResponseWrapper<ChatOutput, ChatTokenUsage> 轉換爲 Semantic Kernel 的 IReadOnlyList<ChatMessageContent>

實現代碼如下:

public async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
{
    var chatMessages = chatHistory
        .Where(x => !string.IsNullOrEmpty(x.Content))
        .Select(x => new ChatMessage(x.Role.ToString(), x.Content!)).
        ToList();

    ChatParameters? chatParameters = null;
    if (executionSettings?.ExtensionData?.Count > 0)
    {
        var json = JsonSerializer.Serialize(executionSettings.ExtensionData);
        chatParameters = JsonSerializer.Deserialize<ChatParameters>(
            json,
            new JsonSerializerOptions { NumberHandling = JsonNumberHandling.AllowReadingFromString });
    }

    var response = await _dashScopeClient.TextGeneration.Chat(_modelId, chatMessages, chatParameters, cancellationToken);

    return [new ChatMessageContent(new AuthorRole(chatMessages.First().Role), response.Output.Text)];
}

接下來實現 GetStreamingChatMessageContentsAsync,調用 Kernel.InvokePromptStreamingAsync 時會用到它,同樣也是轉手買賣。

ChatHistoryPromptExecutionSettings 參數的轉換與 GetChatMessageContentsAsync 一樣,所以引入2個擴展方法 ChatHistory.ToChatMessagesPromptExecutionSettings.ToChatParameters 減少重複代碼,另外需要將 ChatParameters.IncrementalOutput 設置爲 true

不同之處是返回值類型,需要將 Sdcb.DashScope 的 IAsyncEnumerable<ResponseWrapper<ChatOutput, ChatTokenUsage>> 轉換爲 IAsyncEnumerable<StreamingChatMessageContent>

實現代碼如下:

public async IAsyncEnumerable<StreamingChatMessageContent> GetStreamingChatMessageContentsAsync(
    ChatHistory chatHistory,
    PromptExecutionSettings? executionSettings = null,
    Kernel? kernel = null,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    var chatMessages = chatHistory.ToChatMessages();
    var chatParameters = executionSettings?.ToChatParameters() ?? new ChatParameters();
    chatParameters.IncrementalOutput = true;

    var responses = _dashScopeClient.TextGeneration.ChatStreamed(_modelId, chatMessages, chatParameters, cancellationToken);

    await foreach (var response in responses)
    {
        yield return new StreamingChatMessageContent(new AuthorRole(chatMessages[0].Role), response.Output.Text);
    }
}

到這裏2個方法就實現好了,還剩下很容易實現的1個屬性,輕鬆搞定

public sealed class DashScopeChatCompletionService : IChatCompletionService
{
    private readonly DashScopeClient _dashScopeClient;
    private readonly string _modelId;
    private readonly Dictionary<string, object?> _attribues = [];

    public DashScopeChatCompletionService(
        IOptions<DashScopeClientOptions> options,
        HttpClient httpClient)
    {
        _dashScopeClient = new(options.Value.ApiKey, httpClient);
        _modelId = options.Value.ModelId;
        _attribues.Add(AIServiceExtensions.ModelIdKey, _modelId);
    }

    public IReadOnlyDictionary<string, object?> Attributes => _attribues;
}

到此,DashScopeChatCompletionService 的實現就完成了。

接下來,實現一個擴展方法,將 DashScopeChatCompletionService 註冊到依賴注入容器

public static class DashScopeServiceCollectionExtensions
{
    public static IKernelBuilder AddDashScopeChatCompletion(
        this IKernelBuilder builder,
        string? serviceId = null,
        Action<HttpClient>? configureClient = null,
        string configSectionPath = "dashscope")
    {
        Func<IServiceProvider, object?, DashScopeChatCompletionService> factory = (serviceProvider, _) =>
            serviceProvider.GetRequiredService<DashScopeChatCompletionService>();

        if (configureClient == null)
        {
            builder.Services.AddHttpClient<DashScopeChatCompletionService>();
        }
        else
        {
            builder.Services.AddHttpClient<DashScopeChatCompletionService>(configureClient);
        }

        builder.Services.AddOptions<DashScopeClientOptions>().BindConfiguration(configSectionPath);
        builder.Services.AddKeyedSingleton<IChatCompletionService>(serviceId, factory);
        return builder;
    }
}

爲了方便通過配置文件配置 ModelId 與 ApiKey,引入了 DashScopeClientOptions

public class DashScopeClientOptions : IOptions<DashScopeClientOptions>
{
    public string ModelId { get; set; } = string.Empty;

    public string ApiKey { get; set; } = string.Empty;

    public DashScopeClientOptions Value => this;
}

最後就是寫測試代碼驗證實現是否成功,爲了減少代碼塊的長度,下面的代碼片段只列出其中一個測試用例

public class DashScopeChatCompletionTests
{
    [Fact]
    public async Task ChatCompletion_InvokePromptAsync_WorksCorrectly()
    {
        // Arrange
        var builder = Kernel.CreateBuilder();
        builder.Services.AddSingleton(GetConfiguration());
        builder.AddDashScopeChatCompletion();
        var kernel = builder.Build();

        var prompt = @"<message role=""user"">博客園是什麼網站</message>";
        PromptExecutionSettings settings = new()
        {
            ExtensionData = new Dictionary<string, object>()
            {
                { "temperature", "0.8" }
            }
        };
        KernelArguments kernelArguments = new(settings);

        // Act
        var result = await kernel.InvokePromptAsync(prompt, kernelArguments);

        // Assert
        Assert.Contains("博客園", result.ToString());
        Trace.WriteLine(result.ToString());
    }

    private static IConfiguration GetConfiguration()
    {
        return new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json")
            .AddUserSecrets<DashScopeChatCompletionTests>()
            .Build();
    }
}

最後的最後就是運行測試,在 appsettings.json 中添加模型Id

{
  "dashscope": {
    "modelId": "qwen-max"
  }
}

注:qwen-max 是通義千問千億級大模型

通過 user-secrets 添加 api key

dotnet user-secrets set "dashscope:apiKey" "sk-xxx"

dotnet test 命令運行測試

A total of 1 test files matched the specified pattern.
博客園是一個專注於提供信息技術(IT)領域知識分享和技術交流的中文博客平臺,創建於2004年。博客園主要由軟件開發人員、系統管理員以及對IT技術有深厚興趣的人羣使用,用戶可以在該網站上撰寫和發佈自己的博客文章,內容涵蓋編程、軟件開發、雲計算、人工智能等多個領域。同時,博客園也提供了豐富的技術文檔、教程資源和社區互動功能,旨在促進IT專業人士之間的交流與學習。

Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: < 1 ms - SemanticKernel.DashScope.IntegrationTest.dll (net8.0)

測試通過!連接 DashScope 的 Semantic Kernel Connector 初步實現完成。

完整實現代碼放在 github 上,詳見 https://github.com/cnblogs/semantic-kernel-dashscope/tree/v0.1.0

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章