sue@blog:~/posts/local-llm-production-design.md
← cd ../posts
$ cat local-llm-production-design.md
Local LLM / Production Design

ローカルLLMを実務レベルで使うための設計 — 98回のフェイルオーバーで見えた現実

ローカル起動86回、クラウド復帰0回。仕事の配置、コンテキスト削減、handoff、冪等性、プロセス管理、復帰監視、安全ゲート、完了率を、設計意図まで遡って解剖します。

26 min read by Suguru Ooki
Claude Code Ollama Qwen ローカルLLM Failover

ローカルLLMを入れれば、機密データを外へ出さず、クラウドAPIの上限にも左右されず、好きなだけAIを使える。そう考えて仕組みを作り始めました。

実際に作ったのは、Claude Codeが利用上限に達したとき、作業状態を保存してMac上のQwenへ引き継ぎ、上限解除後にクラウド側へ戻すフェイルオーバーです。

2026年7月25日時点で、この仕組みは98回発火しました。ローカルLLMの起動には86回成功しています。一方で、クラウド側へ自動復帰できた回数は0回でした。

この数字から分かったのは、ローカルLLMを実務で使えるかどうかは、モデルの賢さより周辺設計で決まるということです。

まず結論

実務レベルのローカルLLMには、少なくとも次の8つの設計領域が必要です。

  1. 仕事をブラウザ・ローカルPC・クラウドへ配置する
  2. 小さなモデルへ渡すコンテキストを減らす
  3. 会話ではなく作業状態をhandoffする
  4. 二重起動や再実行を防ぎ、冪等にする
  5. モデルだけでなく実行プロセスとMacの資源を管理する
  6. クラウドへ戻る経路を別の状態機械として監視する
  7. 実行権限と業務上の承認を分け、安全ゲートを置く
  8. 応答率ではなく仕事の完了率を計測する

各テーマは、設計意図、避けたかった失敗、実装方法、観測指標、導入チェックリストを独立した記事で詳しく解説しています。このページは全体像と実測結果をつかむための入口として使ってください。

これに加えて、切替処理の入口では一時的な通信エラーとサブスクリプション上限を区別します。誤検知すれば、障害時に大量のローカルセッションを起動してしまうためです。

私の環境では「クラウドが止まったらローカルで拾う」部分は動きました。しかし「仕事を最後まで終え、適切なタイミングでクラウドへ戻す」部分は未完成でした。

ローカルで返事が出たことと、実務が完了したことは別です。

DEFINITION「実務レベル」をモデル性能だけで決めない

ローカルLLMの比較では、パラメータ数、量子化、tokens/sec、ベンチマークスコアが注目されます。もちろん重要ですが、業務で困るのは別のところです。

この記事での「実務レベル」

任せた仕事が、決められた安全範囲で、観測可能な状態のまま、必要な完了条件まで進むこと。文章が自然かどうかだけでは足りません。

DESIGN 00設計の出発点は「どこで動かすか」

業務AIは「クラウドを使うか、ローカルを使うか」の二択ではありません。データの機密性とタスクの難しさに応じて、3層へ配置します。

実行場所向いている仕事強み主な制約
ブラウザ内短文分類、マスキング、定型変換サーバーへ送らず配布しやすいモデルサイズ、メモリ、長文処理
ローカルPC社内文書の要約、ログ分類、コード差分確認データを端末外へ出さない端末差、起動管理、品質のばらつき
クラウドLLM複雑な設計、大規模実装、難しい判断高い推論能力と安定したツール利用利用上限、費用、データ管理

大事なのは、利用者に毎回選ばせないことです。タスクの種類、データ区分、必要な品質からルーターが自動で決める方が運用できます。

私のフェイルオーバーは、この3層のうち「クラウドからローカルPCへ一時退避し、再びクラウドへ戻す」経路です。

SYSTEM作った仕組み

全体の流れは次の通りです。

Claude Code
  │ 利用上限を検知
  ▼
作業状態をhandoffへ保存
  │ 直前の指示 / TODO / Git状態 / 最後の回答
  ▼
Mac上のQwenを軽量モードで起動
  │ Read / Edit / Bashだけで可逆な作業を継続
  ▼
復帰ウォッチャーが上限解除を確認
  │ 最小プローブ
  ▼
クラウド側でhandoffを読み、作業を再開

ローカル側は、専用ポートと専用モデルディレクトリでqwen3.5:9bを動かします。既定のOllamaとは分け、非常用のモデルはMac内蔵ストレージへ置きました。

DESIGN DETAILS8つの設計を「意図 → 実装 → 反省」で分解する

ここからは、当時の調査メモ、フェイルオーバーの実装コメント、その後に追加した資源監視の記録を遡り、まとめで列挙した8つの言葉を具体化します。最初から完成形を思いついたわけではありません。失敗するたびに、責務を一つずつ分けていった結果です。

切替処理の前提

HTTP 429を一律に扱いません。サブスクリプション上限を示す文面に一致し、かつ「あなたの利用上限ではない」という文面に一致しない場合だけ、フェイルオーバーを起動します。サーバー側の一時的な混雑なら、実行先を替えても解決しないためです。

1. 仕事の配置 — モデル選びより先に、データと完了条件で置き場所を決める

意図は、機密データを守りながら、小さなモデルへ難しすぎる仕事を渡さないことでした。

配置軸は「データの機密性」と「仕事の難しさ」だけではありません。実装を続けるうちに、「完了を機械で判定できるか」も重要だと分かりました。短文分類やマスキングはブラウザ、社内文書やログの一次処理はローカルPC、複雑な設計や複数ファイルの実装はクラウドへ置きます。さらにローカルへ渡すのは、「候補0件」「ファイル生成済み」「テスト成功」のように終了条件をコマンドで確認できる仕事からにしました。

利用者に毎回モデルを選ばせる設計にはしていません。ルーターがタスク種別、データ区分、必要品質から実行先を決めます。レート上限も作業ディレクトリではなくアカウント全体にかかるため、後の実装ではアカウント単位の上限台帳を持たせました。別のクラウド実行先が安全に使えると見込めるときだけ渡し、データ境界を越える場合や行き先が無い場合はローカルへ落とします。

空いているモデルへ送るのではなく、そのデータと仕事を置いてよい場所へ送る。

2. コンテキストの削減 — 能力説明ではなく、今の仕事へ入力予算を使う

意図は、9Bモデルの入力予算をプラットフォームの説明で使い切らないことでした。

通常のClaude Codeには、グローバル指示、プロジェクト指示、ルール、スキル、プラグイン、MCPツール、自動メモリが入ります。私の環境では、指示ファイルとルールだけでも100KBを超えていました。これをそのままローカルモデルへ渡すと、回答前のprompt evalに数分かかり、肝心のユーザー指示が大量のツール定義に埋もれます。

そこでローカル継続時は軽量モードにし、通常のフック、指示ファイルの自動読込、プラグイン、MCP、自動メモリを外しました。残したツールはRead、Edit、Bashだけです。

約200KB → 約4KB

記録上、初期入力は約50分の1になりました。汎用能力を大量に説明するより、現在の仕事に必要な事実と行動規則へ入力予算を使います。

モデル名も一か所だけではなく、メイン、軽量処理、サブエージェントの3経路すべてをローカルモデルへ向けます。一つでも漏れると、Ollamaへ存在しないクラウドモデル名を要求するためです。

削った代わりに、「使えるツール」「検索やテストはBashで行う」「一度に一つ進める」「推測せずファイルを読む」を短く明示しました。

3. handoff — 会話をコピーせず、再開に必要な作業状態を保存する

意図は、別のモデルが会話を再現することではなく、中断地点から安全に仕事を再開できることでした。

そのため会話全文は渡しません。handoffへ保存するのは、直前のユーザー指示、未完了TODO、ブランチ、Git statusの先頭40行、直近5コミット、直前のアシスタント出力末尾です。作業ディレクトリと上限リセット情報も添えます。

保存する情報再開時の役割
直前のユーザー指示何を終えるか
未完了TODOどこまで進んだか
Git状態何を壊してはいけないか
直近コミット現在の基準点
最後の回答直前に何を考えていたか

handoffは共有ディレクトリへ置き、クラウドの実行プロファイルを跨ぐ場合は発火元と引き継ぎ先のプロジェクトメモリにも書きます。プロファイルが変わるとメモリの保存場所も変わり、片側だけでは自動読込できないためです。また、handoffは作業ディレクトリの外にあるので、再開時に明示的に読取対象へ追加します。

復帰時にはhandoffを正本だと思い込まず、ブランチ、最新コミット、未コミット差分を再確認します。handoffは作業そのものではなく、現在地を見つけるためのチェックポイントです。

4. 冪等性 — 「継続しない」より「同じ変更を二重にする」を重く見る

意図は、同じエラーが連続したときに、同じ仕事を複数のエージェントが始めないことでした。

利用上限エラーは一度だけ届くとは限りません。並列セッションから同じ作業ディレクトリで発火することもあります。そこで重複防止を複数の粒度に分けました。

  1. セッション単位では、排他的作成を使ってマーカーを原子的に一度だけ作る
  2. 作業ディレクトリ単位では、600秒のクールダウンを置く
  3. 復帰処理では、.resumedマーカーで同じhandoffの再開を一度に制限する
  4. 復帰ウォッチャー自体は、mkdirの原子性を使った単一インスタンスロックで守る
  5. クラウド実行先を行き来する連鎖には最大ホップ数を置き、超えたらローカルへ落とす

クールダウン時刻は、フックが呼ばれたときではなく、継続セッションを起動すると決めたときだけ更新します。毎回更新すると、エラーの連発によって待ち時間が永久に延びるためです。

失敗は止めないが、見えなくしない

フック本体は原則exit 0にし、フェイルオーバーの障害で元のClaude Codeまで止めません。一方でhandoff、spawn、抑止理由、復帰結果は別に記録します。

5. プロセス管理 — ローカルLLMを「モデル」ではなく常駐サービスとして扱う

意図は、AIの継続より先にMacを生存させることでした。

21GBのメモリ暴走とWindowServerの監視タイムアウトを経験し、個々のジョブが正しくても、同時に走る総量が機体の容量を超えれば実務システムではないと分かりました。

設計上はOllamaを起動する主体を一つにし、非常用インスタンスは専用ポート、専用モデルディレクトリ、内蔵ストレージへ分離します。通常系と同じポート、外付けSSD、起動設定に依存させると、障害時に一緒に落ちるためです。モデル数、並列数、keep-alive、コンテキスト長の正本も一か所へ寄せます。

さらに重い無人ジョブの入口にはresource guardを置きました。主指標は「いま」の逼迫を示す空きメモリ率とCPUコアあたりのloadです。swapと圧縮メモリは、一度増えると健全化後も戻りにくい遅行指標だったため、主判定から遠いbackstopへ下げました。同時実行はPIDに結び付いたリースで数え、上限を超えたジョブはキューへ積まず、その回を見送ります。後で一斉に再開して再び機体を詰まらせないためです。計測に失敗した場合も重いジョブは見送りますが、全処理が黙って止まり続けないよう、判定をJSONLへ残し、見送りが連続した状態自体を異常として通知します。

走り出した後の減圧は別のwatchdogが担当します。危険の手前でモデルをアンロードし、新しい重いジョブへ短いブラウンアウトを出します。ただし、ユーザーのプロセスを自動でkillしません。

無害な減圧は自動化し、誰のプロセスを止めるかは人へ残す。

6. 復帰監視 — フェイルオーバーとフェイルバックを別の状態機械にする

意図は、「ローカルで拾えた」を成功扱いせず、元の実行先へ戻るところまで制御することでした。

復帰ウォッチャーは15分ごとに動きますが、保留handoffが無ければAPIプローブも打たず終了します。保留がある場合だけ、新しいものから一件を選び、「PING_OKだけ返す」という最小プローブで上限解除を確認します。解除されていなければ理由を記録し、解除されていれば.resumedを先に置いて二重再開を防ぎ、別ログへ再開結果を残します。マーカーを先に置くのは重複防止を優先する設計であり、起動自体が失敗した場合の自動再試行を弱くします。そのため再開ログと通知を対にし、マーカーだけを成功指標にしません。

最初の設計では、12時間を超えたhandoffを期限切れにしていました。しかし2026年7月25日時点では自動復帰は0件で、69件が期限切れ、29件は導入前の過去分として意図的にスキップされていました。固定TTLは、実際のリセット時刻を表していなかったのです。

この反省から後のルーティング実装では、上限状態を作業単位ではなくアカウント単位の台帳にしました。時刻を読める上限はリセット予定時刻を使い、週次上限のように日付が分からないものは別の再試行間隔を使います。見積りが外れても同じ実行先を往復し続けないよう、最大ホップ数を最後の砦にしています。

復帰監視で大切なのは、時間を待つことではなく、状態遷移の根拠と失敗理由を残すことでした。

7. 安全ゲート — ツール実行権限と、業務上の承認を分離する

意図は、無人セッションを動ける状態にしながら、外部への影響は人間の権限として残すことでした。

初期実装では編集だけを自動承認するモードを使いました。しかし実ログでは、Gitやテスト、GitHub CLIが承認待ちになり、承認者のいないセッションが「実行します」と報告して止まりました。反対に、すべてを無条件で許可すれば、投稿、merge、本番デプロイまで進む危険があります。

そこで後続の実装では二つを分けました。

境界含めるもの
実行権限ファイル確認、編集、Git、テストなど、作業を進めるための道具
業務上の承認外部投稿、push、merge、本番デプロイ、決済、認可、外部送信を決定する権限

ガード用フックが有効なクラウド側の無人継続では、実行権限を広げても、高リスク操作はフックで止めます。フックが無効になる軽量なローカル側には同じ権限拡張を適用せず、プロンプトでも「可逆な作業だけ進め、承認が必要ならドラフトを作って停止」と明示します。

また、handoffの本文をtmuxのシェルコマンドへ直接展開しません。ランチャーへファイルパスだけを渡し、本文は単一の引数として読み込みます。引き継いだ文章にコマンド置換の記号が入っていても、シェルに再評価させないためです。

安全ゲートは注意書きだけではない

権限、ガード、プロンプト、引数境界を重ね、どれか一つが破れても外部影響へ直結しないようにします。

8. 完了率の計測 — 「返事をした」ではなく、検証可能な終了状態まで追う

意図は、起動数や生成文字数で成功した気にならないことでした。

計測は次のファネルに分けます。

段階確認すること
検知本当にサブスクリプション上限だったか
handoff再開に必要な状態を保存できたか
spawn継続セッションを一度だけ起動できたか
executionツールを呼び、ファイルや差分を作れたか
verificationテストや件数確認など、完了条件を満たしたか
resume元の実行先へ戻り、重複なく再開できたか
acceptance人が必要な最終判断を行えたか

フックのログにはspawnかmemory-onlyか、その理由、ルート、handoff先を残します。継続セッションの出力、.resumed、.skip、ウォッチャーの判定も別々に数えます。これによって、98件中86件を起動できた一方、クラウド復帰は0件だったと分解できました。

タスクごとの完了条件も先に決めます。「候補が0件」「指定ファイルが存在する」「テストが成功する」のように機械判定できる状態です。外部書込みが必要な仕事は、ドラフト作成を自動処理の完了とし、送信は人間の別工程にします。

ローカルLLMのKPIはtokens/secでも応答率でもなく、決めた安全境界の中で検証可能な終了状態へ到達した割合。

FIELD RESULTS98回動かして分かった、4つの現実

1. 「落ちたら拾う」は動いたが、「戻る」は0回だった

指標実測値
handoff総数98件
ローカルセッション起動86件
クールダウンによる抑止12件
クラウド側への自動復帰0件
期限切れ・過去分としてスキップ98件

最大の原因は、handoffの有効期限を12時間にしていたことでした。実際の上限解除が12時間を超えるケースでは、解除される前にhandoffが期限切れになります。

時間ベースの固定TTLではなく、上限のリセット予定時刻を基準に待つべきでした。リセット時刻が分からない場合も、古いhandoffを捨てる前に人へ通知する方が安全です。

起動成功率と業務完了率を分ける

起動だけを見れば86/98で成功に見えます。クラウド復帰まで含めると0/98です。

2. Ollamaの二重起動が、82万行のエラーを作っていた

Macには、手動で作ったlaunchdサービスとHomebrewのサービスが両方残っていました。どちらも同じポートでollama serveを起動しようとしていました。

調査時のログは572MB、約217万行。そのうちaddress already in useが821,819行、37.8%でした。

さらに2つのサービスは設定が違いました。一方にはモデル数、並列数、keep-alive、KV cacheの制限があり、もう一方にはありません。

起動競争で制限のない方が勝てば、メモリ暴走対策が無効になります。これは以前書いたローカルLLMの21GBメモリ暴走やWindowServer監視タイムアウトの再発条件でした。

ローカルLLMの運用では、モデル設定より先にプロセスの所有者を一つにする必要があります。

3. 外付けSSDを抜くと、通常運転のモデルが消えた

大きなモデルは外付けSSDへ置いていました。しかしSSDが外れた状態では、通常のOllamaがモデルディレクトリを作れず起動に失敗します。

一方、非常用の9BモデルはMac内蔵ストレージの専用ディレクトリに置いていたため、生き残りました。

フェイルオーバー先が本体と同じストレージ、同じポート、同じ設定に依存していたら、障害時に一緒に落ちます。非常用経路は依存を分ける必要があります。

4. 9Bモデルは「巡回」には使えたが、「実装の続き」は難しかった

ローカル継続セッション46件の出力サイズを調べました。

成功したのは、対象IssueやDraft PRが0件か確認するような定型巡回です。

典型的な失敗は、実行すべきコマンドを本文に書き、「このコマンドを実行します」と宣言して止まることでした。文章は正しくても、ツール呼び出しが出ていません。

ローカルへ任せやすいクラウドへ残す
候補が0件かの巡回複雑な設計判断
Git差分の要約複数ファイルにまたがる実装
ログの分類と件数集計原因が不明なデバッグ
定型文の下書き外部サービスへの書込み
長文の一次要約本番変更、決済、権限変更

NEXT ACTIONS改善はこの順番で行う

1. 実行プロセスを一つにする

launchd、Homebrew Services、手動起動を棚卸しし、Ollamaを起動する主体を一つにします。ポート、モデル置き場、並列数、メモリ設定の正本も一つにします。

2. 復帰条件を固定TTLから変える

「12時間経ったら破棄」ではなく、上限リセット時刻、最終プローブ結果、人への通知を組み合わせます。handoffを破棄する処理にも理由を記録します。

3. 非常用モデルを内蔵ストレージへ置く

通常運転の大きなモデルは外付けでも構いません。障害時に必要な最小モデル、設定、handoff、ランチャーは内蔵側へ残します。

4. 完了条件が機械判定できる仕事だけ渡す

「候補0件」「ファイルが生成された」「テストが通った」のように、完了をコマンドで検証できる仕事から始めます。「いい感じに実装する」は渡しません。

5. モデルではなく系全体を計測する

tokens/secだけでなく、handoff作成、spawn、ツール呼び出し、完了、復帰、失敗理由を記録します。今回の0/98は、この分解があったから発見できました。

CHECKLIST導入前に確認すること

まとめ

ローカルLLMを実務で使うために必要だったのは、もっと大きなモデルではありませんでした。

必要だったのは、仕事の配置、コンテキストの削減、handoff、冪等性、プロセス管理、復帰監視、安全ゲート、そして完了率の計測です。

98回のフェイルオーバーで、ローカル起動は86回成功しました。しかしクラウド復帰は0回でした。この差が、デモと実務の距離です。

ローカルLLMは「返事ができる」だけでは足りません。壊れず、重複せず、観測でき、終わったことを確認できるところまで設計して、初めて仕事を任せられます。