2026年5月31日 星期日

Ollama 學習筆記 : 本地模型的函式呼叫

本篇旨在測試 ollama 套件的函式呼叫功能. 函式呼叫機制是透過請求訊息中的 tools 參數告訴模型, 如果需要存取外部資料時 (call-out) 時可呼叫那些函式. Ollama 官網中的模型如果帶有 tools 標籤, 表示該模型支援函式呼叫, 例如 gemma4 與 qwen3 :




本篇旨在測試本地模型的函式呼叫. 

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


關於雲端模型函式呼叫可參考 OpenAI API 測試筆記 :


從之前的 OpenAI API 測試紀錄可知, 函式呼叫時需傳入一個 tools 參數給 API, 其值為一個描述函式呼叫介面 (函式名稱與參數結構) 的字典串列 (稱為 JSON Schema), Ollama 的 ollama 套件也是如此, 但早期它在 ollama._utils 模組中提供了一個便利的 convert_function_to_tool(func) 函式可自動去解析函式的名稱, 型別標註以及 Docstring (函式說明文件), 然後將其轉換為 Ollama 核心看得懂的 JSON Schema. 

不過自從 ollama 0.4.0 版本之後已進一步優化, 不需要手動呼叫 convert_function_to_tool(func) 函式了, 只要用標準的 Python 語法加上型別標註與 Google 風格的 Docstring 來撰寫函式, 然後將函式放進 tools 串列, ollama 套件會在幕後自動執行 convert_function_to_tool(), 毋須顯式呼叫它. 

先用 pip show 檢視目前安裝的 ollama 套件版本 :

PS C:\Users\USER> pip show ollama  
Name: ollama
Version: 0.6.2
Summary: The official Python client for Ollama.
Home-page: https://ollama.com
Author:
Author-email: hello@ollama.com
License-Expression: MIT
Location: C:\Users\USER\AppData\Local\Programs\Python\Python312\Lib\site-packages
Requires: httpx, pydantic
Required-by:

版本式最新的 0.6.2, 所以支援自動呼叫 convert_function_to_tool() 功能. 


1. 單一函式呼叫 : 

下面是透過 tools 參數指定以函式呼叫來計算房屋總價的範例 :

先匯入 ollama 套件 : 

>>> import ollama 

定義一個加上型別標註的房屋總價計算函式 calculate_house_price(), 兩個傳入參數型別都是 float, 傳回值型別也是 float, 開頭為 Docstring 函式描述 (說明傳入參數意義) : 

>>> # 加上型別與說明文字的標準 Python 函式
def calculate_house_price(ping: float, price_per_ping: float) -> float:
    """
    計算房屋總價(坪數 * 每坪單價)
    Args:
        ping: 房屋的坪數大小(單位:坪)
        price_per_ping: 每坪的單價(單位:萬元)
    """
    return round(ping * price_per_ping, 2)

注意, Docstring 在函式呼叫中的角色很重要, 可讓模型看懂函式的參數意義, 確保能精準的對齊語意. 然後在呼叫 ollama.chat 時將函式名稱 calculate_house_price 放進串列傳給 tools 參數即可, ollama 會在背後隱式地呼叫 convert_function_to_tool() 來建立函式呼叫的 JSON schema :

>>> reply=ollama.chat(
    model='gemma4:e4b',
    messages=[{'role': 'user', 'content': '幫我算 30 坪, 每坪 60 萬的房子總價'}],
    tools=[calculate_house_price] # ollama 會呼叫 convert_function_to_tool()
    )
>>> pprint(reply)  
ChatResponse(model='gemma4:e4b', created_at='2026-05-31T08:56:18.1535995Z', done=True, done_reason='stop', total_duration=8857022300, load_duration=5518332400, prompt_eval_count=138, prompt_eval_duration=70437200, eval_count=223, eval_duration=2999849600, message=Message(role='assistant', content='', thinking='The user wants to calculate the total price of a house given its size (30 ping) and the price per ping (60 million).\nThe available tool is `calculate_house_price`.\nThis tool requires two arguments: `ping` (the area in ping) and `price_per_ping` (the price per ping).\n\n1.  **Identify `ping`**: The user stated "30 坪", so `ping` = 30.\n2.  **Identify `price_per_ping`**: The user stated "每坪 60 萬". The tool description for `price_per_ping` specifies the unit is "萬元" (ten thousand NT dollars). Since 60萬 = 60 (in units of 萬元), `price_per_ping` = 60.\n\nI should call the `calculate_house_price` tool with these values.', images=None, tool_name=None, tool_calls=[ToolCall(function=Function(name='calculate_house_price', arguments={'ping': 30, 'price_per_ping': 60}))]), logprobs=None)

觀察 API 傳回的 ChatResponse 物件, 關鍵訊息放在 message 屬性中, 回應內容 content 為空字串,  這是因為模型認為現在的第一要務是去叫 Python 函式做計算而非聊天, 所以它沒有吐出任何回應. thinking 參數則紀錄了 gemma4 模型的思考鏈推理過程, 它理解使用者想計算房屋總價且手邊有 calculate_house_price 工具可用, 然後核對 Docstring 的兩個參數 (坪數與單價), 得到思考結論 :  應該用這組參數去呼叫工具. 然後它把函式打包成 ToolCall 物件, 而且參數都依照函式參數的型別標示轉成數值 (避免了型別錯誤).  

所以, 函式呼叫的第一階段目標就是取得回應訊息中的 message 鍵的 tool_calls 鍵 : 

>>> print(reply.message.tool_calls)  
[ToolCall(function=Function(name='calculate_house_price', arguments={'ping': 30, 'price_per_ping': 60}))]

第二階段應用程式要根據 tool_calls 鍵之值來研判是否要呼叫函式, 要的話就從裡面取出要執行的函式名稱與參數後呼叫它 : 

>>> # 判斷是否有工具呼叫需求
if reply.message.tool_calls:
    for tool in reply.message.tool_calls:
        func_name=tool.function.name       # 直接用 . 欄位名稱取得函式名稱
        func_args=tool.function.arguments  # 取得參數字典 {'ping': 30, 'price_per_ping': 60}        
        print(f"命令確定!即將執行地端函式: {func_name}")        
        if func_name == "calculate_house_price":
            # 直接用 ** 語法解包傳入參數執行函式呼叫
            final_ans=calculate_house_price(**func_args)
            print(f"實體計算答案: {final_ans} 萬元")

執行結果如下 :
            
命令確定!即將執行地端函式: calculate_house_price
實體計算答案: 1800 萬元

完整程式碼如下 :

# ollama_function_call_1.py
import ollama

# 加上型別與說明文字的標準 Python 函式
def calculate_house_price(ping: float, price_per_ping: float) -> float:
    """
    計算房屋總價(坪數 * 每坪單價)
    Args:
        ping: 房屋的坪數大小(單位:坪)
        price_per_ping: 每坪的單價(單位:萬元)
    """
    return round(ping * price_per_ping, 2)

# 呼叫 ollama.chat() 傳入 tools 參數 
reply=ollama.chat(
    model='gemma4:e4b',
    messages=[{'role': 'user', 'content': '幫我算 30 坪, 每坪 60 萬的房子總價'}],
    tools=[calculate_house_price] # ollama 會呼叫 convert_function_to_tool()
    )
# 輸出工具呼叫需求
print(reply.message.tool_calls)
# 判斷是否有工具呼叫需求
if reply.message.tool_calls:
    for tool in reply.message.tool_calls:
        func_name=tool.function.name       # 直接用 . 欄位名稱取得函式名稱
        func_args=tool.function.arguments  # 取得參數字典 {'ping': 30, 'price_per_ping': 60}        
        print(f"命令確定!即將執行地端函式: {func_name}")        
        if func_name == "calculate_house_price":
            # 直接用 ** 語法解包傳入參數執行函式呼叫
            final_ans=calculate_house_price(**func_args)
            print(f"實體計算答案: {final_ans} 萬元")

此範例因為只有單一個函式呼叫, 所以只需要用一個最簡單的條件判斷式即可, 不需要建立函式對照表字典. 


2. 多函式呼叫 : 

下面是多函式呼叫的範例 :

# ollama_function_call_2.py
import ollama

def add_two(a: int, b: int) -> int:
    """將兩數相加"""
    return int(a) + int(b)

def multiply_two(a: int, b: int) -> int:
    """將兩數相乘"""
    return int(a) * int(b)

available_funcs={"add_two": add_two, "multiply_two": multiply_two}
messages=[{"role": "user", "content": "請計算 123 加 456 是多少? 算完之後再將結果乘以 5."}]
print("正在將任務送給 Ollama...")

# 使用 while 迴圈以應付 AI 的多步思考
while True:
    reply=ollama.chat(
        model='gemma4:e4b',
        messages=messages,
        tools=[add_two, multiply_two] 
        )
    
    # 每次都必須把 AI 的回應(不管是想叫工具還是想說話)塞進歷史紀錄
    messages.append(reply['message'])
    
    # 檢查 AI 這輪是不是又想呼叫工具
    if reply['message'].get('tool_calls'):
        print("\n偵測到 AI 決定調用工具!")
        
        for tool in reply['message']['tool_calls']:
            func_name=tool['function']['name']
            func_args=tool['function']['arguments']
            print(f"-> AI 選擇了函式: {func_name} | 參數: {func_args}")
            
            if func_name in available_funcs:
                real_result=available_funcs[func_name](**func_args)
                print(f"[Python 執行結果]: {real_result}")
                
                # 回傳工具呼叫結果,將結果存入記憶送回大腦,讓 AI 決定下一步
                messages.append({
                    'role': 'tool',
                    'name': func_name,
                    'content': str(real_result)
                    })
        # 執行完本次工具後進入下一次迴圈,讓 AI 看看這個結果滿不滿意
        continue 
        
    else:
        # AI 不再需要呼叫任何工具,回應最終答案
        print("\n最終答案整合完畢!")
        print(f"AI 最終回覆: {reply['message']['content']}")
        break # 跳出迴圈

這個程式是一個典型的本地端 AI 代理透過一個 while 迴圈扮演 AI 大腦與地端 Python 函式之間的傳話筒與執行官的範例, 讓不擅長複雜數學的模型也能透過借用工具精準完成連鎖計算任務, 實現了多輪自動化工具呼叫機制. 

此處的提示詞 "請計算 123 加 456 是多少? 算完之後再將結果乘以 5" 是一個需要多步驟進行連鎖運算的複雜指令, 此程式能讓 AI 自動拆解步驟並連續調用本地的 Python 函式來解決問題. 此程式先定義了兩個基礎數學函式 add_two() 與 multiply_two(), 並加上了型別標註與 Docstring 說明, 讓 ollama 套件能自動將其轉換為 AI 看得懂的工具規格. 然後利用 available_funcs 字典作為路由對照表, 將 AI 決定的函式名稱字串動態映射到真正的 Python 函式. 

while 迴圈為此程式的核心-多輪自動化控制, 因為使用者的問題包含兩個步驟 (先加後乘), AI 無法一次給出最終答案, 所以必須靠迴圈來控制 :
  • 第一輪迴圈 :
    AI 辨識出需要加法, 吐出 add_two 請求, 函式執行完得到 579, 以 role: 'tool' 角色將結果存入歷史紀錄並進到下一輪. 
  • 第二輪迴圈 :
    AI 拿到 579 後發現還有乘法任務, 再度吐出 multiply_two 請求, 函式執行完得到 2895 並將結果存入歷史紀錄並進到下一輪. 
  • 第三輪迴圈 :
    AI 發現所有運算皆已完成不再觸發 tool_calls, 於是轉入 else 區塊生成最後回應結束程式. 
程式在迴圈中不斷執行 messages.append() 將 AI 的思考決定與本地 Python 函式執行結果存入對話紀錄, 這種滾動式記憶是確保 AI 大腦不會失憶, 能順利執行下一個正確步驟的關鍵. 

執行結果如下 :

>>> %Run ollama_function_call_2.py  
正在將任務送給 Ollama...

偵測到 AI 決定調用工具!
-> AI 選擇了函式: add_two | 參數: {'a': 123, 'b': 456}
[Python 執行結果]: 579

偵測到 AI 決定調用工具!
-> AI 選擇了函式: multiply_two | 參數: {'a': 579, 'b': 5}
[Python 執行結果]: 2895

最終答案整合完畢!
AI 最終回覆: 123 加 456 的結果是 579。
再將 579 乘以 5,結果是 2895。

與 OpenAI API 的函式呼叫比起來, 不需要手刻函式呼叫的 JSON Schema 真是省事很多, 程式碼也變得非常簡潔. 

沒有留言 :