2025年5月3日 星期六

OpenAI API 學習筆記 : 用 Function calling 讓模型開外掛 (四)

在前面的 Function calling 測試中, 我們以呼叫 Google 搜尋函式協助取得超出模型知識範圍以外的資料讓模型能做出正確回應, 但都只是呼叫單一個外部函式 search_google() 而已, 事實上 Function calling 機制允許呼叫多個外部函式, 因為當傳送 tools 參數給 API 時, 若模型判斷須呼叫外部函式, 則回應訊息中的 tool_calls 屬性值是一個包含所需函式物件組成之串列, 應用程式只要迭代此串列即可逐一呼叫這些函式. 

本系列全部文章索引參考 : 


首先將 API 金鑰等機敏資訊放在目前工作目錄下的環境變數檔案 .env 內, 然後用 dotenv 模組載入後用 os.environ.get() 取出來 : 

>>> from dotenv import load_dotenv   
>>> load_dotenv()     
True
>>> import os     
>>> openai_api_key=os.environ.get('OPENAI_API')      
>>> custom_search_key=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')     
>>> cx=os.environ.get('SEARCH_ENGINE_ID')      

作法參考 :


首先用一個簡化的範例來說明, 例如同時要查詢目前某地的氣溫與指定之貨幣兌換匯率, 可呼叫下面兩個外部函式來協助 :

>>> def get_weather(location):
    # 呼叫即時氣候查詢平台 API
    temp=25
    description='晴時多雲'
    return f'{location}目前氣溫為攝氏 {temp} 度, {description}.'
>>> def get_exchange_rate(base, target):
    # 呼叫即時匯率 API (原始貨幣 base 兌目標貨幣 taget)
    rate=32.1
    return f'1 {base} ≈ {rate} {target}' 

由於並非真的有去查詢即時的氣溫與匯率, 所以傳回值都是固定不變 :

>>> get_weather('高雄')   
'高雄目前氣溫為攝氏 25 度, 晴時多雲.'
>>> get_exchange_rate('美元', '台幣')     
'1 美元 ≈ 32.1 台幣'

接下來要針對這兩個外部函式撰寫 tools 字典串列, 用來描述函式的名稱, 參數, 與用途 :

>>> tools=[{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "取得指定地點的目前氣溫",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "要查詢的地點, 例如:高雄"
                    }
                },
            "required": ["location"]
            }
        }
    },
    {
    "type": "function",
    "function": {
        "name": "get_exchange_rate",
        "description": "取得匯率",
        "parameters": {
            "type": "object",
            "properties": {
                "base": {
                    "type": "string",
                    "description": "原始貨幣 (例如 : 美元)"
                    },
                "target": {
                    "type": "string",
                    "description": "目標貨幣(例如 : 台幣)"
                    }
                },
            "required": ["base", "target"]
            }
        }
    }]

此串列有兩個元素, 都是描述外部函式的字典, 函式名稱定義在 name 屬性中, 參數則定義在 parameters 的 prperties 屬性裡. 

接下來是匯入 OpenAI 模組並建立 OpenAI 物件 :

>>> from openai import OpenAI
>>> client=OpenAI(api_key=openai_api_key)    

設定模型與查詢訊息 (字典串列) :

>>> model='gpt-3.5-turbo'   
>>> messages=[
    {'role': 'system', 'content': '你是一個繁體中文AI助理'},
    {'role': 'user', 'content': '請給我目前高雄的氣溫, 以及美元兌台幣匯率'}]   

然後呼叫 OpenAI API 取得回應 : 

>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools)   

傳回值是一個 ChatCompletion 物件, 可用 rich.print() 函式來檢視 :

>>> from rich import print as pprint  
>>> pprint(reply)  
ChatCompletion(
    id='chatcmpl-BSbCpSjvnLMhZNqdb3271p5mIgVcw',
    choices=[
        Choice(
            finish_reason='tool_calls',
            index=0,
            logprobs=None,
            message=ChatCompletionMessage(
                content=None,
                refusal=None,
                role='assistant',
                audio=None,
                function_call=None,
                tool_calls=[
                    ChatCompletionMessageToolCall(
                        id='call_rzz6bdJ7m7JuVcsKdzlWVHC3',
                        function=Function(
                            arguments='{"location": "高雄"}',
                            name='get_weather'
                        ),
                        type='function'
                    ),
                    ChatCompletionMessageToolCall(
                        id='call_gM828vL1bI06oHk9bF2vRPgQ',
                        function=Function(
                            arguments='{"base": "美元", "target": "台幣"}',
                            name='get_exchange_rate'
                        ),
                        type='function'
                    )
                ],
                annotations=[]
            )
        )
    ],
    created=1746155903,
    model='gpt-3.5-turbo-0125',
    object='chat.completion',
    service_tier='default',
    system_fingerprint=None,
    usage=CompletionUsage(
        completion_tokens=54,
        prompt_tokens=185,
        total_tokens=239,
        completion_tokens_details=CompletionTokensDetails(
            accepted_prediction_tokens=0,
            audio_tokens=0,
            reasoning_tokens=0,
            rejected_prediction_tokens=0
        ),
        prompt_tokens_details=PromptTokensDetails(
            audio_tokens=0,
            cached_tokens=0
        )
    )
)

從 finish_reason='tool_calls' 可知模型判斷此 prompt 需要呼叫外部函式 (message 屬性裡的 與 content=None 表示模型本身沒有回應內容).

接下來要取出 tool_calls 串列, 用迴圈取出裡面的兩個要呼叫的外部函式名稱與參數, 先將它們打包成角色為 assistant 的回應訊息字典, 然後呼叫後取得其傳回內容, 然後將函式 id, 名稱, 傳回值等打包為角色為 tool 的傳回值訊息字典, 把這兩個訊息字典先後加入原本的訊息串列中 (注意, 回應字典須在前, 傳回值字典須在後), 向模型提出第二次請求, 它就會根據外部函式提供的資料做出回應. 

先設定兩個空串列來儲存模型之回應字典與外部函式之傳回值字典 :

>>> tools=[]    # 儲存模型回應之工具函式字典
>>> tool_messages=[]    # 儲存外部函式之傳回值字典

然後用迴圈走訪 tool_calls 屬性裡面模型建議要呼叫的全部外部函式 :

>>> tool_calls=reply.choices[0].message.tool_calls    # 外部函式物件串列
>>> for tool_call in tool_calls:
    function_name=tool_call.function.name
    tools.append({  # 儲存模型回應之工具函式字典
        'id': tool_call.id,
        'type': 'function',
        'function': {
            'name': function_name,
            'arguments': tool_call.function.arguments
            }
        })
    args=json.loads(tool_call.function.arguments)
    func=globals().get(function_name)
    if not func:  # 函式不存在 
        tool_messages.append({ 
            'role': 'tool',
            'tool_call_id': tool_call.id,
            'content': f'函式 {function_name} 不存在'
            })  
        continue
    content=func(**args)  # 呼叫外部函式
    tool_messages.append({  # 儲存外部函式傳回值
        'role': 'tool',
        'tool_call_id': tool_call.id,
        'name': function_name,
        'content': content
        })

檢視所打包的工具函式字典 :

>>> tools  
[{'id': 'call_rzz6bdJ7m7JuVcsKdzlWVHC3', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"location": "高雄"}'}}, {'id': 'call_gM828vL1bI06oHk9bF2vRPgQ', 'type': 'function', 'function': {'name': 'get_exchange_rate', 'arguments': '{"base": "美元", "target": "台幣"}'}}]

將它放入角色為 assistant 的訊息字典中 : 

>>> assistant_messages={"role": "assistant", "tool_calls": tools}    
>>> assistant_messages    
{'role': 'assistant', 'tool_calls': [{'id': 'call_rzz6bdJ7m7JuVcsKdzlWVHC3', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"location": "高雄"}'}}, {'id': 'call_gM828vL1bI06oHk9bF2vRPgQ', 'type': 'function', 'function': {'name': 'get_exchange_rate', 'arguments': '{"base": "美元", "target": "台幣"}'}}]}

先將此工具函式字典存入 messages 訊息字典串列中 :

>>> messages.append(assistant_messages)   
>>> messages    
[{'role': 'system', 'content': '你是一個繁體中文AI助理'}, {'role': 'user', 'content': '請給我目前高雄的氣溫, 以及美元兌台幣匯率'}, {'role': 'assistant', 'tool_calls': [{'id': 'call_rzz6bdJ7m7JuVcsKdzlWVHC3', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"location": "高雄"}'}}, {'id': 'call_gM828vL1bI06oHk9bF2vRPgQ', 'type': 'function', 'function': {'name': 'get_exchange_rate', 'arguments': '{"base": "美元", "target": "台幣"}'}}]}]

接下來檢視迴圈中所打包的外部工具函式傳回值字典串列 : 
 
>>> tool_messages   
[{'role': 'tool', 'tool_call_id': 'call_rzz6bdJ7m7JuVcsKdzlWVHC3', 'name': 'get_weather', 'content': '高雄目前氣溫為攝氏 25 度, 晴時多雲.'}, {'role': 'tool', 'tool_call_id': 'call_gM828vL1bI06oHk9bF2vRPgQ', 'name': 'get_exchange_rate', 'content': '1 美元 ≈ 32.1 台幣'}]

將它串接到第一次查詢的訊息字典串列後面 (兩個都是串列) : 

>>> messages += tool_messages   
>>> messages     
[{'role': 'system', 'content': '你是一個繁體中文AI助理'}, {'role': 'user', 'content': '請給我目前高雄的氣溫, 以及美元兌台幣匯率'}, {'role': 'assistant', 'tool_calls': [{'id': 'call_rzz6bdJ7m7JuVcsKdzlWVHC3', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"location": "高雄"}'}}, {'id': 'call_gM828vL1bI06oHk9bF2vRPgQ', 'type': 'function', 'function': {'name': 'get_exchange_rate', 'arguments': '{"base": "美元", "target": "台幣"}'}}]}, {'role': 'tool', 'tool_call_id': 'call_rzz6bdJ7m7JuVcsKdzlWVHC3', 'name': 'get_weather', 'content': '高雄目前氣溫為攝氏 25 度, 晴時多雲.'}, {'role': 'tool', 'tool_call_id': 'call_gM828vL1bI06oHk9bF2vRPgQ', 'name': 'get_exchange_rate', 'content': '1 美元 ≈ 32.1 台幣'}]

然後用這個附加外部工具函式查詢結果的訊息字典串列再次向模型提出請求 : 

>>> reply2=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools)   
>>> pprint(reply2)  
ChatCompletion(
    id='chatcmpl-BSo81OE6AnI1hlmkaaFnxi5yumNFp',
    choices=[
        Choice(
            finish_reason='stop',
            index=0,
            logprobs=None,
            message=ChatCompletionMessage(
                content='目前高雄的氣溫是攝氏 25 
度,晴時多雲。美元兌台幣的匯率為 1 美元約等於 32.1 台幣。',
                refusal=None,
                role='assistant',
                audio=None,
                function_call=None,
                tool_calls=None,
                annotations=[]
            )
        )
    ],
    created=1746205577,
    model='gpt-3.5-turbo-0125',
    object='chat.completion',
    service_tier='default',
    system_fingerprint=None,
    usage=CompletionUsage(
        completion_tokens=63,
        prompt_tokens=238,
        total_tokens=301,
        completion_tokens_details=CompletionTokensDetails(
            accepted_prediction_tokens=0,
            audio_tokens=0,
            reasoning_tokens=0,
            rejected_prediction_tokens=0
        ),
        prompt_tokens_details=PromptTokensDetails(
            audio_tokens=0,
            cached_tokens=0
        )
    )
)

從 finish_reason='stop' 可知模型透過包含外部函式傳回值的 messages 訊息字典串列已能生成回應 , 雖然仍傳送了 tools 參數, 但它判斷毋須再呼叫外部函式, 直接將生成的答案放在 ChatCompletionMessage 物件的 content 屬性中 : 

>>> reply2.choices[0].message.content   
'目前高雄的氣溫是攝氏 25 度,晴時多雲。美元兌台幣的匯率為 1 美元約等於 32.1 台幣。'

總結一下上面的測試, 本篇我們使用兩個虛擬的外部工具函式 get_weather() 與 get_exchange_rate() 讓模型在需要時呼叫, 我們向 API 提交的請求都會攜帶含有這兩個工具函式描述的 tools 參數, 模型會視 prompt 內容判斷這問題是否需要 call-out, 如果需要就會回應一個 finish_reason='tool_calls' 且 contemt=None 的訊息, 我們可以從 tool_calls 屬性取出模型想呼叫的各個外部函式與其參數, 應用程式就可以逐一呼叫來取得它們傳回的答案, 將這些答案提供給模型以便生成最後的回應. 

本篇重點在於如何打包第二次請求所需要的訊息, 在前面只呼叫一個外部函式的測試中, 我們是將第一次回應內容中的 ChatCompletionMessage 物件直接放進訊息串列中, 然後將外部函式傳回之結果打包成一個角色為 tool 的訊息字典也放進訊息串列中, 向模型提出第二次請求, 這種作法在命令列環境可以運行, 但在 Gradio 網頁平台會出現錯誤, 因為將 ChatCompletionMessage 物件直接放進訊息串列不符合新版 OpenAI API 規範. 上面的測試則是遵循此規範, 將 ChatCompletionMessage 物件中的全部外部函式 id, 名稱與參數等先存放到一個字典串列, 然後以 tool_calls 為鍵, 打包成一個角色為 assistant 的字典再放進訊息串列中. 

沒有留言 :