截至目前為止,我整合 ChatGPT API 的應用限於靠預訓練知識校閱文章、彙整文件或翻譯,頂多整合向量資料庫試玩 RAG。有一種重要應用還沒試過 - 提供自訂函式或功能給 ChatGPT 呼叫!

即便最新版 ChatGPT 已具備上網能力,但有很多時侯我們想整合的資料來企業內部或屬於個人隱私,總不能把庫存資料、訂單內容或個人行事曆丟上 Internet 給 ChatGPT 爬吧?

【題外話】前幾天感嘆,AI 興起後連我自己都少 Google,部落格點閱量一落千丈... 有網友妙回:未來會讀你部落格的,也只剩 AI 了。發現 ChatGPT 新增可上網的小地球「搜尋網頁」功能,標示參考來源時可顯示部落格名稱、點下去會開啟文章,或多或少能填補一些消失的搜尋流量吧。

針對這類應用情境,Open AI 提供的解決方案是 Function Calling,一些經典範例像是詢問訂單時讀取客戶資料、參考行程表排定會議、執行數學運算(ChatGPT 數學不好,同樣任務交給程式算易如反掌)、串接資料整理流程寫入資料庫等、操作顯示 UI (例如:在地圖上特定點放大頭針)... 多不勝數,瞬間腦中湧出一大堆好玩的應用方式。

那還等什麼,馬上寫個範例體驗看看吧。

我是 Azure OpenAI API (AOAI) + C# 派,MS Learn 文件 Azure OpenAI client library for .NET / Use chat tools 有程式範例可參考,我選了一個簡單易懂的應用場景 - 查詢當下國際股市指數。(註:大盤指數為公開資訊 ChatGPT 有可能上網取得,以其為例較簡單易懂,實務應用會是限內部存取的資訊)

函式呼叫的運作概念是在一般交談過程中,當 LLM 發現有部分資料不在知識範圍(在本例為各市場股票指數的最新數字),而我們已寫好一些函式,並事先向 ChatGPT 說明函式用途、需要輸入哪些參數、格式為何。有了這些資訊,ChatGPT 會聰明地判斷何時該呼叫這些函式,從回應結果取得所需資訊用以組織答案。

總之,身為開發者的任務就是設計一個函式,向 ChatGPT 清楚說明函式用途及使用方式,剩下來就看 ChatGPT 如何聰穎過人,活用你的函式吐出令人驚豔的回答。

以上回的 .NET ChatGPT 聊天程式為起點,簡單說明如何加入自訂函式給 ChatGPT 呼叫,有以下重點:(各點對應程式碼中的 [1]、[2]... 標示)

  1. CompleteChatAsync() 要多傳入 ChatCompletionOptions,內含自訂工具函式定義
  2. 為使程式模組化,我將產生函式定義及實務函式邏輯包成獨立類別 StockIdxService,細節後面再介紹
  3. 簡單聊天或問答通常是一次 CompleteChatAsync() 得到結果,但整合函式呼叫時會歷經多個回合得到最後結果,故這裡要用 while (true) 迴圈,產生結果再 break
  4. 每次呼叫完 CompleteChatAsync() 先判斷回應內容是否標記為函式請求
  5. 若為函式請求,將呼叫函式請求(可能有多個)包成 AssistantChatMessage 加入下回合呼叫 CompleteChatAsync() 的前後文
  6. 由函式請求物件取得函式名稱,交給對應的函式處理邏輯
  7. 函式執行回應的文字內容包成 ToolChatMessage,加入下回合呼叫 CompleteChatAsync() 的前後文
  8. 若回傳結果的 FinishReason == ChatFinishReason.Stop 代表 ChatGPT 產生回答,結束 while 迴圈;否則再次以新的前後文再次呼叫 CompleteChatAsync() 進入下一次迴圈

ChatGPT 呼叫函式每次可能動用一個或多個函式,過程可能反覆呼叫多次,我們不用管那麼多,每次被呼叫都依參數正確傳回結果就好。程式範例如下:

using System.Text;
using Azure.AI.OpenAI;
using OpenAI.Chat;

namespace chat_tool_demo
{
    public class GptChatService
    {
        List<ChatMessage> history = [];
        public void NewSession() => history.Clear();
        ChatClient client;
        public GptChatService(string apiUrl, string apiKey, string deployName)
        {
            client = new AzureOpenAIClient(
                new Uri(apiUrl),
                new System.ClientModel.ApiKeyCredential(apiKey))
                .GetChatClient(deployName);
        }

        public string SystemPrompt { get; set; } = "你是AI助理,負責以zh-tw解答使用者問題";

        //https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.openai-readme?view=azure-dotnet#use-chat-tools
        public async Task<string> Complete(string msg, bool includeHistory = false)
        {
            var chatMessages = new List<ChatMessage>() { new SystemChatMessage(SystemPrompt) };
            if (includeHistory) chatMessages.AddRange(history);
            chatMessages.Add(new UserChatMessage(msg));
            // [1] 加入工具宣告
            ChatCompletionOptions options = new()
            {
                Tools = { StockIdxService.CreateChatTool() } // [2]
            };
            StringBuilder sb = new();
            //  [3] 因涉及工具呼叫,可能巡迴多次
            while (true)
            {
                var completion = await client.CompleteChatAsync(chatMessages, options);
                // [4] 判斷是否回應為要求呼叫工具
                if (completion.Value?.FinishReason == ChatFinishReason.ToolCalls)
                {
                    // [5] 加入 AssistantChatMessage 以包含 ToolCall 的回應
                    chatMessages.Add(new AssistantChatMessage(completion));
                    // 取得工具呼叫資訊
                    foreach (var toolCall in completion.Value.ToolCalls)
                    {
                        // 依工具名稱決定處理方式
                        switch (toolCall.FunctionName)
                        {
                            case nameof(StockIdxService.GetStockIndex):
                                // [6] 呼叫工具提供方法,傳入 ChatToolCall 物件內含呼叫參數等資訊
                                var toolCallOutput = await StockIdxService.HandleToolCallAsync(toolCall);
                                // 偵錯用:顯示工具呼叫結果,代表工具函式有被觸發
                                Console.WriteLine($"DEBUG: {toolCallOutput}");
                                // [7] 加入工具回應下回合一併傳入 CompleteChatAsync()
                                chatMessages.Add(new ToolChatMessage(toolCall.Id, toolCallOutput));
                                break;
                            default:
                                throw new NotImplementedException($"Unknown tool call: {toolCall.FunctionName}");
                        }
                    }
                }
                else if (completion.Value?.FinishReason == ChatFinishReason.Stop) // [8]
                {
                    sb.Append(completion.Value.Content[0].Text);
                    break;
                }
            }
            var resp = sb.ToString();
            if (includeHistory)
            {
                history.Add(new UserChatMessage(msg));
                history.Add(new AssistantChatMessage(resp));
            }
            return resp;
        }
    }
}

資訊源部分,為求簡便我沒找查股票指標的專用 Web API,選擇直接爬雅虎股市資訊網頁解析,從中抓回國際金融市場指標,這部分非本次重點,跳過。關鍵在 CreateChatTool()、HandleToolCallAsync() 兩個方法。

  1. CreateChatTool() 呼叫 ChatTool.CreateFunction() 傳回一個 ChatTool 定義物件,其中包含 functionName、functionDescription 及 functionParameters,這部分愈詳細愈好。雖然用中文聊天、資料來源的股市指標名稱也是中文,依實測的結果,相關說明及指標名稱用英文效果較好,畢竟 ChatGPT 的母語是英文。
  2. HandleToolCallAsync(ChatToolCall toolCall) 的工作重點是從 FunctionArguments JSON 資料取出輸入參數,以字串形式傳回查詢結果,相對簡單。

程式範例如下:

using AngleSharp;
using OpenAI.Chat;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace chat_tool_demo
{
    public class StockIdxService
    {
        #region 由外部資訊源取得股票指數
        static HttpClient httpClient = new HttpClient();
        public class StkIndex
        {
            public string Name { get; set; }
            public string Prz { get; set; }
            public string Chg { get; set; }
            public string ChgPerc { get; set; }
            public StkIndex(string csv)
            {
                var parts = csv.Split("\t");
                Name = parts[1];
                Prz = parts[3];
                Chg = parts[4];
                ChgPerc = parts[6];
            }
        }
        public static async Task<IEnumerable<StkIndex>> GetStockIndice()
        {
            // 讀取全球主要國際指數進行解析
            var response = await httpClient.GetAsync("https://tw.stock.yahoo.com/world-indices");
            if (response.IsSuccessStatusCode)
            {
                var html = await response.Content.ReadAsStringAsync();
                var ctx = BrowsingContext.New(Configuration.Default);
                var doc = await ctx.OpenAsync(req => req.Content(html));
                return doc.QuerySelectorAll(".List\\(n\\)").Select(
                    li => string.Join("\t", li.QuerySelectorAll("span").Select(span => span.TextContent).ToArray())
                ).Select(s => new StkIndex(s)).ToArray(); 
            }
            throw new HttpRequestException($"Failed to get exchange rates: {response.StatusCode}");
        }
        #endregion

        // 指數之英文中文對照表
        static Dictionary<string, string> IdxNameMap = JsonSerializer.Deserialize<Dictionary<string, string>>("""
    {
        "Dow Jones Industrial Average": "道瓊工業指數",
        "S&P 500 Index": "S&P 500指數",
        "NASDAQ Index": "NASDAQ指數",
        "Philadelphia Semiconductor Index": "費城半導體指數",
        "Nikkei 225 Index": "日經225指數",
        "Hang Seng Index": "香港恒生指數",
        "Shanghai Composite Index": "上海綜合指數",
        "Shanghai A-Share Index": "上海A股指數",
        "Shanghai B-Share Index": "上海B股指數",
        "Shenzhen Composite Index": "深圳綜合指數",
        "Shenzhen A-Share Index": "深圳A股指數",
        "Shenzhen B-Share Index": "深圳B股指數",
        "Korea Composite Stock Price Index (KOSPI)": "韓國綜合指數",
        "German Stock Market (DAX)": "德國股市",
        "UK Stock Market (FTSE 100)": "英國股市",
        "French Stock Market (CAC 40)": "法國股市",
        "Jakarta Composite Index": "印尼綜合指數",
        "Bombay Stock Exchange Index (BSE)": "印度孟買指數",
        "Malaysian Stock Market (KLCI)": "馬來西亞股市",
        "Singapore Stock Market (STI)": "新加坡股市",
        "Philippine Stock Market (PSEi)": "菲律賓股市",
        "Russian Stock Market (RTS)": "俄羅斯股市"
    }
  """)!;
        public static async Task<string> GetStockIndex(string indexName)
        {
            if (IdxNameMap.ContainsKey(indexName)) indexName = IdxNameMap[indexName];
            var found = (await GetStockIndice()).FirstOrDefault(i => i.Name == indexName);
            if (found != null)
                return $"Stock Index: {found.Name}, Price: {found.Prz}, Change: {found.Chg}, Change%: {found.ChgPerc}";
            else
                return $"Stock Index {indexName} not found.";
        }

        // 傳回 ChatTool 定義物件
        public static ChatTool CreateChatTool()
        {
            return ChatTool.CreateFunctionTool(
                functionName: nameof(GetStockIndex),
                functionDescription: "Get the stock index data for marjor stock markets including Dow Jones Industrial Average, S&P 500 Index, NASDAQ Index, Philadelphia Semiconductor Index, Nikkei 225 Index, Hang Seng Index, Shanghai Composite Index, Shanghai A-Share Index, Shanghai B-Share Index, Shenzhen Composite Index, Shenzhen A-Share Index, Shenzhen B-Share Index, Korea Composite Stock Price Index (KOSPI), German Stock Market (DAX), UK Stock Market (FTSE 100), French Stock Market (CAC 40), Jakarta Composite Index, Bombay Stock Exchange Index (BSE), Malaysian Stock Market (KLCI), Singapore Stock Market (STI), Philippine Stock Market (PSEi), Russian Stock Market (RTS)",
                functionParameters: BinaryData.FromString("""
                    {
                        "type": "object",
                        "properties": {
                            "indexName": {
                                "type": "string",
                                "description": "Index name",
                                "enum": [
                                    "Dow Jones Industrial Average", "S&P 500 Index", "NASDAQ Index", "Philadelphia Semiconductor Index", "Nikkei 225 Index", "Hang Seng Index", "Shanghai Composite Index", "Shanghai A-Share Index", "Shanghai B-Share Index", "Shenzhen Composite Index", "Shenzhen A-Share Index", "Shenzhen B-Share Index", "Korea Composite Stock Price Index (KOSPI)", "German Stock Market (DAX)", "UK Stock Market (FTSE 100)", "French Stock Market (CAC 40)", "Jakarta Composite Index", "Bombay Stock Exchange Index (BSE)", "Malaysian Stock Market (KLCI)", "Singapore Stock Market (STI)", "Philippine Stock Market (PSEi)", "Russian Stock Market (RTS)"
                                ]
                            }
                        },
                        "required": [ "indexName" ]
                    }
                    """)
           );
        }

        // 處理 ToolCall 呼叫傳回結果
        public static async Task<string> HandleToolCallAsync(ChatToolCall toolCall)
        {
            if (toolCall.FunctionName == nameof(GetStockIndex))
            {
                try
                {
                    using var args = JsonDocument.Parse(toolCall.FunctionArguments);
                    if (args.RootElement.TryGetProperty("indexName", out var indexName) && !string.IsNullOrEmpty(indexName.GetString()))
                        return await GetStockIndex(indexName.GetString()!);
                    else
                        return "Invalid or missing 'indexName' argument.";
                }
                catch (JsonException ex)
                {
                    return $"Error parsing JSON: {ex.Message}";
                }
            }
            return "Unknown function call.";
        }
    }
}

最後看一下測試效果。ChatGPT 跟我們聊國際金融市場,話鋒一轉問起股市現況時,ChatGPT 一想有函式可用,便會依我們提示的參數列舉挑選股市指標名稱,呼叫工具函式取得回答,並將其融合到給使用者的回應,搖身一變成為掌握最近股市行情的聊天機器人。

有了這個機制,我們便可以發揮想像力,整合各種自訂服務,賦與 ChatGPT 查庫存、查通訊錄、查行事曆、查知識庫的能力,彙整內部或私有資訊生成報告、摘要或解答問題。進一步,還能讓它主動寫信、訂時程、下訂單(當然,建議有人複核過再放行),賜與 ChatGPT 各式特異功能,ChatGPT API 一下子變得更好玩了,哈!

This blog post discusses integrating custom functions with the ChatGPT API, particularly for scenarios where data should not be exposed to the internet. The post provides a step-by-step guide to implementing function calling using Azure OpenAI API and C#, demonstrating the process with a stock index query example. This approach enables creating intelligent applications that can interact with internal data securely.


Comments

# by Sean

可以試用Semantic Kernel看看,AutoInvokeKernelFunctions 自動判斷呼叫Function 新版的 Ollama + llama3.1之後也支援 tool calling,不過model參數量要大一些的,才比較聰明一些

# by Eric

或許部落格的流量只能靠還在用RSS閱讀器的老屁股了 自己也是越來越少google,從RSS閱讀器跳轉過來的

Post a comment