基于ChatGPT函数调用来实现C#本地函数逻辑链式调用助力大模型落地
6 月 13 日 OpenAI 官网突然发布了重磅的 ChatGPT 更新,我相信大家都看到了 ,除了调用降本和增加更长的上下文版本外,开发者们最关心的应该还是新的函数调用能力。通过这项能力模型在需要的时候可以调用函数并生成对应的 JSON 对象作为输出。这使开发人员能更准确地从模型获取结构化数据,实现从自然语言到 API 调用或数据库查询的转换,也可以用于从文本中提取结构化数据。如果说之前的ChatGPT只能基于提示词结合类似的工具来实现调用链提示(比如大火的python LLM自动化库LangChain或者微软的Semantic Kernel),那么现在官方下场直接提供函数调用接口,无疑在稳定性(基于三方库的函数调用主要是依赖提示词实现,其稳定性和提示词质量高度相关)和易用性上都上了一大台阶。
今天.NET社区相关的SDK终于更新到了新的版本可以支持函数调用。今天我们就以一个具体的案例来讲一下什么是函数调用,基于函数调用我们可以实现哪些能力,从而将一个只能聊天的大语言模型落地到更加真实的业务场景中。相关代码demo已经更新到了github:https://github.com/sd797994/ChatgptFunctionCallDemo
现在我们假设一个业务场景,假设用户需要询问今天或者明天某个城市的天气情况,并且将相关的查询发送一封邮件到某个目标地址。在传统的开发中,我们一般会定义一个表单,让用户选择城市和日期,然后点击发送。系统会调用天气接口获取到天气,然后通过一段模板文本将占位符中的城市+日期+天气状况替换成查询的实际内容,然后发送给目标邮箱。整个流程大体如下:
在没有chatgpt之前,以上这个简单的操作是需要用户通过相对规范的表单操作来实现的,就算是基于传统的自然语言模型去处理这个任务,也需要大量的语意识别训练来识别用户的语意,然后根据语意去硬编码一些过程调用才能实现以上逻辑。无论从开发的难度和用户体验上来讲,都达不到商业化的预期的。但是现在基于大语言模型和函数调用,以上这些功能只需要单个开发者用极短的时间即可实现。因为基于大语言模型本身的逻辑思维,它可以选择调用哪些函数来实现功能,而我们要做的仅仅是告诉它有哪些功能而已。
接下来我们就基于实际的操作看看AI是如何实现的,首先我们更新到最新官方推荐的社区SDK版本
<PackageReference Include="Betalgo.OpenAI" Version="7.1.0-beta" />
接下来我们需要定义一个函数调用库,这个调用库主要的作用就是将我们的函数以表达式编译的方式生成匿名委托缓存,同时使用反射生成ChatGpt可识别的函数命名规范,具体的调用库实现这里不再赘述,有兴趣的可以具体看看项目下的ChatGptFunctionCallProcessor相关实现,重点是讲讲如何调用openai的接口实现业务功能的:
首先定义一个日期函数,用于将用户口语化的日期转化成真实的日期,比如“今天”,“明天”转化成实际的日期来供天气函数查询。接着我们定义一个天气查询函数,用于查询对应城市的某日的天气情况,最后我们定义一个发邮件的函数,让gpt可以通过它来发送邮件,完整的类函数定义如下:
public class FunctionCallCentner { [Description("查询用户希望的日期对应的真实日期")] public async Task<CommonOutput> GetDate(GetDayInput input) { await Task.CompletedTask; Console.WriteLine($"system:GetDate函数调用触发,参数:city={input.DateType}"); return new CommonOutput() { data = new GetDayOutput { Date = DateTime.Now.AddDays(input.DateType == DateType.Yesterday ? -1 : input.DateType == DateType.Tomorrow ? 1 : input.DateType == DateType.DayAfterTomorrow ? 2 : 0).ToShortDateString(), }, Success = true }; } [Description("根据城市和真实日期获取天气信息")] public async Task<CommonOutput> GetWeather(GetWeatherInput input) { if (!DateTime.TryParse(input.Date, out _)) return new CommonOutput() { Success = false, message = "日期格式错误" }; await Task.CompletedTask; Console.WriteLine($"system:GetWeather函数调用触发,参数:city={input.City},date={input.Date}"); return new CommonOutput() { data = new GetWeatherOutput { City = input.City, Date = input.Date, Weather = "overcast to cloudy", TemperatureRange = "22˚C-28˚C" }, Success = true }; } [Description("向目标邮箱发送电子邮件")] public async Task<CommonOutput> SendEmail(SendEmailInput input) { await Task.CompletedTask; Console.WriteLine($"system:SendEmail函数调用触发,参数:targetemail={input.TargetEmail},content={input.Content}"); return new CommonOutput() { Success = true }; } }
这里面的我就不做具体的实现了,只是打印了log而已。接着我们需要对这些入参和出参进行定义,如下:
public class GetDayInput { [Description("日期枚举")] public DateType DateType { get; set; } } [JsonConverter(typeof(JsonStringEnumConverter))] public enum DateType { Yesterday, Today, Tomorrow, DayAfterTomorrow } public class GetDayOutput { public string Date { get; set; } } public class GetWeatherInput { [Description("城市名称")] public string City { get; set; } [Description("真实日期,格式:yyyy/mm/dd")] public string Date { get; set; } } public class GetWeatherOutput: GetWeatherInput { public string Weather { get; set; } public string TemperatureRange { get; set; } } public class SendEmailInput { [Description("目标邮件地址")] public string TargetEmail { get; set; } [Description("邮件完整内容")] public string Content { get; set; } } public class CommonOutput { public string message { get; set; } public object data { get; set; } public bool Success { get; set; } }
可以看到无论是函数还是入参都需要编写Description特性,这是gpt理解这个函数的方法用途以及入参定义的关键,一定不能缺少。另外官方的demo中并没有涉及出参的描述,所以这里我也没有添加。猜测可能gpt会自动基于出参的内容自动化的提取结果。
接着我们编写具体的业务代码,这里的关键是当gpt返回结果时,我们需要根据gpt返回的操作(直接输出内容/函数调用)来判断,如果gpt要求函数调用,则我们需要调用本地函数后再组装成新的chatmessage[]再次调用gpt,也就是说其实本质上是多轮递归式的调用来实现的逻辑链,比如当我问“天气+邮件”时,gpt首先会告诉我调用天气,并给我对应的参数。我返回天气,gpt在组装邮件的内容并告诉我调用邮件,给我参数。我再调用发送邮件并返回操作成功。gpt最后判断任务结束,输出内容。核心业务如下:
var key = "sk-Ab...jW"; var openAiService = new OpenAIService(new OpenAiOptions() { ApiKey = key }); var email = "testmyemail@goolg.com"; var userprompt = $"我想分别获取成都市今天和西安市明天的天气情况,并发送到{email}这个邮箱"; Console.WriteLine($"user:{userprompt}"); var center = new FunctionCallCentner(); var messages = new List<ChatMessage> { ChatMessage.FromSystem("You are a helpful assistant."), ChatMessage.FromUser(userprompt) }; await SessionExecute(messages); async Task SessionExecute(List<ChatMessage> messages) { var completionResult = await openAiService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest { Messages = messages, Model = Models.Gpt_3_5_Turbo_0613, Functions = center.GetDefinition().ToList() }); if (completionResult.Successful) { if (completionResult.Choices.First().Message.FunctionCall != null) { completionResult.Choices.First().Message.Content = ""; messages.Add(completionResult.Choices.First().Message); messages.Add(await center.CallFunction(completionResult.Choices.First().Message.FunctionCall.Name, completionResult.Choices.First().Message.FunctionCall.ParseArguments())); await SessionExecute(messages); } else { Console.WriteLine("assistant:" + completionResult.Choices.First().Message.Content); } } }
接下来我们看看gpt实际的运行情况:
可以看到gpt很聪明的将我们的任务进行了拆解,并且正确的调用了对应的函数(比如很聪明的基于用户模糊的问题“今天”“明天”去调用日期函数并且传递正确的枚举值),获取到每一轮函数返回的内容后,执行了正确的发邮件这个动作。并且最后贴心的告诉用户它已经执行完毕任务,让用户及时检查自己的邮箱。
如果说半年前chatgpt的横空出世还仅仅是让人觉得它仅仅是一个大号的聊天plus的话,那么现在基于函数调用让我们见识到了其恐怖的任务拆解,调度执行能力。通过对零散的API进行组装来实现用户复杂需求的实现,这在以往的开发中是根本无法想象的存在,说实话这东西将会颠覆现有的IT软件开发/交互,甚至很多IT岗位将面临被GPT平替(比如基于函数调用+低代码)。。。