ローカルAIエージェントのツール連携を表した抽象的なテックビジュアル

Article

Gemma 4-31B をローカルで tool-calling エージェントとして使ってみた所感

2026年4月19日
7 min read
#AI#Gemma#ローカルLLM#tool-calling#Mac Studio

Atlas に Chat 機能を追加しようとしたとき、最初に考えたのは「OpenAI API を使えばいい」だった。でもせっかく Mac Studio 64GB を持っていて、Gemma 4 31B がローカルで動いているなら、外部 API に頼らずに完結させたい。スパイク検証を始めた。


検証環境

  • マシン: M4 Max Mac Studio 64GB (メモリ帯域 ~400 GB/s)
  • モデル: Gemma 4 31B IT 4bit量子化 (mlx-community/gemma-4-31b-it-4bit)
  • 推論エンジン: MLX VLM server (port 11434)
  • メモリ使用量: ~23GB (64GB の約 36%)

まず「MLX server が OpenAI 互換の tools パラメータをそもそも受け付けるのか」を確認するところから始めた。


スパイク検証

throwaway なコードを書いてリクエストを投げてみた:

payload := map[string]interface{}{
    "model": "mlx-community/gemma-4-31b-it-4bit",
    "messages": []map[string]interface{}{
        {"role": "user", "content": "最近の家族のファイルを検索して"},
    },
    "tools": []map[string]interface{}{
        {
            "type": "function",
            "function": map[string]interface{}{
                "name": "search_hybrid",
                "description": "ファイルを検索する",
                "parameters": map[string]interface{}{
                    "type": "object",
                    "properties": map[string]interface{}{
                        "query": map[string]interface{}{
                            "type":        "string",
                            "description": "検索クエリ",
                        },
                    },
                    "required": []string{"query"},
                },
            },
        },
    },
}

レスポンスが返ってきた。tool_calls フィールドが入っていた。

{
  "choices": [{
    "message": {
      "role": "assistant",
      "tool_calls": [{
        "id": "call_abc123",
        "type": "function",
        "function": {
          "name": "search_hybrid",
          "arguments": "{\"query\": \"家族 最近\"}"
        }
      }]
    },
    "finish_reason": "tool_calls"
  }]
}

動いた。 MLX server は OpenAI 互換の tool-calling に対応していた。


良かった点

JSON 整形度: arguments の JSON は完璧だった。引数の型も必須フィールドも指定した schema に従う。これは正直驚いた。4bit 量子化でここまで instruction following が安定しているとは思っていなかった。

複数 tool から適切に選択: 6 つの tool (search_hybrid / get_file / list_recent / get_metadata / patch_file / update_metadata) を渡しても、クエリの文脈から適切なものを選ぶ。「最近のファイル一覧」なら list_recent、「この UUID のファイルを読んで」なら get_file を呼ぶ。

自律的な tool 連鎖: system prompt でエージェント動作を細かく説明しなくても、自分で検索して → 読んで → 回答する、という流れを組み立てられた。

日本語出力: 自然で実用レベル。要約も的確だった。


重大発見: role=tool が黙って破棄される

ここで詰まった。

tool_calls を受け取った後、tool を実行して結果を返す。OpenAI のプロトコルでは role: "tool" でメッセージを送る:

{
  "role": "tool",
  "tool_call_id": "call_abc123",
  "content": "[{\"id\": \"...\", \"title\": \"塾の入室説明会\", ...}]"
}

ところが Gemma はこれを無視した。同じ tool を繰り返し呼び続ける。結果を見ないまま同じ検索を 5 回やってループが破綻する。

デバッグのために入力トークン数を計測した:

turn 1 (user): 342 tokens
turn 2 (tool_calls): 412 tokens
turn 3 (tool result): 417 tokens  ← +5 しか増えない

tool result を送ったのに 5 トークンしか増えていない。 tool result の本文 (数百トークン) が消えている。

原因は MLX server が使っている Gemma のチャットテンプレートにあった。Gemma のテンプレートは role: "tool" メッセージを処理するケースが実装されていない。受け取ったメッセージを黙って捨てていた。

回避策: role=tool を role=user にリラベル

func (s *Shim) rewriteMessages(messages []Message) []Message {
    result := make([]Message, 0, len(messages))
    for _, m := range messages {
        if m.Role == "tool" {
            // role=tool を role=user に変換してプレフィックスを付ける
            result = append(result, Message{
                Role:    "user",
                Content: fmt.Sprintf("[tool result for %s]\n%s", m.Name, m.Content),
            })
        } else {
            result = append(result, m)
        }
    }
    return result
}

この shim を入れた瞬間に動いた。tool result がコンテキストに入るようになり、Gemma が結果を読んで次の行動を決められるようになった。

ついでに、この shim を適用するかどうかを endpoint の URL で自動判定するようにした:

func needsRoleShim(endpoint string) bool {
    return strings.Contains(endpoint, "localhost") ||
           strings.Contains(endpoint, "127.0.0.1")
}

localhost なら Gemma、それ以外は OpenAI か互換サービス——という前提だ。後に OpenAI に切り替えたとき、コード変更ゼロでそのまま動いた。


パフォーマンス

スループット: ~26 tok/s (M4 Max, 400 GB/s メモリ帯域)
1 ターン応答: 2-4 秒 (検索 + 読み込み + 生成)
3 ターン合計: 8-10 秒
メモリ使用: ~23GB

律速はメモリ帯域だ。M4 Max の 400 GB/s がほぼ上限まで使われている。コア数でもメモリ容量でもなく、どれだけ速くモデルの重みをメモリから読み出せるかがボトルネック。


課題

multi-turn でのレイテンシ爆発: context が膨れると急激に遅くなる。8KB を超えたあたりから顕著で、長い会話では 1 ターンに 30 秒以上かかることがあった。KV cache の効果が薄れると推測しているが、詳細は追えていない。

search ループ問題: 検索結果が期待通りでないとき、同じ tool を引数を変えながら繰り返し呼ぶ傾向がある。max_iter = 5 でハードリミットを設けて対処しているが、本来は「十分な情報が集まったら回答する」という判断を自分でやってほしい。

embedding サーバーとの GPU 共有: 同じ Unified Memory 上で Gemma (23GB) と e5-large embedding サーバーが同居している。embedding リクエストが来るたびに Gemma の推論が止まることがあり、Chat API の応答が不安定になることがあった。

コールド start: MLX は最初の推論リクエストでモデルを memory-map するので、久しぶりに使うと最初の 1 リクエストが遅い (10+ 秒)。常時アクティブなユースケースなら問題ないが、「たまに使う」という使い方では気になる。


課題の深掘り: なぜ 50-100 秒まで伸びるのか

スパイク検証の段階では「8-10 秒で動いた」と書いたが、実用として使い始めると状況が違った。

複雑なクエリ——「去年の家族の医療費を月別に集計して」のようなもの——は tool を 4-5 回呼ぶ。1 ターン 20-30 秒が 4-5 回で 80-150 秒。タイムアウトの 180 秒に達するケースが出てきた。

embedding サーバーとの競合も重なると、一つのチャットが詰まって後続が全部待たされる。chat 優先で process queue を pause するロジックを入れたが、根本的な解決にはならなかった。


まとめ

Gemma 4 31B はローカル tool-calling エージェントとして 「動く」。スパイク検証レベルの用途、精度の確認、設計検証——これらには十分だ。

ただし実用サービスとして家族が日常的に使うレベルに持っていくには、以下のチューニングが必要だった:

  1. role=tool shim (Gemma チャットテンプレートの制約回避)
  2. max_iter によるループ防止
  3. context 肥大対策 (search result の pruning, body 3K rune truncate)
  4. embedding サーバーとの競合対策

これらをやってもレイテンシの壁を越えられなかった。そこで OpenAI API への切り替えを検討し始めた。それは次の記事で。

ローカル LLM の可能性は本物だ。M5 Ultra (~900-1000 GB/s 推定) なら ~55-65 tok/s、つまり今の 2-2.5 倍速になる計算だ。そうなれば実用域に入ると思う。今は待ちの時期だと割り切っている。