
Article
ローカル Gemma から GPT-5.4-mini に切り替えたら世界が変わった
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 が現実解だ。
環境変数で切り替えられる設計にしておいたことで:
- ローカル Gemma でプロトタイプを作り、動作確認できた
- OpenAI に切り替えて家族が使えるレベルにできた
- 将来ローカルに戻す選択肢を失っていない
この柔軟性が最終的には一番価値があったと思っている。特定の backend に縛られた実装にしなくて本当によかった。


