💡 TL;DR (この記事の3行要約)
- 課題: 12GB VRAM環境で自律型AIエージェント(Aider等)をローカルLLMで常時運用する際、リソース衝突による極端な速度低下、KVキャッシュ汚染による推論精度低下、およびパースエラーによるエージェントの処理の停止や例外の発生が課題となる。
- 対策: メモリ特性の異なるMoEとDenseモデルの最適な配置と、エージェント側を無改造のまま中継・フォーマット補正するプロキシ、無停止監視デーモンとTUIを分離した監視基盤を構築。
- 成果: エージェント本体を変更せずに、ローカルLLMを安定運用する制御プレーンを構築し、systemdとSSHによる運用まで自動化した。
1. はじめに
本稿で紹介するデュアルモデル構成の理論的背景(なぜこの構成に至ったのか、およびMoEがCPUオフロードで高速動作する仕組みなど)については、以下のZenn記事で詳しく公開しています。
有限リソースにおける自律型AIエージェント構築 ― プロファイリングが導いたデュアルモデル構成と透過型制御プレーン ―
本記事でいう**「透過型制御プレーン」とは、LLMやエージェント本体には直接手を加えず、その周囲で「ルーティング」「監視」「デプロイ」「エラーハンドリング」の4つの役割**を担う補助インフラレイヤーを指します。本稿では、この構成を実用的な開発用プロダクション環境として動作させるために作成した自作ツール群 llm-toolbox の具体的な実装方法、制御コードの動作解説、およびデプロイ・運用マニュアルに特化して解説します。
2. 全体アーキテクチャとリポジトリ構成
ローカルLLM環境をエージェント等の開発インフラとして常時実用運用しようとすると、VRAMの競合、KVキャッシュ汚染、頻繁なモデル切り替えによるオーバーヘッド、複数プロセスの死活管理、そしてリソース逼迫の監視など、推論エンジン単体では解決できない頭の痛いインフラ課題が山積みになります。
llm-toolbox は、これらの課題を「Proxy」「Monitor」「Deployment」の3レイヤーに役割分離して、力技ではなくインフラの仕組みで解決しました。
システム全体のデータフローおよび各コンポーネントの連携関係は、以下の通りです。
リポジトリツリー構成:
llm-toolbox/
├── monitor/ # プロファイリング・監視基盤
│ ├── llama_monitor_daemon.py # バックグラウンド監視デーモン
│ ├── llama_monitor_tui.py # Textual/Curses等によるリアルタイム描画TUI
│ └── monitor.env # 環境変数定義ファイル
├── proxy/ # 透過型制御プロキシ群
│ ├── __main__.py # パッケージエントリポイント
│ ├── aider_proxy.py # エージェント(Aider等)用動的ルーティングプロキシ
│ ├── kilo_proxy.py # Kilo(別エージェント・エディタ等)用中継プロキシ
│ ├── codex_proxy.py # コード補完(Codex互換)用中継プロキシ
│ ├── proxy_common.py # 共通ロジック(エラーハンドリング、接続維持)
│ ├── proxy_etags.py # ETag/キャッシュ制御マネージャー
│ └── proxy_metrics.py # プロキシ層でのパフォーマンス計測
├── scripts/ # 起動・初期化スクリプト
│ ├── init-aider.sh # 環境構築・初期化シェルスクリプト
│ └── init-aider-python.sh # Python環境のセットアップスクリプト
├── systemd/ # 永続稼働のための設定ファイル
│ ├── llm-proxy@.service # プロキシ用テンプレートUnitファイル
│ └── stateforge-monitor.service # モニターデーモン用Unitファイル
└── llm-startup-dual.sh # リモート環境構築・再起動用自動化スクリプト(ルート直下)
以降では、本基盤を構成する「Proxy」「Monitor」「Deployment (systemd/SSHスクリプト)」の3要素の実装と役割について、順に解説していきます。
※ kilo-proxy.pyはテスト中、odex_proxy.pyは設計中の試作品です。!
3. 制御プロキシ (proxy) の実装詳細
3.1. aider_proxy.py による割り込みと補正ロジック
本プロキシは、エージェントとLLMサーバーの間に配置し、通信内容を透過的に加工・制御するレイヤーです。
Aiderをはじめとするエージェントツールは通常、API呼び出し先を指定するパラメータは変更できても、中継時のリクエスト加工や動的なKVキャッシュの初期化を自ら行うことはできません。
これを解決するため、エージェントとローカルLLMサーバーの間に割り込んで中継・補正を行うプロキシ aider_proxy.py を実装しました。
import json
import re
import requests
from flask import Flask, request, Response
from proxy.proxy_common import handle_exception # 共通エラーハンドリングのインポート
app = Flask(__name__)
@app.route('/v1/chat/completions', methods=['POST'])
def chat_completions():
data = request.get_json(silent=True) or {}
model_name = data.get('model', '').lower()
# 1. 動的ルーティング判定
if 'sonnet' in model_name or 'architect' in model_name:
target_url = "http://127.0.0.1:9093/v1/chat/completions" # Gemma(Architect)
is_editor = False
else:
target_url = "http://127.0.0.1:9090/v1/chat/completions" # Qwen(Editor)
is_editor = True
# 2. KVキャッシュ汚染の自動回避
messages = data.get('messages', [])
if messages and not any(m.get('role') == 'assistant' for m in messages):
try:
requests.post("http://127.0.0.1:9090/slots/0?action=erase", timeout=3)
requests.post("http://127.0.0.1:9093/slots/0?action=erase", timeout=3)
except Exception as e:
print(f"[WARNING] Slot clear failed: {e}")
# 3. タイムアウト対策として非ストリーム化
data['stream'] = False
headers = {k: v for k, v in request.headers.items() if k.lower() not in ['host', 'content-length']}
try:
response = requests.post(target_url, json=data, headers=headers, timeout=3600)
response_json = response.json()
# 4. フォーマット自動補正 (Editorタスク時のみ適用)
if is_editor:
content = response_json['choices'][0]['message']['content']
content = normalize_patch_format(content)
response_json['choices'][0]['message']['content'] = content
return Response(json.dumps(response_json), status=response.status_code, mimetype='application/json')
except Exception as e:
return handle_exception(e)
def normalize_patch_format(text: str) -> str:
# SEARCH/REPLACEブロックの改行漏れを正規表現で自動修復
text = re.sub(r'(<<<<<<< SEARCH)([^\n])', r'\1\n\2', text)
return text
💡 解決の3つのポイント
-
① 役割ごとのモデル切り替え(ルーティング): 送信されたリクエストのモデル名を判定し、
Sonnet(Architect)なら Gemma-4-26B-MoE (ポート9093) へ、Haiku(Editor)なら Qwen-3.6-35B-Coder (ポート9090) へ自動で振り分けます。 -
② KV Cache強制クリアによる履歴干渉(セッション汚染)防止: 初手メッセージ(対話開始時)を検知した瞬間に
/slots/0?action=eraseを叩いてKVキャッシュを強制消去し、前回のセッション内容との干渉を防ぎます。 -
③ コードブロックのフォーマット自動補正: ローカルモデルで発生しがちな
<<<<<<< SEARCH直後の改行漏れを正規表現で自動修復し(詳細はGitHubを参照)、エージェント側でのパッチ適用フェーズにおけるパースエラーや処理中断を防ぎます。
💡 コラム:なぜ Anthropic のモデル名 (Sonnet/Haiku) を指定するのか?
Aiderは標準で「Architectモデル=Sonnet」「Editorモデル=Haiku」によるデュアルモデル構成をサポートしています。ローカルモデル独自の名称を設定して運用する場合、Aider内部の判定ルールや設定ファイルの定義を細かく書き換える必要があり、ツールのバージョンアップ等に伴うメンテナンスコストが高くなります。
そこで、Aiderには標準の claude-3-5-sonnet と claude-3-haiku を指定してリクエストを送信させ、本プロキシ側でその文字列を検知してローカルのGemmaとQwenにルーティングします。これにより、クライアント側のコード変更を完全にゼロにした運用が成立します。
3.2. proxy_commonによるマルチテナント・フロントエンド対応
llm-toolbox は、Aiderだけでなく、エディタのコード補完プラグインやその他の自律エージェントも同時に接続可能なマルチテナント設計を採用しています。
リポジトリ内の kilo_proxy.py(Kiloエージェント用)や codex_proxy.py(Fauxpilot/Copilot互換インターフェース用)は、すべて共通の proxy_common.py を仲介する構造になっています。
# 共通ロジックの設計概念
# エラーハンドリングの一元化、およびタイムアウト回避のためのキープアライブ処理
import sys
import traceback
from flask import jsonify
def handle_exception(e):
# エラー情報をトラッキングし、エージェントが例外エラーで停止しない形式のJSON応答へ変換
exc_type, exc_value, exc_tb = sys.exc_info()
error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
print(f"[ERROR] Proxy error occurred:\n{error_msg}", file=sys.stderr)
return jsonify({
"error": {
"message": f"Internal proxy error: {str(e)}",
"type": "proxy_error",
"param": None,
"code": "500"
}
}), 500
💡 共通化のインフラ的メリット
-
エラーハンドリングの一元化: LLMサーバーのダウンやタイムアウト発生時、エージェント側が予期せぬパースエラーで処理が中断するのを防ぐため、
proxy_common.py内のhandle_exceptionを介してOpenAI API互換のクリーンなエラー応答に正規化します。 - タイムアウト回避(キープアライブ): 長時間の推論時にHTTP接続が切断されるのを防ぐため、応答がない間もダミーのパケット(スペースなど)を定期送出し続けるロジックが共通で組み込まれています。これにより、異なるクライアントツールを同じLLMサーバー基盤へ同時に接続しても、バックエンドを共有しつつ個別に安定稼働させることが可能となります。
4. プロファイリング基盤 (monitor/) のデーモン・TUI分離設計
本基盤では、監視プロセスとUI描画処理を一体化させず、「収集(Daemon)」と「表示(TUI)」を完全に分離しています。UIを閉じてもバックグラウンドでリソース状態の監視や統計収集が途切れることなく継続するメリットが得られます。
データフローは以下の通りです。
💡 なぜ収集(デーモン)と描画(TUI)を分離するのか?
UI描画(Textual等)による一時的な負荷スパイクから推論エンジンを守り、かつ以下のインフラ的メリットを得るためです。
-
SSH切断時でもサンプリング継続:
tmux等を使わなくても、SSH瞬断時に監視が途切れません。 - systemdによる無停止運用: UIのないデーモンに徹することで、自動起動や再起動の管理が容易になります。
- 障害時の履歴分析 (Post-mortem): SQLiteに永続蓄積し、後からボトルネックを事後追跡できます。
4.1. llama_monitor_daemon.py による無停止サンプリング
psutil と llama-server の /metrics から数秒おきにリソース状態をサンプリングし、時系列データとして常時ログへ蓄積します。
4.2. llama_monitor_tui.py によるリアルタイム可視化
必要なときだけ起動して、蓄積データをリアルタイム描画するTUIビューアです。
以下は、llama_monitor_tui.py の実際の稼働画面(Gemma4待機時、およびQwenタスク読み込み時のVRAM/RAM/ポート詳細監視)です。
画像: TUI監視ツール「Monitor」による全体の稼働状態(Gemma4が回答作成中、Qwen3.6がタスク待機中)(表示はサンプルです)
このプロファイリングから、以下の実測値とリソース使用状況が確認されました。
Gemma-4-26B-MoE (ポート 9093): CPUオフロード率が高くても、メモリ特性(MoE)により 9.7 t/s のデコード速度を維持しています。
Qwen-3.6-35B-A3B-Coder (ポート 9090): 2モデル並行稼働状態でもGPU VRAM使用量を 約5.9GB(5919.0/12288.0MB)に抑え込みつつ、デコード時には 9.6 t/s を記録しています。
この監視基盤から得られたリソース推移と推論速度(t/s)の実測値があったからこそ、メモリ特性(MoEとDense)に合わせた配置(GemmaはCPU寄りにオフロード、QwenはGPU寄りに完全に収める)が最適であると確信できました。本稿の基盤を使えば、感覚値ではなく実測値に基づいたリソース設計が視覚的に一発で分かります。
5. systemdによるサービス永続化とプロセス管理
複数のプロキシやモニターデーモンを手動でバックグラウンド起動させていると、万が一プロセスが落ちたときに手動で復旧させる羽目になります。ここは Linux 標準の systemd に管理を任せるのが一番確実でした。
テンプレートUnitファイルによるプロキシ管理 (systemd/llm-proxy@.service)
プロキシを多重起動する際、サービス定義ファイルを複数作成する手間を省くため、systemdの**「テンプレートUnit(インスタンス化)」**機能を使用します。
systemdの設定ファイル名に含まれる @ 記号はテンプレートUnitであることを示し、起動時に @ の後に渡された引数(インスタンス名)を、ファイル内の %i 変数へ動的に展開して実行する仕組みです。
これにより、Aider用のプロキシ(ポート9092)、Kilo用のプロキシ(ポート9091)、Codex補完用のプロキシなど、接続するエージェントやエディタが増えるたびに /etc/systemd/system/ 以下に中身がほぼ同じサービス設定ファイルを量産(コピー&ペースト)する必要がなくなります。管理するユニット設定ファイルは llm-proxy@.service のたった1枚で済み、引数を変えて有効化するだけで、個々の独立したプロセスとして起動・監視・管理できます。
[Unit]
Description=LLM Gateway Proxy for %i
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/irom/dev/llm-toolbox
# インスタンス名 (%i) を用いてモジュールを動的に指定して起動
ExecStart=/usr/bin/python3 -m proxy.%i
Restart=always
User=irom
Group=irom
RestartSec=3
[Install]
WantedBy=multi-user.target
💡 起動および常時有効化マニュアル
このファイルを /etc/systemd/system/llm-proxy@.service として配置することで、引数の値(aider や kilo)が自動的に %i に展開されます。これにより、以下のコマンドを実行するだけで複数のプロキシプロセスを同時に制御可能になります。
# サービスファイルの再読み込み
sudo systemctl daemon-reload
# プロキシ(Aider用とKilo用)の起動および自動起動の有効化
sudo systemctl enable --now llm-proxy@aider llm-proxy@kilo
# モニターデーモンの起動
sudo systemctl enable --now stateforge-monitor.service
💡 運用設計上のメリット
-
依存関係の制御:
After=network.targetなどを定義し、LLMサーバー起動後にプロキシが安全にポートを待ち受けるよう順序を自動制御します。 -
自動復旧(自己修復):
Restart=alwaysとRestartSec=3により、万が一プロセスがメモリ不足(OOM)等で強制終了しても、3秒後に systemd が自動復旧させます。 -
権限分離(セキュリティ): GPUデバイス権限が必要なLLMサーバーとは別に、中継プロキシは一般ユーザー権限(
User/Group=irom)で動作させ、万が一の攻撃時のリスクを最小特権の原則で抑え込みます。
6. SSH 1セッションによる高速デプロイ (llm-startup-dual.sh)
開発端末(Mac)からリモートサーバーへモデル構成の変更をデプロイする際、複数回の scp や ssh 接続を重ねると、接続遅延のせいでデプロイのたびに何十秒も待たされることになります。
これを解消するため、ローカル側に一時ファイルを生成せず、1回のSSH接続のみで「リモート側でのシステム設定ファイル生成(sudo tee)」から「systemdサービスの再起動」までを完結させる自動起動スクリプト llm-startup-dual.sh を実装しました。
#!/usr/bin/env bash
# llm-startup-dual.sh (骨格コードの抜粋 / 全文はGitHubを参照)
set -e
ARCHITECT=$1
EDITOR=$2
# ... [モデル名判定やリモートホストの自動探索等は省略] ...
echo "Connected to $HOST. Injecting configs and restarting services in 1 session..."
# 外側のヒアドキュメント(EOF)は非クォートでローカル変数を展開
# 内側のシステムファイル生成(SERVICE_EOF)はシングルクォートで括り、リモート側のエスケープを維持
ssh -t "$HOST" bash -s << EOF
set -e
echo "Generating systemd service files on remote..."
sudo tee /etc/systemd/system/llama-server.service > /dev/null << 'SERVICE_EOF'
[Unit]
Description=llama.cpp Server for Editor model
# ... [systemd サービス定義] ...
[Service]
ExecStart=sg render -c 'numactl --physcpubind=0-5 --localalloc /opt/llama.cpp/build/bin/llama-server -m ${EDIT_PATH} --port 9090 ${EDIT_TUNE}'
SERVICE_EOF
# ... [Gemma(Architect)用のサービスファイル生成等も同様に実行] ...
echo "Applying systemd configurations..."
sudo systemctl daemon-reload
sudo systemctl restart llama-server llama-server-architect
echo "Restarting proxy and monitor services..."
sudo systemctl restart llm-proxy@aider llm-proxy@kilo
EOF
💡 1セッション流し込みの仕組み
このスクリプトは、外側のヒアドキュメント(クォートなし << EOF)でローカル環境変数を展開しつつ、内側のヒアドキュメント(シングルクォートあり << 'SERVICE_EOF')でリモート側のエスケープ仕様を維持します。これにより、クライアント側の一時ファイルを一切汚すことなく、1回のSSHセッションですべてのデプロイとサービス再起動を瞬時に完了させることができます。
7. セキュリティとライセンスに関する注意点
ローカルLLMおよび自律エージェントを本番環境で運用するにあたり、以下の防衛的観点と規約をご確認ください。
-
データの秘匿性:
本システムはすべてホスト内部のループバックアドレス(127.0.0.1)経由で通信を行い、推論処理はリモートGPUサーバー内で完結します。クラウドのAPIへプロンプトや機密コードが送信されることはありません(ただし、他の関連ツールやプラグイン等による外部通信の設定には別途ご注意ください)。 -
ライセンス規約:
Gemma (Gemma Terms of Use) および Qwen (Qwen License Agreement) は、それぞれ個別のライセンス規約(商用利用や研究利用に関する制約など)を有しています。特に商用の企業内開発プロジェクトに導入する際は、各モデルの最新ライセンスを確認のうえ運用してください。
8. 💡 本書で登場する主なローカルLLM用語集
-
プレフィル (Prompt Processing / PP):
ユーザーの入力プロンプトや過去の対話履歴のコンテキストをLLMに「ロードして理解させる」初期計算フェーズ。ローカルではコンテキスト長に比例して処理時間(Prefill latency)が増大します。 -
デコード (Text Generation / TG):
理解したコンテキストに基づき、実際に回答のトークン(文字)を1つずつ「生成・出力する」フェーズ。VRAMの物理アクセス帯域幅がデコード速度の上限を決定します。 -
KVキャッシュ (KV Cache):
LLMが一度読み取ったコンテキストのKey-Valueペアデータを一時記憶しておくメモリキャッシュ。これが有効に機能することで、2回目以降の対話時に履歴を再読み込みする計算負荷(Prefill時間)をほぼゼロにできます。 -
MoE (Mixture of Experts):
モデル内部に複数の専門家(Expert)ネットワークを内包し、トークンごとに最適な一部のExpertのみを活性化して計算するアーキテクチャ。全パラメータを動かすDenseモデルに比べ、処理に必要なメモリ帯域を劇的に減らせるため、CPUオフロードと抜群の相性を示します。 -
CPUオフロード:
GPUのVRAM不足によるメモリ不足エラー(OOM)を防ぐため、VRAMに収まらないレイヤーの重みをシステムRAMに配置し、CPUで演算処理させる技術。安全に大容量モデルを動かせる反面、転送帯域の違いから処理速度は著しく低下します。
9. まとめ
llm-toolbox は、Proxy・Monitor・Deploymentを分離し、ローカルLLMを長時間安定運用するための制御プレーンとして設計しました。モデルが入れ替わっても周辺基盤はそのまま再利用できるため、将来のモデル更新にも追従しやすい構成となっています。
ローカルLLMを実用的な開発基盤として運用するには、モデルそのものだけでなく、それを支えるインフラ設計も重要です。本稿で紹介した構成が、同様の環境を構築する際の参考になれば幸いです。
GitHubでは、本稿で解説した以下の設定・コード一式を公開しています。必要な機能だけを切り出して、既存のローカルLLM環境へ部分的に組み込んで活用することも可能です。
GitHub (akkyey/llm-toolbox)
├── proxy/ # 制御プロキシ(ルーティング・補正)
├── monitor/ # 監視デーモン・TUI
├── systemd/ # systemd Unitファイル
└── scripts/ # デプロイ自動化スクリプト

