打造專屬ChatGPT:透過OpenAI Tool Calling深度整合資源

A. 前言

在科技快速發展的時代,如何有效整合多種資源以提升產品的功能和效益,成為各界關注的焦點。傳統的聊天機器人,無論其對話能力多精準,通常需要用戶親自點擊按鈕或清晰表達需求才能調用額外工具進行查詢。然而,隨著大型語言模型(LLM)的出現,這種互動模式正在得到顛覆。

透過強大的LLM模型,我們能夠精準捕捉使用者的意圖,自動觸發適當的外部工具來獲取所需信息,並最終由LLM以自然、接近真人客服的方式組織出回應。這樣的創新不僅提升了互動體驗,更模糊了人機之間的界限。OpenAI搭配其先進的LLM模型,推出了Tool Calling,前稱Function Calling,這一功能為開發者提供了更加靈活的開發方式。Tool Calling能夠通過LLM識別用戶的意圖,決定合適的工具調用方案,從而使LLM模型得以與現有系統深度整合。

在這篇文章中,將記錄如何使用OpenAI的Tool Calling功能,打造真正個人化的ChatGPT,實現更優化的資源整合和更流暢的用戶互動體驗。

B. Tool call的生命週期

下圖是我們的程式與OpenAI進行Tool Calling時的互動,

  1. 首先我們透過API呼叫將訊息(Prompt)及我們擁有的工具提供給LLM模型。
  2. LLM模型根據我們的訊息(Prompt)決定是否要呼叫額外的工具或是如何回覆訊息。
  3. 接著LLM模型將最後結果回傳給我們,當確定需要呼叫額外的工具時,他同時把呼叫時所需的參數整理出來。
  4. 了解到需要呼叫我們的工具後,根據給定的參數進行呼叫。
  5. 根據工具的呼叫結果,將資訊提供給LLM,讓LLM幫你整合出後續合適的處理方式。
Tool call的生命週期 | https://platform.openai.com/docs/guides/function-calling
Tool call的生命週期 | https://platform.openai.com/docs/guides/function-calling

C. 實作Tool Calling

1. 定義工具(Tool)

首先定義工具並使用pydantic模組定義工具所需的參數,為後續呼叫API需要的JSON Schema做準備。

其中def get_daily_revenuedef get_historical_pedestrian_flow這兩個function就是我們的工具。

透過pydantic模組,定義了工具所需的參數,繼承BaseModel的兩個類別class DailyRevenueclass HistoricalPedestrianFlow可以使用model_json_schema方法可以快速的生成JSON Schema。

由於後續組裝呼叫用的tools參數時,會需要幫每個工具取一個名字,因此透過function def tool_name_mapping將工具的名字與function對應。OpenAI取出的函數參數是JSON字串,我們透過繼承BaseModel的兩個類別呼叫model_validate_json方法驗證OpenAI萃取出的參數是否正確。

from pydantic import BaseModel
from enum import Enum


class DailyRevenue(BaseModel):
    class Config:
        extra = "forbid"


def get_daily_revenue(params=None):
    # 用於tool call呼叫,在資料庫中查詢當日店鋪收益

    return {
        "revenue": 15_000_000,
        "currency": "NT$",
        "memo": "當日匯率1美元兌換31.5元台幣",
    }


class DayOfTheWeek(str, Enum):
    SUN = "SUN"
    MON = "MON"
    TUE = "TUE"
    WED = "WED"
    THU = "THU"
    FRI = "FRI"
    SAT = "SAT"


class HistoricalPedestrianFlow(BaseModel):
    day_of_the_week: DayOfTheWeek
    # = Field(
    #    title="一週中的某一天", description="一週中的某一天"
    # )

    class Config:
        extra = "forbid"


def get_historical_pedestrian_flow(params: HistoricalPedestrianFlow):
    # 用於tool call呼叫,查詢每週的某一天的歷史人流
    return {
        "day_of_the_week": params.day_of_the_week,
        "historical_pedestrian_flow": 300,
    }


def tool_name_mapping(tool_name, json_text: str = ""):
    # 將 tool_name與tool function對應
    if tool_name == "get_daily_revenue":
        fn = get_daily_revenue
        params = DailyRevenue.model_validate_json(json_text)
    elif tool_name == "get_historical_pedestrian_flow":
        fn = get_historical_pedestrian_flow
        params = HistoricalPedestrianFlow.model_validate_json(json_text)

    return fn(params)

2. 準備tools參數

tools參數的基本結構如下:

{
    "type": "function",
    "function" : {
        "name": string,Required,function(tool)的名稱,
        "description": string,Optional,描述function(tool)的用途,讓模型知道使用時機
        "parameters": JSON Schema,
        "strict": "boolean or null,Optional,Defaults to false,控制是否嚴格遵守papameters的設定(只支援部分的JSON Schema)"
    }
}

其中function的parameters(後續繼續呼叫工具需要的參數),我們透過DailyRevenue.model_json_schema()HistoricalPedestrianFlow.model_json_schema()來達成;namedescription盡可能簡潔且明確的表達每個工具的用途,避免OpenAI選錯工具。

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_daily_revenue",
            "description": "在資料庫中查詢當日店鋪收益",
            "parameters": DailyRevenue.model_json_schema(),
            "strict": True,
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_historical_pedestrian_flow",
            "description": "查詢每週的某一天的歷史人流",
            "parameters": HistoricalPedestrianFlow.model_json_schema(),
            "strict": True,
        },
    },
]

3. 呼叫API

from openai import OpenAI

client = OpenAI()

messages = [
    {"role": "system", "content": "你是店家小幫手,使用繁體中文"},
    {
        "role": "user",
        "content": f"不知道今天賺了多少,明天(星期四)歷史人流大概有多少",
    },
]
completion = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)

choice = completion.choices[0]

finish_reason = choice.finish_reason
print(f"finish_reason {finish_reason}")

這裡模擬了一個情境,我們的系統是店家小幫手,使用者(商家)問了他們的小幫手,今天的營業額跟明天的歷史人流。

系統收到商家的訊息後,由於本身不具備語言理解能力,所以呼叫了OpenAI的GPT-4o模型,並將歷史對話跟我們有的工具都告訴模型,模型看了我們提供的資料後,把結論告訴了我們的系統,當finish_reason是”tool_calls“時,我們就呼叫我們剛剛準備的工具。

4. 工具調用

import json

tool_call_assistant = []
tool_messages = []
for tool in choice.message.tool_calls:
    tool_res = tool_name_mapping(tool.function.name, tool.function.arguments)

    tool_call_assistant.append(
        {
            "id": tool.id,
            "type": "function",
            "function": {
                "name": tool.function.name,
                "arguments": tool.function.arguments,
            },
        }
    )
    tool_messages.append(
        {"role": "tool", "content": json.dumps(tool_res), "tool_call_id": tool.id}
    )

messages.append(
    {
        "role": "assistant",
        "tool_calls": tool_call_assistant,
    }
)
messages.extend(tool_messages)

choice.message.tool_calls中可以了解到需要呼叫哪些工具,由於一組問題可能需要多個函數才能解決,所以choice.message.tool_calls長度不一定為一,因此我們透過for loop將資料取出,並透過前面定義的tool_name_mapping來將工具名稱及參數帶入我們定義的工具。

在迴圈中我們透過tool_call_assistant將使用的工具名稱及參數記錄起來,另外再用tool_messages記錄工具呼叫的結果。

接著我們將tool_call_assistant包裝成Assistant訊息,然後放進messages中;接續是Tool訊息

5. 讓LLM模型幫我們組織語言

接下來我們如果再把messages透過API傳遞給模型,模型看起來會像是:

  1. 系統訊息:店家小幫手。
  2. 使用者問問題:今日收益及明日人流?
  3. 助手回覆:請調用工具,且提供了工具的名稱及參數。
  4. 工具處理結果
messages.append({"role": "system", "content": "將資訊轉換為美元"})

completion = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)
print(completion.choices[0].message.content)
# 今天的店鋪收益是15,000,000台幣,大約相當於476,190美元(根據當日匯率1美金兌換31.5台幣)。

# 至於明天(星期四)的歷史人流,大約有300人。

透過自定義工具取得的結果格式在我們的掌握中,其實未必要讓LLM模型幫我們組織語言,這邊模擬還有一個其他需求(轉換幣值),將這些訊息透過API呼叫模型進行處理。

D. 注意事項

1. 處理finish_reason

前面提到finish_reason是”tool_calls“時…,很明顯finish_reason不只有tool_calls這個可能,以下是官方提供的條件判斷範例:

# Check if the conversation was too long for the context window
if response['choices'][0]['message']['finish_reason'] == "length":
    print("Error: The conversation was too long for the context window.")
    # Handle the error as needed, e.g., by truncating the conversation or asking for clarification
    handle_length_error(response)
    
# Check if the model's output included copyright material (or similar)
if response['choices'][0]['message']['finish_reason'] == "content_filter":
    print("Error: The content was filtered due to policy violations.")
    # Handle the error as needed, e.g., by modifying the request or notifying the user
    handle_content_filter_error(response)
    
# Check if the model has made a tool_call. This is the case either if the "finish_reason" is "tool_calls" or if the "finish_reason" is "stop" and our API request had forced a function call
if (response['choices'][0]['message']['finish_reason'] == "tool_calls" or 
    # This handles the edge case where if we forced the model to call one of our functions, the finish_reason will actually be "stop" instead of "tool_calls"
    (our_api_request_forced_a_tool_call and response['choices'][0]['message']['finish_reason'] == "stop")):
    # Handle tool call
    print("Model made a tool call.")
    # Your code to handle tool calls
    handle_tool_call(response)
    
# Else finish_reason is "stop", in which case the model was just responding directly to the user
elif response['choices'][0]['message']['finish_reason'] == "stop":
    # Handle the normal stop case
    print("Model responded directly to the user.")
    # Your code to handle normal responses
    handle_normal_response(response)
    
# Catch any other case, this is unexpected
else:
    print("Unexpected finish_reason:", response['choices'][0]['message']['finish_reason'])
    # Handle unexpected cases as needed
    handle_unexpected_case(response)

2. 在Tool calling中使用結構化輸出(Structured outputs)

預設情況下,當你使用Tool calling時,模型會盡可能讓輸出符合設定的格式。但在複雜的結構下,模型輸出未必符合預期。

結構化輸出(Structured outputs)是一項功能,可確保函數呼叫的模型輸出精確符合你提供的結構。設定方式是將參數strict設為True,就可以啟用函數呼叫的結構化輸出。

在我們的範例準備tools參數,其實使用的就是結構化輸出(Structured outputs)的方式,目前OpenAI結構化輸出(Structured outputs)除了用在Tool calling外,還用在呼叫API時設定期望格式的response_format參數

在response中萃取出特定格式,其實透過Tool calling也能做到,那麼要如何選擇呢?具體可以參考打造專屬ChatGPT:利用LLM進行結構化輸出(Structured Outputs)中的說明。

可能因為結構化輸出(Structured outputs)功能推出沒有太久,OpenAI在這個名詞上的定義及文件中出現的方式及位置有點讓人混淆,目前為止你可以這樣看:

OpenAI的結構化輸出(Structured outputs)

使用JSON Schema定義的結構,使用時配合設定為True的參數strict

3. Parallel tool calling

在我們的範例中,透過迴圈處理模型對於工具選擇的建議,tools中我們同步設定strict參數為True,這個組合可能導致strict未如預期運行,此時可以設定parallel_tool_calls為False,確保一次只選擇一個最符合的工具。

4. Tool Choice

預設的工具選擇方式為auto(tool_choice="auto"),除此之外還有required和none,前者強制模型選一個工具,後者則忽略工具。

如果你想指定模型選擇哪個工具可以將tool_choice設定為{"type": "function", "function": {"name": "my_function"}}

如果你的tool_choice選擇指定特定工具或是requiredfinish_reason就會是stop而不是tool_calls

5. 計費規則

當你把工具隨著API傳遞給模型時,為了描述這個工具你透過JSON Schema來定義,OpenAI對於工具的定義也是會收費的,可以透過completion.usage取得token用量,確保花費符合預期。

6. refusal

使用Tool calling時,如果發生模型拒絕回答,拒絕資訊會出現在completion.choices[0].message.refusal

7. 控制Tool的數量

雖然一次最多可以傳送120個工具,但OpenAI建議不要超過20個,在10-20個左右的工具,已經有發現明顯的準確率下降。系統如果需要大量工具時,可以考慮使用fine-tuning的功能。

E. 總結

篇文章介紹了如何利用OpenAI的Tool Calling功能來打造個人化的ChatGPT,實現更優化的資源整合與流暢的用戶互動體驗。

首先探討了科技發展帶來的轉變,強調大模型在捕捉使用者意圖及自動調用外部工具方面的能力提升,然後深入闡述Tool Calling的功能及其生命週期,包括定義工具、準備參數與工具調用。

此外,也詳細說明了注意事項,如處理finish_reason、在調用中使用結構化輸出、工具選擇策略及費用規範,並提供了一系列的實作範例來展示Tool Calling的實際應用。