🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

前回「MCP根本上不需要」的人,察覺到了更前面的問題

前幾天,我寫了這篇文章。

「我可以不再建立MCP伺服器了嗎?」〜為什麼CLI壓倒MCP的真正理由〜
連結

我用熱情洋溢的筆觸寫道:「MCP是浪費Token,而CLI效率高達35倍!」,自信滿滿地發佈了。

……但在按下發佈鍵的瞬間,我察覺到了一個問題。

「等等,無論是MCP還是CLI,只要LLM在『呼叫工具』的部分有問題,那一切根本都不成立啊。」

MCP與CLI的討論是建立在「工具呼叫正常運作的前提」之上。然而,卻沒有人在本地LLM上驗證過這個 前提

Claude和GPT-4o對於工具呼叫來說是100%的成功率,這是理所當然的。但是,如果是在自己的PC上免費運行的本地LLM呢?在收費為零、數據不外傳的Ollama環境中,是否也能正常運作?

提出MCP不需要論的我,卻忽視了這更根本的問題。真是令人羞愧。

那我就轉百次來測量吧。

這樣開始的實驗結果是「87%」。看到這個數字,你會認為「竟然這麼高」還是「在實際情況下根本無法使用」——

隨著閱讀的深入,這種印象可能會發生變化。


我會先告訴你結論

首先是數字。

benchmark_bar_chart.png

指標 結果
工具呼叫成功率 87%(87 / 100)
JSON解析成功率 87%(87 / 100)
平均響應時間 1.19秒
最小 / 最大 0.59秒 / 2.86秒

benchmark_boxplot.png

中位數約為 1.0秒。幾乎所有請求都在 0.7至1.5秒 之間。對於3B參數的本地模型來說,這算是相當快的。

到這邊,你可能會覺得「哼,87%嘛」。但接下來,當我一一檢視成功的87個JSON時,卻見到了意外的世界

在此之前,為了那些「Ollama到底是什麼?」「Function Calling又是什麼?」的朋友們,我來簡要解釋一下(已知的朋友可以跳到驗證的全貌)。


Ollama 到底是什麼?

Ollama 是一種工具,可以讓你在自己的PC上只需一行命令運行LLM(大型語言模型)

brew install ollama     # 安裝
ollama pull llama3.2    # 下載模型(約2GB)
ollama serve            # 啟動伺服器 → 監聽於localhost:11434

只要這麼簡單,你的Mac(或Linux)就變成了AI伺服器

觀點 ChatGPT API Ollama
費用 按需計費 完全免費
數據 發送至OpenAI 不會外流
網路 需要 可離線
速度 依賴於機器性能
模型 GPT-4o 等 Llama, DeepSeek, Qwen 等

而且,Ollama還擁有與OpenAI兼容的API(/v1/chat/completions),因此openai庫的代碼幾乎可以無縫運行。只需將現有代碼的base_url改為localhost:11434/v1即可。

當前有關MCP和CLI的激烈討論,但不論如何 LLM是否能正確呼叫工具(= Function Calling的可靠性)始終是根本問題。即使是CLI,LLM是否能用正確的JSON傳遞參數也是至關重要的。本篇文章就這個問題進行了在本地LLM上的實測


Function Calling是什麼?——「說謊的天才」如何變成「能幹的指揮者」

LLM有一個致命的弱點,就是喜歡裝懂

👤「東京今天的天氣如何?」
🤖「東京今天晴天,氣溫24℃!」
👤「哦,謝謝」
👤(......現在外面正在下雨)

LLM並不連接互聯網,也沒有即時信息,但一旦被問到就會隨口而出一個看似正確的回答。這就是所謂的「幻覺」。

Function Calling = 「別自己回答,問專家」的方式

這就是Function Calling的用途。我們告訴LLM:

「你不需要知道天氣,但你需要決定問誰。」

這樣LLM的行為會變成這樣:

👤「東京今天的天氣如何?」
🤖「我不懂天氣,但如果你將「東京」這個參數傳遞給get_current_weather這個函數,便會知道。」
📞 → 呼叫天氣API → 獲得「22℃,晴天」這一事實
🤖「東京是22℃,晴天!」(← 現在是真正的答案)

一個說謊的天才,進化成為「能夠打電話給合適專家的有效指揮者」。這就是Function Calling的本質。

換句話說,這是AI代理的心臟

不论是MCP,CLI工具整合,還是Cursor的工具執行——背後的運作原理都是這個Function Calling。LLM會回傳「應該用什麼引數來呼叫哪個函數」的JSON。如果這個部份無法正常運行,AI代理就無法運作。

此次的驗證,就是測量本地LLM在100次中能正確呼叫「電話」的次數


驗證的全貌

架構

驗證流程

項目 內容
機器 MacBook(Apple Silicon)
Ollama v0.17.5
模型 llama3.2(3B,約 2GB)
提示詞 「請告訴我東京今天的天氣」(每次一樣)
工具定義 get_current_weather(location, unit)
嘗試次數 100次

工具定義只有一個簡單的天氣獲取函數。如果工具更複雜,失敗的可能性會更高,但我們先在最基本的情況下進行測試。

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_current_weather",
        "description": "獲取指定位置的當前天氣",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "城市名(例:東京、大阪)",
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "溫度單位",
                },
            },
        },
        "required": ["location"],
    },
}]

深入挖掘①:解剖失敗的13次

成功率87%已經知道了。那麼其餘的13%發生了什麼? 不查看失敗的「質量」,就無法採取對策。

最大的發現:失敗的方式與預想不同

如實說,我原本預測會是這樣:

  • 「會呼叫工具但JSON出錯」的情況會很多
  • json.loads()崩潰的案例會是主要的失敗原因

結果卻是相反的。

所有的13次都是「沒有使用工具,而是用文字回應」的情況。JSON解析錯誤為0。

也就是說——只要有呼叫工具,JSON就是100%有效。

「不是JSON的結構報錯,而是「使用/不使用工具的判斷出錯」是瓶頸。這是一個重要的發現,使得對策的方向完全改變。

失敗時,模型返回了什麼?

{
  "name": "get_current_weather",
  "parameters": {
    "location": {"type": "string", "description": "東京"},
    "unit": {"type": "string", "description": "celsius"}
  }
}

這看起來似乎很正常。但這並不是呼叫工具而是重複工具定義

「呼叫函數」與「解釋函數的規範」混淆了。3B參數的小模型,在instruction following中迷路的瞬間彰顯出來。

失敗在什麼時候發生?

試驗  1: ✅    試驗  2: ❌    試驗  3: ✅    試驗  4: ❌
試驗  5: ✅    試驗  6: ❌    試驗  7: ❌    試驗  8: ✅
                    ↑ 失敗集中在前期 ↑
...
試驗 86: ❌    試驗 87: ✅    ...
試驗 97: ✅    試驗 98: ✅    試驗 99: ✅    試驗100: ✅
                    ↑ 後期穩定 ↑

前期(試驗1〜14)共6次失敗。後50次的失敗僅3次。

Ollama的模型加載剛開始推理不穩定,隨著快取的溫暖變得穩定——這裡建立了一個假設。如果在實際應用中,建議加入一次暖身的虛擬請求


深入挖掘②:打開「成功」的內容後卻是混亂

到目前為止的討論表明「失敗是因為不使用工具,JSON不出錯」,那麼成功的87次的JSON,真的能正常使用嗎?

當我一一檢查內容時,雖然提示詞和工具定義完全相同,但輸出每次都不同的現實浮現出來。

模式A:完美(理想狀態)

{"location": "東京", "unit": "celsius"}

簡單、正確、範例。整體約一半都是這種情況。

模式B:架構混入引數中

{
  "location": {"description": "東京", "type": "string"},
  "unit": "celsius"
}

location中不是入出字串而是物件。還將工具定義中的descriptiontype直接混進值中——本地LLM獨有的「架構污染」json.loads()能通過,但在應用端使用result["location"]時會因為型別錯誤而崩潰,這是令人厭煩的模式。

模式C:東京變成中文

{"location": "东京", "unit": "celsius"}

東京(日文)→ 东京(簡體中文)。在100次中發生2次。Llama 3.2的多語言訓練數據開始浮現。

模式D:日中韓三語混雜的輸出

{
  "location": {"description": "都市名(例: 東京, 大阪)", "type": "string"},
  "unit": {"description": "溫度的單位", "type": "string"}
}

其中도시是韓文的「城市」。在日文提示下,日語、中文、韓文的內容同時出現在一個JSON中,這是非常奇特的輸出。3B參數的模型是否在CJK語言的邊界上存在模糊不清的情況。

引數質量的分布

引數品質的分布

結論:json.loads()能通過 ≠ 應用上運行正常。使用Pydantic等進行的型別驗證是必須的。


若要實用化,這樣做

經過以上的分析,失敗的原因與成功的陷阱已經明朗了。對於這些問題,我提出四個具體的解決方案。

1. 透過重試提高成功率

失敗率13%。如果重試兩次,理論上的失敗率為0.13 × 0.13 = 1.7%。可以有效地提升到超過98%

2. 透過Pydantic進行驗證

from pydantic import BaseModel

class WeatherArgs(BaseModel):
    location: str
    unit: str = "celsius"

parsed = WeatherArgs(**json.loads(arguments))

架構混入的情況可以僅此一項排除。

3. 加入暖身請求

若前期失敗集中,則在實際請求前先發送一次虛擬請求。

4. 嘗試使用tool_choice="required"

這次我以auto進行測量。如果指定required,則會消除「不使用工具」的選擇,因此可能會提高成功率(作為下一次驗證主題)。


完整腳本(可直接拷貝運行)

設置

# 安裝Ollama(macOS)
brew install ollama

# 下載模型(約2GB)
ollama pull llama3.2

# 啟動Ollama伺服器
ollama serve

# Python環境
python3 -m venv .venv && source .venv/bin/activate
pip install openai matplotlib

基準測試腳本

<details>
<summary>📄 ollama_fc_benchmark.py(點擊展開)</summary>

#!/usr/bin/env python3
"""
Ollama本地LLM功能調用基準測試
pip install openai matplotlib
"""

import csv
import json
import sys
import time
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import rcParams
from openai import OpenAI, APIConnectionError, APITimeoutError, APIStatusError

# ── 設定(此處可改變以測試其他模型)──
MODEL_NAME = "llama3.2"
BASE_URL = "http://localhost:11434/v1"
API_KEY = "ollama"
NUM_TRIALS = 100
REQUEST_TIMEOUT = 120

USER_PROMPT = "請告訴我東京今天的天氣"

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_current_weather",
        "description": "獲取指定位置的當前天氣",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "城市名(例如:東京、大阪)",
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "溫度單位",
                },
            },
        },
        "required": ["location"],
    },
}]

OUTPUT_DIR = Path(".")
CSV_PATH = OUTPUT_DIR / "ollama_benchmark_results.csv"
BAR_CHART_PATH = OUTPUT_DIR / "benchmark_bar_chart.png"
BOXPLOT_PATH = OUTPUT_DIR / "benchmark_boxplot.png"

def run_single_trial(client: OpenAI, trial_no: int) -> dict:
    result = {
        "trial": trial_no,
        "response_time_sec": 0.0,
        "tool_call_success": False,
        "json_parse_success": False,
        "function_name": "",
        "raw_arguments": "",
        "parsed_arguments": "",
        "error": "",
    }
    start = time.perf_counter()
    try:
        response = client.chat.completions.create(
            model=MODEL_NAME,
            messages=[{"role": "user", "content": USER_PROMPT}],
            tools=TOOLS,
            tool_choice="auto",
            timeout=REQUEST_TIMEOUT,
        )
    except (APIConnectionError, APITimeoutError, APIStatusError) as exc:
        result["response_time_sec"] = round(time.perf_counter() - start, 3)
        result["error"] = f"{type(exc).__name__}: {exc}"
        return result
    except Exception as exc:
        result["response_time_sec"] = round(time.perf_counter() - start, 3)
        result["error"] = f"Unexpected: {type(exc).__name__}: {exc}"
        return result

    result["response_time_sec"] = round(time.perf_counter() - start, 3)
    message = response.choices[0].message if response.choices else None
    if message is None:
        result["error"] = "Empty response (no choices)"
        return result
    if not message.tool_calls:
        result["raw_arguments"] = (message.content or "")[:500]
        result["error"] = "No tool_calls in response"
        return result

    tc = message.tool_calls[0]
    result["tool_call_success"] = True
    result["function_name"] = tc.function.name or ""
    result["raw_arguments"] = tc.function.arguments or ""
    try:
        parsed = json.loads(tc.function.arguments)
        result["json_parse_success"] = True
        result["parsed_arguments"] = json.dumps(parsed, ensure_ascii=False)
    except (json.JSONDecodeError, TypeError) as exc:
        result["error"] = f"JSON parse error: {exc}"
    return result

CSV_FIELDS = [
    "trial", "response_time_sec", "tool_call_success",
    "json_parse_success", "function_name", "raw_arguments",
    "parsed_arguments", "error",
]

def save_csv(results):
    with open(CSV_PATH, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=CSV_FIELDS)
        writer.writeheader()
        writer.writerows(results)

def setup_matplotlib_fonts():
    candidates = ["Hiragino Sans", "Hiragino Kaku Gothic Pro",
                  "Yu Gothic", "Meiryo", "Noto Sans CJK JP", "IPAexGothic"]
    from matplotlib.font_manager import fontManager
    available = {f.name for f in fontManager.ttflist}
    for name in candidates:
        if name in available:
            rcParams["font.family"] = name
            break
    else:
        rcParams["font.family"] = "sans-serif"
    rcParams["axes.unicode_minus"] = False

def generate_bar_chart(tc_rate, json_rate):
    fig, ax = plt.subplots(figsize=(7, 5))
    bars = ax.bar(["工具呼叫成功", "JSON解析成功"],
                   [tc_rate, json_rate], color=["#4C72B0", "#55A868"],
                   width=0.5, edgecolor="white")
    for bar, val in zip(bars, [tc_rate, json_rate]):
        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1.5,
                f"{val:.1f}%", ha="center", va="bottom", fontsize=14, fontweight="bold")
    ax.set_ylim(0, 115)
    ax.set_ylabel("成功率 (%)")
    ax.set_title(f"Ollama FC基準測試 — {MODEL_NAME}({NUM_TRIALS}次測試)")
    ax.spines[["top", "right"]].set_visible(False)
    ax.yaxis.grid(True, alpha=0.3)
    fig.tight_layout()
    fig.savefig(BAR_CHART_PATH, dpi=150)
    plt.close(fig)

def generate_boxplot(times):
    fig, ax = plt.subplots(figsize=(6, 5))
    ax.boxplot(times, vert=True, patch_artist=True,
               boxprops=dict(facecolor="#4C72B0", alpha=0.6),
               medianprops=dict(color="orange", linewidth=2))
    ax.set_xticklabels([MODEL_NAME])
    ax.set_ylabel("響應時間 (秒)")
    ax.set_title(f"響應時間分布({NUM_TRIALS}次測試)")
    ax.spines[["top", "right"]].set_visible(False)
    ax.yaxis.grid(True, alpha=0.3)
    fig.tight_layout()
    fig.savefig(BOXPLOT_PATH, dpi=150)
    plt.close(fig)

def main():
    print(f"Ollama FC基準測試 — {MODEL_NAME} x {NUM_TRIALS}次測試")
    client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
    try:
        client.models.list()
    except Exception as exc:
        print(f"無法連接Ollama:{exc}")
        sys.exit(1)

    results = []
    for i in range(1, NUM_TRIALS + 1):
        result = run_single_trial(client, i)
        results.append(result)
        s = "✓" if result["tool_call_success"] else "✗"
        j = "✓" if result["json_parse_success"] else "✗"
        print(f"  [{i:3d}/{NUM_TRIALS}] TC={s} JSON={j} " +
              f"time={result['response_time_sec']:.2f}s" +
              (f"  {result['error'][:50]}" if result['error'] else ""))

    tc = sum(1 for r in results if r["tool_call_success"])
    jp = sum(1 for r in results if r["json_parse_success"])
    times = [r["response_time_sec"] for r in results]
    print(f"\n工具呼叫:{tc/NUM_TRIALS*100:.1f}%  JSON:{jp/NUM_TRIALS*100:.1f}%")
    print(f"  平均:{sum(times)/len(times):.2f}s")

    save_csv(results)
    setup_matplotlib_fonts()
    generate_bar_chart(tc / NUM_TRIALS * 100, jp / NUM_TRIALS * 100)
    generate_boxplot(times)
    print("完成。")

if __name__ == "__main__":
    main()

</details>

執行

python ollama_fc_benchmark.py

只需將MODEL_NAME更改為 "deepseek-r1:8b""qwen2.5",便能迅速測試其他模型。


總結

觀點 評價
成功率87% 對於原型或個人開發來說足夠實用
JSON在TC成功時100%有效 並不是語法錯誤,而是「呼叫/不呼叫」的判斷是瓶頸
參數的質量 存在架構混入及多語言共存 → 型別驗證必需
速度1.19秒/次 作為本地3B模型,速度相當快
穩定性 前期失敗集中的情形 → 加入暖身請求有效
加上重試 重試兩次理論上可提高到超過98%

接下來想做的事情

  • DeepSeek / Qwen 2.5 / Gemma的成功率比較
  • 使用tool_choice="required"能否提高成功率?
  • 日本語對英語提示的差異
  • 與MCP伺服器整合的實測(CLI是否能勝出也想進行驗證)

結語

實話說,在實驗之前我覺得「在本地LLM上進行Function Calling根本不可能」。

100次的數據很好地擊破了這一先入為主的觀念。87%能運作。JSON不會損壞。但是「成功的JSON內容卻是混亂的」,這是實際運行後才會發現的現實

0元,數據流出為零,假如加上重試則可達98%的成功率。在這樣的條件下,它作為個人開發或原型製作的工具是完全足夠的。

儘管有「MCP根本不需要,CLI完全可以」的說法,但無論是MCP還是CLI,最根本的問題都是LLM是否能「正確用引數呼叫函數」。此次的驗證實際上是 測量其在本地LLM中能否成立的基礎研究

這篇文章的腳本可以直接拷貝並運行。若在不同的模型和不同的提示詞上進行測試,必定會發現不同的景象。如果你得到了有趣的結果,請務必在評論中告訴我。


原文出處:https://qiita.com/toarusyakaijin/items/3ab5caa68a8a33f5ec73


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝26   💬2  
767
🥈
我愛JS
💬5  
16
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付