はじめに
ClaudeやChatGPTのようなクラウドAIを日常的に使っていると、必ずぶつかる壁がある。「これ、別に賢さは要らないんだけど、量が多い」という作業だ。大量のファイルを同じルールで整形する、決まった手順でデータを分類する、定型的な集計を繰り返す——こういうタスクにフラッグシップモデルのトークンを使うのは、正直もったいない。
一方で自宅には、NVIDIAのDGX Spark(GB10、128GB統合メモリ)というローカルGPUマシンがある。「これを単体のチャットAIとして使うのではなく、クラウドAIの配下で動く"部下"にできないか」というのが今回の出発点だった。
この記事は、その構想を実際に動くところまで作り、比較実験をし、途中で何度も壁にぶつかりながら解決した過程の記録。結論から言うと、構想は動いた。ただし、その過程で得られた教訓の方が構想そのものより価値があったというのが正直な感想。
全体構想:判断はクラウド、実行はローカル
設計方針はシンプルにした。
- クラウドAI(Claude / Codex) が判断・監督・品質チェックを担当する。タスクを分解し、ローカルに投げるかどうかを判断し、返ってきた成果物をレビューする。
- ローカルAI(DGX Spark上のLLM) は、大量・反復・そこまで難易度の高くない作業を黙々とこなす実行部隊。
クラウドAIが完全に手放しで委任するのではなく、最終責任と品質保証はクラウドAI側に残すというのがポイント。ローカルAIは「信頼できるが目は離せない新人」くらいの位置づけにした。
アーキテクチャ
最終的にできあがった構成は次の三層。
- クラウドAI:Claude・Codexが判断・監督・品質チェックを担う。
- Mac mini(実行環境):日常使いのMac miniにOpenHandsのSDKを導入し、実際のファイル操作・コマンド実行を行わせる。ここが今回一番手をかけた部分で、後述の通りサンドボックス隔離を組んでいる。
- DGX Spark(GPU推論エンジン):Dockerで動かしたvLLM上でQwenモデルを動かし、推論だけに専念させる。
Mac miniとDGX SparkはSSHポートフォワードで結んでいて、LAN上には一切公開していない。
なぜMac miniにOpenHandsを置いたのか
最初は「GPUがあるDGX Spark上でエージェントも実行すればいい」と考えていたが、これは間違いだった。触りたいファイルは大抵Mac mini側にある。 エージェントの「頭脳(LLM推論)」と「手足(ファイル操作・コマンド実行)」は別の場所にあってよく、今回は「頭脳はDGX Spark、手足はMac mini」という分離にした。OpenHands SDKはDGX Spark側にも入れてあり、DGX Spark上のファイルを触りたいときはそちらを使う、という使い分けにしている。
実装のポイント1:暴走対策としてのサンドボックス隔離
ローカルLLMをエージェントとして動かす上で一番怖いのは、LLMの判断ミスやハルシネーションでMac mini上の意図しないファイルが書き換わる・消えることだった。
実際に検証してみると、OpenHandsのworkspaceパラメータは隔離境界としてまったく機能しないことが分かった。絶対パスを指定すればfile_editor・terminalどちらのツールもworkspace外へ自由に書き込める。つまり「ワークスペースを指定しておけば安全」という思い込みは通用しない。
そこで採用したのが、macOS標準のsandbox-exec(Seatbelt機構)によるOS強制のプロセスサンドボックス。LLMの判断に一切頼らず、カーネルレベルでアクセスを制限する。
~/apps/openhands/
├── sandbox/openhands.sb … サンドボックスプロファイル
├── outbox/ … OpenHandsが書き込める唯一の場所
└── run_sandboxed.sh … 起動ラッパー(唯一の起動経路)
プロファイルの要点:
- 書き込みは
(deny file-write*)を基本にし、outbox/だけをallow - 読み取りは
(allow default)を基本にしつつ、~/.ssh・Keychain・ブラウザのCookie/プロファイル等は明示的にdeny
実機で検証した結果は以下の通り。
| テスト | 結果 |
|---|---|
| outbox内への書き込み | 成功 |
outbox外(~/Documents等)への書き込み・削除 |
Operation not permittedで拒否、ファイル無傷 |
~/.ssh/configの読み取り |
Operation not permittedで拒否 |
| OpenHandsに直接「outbox外に書け」と指示 | ツールは実行を試みるがOSに拒否され、エージェントも失敗を正直に報告 |
LLMに「やらないで」と指示するのではなく、OSレベルで物理的にできなくする。 これが今回一番効果があった設計判断だった。成果物はいったんoutboxに置かれ、クラウドAIがレビューした上で、Claude/Codex自身の通常のファイル操作(既存の安全ルールが効く経路)で本来の場所へ反映する、という二段構えにしている。
実装のポイント2:推論エンジンとモデルの選定
DGX Spark側は当初Qwen3.5-9Bをllama.cppで動かしていたが、性能優先でQwen3.8-27B(Mamba+Attentionのハイブリッドアーキテクチャ、次トークン予測用のMTP: Multi-Token Predictionレイヤーをチェックポイント自体に内蔵)へ切り替えた。
エンジンはvLLMを採用した。決め手はMTP対応。Qwen3.8-27Bは追加のdraftモデルなしでMTPによる投機的デコーディングが使えるが、llama.cppはこのレイヤーを認識せずunused tensor -- ignoringで無視してしまう。vLLMは--speculative-configで明示的に有効化できる。
量子化形式はNVFP4(Inferact/Qwen3.8-27B-NVFP4、NVIDIA modelopt形式)を採用。同じNVFP4でもlm_headまで量子化するチェックポイントは動作中のvLLMバージョン(0.19.2rc1)では起動に失敗したため、lm_head・MTPレイヤー・線形アテンション層を量子化除外している版を選んだ。
最終的なベンチマーク(実処理速度):
| 構成 | 精度 | MTP | 生成速度(tok/s) |
|---|---|---|---|
| llama.cpp Qwen3.5-9B | Q8_0 | - | 27.72 |
| llama.cpp Qwen3.8-27B | Q4_K_M | - | 12.37 |
| llama.cpp Qwen3.8-27B | Q8_0 | - | 7.83 |
| vLLM Qwen3.8-27B(公式FP8) | FP8 | 無効 | 8.28 |
| vLLM Qwen3.8-27B(公式FP8) | FP8 | 有効 | 12.0 |
| vLLM Qwen3.8-27B(Inferact NVFP4) | NVFP4 | 無効 | 約8.5 |
| vLLM Qwen3.8-27B(Inferact NVFP4) | NVFP4 | 有効 | 13.1〜13.2(採用) |
MTPを有効にするだけで、同じ量子化形式でも生成速度が4〜5割ほど向上している(FP8: 8.28→12.0、NVFP4: 約8.5→13.1〜13.2)。モデルサイズは3倍近くになったが、MTPと量子化の工夫で速度低下は最小限に抑えられた。
実装のポイント3:常時接続とタスク非依存の安全弁
Mac mini→DGX Spark間は、LaunchAgentで常駐させたSSHポートフォワード(ssh -N -L 8081:127.0.0.1:8081 DGX-Spark)で結んでいる。ログイン時に自動起動し、切断時は自動再接続する。ファイアウォール変更やAPIキー発行は一切していない。
もう一つ重要だったのが、OpenHands SDK側のmax_output_tokens設定。これは「1ターンでLLMが生成してよい最大トークン数」を指定するもので、ローカルAIが特定タスク専用ではなく汎用の作業委任先である以上、タスクの想定出力サイズに合わせてチューニングするのは誤りという結論に至った。CUDAレベルの異常終了防止・GPU上のKVキャッシュプールの独占防止のための、タスクに依存しない固定の安全弁として扱うのが正しい。ちなみにClaude Code自身もサブエージェントの出力上限を8000トークン固定で運用しており、これを参考値として採用した。
実験:クラウド単独 vs ローカル委任、どちらが速いか
いつものルーチンワーク(家計簿アプリの資産スナップショットを分析・記録するタスク)を題材に、実際に比較した。
- 測定方法:時間=ユーザーの指示から成果受領までの経過時間。トークン=クラウドAI・ローカルAI双方の消費量の合計。
- Path A(クラウドAI単独):約2分46秒で完走、正本と完全一致。
- Path B(ローカルAIへ委任+クラウドAIがレビュー):最終的に約5分46秒で完走、正本と完全一致(端数まで含めて)。
速度は約2.1倍クラウドAI単独が速いが、ローカルAIの分類精度は実用レベルで、最終的に完走できることを実証した。 ただし、この「約5分46秒」に至るまでには、後述する通りかなりの回り道があった。
トラブルシューティング全部乗せ
正直に言うと、Path Bが一発で成功したことは一度もない。以下、実際に起きた問題と、原因の特定に至るまでの過程を時系列で並べる。これから同じようなローカルLLMエージェント基盤を組む人にとっては、この節が一番参考になるはず。
1. thinkモードが出力を食いつぶす
Qwenはデフォルトで<think>...</think>という長い思考過程を出力する。複雑なタスクでは、この思考だけで出力トークン上限を使い切り、ファイル保存などの実際のツール呼び出しに一度も到達しないまま応答が終わる現象が起きた。分類ロジック自体は完璧に正しかった(62件の明細を人間の正解と一致するレベルで仕分けていた)だけに、「実行に辿り着けない」というのは意外な壁だった。
対処:litellm_extra_body={"chat_template_kwargs": {"enable_thinking": False}}で明示的に無効化。reasoning_effort="none"のような一般的なパラメータだけでは不十分だった。
2. MTPがvLLMごとクラッシュさせる
投機的デコーディング(MTP)を有効にした状態でエージェントタスクを回すと、torch.AcceleratorErrorやindexSelectSmallIndexのアサーション違反でvLLMプロセスごと2回クラッシュした。単発のベンチマークでは問題なかったのに、複数ターン・ツール呼び出しを伴うタスクでだけ再現する。
一旦はMTPを無効化して先に進んだが、後述の対処(次項)を全部適用した上でMTPを再度有効化したところ、12分以上クラッシュせず安定稼働した。主因は「無制限の生成+投機的デコーディング」という組み合わせだった可能性が高いという結論に落ち着いている。
3. コンテキスト長超過
辞書ファイルの読み込みだけで--max-model-len 32768を超過し、ContextWindowExceededErrorが発生。98304まで拡張して解決。幸いDGX Sparkは128GB統合メモリなのでKVキャッシュに余裕を持たせやすく、コンテキスト長は気前よく確保する方針にした。
4. 生成上限なしの暴走
vLLMのメトリクス(/metrics)を確認したところ、完了リクエスト数が変わらないまま生成トークン総数だけが増え続けており、1ターンだけで37,000トークン以上(推定約71分相当)を生成し続けていたことが判明した。max_output_tokensを設定していなかったのが原因。これが前述の「タスク非依存の固定安全弁」という結論につながっている。
5. 真犯人はLLMではなくツール実行側だった
上限設定後も約49分かかった。「トークン生成速度だけでは説明できない遅さだ」という指摘を受けてvLLMの詳細メトリクス(request_prefill_time_seconds_sum・request_decode_time_seconds_sum)を確認したところ、完了11ターン分のvLLM実処理時間は合計わずか約222秒(3.7分)。経過49分の92%はvLLMの外側、つまりOpenHandsのエージェントループ・ツール実行側で消費されていたことが分かった。
ログを遡ると、tmux is not installed. Falling back to subprocess-based terminal, which may be less stableという警告が出ていた。tmuxを導入すると、最大の空白時間は約30分→約4分47秒まで縮んだ。
教訓:ローカルLLMエージェントが遅いとき、まずLLMの生成速度を疑いがちだが、実際のボトルネックはツール実行・エージェントループ側にあることが多い。 メトリクスで実処理時間を切り分けずに「モデルが遅い」と結論するのは早計だった。
6. tmux導入がさらに別のハングを誘発
ところが、tmuxを導入した直後の試行でプロセスが完全にハングした(vLLMへのリクエストが一切来ないまま55分経過)。調べてみると、OpenHands本体の既知バグ(GitHub Issue #12011)と一致していた。原因は、TerminalToolの実装がTMUX_SOCKET_NAME = "openhands"という固定のtmuxソケット名を全実行で共有する設計になっていたこと。セッション名自体はUUIDでユニークだが、ソケットは共有されている。
過去にkill/pkillでPythonプロセスを何度も強制終了していたため、このソケット上に残留セッションが蓄積していた。
tmux -L openhands kill-server
でソケットごと一掃してから再試行したところ、約10分で完走し、成果物が正しく生成された。強制終了する運用を続ける限り、この後片付けをセットで行う必要がある。
まとめ:問題は「判断力」ではなく「実行の完遂」だった
一連のトラブルを振り返って一番印象的だったのは、ローカルLLM(Qwen3.8-27B)自体の判断力は最初から実用レベルだったということ。62件の複雑な按分処理を、人間の正解と一致する精度でこなしていた。今回ハマった問題はどれも「賢さ」の問題ではなく、「最後まで実行を完遂できるか」という、エージェント基盤側(thinkモードの扱い、暴走対策、ツール実行の安定性)の問題だった。
これは今回固有の話ではなく、成熟した著名なエージェントフレームワーク(PydanticAI等)でも、ローカルLLM×ツール呼び出しの信頼性問題は同種の形で報告されている、業界共通の課題のようだった。
今後の課題:委任した仕事の完了をどう知るか
もう一つ、今回の作業で浮き彫りになった論点がある。「部下(ローカルAI)の仕事が終わったことを、発注元(クラウドAI)がどう知るか」 という問題だ。
プロセスが正常終了・異常終了すれば、実行基盤の仕組みで自動的に検知できる。しかしハングした場合は、プロセスが「終了」しない限りこの仕組みは発火しない。 実際、40分・55分とハングした際も自動通知は一切なく、クラウドAI側がvLLMのメトリクス・ログを能動的に確認しに行って初めて異常に気づいた。
これは今後、ジョブキュー・状態管理(ハードタイムアウト・定期的なハートビート)を備えたゲートウェイを挟む必要がある、ということを裏付ける実例になった。現状はまだ、人間・クラウドAIが手動でスクリプトを実行する段階に留まっている。
まとめ
- クラウドAIを判断・監督役、ローカルLLMを実行部隊とする三層構成は、実際に動かすところまで到達した。
- ローカルLLM自体の判断力は実用レベルだったが、実行を完遂させるまでにはthinkモード・投機的デコーディングの安定性・生成上限・ツール実行基盤の安定性という、性能とは別次元の壁が何段も存在した。
- 暴走対策としてのOSレベルサンドボックスは、LLMの判断に頼らない確実な安全装置として有効だった。
- 「委任した仕事の完了をどう検知するか」という運用上の課題が、次に取り組むべきテーマとして残っている。
自宅のGPUマシンをクラウドAIの部下にする、という構想自体は決して突飛なものではなく、地に足をつけて一つずつ検証していけば十分実用に届く、というのが今回得られた実感。同じことをやろうとしている人の参考になれば幸い。
