ローカルLLMの話をすると、モデルの精度やtokens/secが気になります。
自分も最初はそこを見ていました。
ただ、実際のMacで先に困ったのは回答品質ではなく、21GBまで増えたメモリ、WindowServerの監視タイムアウト、Ollamaの二重起動でした。
モデルサーバーだけが動いているわけではありません。同じMacでエージェント、テスト、ブラウザ、定期ジョブも動きます。それぞれは正常でも、合計がMacの容量を超えると画面操作ごと止まります。
それだと仕事を継続するためのローカルLLMが、仕事をしているMacを止めてしまいます・・・!
なので、モデルを速くする前に、Macを普通に使い続けられるようにしました。
Ollamaのログの37.8%がport競合だった
調査したときのOllamaログは572MB、2,175,526行ありました。
そのうちaddress already in useが821,819行。37.8%です。
原因は、手動で作ったlaunchdサービスとHomebrewのサービスが、同じportでollama serveを起動しようとしていたことでした。
しかも2つのサービスで設定が違いました。一方にはモデル数、並列数、keep-alive、KV cacheの制限があり、もう一方にはありません。
制限値を書いて安心していても、制限のない方が先にportを取れば設定は使われません。
ここで、モデル設定より先に次を確認する必要があると分かりました。
- 誰がOllamaを起動しているか
- どの設定を正本にするか
- 重い仕事を同時にいくつ許すか
- 危なくなる前に何を止められるか
- 自動で止めてよいものは何か
図1: 重い処理を始める前の入場制限と、始めた後の減圧を分けています。
Ollamaを起動する人を一人にする
最初にやったのは、Ollamaを起動する仕組みを一つへ揃えることでした。
launchd、Homebrew services、手動起動、アプリ内のプロセスが混ざっていないかを見ます。
その上で、次の設定を一箇所へ集めました。
- listen addressとport
- model directory
- 同時にロードするモデル数
- 並列リクエスト数
- keep-alive
- context length
- KV cache形式
- ログとPIDの保存場所
設定ファイルが合っているだけでは足りません。
実際に動いているプロセスの環境変数、listenしているport、ロード済みモデルも起動前に確認します。想定と違う場合は、新しい重いジョブを始めません。
ここは「設定した内容」と「いま動いているもの」を分けて見るようにしています。
非常用のOllamaを分けた
利用上限時のフェイルオーバーは、通常の環境が不安定なときにも使いたいです。
そこで非常用Ollamaは、専用port、専用model directory、Macの内蔵ストレージへ分けました。
以前は通常利用のモデルを外付けSSDへ置いていました。容量だけ考えると合理的です。
ただ、SSDが外れたときに非常用モデルまでなくなる構成は、非常用としては少し不安でした。小さいモデルだけは内蔵側へ置いています。
速くするための分離ではありません。
通常系の設定変更、port競合、外付けストレージの問題を、非常用まで一緒に受けないための分離です。
重い仕事を始める前にresource guardを通す
無人で動く重いジョブの入口へresource guardを置いています。
主に見ているのは、空きメモリ率とCPUコアあたりのloadです。
swap使用量や圧縮メモリも見ていますが、こちらは負荷が下がった後もしばらく戻りません。主判定へ使うと、Macが回復した後もずっと処理を止めることがあったので、遠いbackstopにしています。
free_memory_ratio < hard_limit → 今回は見送る
load_per_core > hard_limit → 今回は見送る
active_heavy_leases >= limit → 今回は見送る
metrics unavailable → 見送って理由を記録
otherwise → leaseを取って実行
計測できないときは、たぶん大丈夫だろうで始めません。
ただし黙って止まり続けるのも困るので、見送った理由はJSONLへ残します。連続して見送りになった場合は、resource guard自体の異常として通知します。
同時実行数は、ただのカウンターにしない
最初は同時実行数を数字だけで持てばよいと思っていました。
ただ、プロセスが途中で落ちると数字が戻らず、ずっと満員になります。
いまはジョブごとにPIDと開始時刻を持ったリースを作り、生きているリースだけ数えます。
{
"job": "local-llm-continuation",
"pid": 12345,
"startedAt": "2026-08-03T10:00:00+09:00",
"weight": 1,
"owner": "resource-guard"
}
PIDは再利用されるので、開始時刻やコマンド情報も合わせて見ます。
正常終了ならその場でリースを返し、異常終了したものはreaperが掃除します。
上限を超えた仕事を大量のキューへ積むこともやめました。Macが回復した瞬間に保留ジョブが全部起きると、またすぐ重くなるためです。
その回は見送り、次の定期実行で再評価します。必ず実行する必要がある仕事だけ、期限と再開レートを持った別キューへ置きます。
実行後はwatchdogで少しずつ減圧する
resource guardを通ったあとも、モデルのロードやテストで負荷は変わります。
watchdogは別周期で動かし、いきなりプロセスをkillせず段階的に減圧します。
- 新しい重いジョブを短時間受け付けない
- 使っていないローカルモデルをアンロードする
- 並列数を下げる、または新規受付を止める
- 状態と上位プロセスを記録して人へ知らせる
自動で行うのは、影響範囲が小さく、元へ戻せる操作までです。
メモリを多く使っているという理由だけで、ユーザーのブラウザや開発プロセスを自動killしません。どの仕事を止めるかは、メモリ量だけでは決められないためです。
このあたりは、以前の21GBメモリ暴走の調査とWindowServer監視タイムアウトの解剖でかなり痛い目を見ました。
開始と解除で、同じ閾値を使わない
空きメモリが10%を行ったり来たりすると、同じ閾値では許可と拒否が何度も切り替わります。
たとえば10%未満が続いたらbrownoutへ入り、20%以上が5分続いたら解除する、というように条件を分けています。
NORMAL --(危険が3回続く)--> BROWNOUT
BROWNOUT --(回復が5分続く)--> NORMAL
BROWNOUT --(さらに悪化)----> HUMAN_ATTENTION
瞬間的な値だけでは切り替えず、数回の観測か持続時間を使います。
閾値の数値はMacのメモリ量、普段同時に開くアプリ、モデルサイズで変わります。別のMacへそのままコピーせず、実測で調整しています。
見ている数字
- Ollama serverを起動している主体の数
- port bind errorの件数
- ロード中モデル数とresident memory
- resource guardが許可・見送りした件数と理由
- 同時リース数と孤児リース数
- brownoutの回数と継続時間
- モデルを下ろしてからメモリが戻るまでの時間
- WindowServer timeout、swap急増、強制再起動の件数
- ジョブ完了時間とMacの操作感
メモリを使わないことが目的ではありません。
Macを普通に操作できる範囲で、許可した仕事を最後まで終えられるかを見ています。
いまの確認項目
- モデルサーバーを起動する仕組みは一つか
- 実プロセスのportと環境変数を見たか
- 非常用モデルが通常系と同時に壊れない場所にあるか
- 重いジョブの入口にresource guardがあるか
- 同時実行を生存確認できるリースで数えているか
- 計測できないときの動きと通知を決めたか
- 保留ジョブの再開で雪崩が起きないか
- 実行後のwatchdogとbrownoutがあるか
- 自動でkillしてよい対象を限定したか
- 開始と解除の閾値を分けたか
ローカルLLMを一度動かすだけなら、ollama runで十分です。
仕事の裏で何度も動かすようになると、普通の常駐サービスと同じように所有者、同時実行数、減圧、回復が必要になりました。モデルより先にMacを守る、くらいの順番でちょうどよかったです。
ローカルLLM設計・全8回
MacでローカルLLMを実際に動かして、うまくいかなかったところを8つに分けて書いています。