94
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「無料でAIエージェント作れるやん」→ ローカルLLMに100回ツール呼び出しさせたら"成功したはずの出力"がカオスだった話

94
Posted at

前回「MCPなんていらない」って書いた人間が、もっと手前の問題に気づいた話

先日、こんな記事を書きました。私が。

「MCPサーバー作るのもうやめていい?」〜CLIがMCPを圧倒する本当の理由〜

「MCP はトークンの無駄遣い、CLI の方が35倍効率いい!」と勢いよく書き上げて、意気揚々と投稿したわけです。

……が、投稿ボタンを押した直後に気づいてしまった。

「……待てよ。MCP だろうと CLI だろうと、LLM が『ツールを呼ぶ』部分が壊れてたら、そもそも全部ダメじゃん」

MCP vs CLI の議論は「ツール呼び出しがちゃんと動く前提」の話。でもその 前提 を、ローカル LLM で検証した人が誰もいなかった。

Claude や GPT-4o なら、ツール呼び出しは100発100中。当たり前。でもこれ、自分の PC で無料で動くローカル LLM だったら? API 課金ゼロ、データ外部送信ゼロの Ollama 環境で、ちゃんと動くのか?

MCP 不要論を唱えた張本人が、もっと根本的な問題をスルーしていた。恥ずかしい。

じゃあ100回ぶん回して計測するか。

そうして始めた実験の結果が「87%」。この数字を見て、あなたは「意外と高い」と思うか、「本番では使えない」と思うか——。

読み進めると、印象がたぶん変わります。


先に結論を見せます

まず数字から。

benchmark_bar_chart.png

指標 結果
Tool Call 成功率 87%(87 / 100)
JSON Parse 成功率 87%(87 / 100)
平均レスポンスタイム 1.19秒
最小 / 最大 0.59秒 / 2.86秒

benchmark_boxplot.png

中央値は約 1.0秒。ほとんどのリクエストが 0.7〜1.5秒 に収まっています。3B パラメータのローカルモデルにしては、かなり速い。

ここまでは「へぇ、87% か」という感じかもしれません。でもこの後、成功した87回の JSON を1つずつ開けていったら、予想外の世界が見えてきました。

その前に、「そもそも Ollama って何?」「Function Calling って?」という方のために手短に解説します(知っている方は 検証の全体像 へどうぞ)。


Ollama ってそもそも何?

Ollama は、LLM(大規模言語モデル)を 自分の PC でワンコマンドで動かせるツール です。

brew install ollama     # インストール
ollama pull llama3.2    # モデルをダウンロード(約2GB)
ollama serve            # サーバー起動 → localhost:11434 で待ち受け

たったこれだけで、あなたの Mac(や Linux)が AI サーバー になります。

観点 ChatGPT API Ollama
費用 従量課金 完全無料
データ OpenAI に送信 PC から出ない
ネット 必要 オフラインOK
速度 速い マシン性能に依存
モデル GPT-4o 等 Llama, DeepSeek, Qwen 等

しかも、OpenAI 互換の API(/v1/chat/completions)を持っているので、openai ライブラリのコードがほぼそのまま動く。既存のコードの base_urllocalhost: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回

ツール定義はシンプルな天気取得関数1つだけ。複雑なツールだともっと失敗するかもしれませんが、まずは最も基本的なケースで計測しました。

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 で迷子になっている瞬間が見えます。

失敗はいつ起きるのか?

Trial  1: ✅    Trial  2: ❌    Trial  3: ✅    Trial  4: ❌
Trial  5: ✅    Trial  6: ❌    Trial  7: ❌    Trial  8: ✅
                    ↑ 序盤に集中 ↑
...
Trial 86: ❌    Trial 87: ✅    ...
Trial 97: ✅    Trial 98: ✅    Trial 99: ✅    Trial100: ✅
                    ↑ 終盤は安定 ↑

序盤(Trial 1〜14)に6回失敗が集中。後半50回では失敗わずか3回。

Ollama のモデルロード直後は推論が不安定で、キャッシュが温まると安定する——という仮説が立ちます。本番で使うなら ウォームアップのダミーリクエスト を入れるべきでしょう。


深掘り②:「成功」の中身を開けたらカオスだった

ここまでの話で「失敗はツール不使用、JSONは壊れない」とわかった。じゃあ 成功した87回の JSON は、ちゃんと使えるのか?

1件ずつ中身を確認していったら、同じプロンプト・同じツール定義なのに 出力が毎回違う という現実が見えてきました。

パターン 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"}
}

도시 は韓国語で「都市」。日本語プロンプトに対して、日本語・中国語・韓国語が1つの JSON に同居する不思議な出力。3B パラメータの脳内では CJK 言語の境界が曖昧なのかもしれません。

引数品質の分布

結論:json.loads() が通る ≠ アプリで正しく動く。Pydantic 等での型バリデーションは必須。


実用化するなら、こうする

ここまでの分析で、失敗の原因と成功の落とし穴がはっきりしました。それぞれに対する具体策を4つ挙げます。

1. リトライで成功率を跳ね上げる

失敗率 13%。2回リトライすれば、理論上の失敗率は 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. ウォームアップを入れる

序盤に失敗が集中するなら、本番リクエストの前にダミーを1回飛ばしておく。

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

ベンチマークスクリプト

📄 ollama_fc_benchmark.py(クリックで展開)
#!/usr/bin/env python3
"""
Ollama ローカルLLM Function Calling ベンチマーク
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(["Tool Call Success", "JSON Parse Success"],
                  [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("Success Rate (%)")
    ax.set_title(f"Ollama FC Benchmark — {MODEL_NAME} ({NUM_TRIALS} 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("Response Time (sec)")
    ax.set_title(f"Response Time Distribution ({NUM_TRIALS} 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 Benchmark — {MODEL_NAME} x {NUM_TRIALS} 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"\nTool Call: {tc/NUM_TRIALS*100:.1f}%  JSON: {jp/NUM_TRIALS*100:.1f}%"
          f"  Avg: {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("Done.")


if __name__ == "__main__":
    main()

実行

python ollama_fc_benchmark.py

MODEL_NAME"deepseek-r1:8b""qwen2.5" に変えるだけで、別モデルもすぐ計測できます。


まとめ

観点 評価
成功率 87% プロトタイプ・個人開発なら 十分実用的
JSON は TC 成功時 100% 有効 構文エラーではなく「呼ぶ/呼ばない」の判断がボトルネック
引数の品質 スキーマ混入・多言語混在あり → 型バリデーション必須
速度 1.19秒/回 ローカル 3B モデルとして かなり高速
安定性 序盤に失敗集中 → ウォームアップが有効
リトライ込み 2回リトライで 理論上 98% 超え

次にやりたいこと

  • DeepSeek / Qwen 2.5 / Gemma との成功率比較
  • tool_choice="required" で成功率は上がるか?
  • 日本語 vs 英語プロンプトでの差
  • MCP サーバー連携での実測(CLI に負けるのかも含めて検証したい)

おわりに

正直、実験前は「ローカル LLM で Function Calling なんて無理でしょ」と半ば決めつけていました。

100回のデータは、その先入観をいい意味で壊してくれた。87%は動く。JSONは壊れない。でも「成功した JSON の中身がカオス」という、実際に回してみないと絶対にわからないリアル も同時に見せてくれました。

0円。データ流出ゼロ。リトライ込みで98%。 この条件なら、個人開発やプロトタイピングの武器としては十分すぎます。

MCP はもういらない、CLI で十分だ」という議論もありますが、MCP だろうと CLI だろうと、LLM が「正しい引数で関数を呼べるか」は共通の土台です。今回の検証は その土台がローカル LLM でどこまで成り立つのか を測った、いわば地盤調査のようなもの。

この記事のスクリプトはコピペでそのまま動きます。別のモデル、別のプロンプトで試したら、きっと違う景色が見えるはず。面白い結果が出たら、ぜひコメントで教えてください。

94
63
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
94
63

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?