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
時會用到它,同樣也是轉手買賣。
ChatHistory
與 PromptExecutionSettings
參數的轉換與 GetChatMessageContentsAsync
一樣,所以引入2個擴展方法 ChatHistory.ToChatMessages
與 PromptExecutionSettings.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