基於ChatGPT函數呼叫來實現C#本地函數邏輯鏈式呼叫助力大模型落地

2023-06-19 18:00:47

  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 = "[email protected]";
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平替(比如基於函數呼叫+低程式碼)。。。