「ローカルLLMを動かすなら Apple Silicon か、せめて GPU が要る」
私はずっとそう思っていました。手元にあるのは2017年製の Intel iMac。Apple Silicon でもなければ、外付けの GPU もありません。8年前の、CPU しかない机の上のパソコンです。試す前から答えは出ている気がしていました。
ところが、実際にやってみたら平均2.8秒で返ってきました。
この記事は、その環境をゼロから作って、モデルを3つ実測で比べて、実際のアプリに組み込むまでの記録です。明日そのまま試せる結論だけ先に書くと、「Ollama を入れて、4bit量子化の3Bクラスのモデルを選ぶ」。これだけで CPU のみのマシンでも実用速度に届きます。
動かした機材
| 本体 | iMac(2017) |
| CPU | Intel Core i5-7500(4コア4スレッド) |
| メモリ | 16GB |
| GPU 加速 | なし |
| OS | macOS 13.7(Ventura) |
いわゆる「まだ使えるけど、そろそろ後輩に譲ろうかな」くらいの位置にいるマシンです。
最初の罠:brew install は成功したのに、起動しない
まず Ollama を入れます。パッケージマネージャで一発……のはずでした。
インストールは何事もなく終わりました。エラーも出ませんし、終了コードは 0。画面には成功の文字。ところが起動しようとすると、うんともすんとも言いません。
原因を追いかけて分かったのは、Intel + macOS 13 という組み合わせが、そのパッケージマネージャのビルド対象から外れていたことでした。対象外なのでソースからのビルドが走り、そのビルドが失敗しているのに、コマンド自体は成功として終わっていた。つまり「入れましたよ」と言いながら、中身が入っていなかったわけです。
平気な顔で嘘をつくタイプのインストーラでした。
対処:公式が配布しているユニバーサルバイナリ版に切り替えたら、あっさり動きました。
教訓:導入がうまくいったかどうかを終了コードで判断しない。実際に起動させて、応答させて、初めて「入った」と言う。以降この手順に変えました。
ollama --version # 入ったか
ollama serve # 起動するか
ollama run <model> "hello" # 応答するか
この3つを通してようやく合格です。1つ目だけでは、さっきの嘘つきインストーラを見抜けません。
モデル選び:7B以上は、最初の1分で候補から消えた
CPU 推論には、身も蓋もない現実があります。大きいモデルは、遅い。
7B クラスを試したところ、数トークン/秒しか出ませんでした。返事が一文字ずつ、考えながら口に出されている感じです。人と会話するには遅すぎますし、アプリから呼ぶには論外です。
そこで候補を2〜3B クラスの4bit量子化モデルに絞りました。ディスク占有は各1.6〜2.0GB。ここまで小さくすると、CPU でもまともに動きます。
さらに、使い方の側も削りました。
-
num_predict=80—— 出力の長さに上限をかける(長く喋らせない) -
temperature=0—— 毎回同じ答えを返させる(揺れを消す) - 用途を短い構造化出力に絞る(自由な文章生成には使わない)
小型モデルは「なんでも屋」には向きませんが、仕事を1つに絞ると急に頼れるようになります。
3モデルを同じ条件で走らせて比べた
候補は Llama 3.2 3B・Qwen2.5 3B・Gemma 2 2B の3つ。同じプロンプト、同じパラメータで走らせ、1件ごとの応答秒数と出力の正誤を計測しました。
お題は「日本語の短い単語を、英語の説明句と4値の分類ラベルからなる JSON に変換させる」構造化出力タスクを10件。うち6件は正解が決まっているもの、残り4件は正解を用意せず目視で妥当性を見るものです。答え方の見本をいくつか先に見せる形(few-shot)で渡しました。
結果です。
| モデル | 平均応答 | 正解が既知の6件の正答 | 正解未設定4件の妥当性(目視) |
|---|---|---|---|
| Llama 3.2 3B | 2.8秒 | 6/6 | 3/4 |
| Qwen2.5 3B | 2.8秒 | 4/6 | 4/4 |
| Gemma 2 2B | 5.1秒 | 3/6 | 1/4 |
**速度では上位2つが並びました。**こうなると決め手は速度ではありません。
決め手にしたのは出力の精度と、利用条件です。実は日本語の扱いが明らかに上手いモデルが他にもあったのですが、利用条件の原文を確認できなかったので候補から外しました。
ollama show <model> --license
このコマンドで条文の原文が読めます。後から「その用途には使えません」と言われるほうが、少し性能が低いより痛いので、ここは先に潰しておく価値があります。組み込むときに求められる帰属表示も、あわせて実装しました。
小型モデルは、JSON を素直に返してくれない
さて、採用が決まったので実際のアプリに組み込みます。ここで次の壁が来ました。
JSON で返して、と頼んだのに JSON で返ってこない。
はい、承知しました。以下がJSONです。
```json
{"en": "...", "label": "..."}
```
ご確認ください。
こういうやつです。丁寧なのは大変よろしいのですが、パーサは丁寧さを解釈できません。大きいモデルは「形式を守れ」という指示にかなり忠実ですが、小型モデルはここが不安定です。
なので、受け取る側で3段構えにしました。
- コードフェンス(``` の囲み)を取り除く
-
最初の
{から最後の}までを切り出す(前置きも後書きも捨てる) - 分類ラベルを、許容する値の集合と照合する(知らない値なら失敗扱い)
どれか1つでも外れたら、その入力は失敗として処理します。モデルの機嫌に期待せず、こちらで受け止める方針です。
初回だけ数十秒かかる、を知らないと事故る
組み込んだ直後、最初のリクエストだけタイムアウトしました。2回目からは数秒なのに、1回目だけ数十秒かかります。
これはモデルをメモリに読み込む処理が、最初のリクエストのときに走るためでした。仕様どおりの動きです。
対処:タイムアウトを60秒に設定し、「初回だけ遅い」ことを運用の手順書に明記しました。数字を変えるだけの話ですが、知らないと「動かない」と誤診する類のものです。
推論サーバが落ちても、アプリは止めない
もう1つ、組み込みで大事だったこと。
最初の実装では、推論サーバを止めるとアプリ全体が巻き添えで止まりました。接続エラーがそのまま上まで駆け上がってきていたためです。
ローカルLLMは、常に起動しているとは限りません。私の環境では同じマシンで別の重い処理も動かすので、必要なときだけ起動して、メモリが苦しくなったら降ろす運用にしています。つまり**「止まっている」が普通に起きる**状態です。
そこで、こう変えました。
- 接続不可・タイムアウト・パース不能・値が不正——この4つを全部、同じ「失敗」の値に畳む
- 失敗した入力だけを処理から外して、残りは進める
- 例外を呼び出し元に漏らさない
賢さが増えるわけではありませんが、居ないときに全体を巻き込まないというのは、同居する相手として大事な性質です。
そして**「推論サーバが落ちている状態」の挙動を、単体テストで固定しました**。正常系・コードフェンス付きの応答・不正な分類値・空文字・パース不能・接続不可・空入力の7ケース。HTTP のところをモックにしてあるので、このテストは推論サーバを起動しなくても走ります。ここが地味に効きます。
python3 -m pytest # 推論サーバ不要
結果
- 2017年製・CPU のみの機材で、ローカルLLM推論は成立した。応答は平均2.8秒・最遅9.5秒で、目標にしていた「10秒以内」を全件クリア
- 推論時のメモリ使用量は約2.2GB。16GB のマシンで、ブラウザや他の作業と同居できることを実測で確認
- 既存の Python アプリへの組み込みまで完了。推論サーバの停止時も、機能を落としながら動き続けることをテスト7件で保証
- 追加のハードウェア費用も、API の従量課金もゼロ
「GPU が無いから」と諦めていたマシンが、用途を絞れば現役で戦えたというのが、いちばんの収穫でした。
コードを置いてあります
実際に使ったコードを、汎用的な課題設定に置き換えて公開しています。
-
benchmark.py—— モデル比較ベンチマーク(応答秒数と正答数を出力) -
ollama_client.py—— 組み込み用クライアント(タイムアウト/JSON抽出/値の検証/失敗時のフォールバック) -
test_ollama_client.py—— 上記の単体テスト7件
ollama serve # 別ターミナルで起動
python3 benchmark.py llama3.2:3b # 応答秒数と正答数を計測
python3 -m pytest # 単体テストは推論サーバ不要
なお、上の表の計測値は元のコードで得た実測値です。公開版は課題設定を差し替えてあるので、benchmark.py をそのまま走らせても表と同じ数字にはなりません。そこだけご承知おきください。
まとめ:明日できること
- Ollama は公式配布のバイナリで入れる(パッケージマネージャは、環境によっては成功を装って失敗する)
- CPU only なら 2〜3B の4bit量子化。7B以上は最初から候補に入れない
-
用途を「短い構造化出力」に絞る。
temperature=0とnum_predictで手綱を握る -
JSON は素直に返ってこない前提で受け止める(フェンス除去 →
{〜}切り出し → 値の照合) - 初回リクエストは遅い。タイムアウトは長めに
- 採用前に
ollama show <model> --licenseを読む
8年前のマシンでも、ここまでは動きます。机の上で眠っているPCがあれば、試してみる価値はあると思います。