2025年5月4日 星期日

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

在前一篇測試中, 為了聚焦在如何讓模型呼叫兩個外掛函式 (查詢氣溫與外幣兌台幣匯率), 我們使用兩個虛擬的外部工具函式來簡化流程. 本篇則是要來實作這兩個工具函式 get_weather() 與 get_exchange_rate(). 

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


以下之測試除了用到 OpenAI API 外, 還需要氣候查詢網站 OpenWeatherMap 的 API key, 這兩個金鑰都放在目前工作目錄下的系統檔 .env 裡面, 然後利用 dotenv 模組將金鑰從裡面讀取出來 : 

>>> from dotenv import dotenv_values   
>>> config=dotenv_values('.env')     
>>> openai_api_key=config.get('OPENAI_API')   
>>> weather_api_key=config.get('OPEN_WEATHER_API_KEY')   


關於如何從 OpenWeatherMap 網站透過 API 取得氣象資料參考 : 


簡而言之, OpenWeatherMap 提供 URL API 讓使用者以 GET 方法取得指定城市之氣象資料 (氣溫, 氣壓, 濕度等), 此處我們只需要氣溫而已, 函式 get_weather() 實作如下 :

>>> def get_weather(api_key, city, country=''):
    # 呼叫 OpenWeatherMap API
    url=f'https://api.openweathermap.org/data/2.5/weather?q={city},{country}&units=metric&lang=zh_tw&appid={api_key}'
    r=requests.get(url)
    data=r.json()
    temp=data['main']['temp']
    description=data['weather'][0]['description']
    return f'{city} 目前氣溫為攝氏 {temp} 度, {description}.'

OpenWeatherMap 的 API 要求至少傳入兩個參數 : api_key 與 city (城市的英文名), 但如果 city 有重複時則必須傳入 counry 代碼才能區別, 國家代碼可在下列網址查到 :


注意, city 一定要用英文, 例如 :

>>> get_weather(weather_api_key, 'Kaohsiung')   
'Kaohsiung 目前氣溫為攝氏 27.97 度, 多雲.'
>>> get_weather(weather_api_key, 'Tokyo')     
'Tokyo 目前氣溫為攝氏 16.63 度, 晴.'

其次, 匯率查詢可用爬蟲去爬台銀牌告利率網頁 :


此爬蟲需要用到 BeautifulSoup 套件 : 

>>> from bs4 import BeautifulSoup       

做法參考 : 


函式 get_weather() 實作如下 :

>>> def get_exchange_rate(base, target):
    # 爬取台銀牌告匯率 (原始貨幣 base 兌目標貨幣 taget)
    url='https://rate.bot.com.tw/xrt?Lang=zh-TW'
    response=requests.get(url)
    soup=BeautifulSoup(response.text, 'lxml')
    currency_rate=dict()
    table=soup.find('table', {'title': '牌告匯率'})
    for tr in table.find('tbody').find_all('tr'):
        tds=tr.find_all('td')
        the_currency=tds[0].find('div', {'class': 'visible-phone'}).text.strip()
        the_rate=tds[2].text
        currency_rate[the_currency]=the_rate
    # 沒找到則回傳 None
    rate=next((v for k, v in currency_rate.items() if base in k), None)
    return f'1 {base} ≈ {rate} {target}'

此函式會將爬取的匯率資料存入 currency_rate 字典中, 然後用 tuple 生成式走訪字典項目時比對鍵裡面是否包含原始貨幣 base (外幣, 例如美金或 USD, 注意, 要用 '美金' 而非 '美元' 才能查到 USD), 找到的話就傳回其值給 rate 變數, 

例如 :

>>> get_exchange_rate('USD', 'TWD')   
'1 USD ≈ 31.07 TWD'
>>> get_exchange_rate('美金', 'TWD')   
'1 美金 ≈ 31.07 TWD'
>>> get_exchange_rate('美金', '台幣')   
'1 美金 ≈ 31.07 台幣'
>>> get_exchange_rate('美元', '台幣')      
'1 美元 ≈ None 台幣'

接下來是根據上面兩個實作的外部工具函式改寫 tools 參數內容 : 

>>> tools=[{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "取得指定地點的目前氣溫",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "要查詢的城市, 例如:kaohsiung"
                    },
                "country": {
                    "type": "string",
                    "description": "要查詢的國家, 例如:Taiwan"
                    },
                "api_key": {
                    "type": "string",
                    "description": "API 金鑰"
                    }               
                },
            "required": ["location", "country", "api_key"]
            }
        }
    },
    {
    "type": "function",
    "function": {
        "name": "get_exchange_rate",
        "description": "取得匯率",
        "parameters": {
            "type": "object",
            "properties": {
                "base": {
                    "type": "string",
                    "description": "原始貨幣 (例如 : 美金)"
                    },
                "target": {
                    "type": "string",
                    "description": "目標貨幣(例如 : 台幣)"
                    }
                },
            "required": ["base", "target"]
            }
        }
    }]

然後匯入 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)   

用 rich.print() 函式來檢視傳回的 ChatCompletion 物件 reply :

>>> from rich import print as pprint  
>>> pprint(reply)  
>>> pprint(reply)
ChatCompletion(
    id='chatcmpl-BT9kyq31rR8GzfHwj87EtHjkWSKuS',
    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_8cvmpk7dmyIEk3816Pobnbvk',
                        function=Function(
                            arguments='{"city": "kaohsiung", "country": 
"Taiwan", "api_key": "dummy_key"}',
                            name='get_weather'
                        ),
                        type='function'
                    ),
                    ChatCompletionMessageToolCall(
                        id='call_OxkZFvcdw0iEjFMcqWs0SxAN',
                        function=Function(
                            arguments='{"base": "USD", "target": "TWD"}',
                            name='get_exchange_rate'
                        ),
                        type='function'
                    )
                ],
                annotations=[]
            )
        )
    ],
    created=1746288716,
    model='gpt-3.5-turbo-0125',
    object='chat.completion',
    service_tier='default',
    system_fingerprint=None,
    usage=CompletionUsage(
        completion_tokens=64,
        prompt_tokens=220,
        total_tokens=284,
        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
        )
    )
)

請注意, 此處模型回應的 get_weather() 函式的 api_key 參數值為 'dummy_key', 實際查詢時要更換為我們自己向 OpenWeatherMap 申請的金鑰.

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

>>> 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)
    if function_name == 'get_weather': 
        args['api_key']=weather_api_key
    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
        })

請注意, 與前一篇使用虛擬的外部工具函式不同的是, 此處查詢 get_weather() 時需要真正的 OpenWeatherMap 金鑰, 所以加入 if 語句判斷是否含是名稱為 get_weather, 是的話就更改 args 字典中 api_key 鍵之值, 這樣在後面呼叫函式 func(**args) 時才會得到正確的回傳資料. 

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

>>> tools
[{'id': 'call_8cvmpk7dmyIEk3816Pobnbvk', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"city": "kaohsiung", "country": "Taiwan", "api_key": "dummy_key"}'}}, {'id': 'call_OxkZFvcdw0iEjFMcqWs0SxAN', 'type': 'function', 'function': {'name': 'get_exchange_rate', 'arguments': '{"base": "USD", "target": "TWD"}'}}]

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

>>> assistant_messages={"role": "assistant", "tool_calls": tools}   
>>> assistant_messages   
{'role': 'assistant', 'tool_calls': [{'id': 'call_8cvmpk7dmyIEk3816Pobnbvk', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"city": "kaohsiung", "country": "Taiwan", "api_key": "dummy_key"}'}}, {'id': 'call_OxkZFvcdw0iEjFMcqWs0SxAN', 'type': 'function', 'function': {'name': 'get_exchange_rate', 'arguments': '{"base": "USD", "target": "TWD"}'}}]}

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

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

回頭檢視迴圈中所打包的外部工具函式傳回值字典串列 : 

>>> tool_messages
[{'role': 'tool', 'tool_call_id': 'call_8cvmpk7dmyIEk3816Pobnbvk', 'name': 'get_weather', 'content': 'kaohsiung 目前氣溫為攝氏 29.97 度, 多雲.'}, {'role': 'tool', 'tool_call_id': 'call_OxkZFvcdw0iEjFMcqWs0SxAN', 'name': 'get_exchange_rate', 'content': '1 USD ≈ 31.07 TWD'}]

將它串接到第一次查詢的訊息字典串列後面 : 

>>> messages += tool_messages   
>>> messages   
[{'role': 'system', 'content': '你是一個繁體中文AI助理'}, {'role': 'user', 'content': '請給我目前高雄的氣溫, 以及美元兌台幣匯率'}, {'role': 'assistant', 'tool_calls': [{'id': 'call_8cvmpk7dmyIEk3816Pobnbvk', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{"city": "kaohsiung", "country": "Taiwan", "api_key": "dummy_key"}'}}, {'id': 'call_OxkZFvcdw0iEjFMcqWs0SxAN', 'type': 'function', 'function': {'name': 'get_exchange_rate', 'arguments': '{"base": "USD", "target": "TWD"}'}}]}, {'role': 'tool', 'tool_call_id': 'call_8cvmpk7dmyIEk3816Pobnbvk', 'name': 'get_weather', 'content': 'kaohsiung 目前氣溫為攝氏 29.97 度, 多雲.'}, {'role': 'tool', 'tool_call_id': 'call_OxkZFvcdw0iEjFMcqWs0SxAN', 'name': 'get_exchange_rate', 'content': '1 USD ≈ 31.07 TWD'}]

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

>>> reply2=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools)   
>>> pprint(reply2)  
ChatCompletion(
    id='chatcmpl-BTHcO9j5hgpsKIdWUXhkCmL2Xs0ra',
    choices=[
        Choice(
            finish_reason='stop',
            index=0,
            logprobs=None,
            message=ChatCompletionMessage(
                content='目前高雄的氣溫為攝氏29.97度,多雲。美元
兌台幣的匯率為1美元約等於31.07台幣。',
                refusal=None,
                role='assistant',
                audio=None,
                function_call=None,
                tool_calls=None,
                annotations=[]
            )
        )
    ],
    created=1746318936,
    model='gpt-3.5-turbo-0125',
    object='chat.completion',
    service_tier='default',
    system_fingerprint=None,
    usage=CompletionUsage(
        completion_tokens=57,
        prompt_tokens=225,
        total_tokens=282,
        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' 可知模型判斷毋須再呼叫外部函式, 直接將生成的答案放在 ChatCompletionMessage 物件的 content 屬性中 : 

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

以上測試結果顯示, 透過兩個外部工具函式協助, gpt-3.5-turbo 模型也能回應即時的氣溫與匯率資料. 做完 Function calling 實驗檢查一下最近 API 用量, 居然只花了 0.11 美元, 四塊台幣不到, 可見 gpt-3.5-turbo 有夠便宜 :


檢查儲值餘額還剩下 9.63 美元 : 





也就是說從去年儲值 10 美元至今只花了 0.37 美元, 大約是 12 塊台幣. 不過等測試生圖要用到 Dalle 模型時應該就會花比較兇了. 

沒有留言 :