写点什么

旁门左道:借助 HttpClientHandler 拦截请求,体验 Semantic Kernel 插件

  • 2024-02-20
    福建
  • 本文字数:4035 字

    阅读完需:约 13 分钟

前天尝试通过 one-api + dashscope(阿里云灵积) + qwen(通义千问)运行 Semantic Kernel 插件(Plugin) ,结果尝试失败,详见前天的博文


今天换一种方式尝试,选择了一个旁门左道走走看,看能不能在不使用大模型的情况下让 Semantic Kernel 插件运行起来,这个旁门左道就是从 Stephen Toub 那偷学到的一招 —— 借助 DelegatingHandler(new HttpClientHandler()) 拦截 HttpClient 请求,直接以模拟数据进行响应。


先创建一个 .NET 控制台项目


dotnet new consoledotnet add package Microsoft.SemanticKerneldotnet add package Microsoft.Extensions.Http
复制代码


参照 Semantic Kernel 源码中的示例代码创建一个非常简单的插件 LightPlugin


public class LightPlugin{    public bool IsOn { get; set; } = false;
[KernelFunction] [Description("帮看一下灯是开是关")] public string GetState() => IsOn ? "on" : "off";
[KernelFunction] [Description("开灯或者关灯")] public string ChangeState(bool newState) { IsOn = newState; var state = GetState(); Console.WriteLine(state == "on" ? $"[开灯啦]" : "[关灯咯]"); return state; }}
复制代码


接着创建旁门左道 BackdoorHandler,先实现一个最简单的功能,打印 HttpClient 请求内容


public class BypassHandler() : DelegatingHandler(new HttpClientHandler()){    protected override async Task<HttpResponseMessage> SendAsync(        HttpRequestMessage request, CancellationToken cancellationToken)    {        Console.WriteLine(await request.Content!.ReadAsStringAsync());        // return await base.SendAsync(request, cancellationToken);        return new HttpResponseMessage(HttpStatusCode.OK);    }}
复制代码


然后携 LightPlugin 与 BypassHandler 创建 Semantic Kernel 的 Kernel


var builder = Kernel.CreateBuilder();builder.Services.AddOpenAIChatCompletion("qwen-max", "sk-xxxxxx");builder.Services.ConfigureHttpClientDefaults(b =>    b.ConfigurePrimaryHttpMessageHandler(() => new BypassHandler()));builder.Plugins.AddFromType<LightPlugin>();Kernel kernel = builder.Build();
复制代码


再然后,发送携带 prompt 的请求并获取响应内容


var history = new ChatHistory();history.AddUserMessage("请开灯");Console.WriteLine("User > " + history[0].Content);var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
// Enable auto function callingOpenAIPromptExecutionSettings openAIPromptExecutionSettings = new(){ ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions};
var result = await chatCompletionService.GetChatMessageContentAsync( history, executionSettings: openAIPromptExecutionSettings, kernel: kernel);
Console.WriteLine("Assistant > " + result);
复制代码


运行控制台程序,BypassHandler 就会在控制台输出请求的 json 内容(为了阅读方便对 json 进行了格式化):


点击查看 json


为了能反序列化这个 json ,我们需要定义一个类型 ChatCompletionRequest,Sermantic Kernel 中没有现成可以使用的,实现代码如下:


点击查看 ChatCompletionRequest


有了这个类,我们就可以从请求中获取对应 Plugin 的 function 信息,比如下面的代码:


var function = chatCompletionRequest?.Tools.FirstOrDefault(x => x.Function.Description.Contains("开灯"))?.Function;var functionName = function.Name;var parameterName = function.Parameters.Properties.FirstOrDefault(x => x.Value.Type == PropertyType.Boolean).Key;
复制代码


接下来就是旁门左道的关键,直接在 BypassHandler 中响应 Semantic Kernel 通过 OpenAI.ClientCore 发出的 http 请求。


首先创建用于 json 序列化的类 ChatCompletionResponse


点击查看 ChatCompletionResponse


先试试不执行 function calling ,直接以 assistant 角色回复一句话


public class BypassHandler() : DelegatingHandler(new HttpClientHandler()){    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)    {        var chatCompletion = new ChatCompletionResponse        {            Id = Guid.NewGuid().ToString(),            Model = "fake-mode",            Object = "chat.completion",            Created = DateTimeOffset.Now.ToUnixTimeSeconds(),            Choices =               [                   new()                   {                       Message = new ResponseMessage                       {                           Content = "自己动手,丰衣足食",                           Role = "assistant"                       },                       FinishReason = "stop"                   }               ]        };
var json = JsonSerializer.Serialize(chatCompletion, GetJsonSerializerOptions()); return new HttpResponseMessage { Content = new StringContent(json, Encoding.UTF8, "application/json") }; }}
复制代码


运行控制台程序,输出如下:


User > 请开灯Assistant > 自己动手,丰衣足食
复制代码


成功响应,到此,旁门左道成功了一半。


接下来在之前创建的 chatCompletion 基础上添加针对 function calling 的 ToolCall 部分。


先准备好 ChangeState(bool newState) 的参数值


Dictionary<string, bool> arguments = new(){    { parameterName, true }};
复制代码


并将回复内容由 "自己动手,丰衣足食" 改为 "客官,灯已开"


Message = new ResponseMessage{    Content = "客官,灯已开",    Role = "assistant"}
复制代码


然后为 chatCompletion 创建 ToolCalls 实例用于响应 function calling


var messages = chatCompletionRequest.Messages;if (messages.First(x => x.Role == "user").Content.Contains("开灯") == true){    chatCompletion.Choices[0].Message.ToolCalls = new List<ToolCall>()    {        new ToolCall        {            Id = Guid.NewGuid().ToString(),            Type = "function",            Function = new FunctionCall            {                Name = function.Name,                Arguments = JsonSerializer.Serialize(arguments, GetJsonSerializerOptions())            }        }    };}
复制代码


运行控制台程序看看效果


User > 请开灯[开灯啦][开灯啦][开灯啦][开灯啦][开灯啦]Assistant > 客官,灯已开
复制代码


耶!成功开灯!但是,竟然开了 5 次,差点把灯给开爆了。


在 BypassHandler 中打印一下请求内容看看哪里出了问题


var json = await request.Content!.ReadAsStringAsync();Console.WriteLine(json);
复制代码


原来分别请求/响应了 5 次,第 2 次请求开始,json 中 messages 部分多了 tool_calls 与 tool_call_id 内容


{  "messages": [    {      "content": "\u5BA2\u5B98\uFF0C\u706F\u5DF2\u5F00",      "tool_calls": [        {          "function": {            "name": "LightPlugin-ChangeState",            "arguments": "{\u0022newState\u0022:true}"          },          "type": "function",          "id": "76f8dead-b5ad-4e6d-b343-7f78d68fac8e"        }      ],      "role": "assistant"    },    {      "content": "on",      "tool_call_id": "76f8dead-b5ad-4e6d-b343-7f78d68fac8e",      "role": "tool"    }  ]}
复制代码


这时恍然大悟,之前 AI assistant 对 function calling 的响应只是让 Plugin 执行对应的 function,assistant 还需要根据执行的结果决定下一下做什么,第 2 次请求中的 tool_calls 与 tool_call_id 就是为了告诉 assistant 执行的结果,所以,还需要针对这个请求进行专门的响应。


到了旁门左道最后 100 米冲刺的时刻!


给 RequestMessage 添加 ToolCallId 属性


public class RequestMessage{    [JsonPropertyName("role")]    public string? Role { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("content")] public string? Content { get; set; }
[JsonPropertyName("tool_call_id")] public string? ToolCallId { get; set; }}
复制代码


在 BypassHandler 中响应时判断一下 ToolCallId,如果是针对 Plugin 的 function 执行结果的请求,只返回 Message.Content,不进行 function calling 响应


var messages = chatCompletionRequest.Messages;var toolCallId = "76f8dead- b5ad-4e6d-b343-7f78d68fac8e";var toolCallIdMessage = messages.FirstOrDefault(x => x.Role == "tool" && x.ToolCallId == toolCallId);
if (toolCallIdMessage != null && toolCallIdMessage.Content == "on"){ chatCompletion.Choices[0].Message.Content = "客官,灯已开";}else if (messages.First(x => x.Role == "user").Content.Contains("开灯") == true){ chatCompletion.Choices[0].Message.Content = ""; //..}
复制代码


改进代码完成,到了最后 10 米冲刺的时刻,再次运行控制台程序


User > 请开灯[开灯啦]Assistant > 客官,灯已开
复制代码


只有一次开灯,冲刺成功,旁门左道走通,用这种方式体验一下 Semantic Kernel Plugin,也别有一番风味。


文章转载自:dudu

原文链接:https://www.cnblogs.com/dudu/p/18018718

体验地址:http://www.jnpfsoft.com/?from=001

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
旁门左道:借助 HttpClientHandler 拦截请求,体验 Semantic Kernel 插件_Java_不在线第一只蜗牛_InfoQ写作社区