ローカル処理から高速なクラウドAIへ切り替わる様子を表した抽象的なテックビジュアル

Article

ローカル Gemma から GPT-5.4-mini に切り替えたら世界が変わった

2026年4月20日
8 min read
#AI#Gemma#GPT-5.4 mini#ローカルLLM#OpenAI

Gemma 4 31B でローカル tool-calling エージェントを構築して、一応動くものができた。でも「一応動く」と「家族が日常的に使えるサービス」は別物だった。


切り替えを決めた理由

家族に「試してみて」と渡してから 1 週間、フィードバックを集めた。

「なんか遅すぎて使う気になれない」 「待ってたら答えが来る前にアプリ閉じちゃった」 「エラーになった」(タイムアウト)

具体的な数字はこうだった:

クエリの種類 レイテンシ
簡単な検索 (1 ターン) 10-15 秒
複雑なクエリ (3-4 ターン) 50-100 秒
長い context タイムアウト (180 秒超)

embedding サーバーとの GPU (Unified Memory) 共有で詰まりが起きるときは、さらに悪化した。process queue を自動 pause する機能を入れて Chat を優先させたが、それでも根本的な解決にはならなかった。

「プライバシーを守るためにローカルで動かす」という目標と「使い物になる速さ」の間でトレードオフを迫られた。


切り替え方法: 環境変数 3 つを変えるだけ

設計段階から「backend は差し替え可能にする」と決めていた。Chat API のコードは endpoint と model と API key を抽象化している。

# Gemma ローカル (切り替え前)
ATLAS_CHAT_MLX_ENDPOINT=http://localhost:11434
ATLAS_CHAT_MODEL=mlx-community/gemma-4-31b-it-4bit
ATLAS_CHAT_API_KEY=

# OpenAI (切り替え後)
ATLAS_CHAT_MLX_ENDPOINT=https://api.openai.com
ATLAS_CHAT_MODEL=gpt-5.4-mini
ATLAS_CHAT_API_KEY=sk-proj-...

コード変更はゼロ。 launchd の plist の EnvironmentVariables セクションを 3 行書き換えて launchctl kickstart するだけだった。

role=tool shim も自動でスキップされた:

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

endpoint が api.openai.com になった瞬間、shim が無効化される。OpenAI は role=tool を正しく処理するので shim は不要だ。


結果の比較

指標 Gemma 4 ローカル GPT-5.4-mini
1 ターン応答 10-15 秒 0.3-1 秒
3 ターン合計 50-100 秒 3-8 秒
タイムアウト 頻発 (180 秒超) ほぼなし
tool-calling 安定性 loop guard 必要 不要
context window ~8K (実効) 128K
role=tool 処理 破棄 (shim 必要) 正常
tool_call_id 検証 緩い 厳密
プライバシー 完全ローカル データが OpenAI に送信
コスト 電気代のみ API 従量課金

「世界が変わった」はやや誇張だが、体感は本当にそれくらい違った。10 秒待つことと 1 秒で返ってくることは、ユーザー体験として別次元だ。


技術的に詰まったところ: tool_call_id の厳密なチェック

切り替えた直後、一部のクエリでエラーになった。

OpenAI は tool_call_id の一致を厳密にチェックする。tool_calls メッセージに id: "call_abc123" が含まれていたら、その後の tool result は必ず tool_call_id: "call_abc123" で返す必要がある。不一致があると 400 エラーになる。

Gemma 相手に開発していたとき、context が長くなると古い tool_calls エントリを pruning するコードを書いていた。これが問題だった。tool_calls を pruning したのに対応する tool result を消し忘れて、孤立した tool result が残っていた。Gemma はこれを無視していたが OpenAI は弾いた。

修正は tool_calls と tool results を必ずペアで消す一貫した pruning ロジックを書き直すことだった。

func pruneToolPairs(messages []Message) []Message {
    // tool_call_id の set を構築
    activeCallIDs := map[string]bool{}
    for _, m := range messages {
        if m.Role == "assistant" && len(m.ToolCalls) > 0 {
            for _, tc := range m.ToolCalls {
                activeCallIDs[tc.ID] = true
            }
        }
    }
    // 孤立した tool result を除去
    result := []Message{}
    for _, m := range messages {
        if m.Role == "tool" && !activeCallIDs[m.ToolCallID] {
            continue // 孤立した tool result を捨てる
        }
        result = append(result, m)
    }
    return result
}

Gemma で開発していたがゆえに気づけなかった問題で、OpenAI のエラーが「正しい実装」に修正させてくれた、という皮肉な結果だった。


context window 128K からの解放

Gemma のローカル運用での実効 context window は ~8K だった。それ以上になるとレイテンシが指数的に悪化する。

そのため tool result に含める検索結果の body を 3K rune に切り詰めたり、古いメッセージを積極的に pruning したりしていた。「必要な情報を切り捨てる」設計が随所に入っていた。

GPT-5.4-mini の 128K context window に切り替えると、この制約がほぼ消えた。検索結果をフルで渡せる。会話が長くなっても古いメッセージを消さなくていい。「情報を切り捨てる」ためのコードが不要になった。


プライバシーのトレードオフ

これが一番悩んだところだ。

Atlas には個人の日記、家族の記録、子どもの学校書類、医療書類が入っている。Tailscale 内のローカルネットワークだけで使う前提で設計した。データが Mac Studio を出ることはないはずだった。

GPT-5.4-mini に切り替えると、Chat で送ったクエリと検索結果の断片が OpenAI のサーバーに渡る。

この判断をどうするか、かなり時間をかけて考えた。最終的な考え方はこうだ:

「使い物にならないプライバシー」より「使える便利さ」を選んだ。

Gemma でのレイテンシでは家族が使わない。使わないシステムはどんなにプライバシーが守られていても意味がない。Chat に送るのはクエリと検索結果の一部であって、ファイルの全内容ではない。「ある程度の情報が OpenAI を通る」というリスクを受け入れて、実際に使えるサービスを優先した。

ただし 将来 Gemma に戻す選択肢は意図的に残してある。環境変数 3 つを変えるだけで切り替えられる設計はそのためだ。M5 Ultra が出て帯域が ~900-1000 GB/s になれば、~55-65 tok/s が期待できる。それなら実用域に入る。そのときに改めて判断する。


M5 Ultra への期待

現在の M4 Max は ~400 GB/s。これで ~26 tok/s。

M5 Ultra の推定帯域は ~900-1000 GB/s。単純計算で ~55-65 tok/s になる。

1 ターン応答が 1-2 秒になれば、GPT-5.4-mini の 0.3-1 秒と大きな差はない。プライバシーを完全に守りながら実用レベルの速さを実現できる。

ただし「M5 Ultra を買う」という判断が必要で、そのコストと比べて API 費用の方が安い、というケースも当然ある。自分の場合は Atlas をどれくらい使い込むかによって変わる。


まとめ: API 依存しない設計の重要性

今回の教訓は「ローカルか API か」という二択ではなく、**「どちらにでも切り替えられる設計にしておく」**ことだ。

ローカル LLM はプロトタイプの検証、動作確認、設計の妥当性確認——こういった用途には最適だ。API を使わなくていいので何度でも試せるし、コストを気にせず実験できる。

でも本番運用、特に家族が日常的に使うサービスとして動かすには、今のところ API が現実解だ。

環境変数で切り替えられる設計にしておいたことで:

  1. ローカル Gemma でプロトタイプを作り、動作確認できた
  2. OpenAI に切り替えて家族が使えるレベルにできた
  3. 将来ローカルに戻す選択肢を失っていない

この柔軟性が最終的には一番価値があったと思っている。特定の backend に縛られた実装にしなくて本当によかった。