luftpause について
luftpauseは、共有推論エンジンを使うエージェントハーネス向けのスケジューリングランタイムです。ツール呼び出し後の状態を追跡し、対話中の実行を優先するほか、OpenAI互換プロキシとMCPランタイムを備えています。
主な機能
- 01再開する実行の優先スケジューリング
- 02呼び出し元間の公平なスケジューリング
- 03KVキャッシュに基づく実行配置
- 04OpenAI互換プロキシ
- 05永続化されるMCPサブエージェント実行
活用シーン
- 01バッチ負荷中も対話型エージェントの応答性を維持
- 02MCP経由でサブエージェントを並列実行
- 03ランタイム再起動後に実行を再開
MCP 対応
MCPランタイムは、spawn、await、run、status、cancel、approve、tools、decideなどのサブエージェント制御ツールを公開します。実行はジャーナルに記録され、再起動後に再開でき、ツール呼び出しでは設定済みの上流MCPサーバーを利用できます。
設定
ローカルサーバーの設定はどのクライアントでも共通です。コピーしてクライアントの MCP 設定ファイルに貼り付けてください。
docker run -i --rm -e Runtime__Model -e DataPlane__Url -e LUFTPAUSE_TOKEN ghcr.io/luftpause/runtime:v0.1.4Runtime__ModelModel the sub-agents run on, as served by an engine behind the luftpause dataplaneDataPlane__UrlURL of the luftpause dataplane that places each turn on the engine poolLUFTPAUSE_TOKENシークレットBearer token required on the MCP endpoint and sent to the dataplane; unset leaves both openローカルサーバーにはマシン上の実行環境(Node.js 18 以上など)が必要です。設定ファイルの場所はクライアントごとに異なります。
ghcr.io/luftpause/runtime:v0.1.4Streamable HTTPツール
ローカルサーバーのツールはマシン上で起動して初めて取得できます。メンテナーはまだツール一覧を提供していません。
詳細
luftpause
エージェントハーネスの下で動くランタイム層です。すべてのツール呼び出しは await です。エンジンは実行を忘れますが、luftpause は維持します。
ツール呼び出しによって、エージェントの実行はターンに分かれます。推論エンジンから見ると、各ターンはどこからともなく届く新しいリクエストです。ツールが戻ると、待っている間に到着したすべてのバッチリクエストの後ろに続きの処理が回され、KV キャッシュが失われていることもあります。luftpause はハーネスとエンジンの間に入り、実行を維持します。
- 続きの処理が優先されます。 ツール呼び出しで一時停止した実行は、バッチ処理より先に再開されます。バッチターンには、戻ってくるインタラクティブな実行のための余裕が確保されます。
- 実行はプレフィックスがある場所にとどまります。 実行の生成中、またはツールで一時停止中は、そのターンは KV キャッシュを保持しているレプリカにのみ送られます。新しいターンはエンジン自身のキューによって受け入れられます。
- 1 人の呼び出し元が、ほかの呼び出し元を飢餓状態にすることはありません。 保留中のターンは呼び出し元の間で公平に処理されます。
- どんなハーネスにも、2 つの接続方法があります。
base_urlを指定して接続する(OpenAI 互換で、独自のループを使用)か、MCP 経由で接続して、再起動後も存続するサブエージェントのspawn/awaitを利用できます。
8 GB のノート PC 向け GPU 1 基で計測しました。エンジンを飽和させた状態(バッチ実行 48 件が殺到し、その横でインタラクティブ実行 4 件を実施)での結果です。
| スループット | レイテンシ | コスト | |
|---|---|---|---|
| 独自のループを持つエージェント。luftpause 経由と SGLang ルーターへの直接接続を比較 | 2.0 倍のインタラクティブ実行(毎分 12.7 件対 6.4 件) | インタラクティブ実行の p50 は 36 秒から 19 秒に短縮 | バッチ −9 % |
| MCP 経由のサブエージェント。luftpause とランタイムからルーターへの直接接続を比較 | 2.7 倍のインタラクティブ実行(毎分 8.0 件対 3.0 件) | インタラクティブ実行の p50 は 58 秒から 27 秒に短縮 | バッチは変化なし |
| ファンアウト、サブエージェント 16 件と 1 件ずつの実行を比較 | 5.3 倍(16 件すべてが 120 秒ではなく 23 秒で完了) | サブエージェントごとの p50 は 15~22 秒で、ルーター経由と同じ | なし。ルーター単体でも 21.5 秒かかります |
| クライアントとしての Claude Code による Wikipedia 検索 6 件 | 2.9 倍(個別に連続実行した場合の 36 秒ではなく 12.5 秒。プロンプトによる指定なし) | 70B サブエージェント:6 件すべてを 1 ラウンドで実行し、9.3 秒 | 失敗したツール呼び出し 0 件、再起動 0 件 |
試してみる
docker compose up -d # fake engines, no GPU needed
claude mcp add --transport http --scope local luftpause http://localhost:7000/mcp
docker compose up -d は公開済みイメージをプルします。代わりにソースからビルドするには --build を追加してください(初回ビルドには数分かかります)。latest をプルします。LUFTPAUSE_VERSION=v0.1.4 docker compose up -d を指定すると、リリースを固定できます。ランタイムは、io.github.vmedvedovskiy/luftpause として公式 MCP レジストリにも掲載されています。
Claude Code では、spawn、await、run、status、cancel、approve、tools、decide が使えるようになりました。モックエンジンは埋め草の応答を返します。サブエージェントが使えるツールは fake-mcp の lookup と deep_search のみで、これらもランダムに 1 秒ほど待ってから埋め草を返します。ファンアウトを確認するには十分です。すでにマシンにある Ollama から実際の回答を取得し、サブエージェントのツールとしてモックの代わりに Wikipedia を使うには、次のようにします。
docker compose -f compose.yaml -f compose.ollama.yaml up -d scheduler dataplane runtime
Wikipedia の API には匿名の呼び出し元向けのレート制限があるため、wikipedia-mcp が再試行している間、ツール呼び出しに数十秒かかることがあります。Llama 3.2 1B は、ツールの引数ではなくスキーマを渡してしまうことがあります。ランタイムはその呼び出しを拒否し、サブエージェントはそのツールを使わずに回答する場合があるため、もう一度依頼するか、より大きなモデルをプルしてください。
Ollama ではモデルをプルする必要があります(ollama pull llama3.2:1b を実行するか、OLLAMA_MODEL を設定します)。また、Docker Desktop からすでにアクセスできる場合を除き(Windows と macOS ではデフォルトのループバックバインドでアクセスできます)、ループバック以外にもリッスンする必要があります(OLLAMA_HOST=0.0.0.0)。Ollama は OLLAMA_NUM_PARALLEL を引き上げない限り、一度に 1 件のリクエストしか処理しないため、サブエージェントはそこで互いの後ろに並びます。luftpause は Ollama からキューのメトリクスを取得できず、上限も設定しません。お使いの GPU 上の SGLang(compose.testbed.yaml)や OpenAI 互換エンドポイント(compose.runpod.yaml にはホスト型 vLLM ポッドの例があります)も同様に動作します。
他との違い
ルーターはリクエストを GPU プールに送りますが、MCP サーバーはサブタスクをローカルモデルに委譲します。どちらも、実行(run)が何なのかは把握していません。luftpause は把握しています。どちらの接続口から届いた場合でも、すべての実行のすべてのターンとツール呼び出しを把握し、エンジンには決して分からない次の 3 つの事実を基に、共通の 1 つのプール上でスケジュールします。一時停止中の実行が再開すること、その処理の種類、そして誰が要求しているかです。
効果をもたらすのはルーティングではなく、受け入れ制御です。ルーティングだけなら、後述の SGLang のキャッシュ認識ルーターと同等です。エンジンがアイドル状態なら、どの構成も同等です。違いが現れるのはエンジンが飽和したときであり、それこそが重要な状況です。
2 つの接続方法
- OpenAI 互換プロキシ — 独自のループを維持するエージェントは、
base_urlを luftpause に設定します。実行ごとにx-luftpause-run-idを送信し、任意でx-luftpause-classとx-luftpause-callerも送信します。これにより、そのループ内のすべてのツール呼び出しがスケジュールされた待機処理になります。 - MCP ランタイム — Claude Code などのエージェントが MCP 経由で接続し、
spawn/awaitを利用します。luftpause は各サブエージェントのループを同じプロキシ経由で実行します。プール上での LLM ターン、上流の MCP サーバーへのツール呼び出し、呼び出し元となる MCP セッション、そしてプロセス終了後も実行を存続させるジャーナルを扱います。
examples/ には、3 つすべてのヘッダーを設定する独自ループのエージェントが 3 つあります。openai パッケージ、OpenAI Agents SDK、LangGraph です。
接続方法
実行ごとにヘッダーを設定できないハーネスも、プロキシ経由で引き続き動作します。エコーバックされる tool_call_id によって実行が認識され、公平性のためには固定の x-luftpause-caller で十分です。
| ツール | MCP 接続口 | プロキシ接続口 |
|---|---|---|
| Claude Code | claude mcp add --transport http luftpause http://localhost:7000/mcp | 該当なし:Chat Completions ではなく、Anthropic Messages API を使用します |
| Codex CLI | ~/.codex/config.toml:[mcp_servers.luftpause] url = "http://localhost:7000/mcp" | 該当なし:wire_api が受け付けるのは responses のみですが、プロキシが提供するのは Chat Completions です |
| Gemini CLI | gemini mcp add --transport http luftpause http://localhost:7000/mcp、または .gemini/settings.json:{"mcpServers": {"luftpause": {"httpUrl": "http://localhost:7000/mcp"}}} | 該当なし:Gemini API を使用します(未検証) |
| Goose | goose session --with-streamable-http-extension "http://localhost:7000/mcp"、または goose configure → 拡張機能を追加 → リモート拡張機能(Streamable HTTP) | GOOSE_PROVIDER=openai OPENAI_HOST=http://localhost:8080 OPENAI_API_KEY=x OPENAI_CUSTOM_HEADERS="x-luftpause-caller=goose" |
| Cursor | ~/.cursor/mcp.json:{"mcpServers": {"luftpause": {"url": "http://localhost:7000/mcp"}}} | 該当なし:「Override OpenAI Base URL」は Cursor のサーバーから呼び出されますが、そのサーバーは localhost に接続できません(未検証) |
| Zed | settings.json:"context_servers": {"luftpause": {"url": "http://localhost:7000/mcp"}} | settings.json:"language_models": {"openai_compatible": {"luftpause": {"api_url": "http://localhost:8080/v1", "available_models": [{"name": "<model>", "max_tokens": 128000}], "custom_headers": {"x-luftpause-caller": "zed"}}}} |
| OpenHands | 設定 → MCP サーバー → http://localhost:7000/mcp を追加 | 設定 → LLM → 詳細設定:カスタムモデル openai/<model>、ベース URL http://localhost:8080/v1、任意の API キー。ヘッダー設定はありません |
| Cline / Roo Code | Cline の cline_mcp_settings.json:{"mcpServers": {"luftpause": {"type": "streamableHttp", "url": "http://localhost:7000/mcp"}}}。Roo の .roo/mcp.json:同じ設定で "type": "streamable-http" | プロバイダー「OpenAI 互換」、ベース URL http://localhost:8080/v1、任意の API キー。「カスタムヘッダー」の下に x-luftpause-caller を設定します(未検証) |
アーキテクチャ
%%{init: {"themeVariables": {"fontFamily": "-apple-system, Segoe UI, Helvetica, Arial, sans-serif"}, "flowchart": {"wrappingWidth": 260}}}%%
flowchart LR
mcp["Claude Code<br/>or any MCP client"]
own["Agents with<br/>their own loop"]
subgraph lp["luftpause"]
runtime["<b>runtime</b> · .NET<br/>spawn · await · journal<br/>sub-agent loops"]
dp["<b>dataplane</b> · Rust<br/>OpenAI-compatible proxy"]
sched["<b>scheduler</b> · .NET<br/>decide(state, event)"]
end
tools[("MCP tool<br/>servers")]
subgraph pool["Engine pool"]
direction TB
engines["SGLang · vLLM"]
router["SGLang router"]
hosted["Hosted OpenAI-<br/>compatible APIs"]
end
mcp -- MCP --> runtime
own -- "HTTP / SSE" --> dp
runtime -- "tool calls" --> tools
runtime -- "LLM turns" --> dp
dp -- "turns" --> pool
sched <-- "Admit · Report · run ended" --> dp
sched -. "health · load" .-> pool
- runtime (
runtime/) — MCP サーバーです。起動された各実行は、エージェントのループを実行する .NETTaskです。ツール呼び出しは、設定された MCP サーバーに送信されます。各実行はruns.jsonlに記録され、再起動時に再開されます。終了した実行はデータプレーン(DELETE /v1/runs/{id})に報告され、スケジューラーはただちにその実行を忘れます。 - dataplane (
dataplane/crates/dataplane) — tokio/axum プロキシです。実行を特定し(ヘッダーまたはツール呼び出し ID を使用)、クラスと呼び出し元のヘッダーを読み取り、スケジューラーにターンの送信先を問い合わせます。エンジンの応答を変更せずにストリーミングし、最初のトークン、使用量、終了理由を報告します。 - scheduler (
scheduler/) — 意思決定の中核は、不変状態に対する純粋関数(Luftpause.Scheduling)で構成されます。gRPC ホストがアクションを実行します。 - テストキット —
fake-engine(シミュレートされたレイテンシーを備えた OpenAI +/classify)、fake-mcp(lookupツールと、処理に時間のかかるdeep_searchツール)、tools/loadgen(エージェント、MCP 経由のサブエージェントへのファンアウト、クローズドループのスループット実行)。
機能
| 機能 | 方法 |
|---|---|
MCP 経由の spawn / await | spawn(task, model?, serviceClass?, system?, maxToolCalls?, tools?, maxTokens?, parallelToolCalls?, parent?, budgetTokens?, budgetToolCalls?, budgetSeconds?, approveTools?) はすぐにハンドルを返します。await(handles, all|any, timeout) は toolCalls / failedToolCalls とともに結果を収集します。そのほかに tools、status、cancel、decide(input, labels?, model?) もあります。labels を指定しない場合は、シングルパスモデルのラベルを使います。2~20 個の labels を指定すると、生成モデルで max_tokens: 1 のターンを 1 回実行し、最初のトークンの対数確率でスコアを付け、label、probabilities、unscored を返します |
| 永続的な実行 | すべての実行は Runtime:StateDir の下にある runs.jsonl に記録されます。記録されるのは、実行の開始、各ツールラウンドのメッセージ、実行の完了です。再起動時にランタイムはジャーナルを再生し、完了済みの実行については await に応答し、未完了の実行は最後に完了したツールラウンドから再開します |
| 実行ツリー | spawn(parent) は実行を子として作成します。status は parent と children を一覧表示します。親を cancel すると、実行中の子孫もキャンセルされます。ランタイムの GET /status は各実行自身の処理とそのサブツリーの合計(ターン数、ツール呼び出し数、失敗したツール呼び出し数、プロンプトトークン数と生成トークン数、ターン時間とツール実行時間)を含むツリーを返します |
| 上限 | spawn(budgetTokens?, budgetToolCalls?, budgetSeconds?) は、実行全体のサブツリー(その実行と、その下で後から開始されるものも含むすべての実行)について、プロンプトと生成の合計トークン数、ツール呼び出し数、実時間を制限します。上限付きの実行の配下では、各ターンとツールラウンドのたびに上限を確認します。また、秒数についてはタイマーで確認するため、アイドル状態のサブツリーでも上限に達します。上限を超えると、サブツリーは budget exceeded: <which> <used>/<limit> でキャンセルされ、status / await は budget とともに limits、used、exceeded を表示します |
| ツールの承認 | サーバーがツールに destructiveHint: true(かつ readOnlyHint: true ではない)を指定している場合、または spawn(approveTools) で指定されている場合、サブエージェントのツール呼び出しはハーネスのユーザーによる承認を待ちます。実行のステータスは AwaitingApproval となり、pendingApproval {tool, arguments} が表示されます。エリシテーションに対応しているクライアントでは、実行を対象とする await または run の実行中に、フィールドが 1 つ(allow)のフォームで確認を求めます。セッションがある場合はエリシテーションリクエストとして、セッションがない 2026-07-28 リクエストの場合は入力要求の結果として表示されます。それ以外のクライアントは approve(handle, allow) で回答します。呼び出しを拒否すると、サブエージェントには Error: the user declined this tool call が返されます。判断はジャーナルに記録されます。再起動の直前に回答済みのものは再度尋ねられず、未回答のものは再度尋ねられます。クライアント:ターミナル上の Claude Code 2.1.218 は await 中にプロンプトを表示し、その回答が実行に渡されます(動作確認済み)。Claude デスクトップアプリの Code タブは、どの権限モードでもプロンプトを表示せずに拒否するため、そこで実行するとツール呼び出しは拒否されます。Python mcp 2.3 クライアントでは、偽スタック上で両方の方法で確認を求められました。どのクライアントでも approve を使用できます |
| MCP Tasks | run(task, ...) は spawn の引数を受け取り、同じ呼び出し内で実行が完了するまで待ちます。Tasks 拡張(io.modelcontextprotocol/tasks、プロトコル 2026-07-28)を有効にしたクライアントには、代わりにタスクが返されます。クライアントは tasks/get をポーリングし、実行中の承認要求はエリシテーション入力リクエストを伴う input_required として表示され、tasks/update で回答します。タスクは runs.jsonl と同じ場所にある tasks.jsonl に記録されます。ランタイム停止時にまだ開いているタスクは再起動時に失敗扱いとなり、その実行は通常どおり再開されます。それ以外のクライアントでは、run は 1 回のブロッキング呼び出しになります。クライアント:C# SDK 2.2 クライアント(インプロセスでテスト済み)。Claude Code 2.1.218 は Tasks を有効にしないため、run はブロッキング呼び出しになります |
| ステータス | :5001 のスケジューラーの GET /status(HTTP/1。gRPC は引き続き :5000)は、処理中のターン数と容量、クラス別および呼び出し元別の保留中ターン数と最古の待機時間、フェーズ別およびクラス別の実行を返します。そこにある GET / は 2 秒ごとにポーリングするページです。また、?runtime=http://host:7000 を指定すると、そのランタイムの実行ツリーとサブツリーのコストも表示します |
| 呼び出し元ごとの公平性 | 各ターンには x-luftpause-caller が付きます(サブエージェントでは MCP セッション、独自ループのエージェントでは任意の文字列)。保留中のターンはまず interactive から処理し、次に処理中のターン数が最も少ない呼び出し元から処理します。そのため、ある呼び出し元が大量の要求を送っても、ほかの呼び出し元が飢餓状態になりません |
| ターン以外のツール | ツールの実行を待っている間、実行はターンスロットを保持しません。ツール呼び出しはランタイムで、HTTP または stdio 経由で MCP サーバーに対して実行されます。spawn(parallelToolCalls: true) を指定すると、モデルは 1 回の応答で複数の呼び出しを要求でき、それらは同時に実行されます(デフォルトでは無効です。Llama 3.x は応答ごとに 1 回の呼び出しを行います) |
| エンジン負荷による受付制御 | SGLang と vLLM の /metrics で待機キューがないと報告されている間、エンジンはターンを受け付けます。キューができると、luftpause はターン数をエンジンの実行数まで制限し、残りを保留します。負荷を報告しないエンジンには上限を設けません |
| 厳密な実行アフィニティ | 生成中、またはツールで一時停止中の実行は、別のレプリカに空きがあっても、直前のターンを処理したレプリカにのみ割り当てられます。そのレプリカにはプレフィックスがキャッシュされているためです。レプリカが異常な場合、または実行がアイドル状態の場合は、別のレプリカに移動することがあります。以下の測定結果が示すとおり、対話処理の改善はこれによるものです。これに加えて KV メモリを推定する方式はスループットを低下させたため、削除しました |
| サービスクラス | バッチターンでは、そこでツールの完了を待っているインタラクティブ実行ごとに、エンジンの空き領域を確保します。これらの実行は戻ってくるためです。luftpause が保留したターンは、まず interactive、次に呼び出し元ごとの公平性、最後に到着順で処理され、配置されるか、クライアントが切断するまで待機します |
| バックエンドとしてのルーター | レプリカには、エンジンを選択するルーター(SGLang ルーターなど)を設定できます。その負荷は、背後にあるエンジンから読み取られます(LoadFrom) |
| モデルによるルーティング | エンドポイントは、提供するモデルを宣言します。ターンは、そのモデルを提供するレプリカにのみ送られます。不明なモデルにはすぐに 404 が返されます |
| ホステッドエンドポイント | API キーを環境変数から取得し、エンドポイントごとのヘルスチェックパスと https を使用することで、OpenAI 互換 API(RunPod 上の vLLM、OpenAI、OpenRouter など)をレプリカとして利用できます |
| スケジューラーの再起動 | データプレーンは、各実行の最後のレプリカ、フェーズ、クラス、呼び出し元(アイドル状態の実行は 30 分後に破棄)と、処理中の各ターンを保持します。起動時および再接続のたびに、イベントストリームを再開する前にこれらをスケジューラーへ送信(Resync)するため、アフィニティ、一時停止中のインタラクティブ実行用の余力、容量カウントはスケジューラー再起動後も維持されます。停止中に保留されたターンは、復旧後、同じターン ID で再び受け付けられます |
| ヘルス | /health(またはエンドポイントごとに指定された任意のパス)を 2 秒ごとにチェックします。異常なレプリカにはターンを送りません |
| ツール呼び出しの健全性 | 引数はスキーマのスカラー型に変換されて検証され、無言で不正な呼び出しを行う代わりに、期待される形式を示して拒否します。テキストとして記述された呼び出しは復元されます |
| 実行 ID | x-luftpause-run-id、またはエージェントが応答に含める tool_call_id |
| シングルパスエンジン | POST /v1/decide(vLLM の /classify 形式)は、実行状態とは別の専用レプリカで処理されます。呼び出し元が指定する labels を使った decide は、2 つ目のモデルを必要とせず、生成レプリカ上で 1 回のインタラクティブターンとして実行されます |
| トレース | OTEL_EXPORTER_OTLP_ENDPOINT を設定すると、各サービスは OTLP 経由で OpenTelemetry スパン(GenAI 規約)をエクスポートします。ランタイムは実行ごとに invoke_agent スパン(親実行、または spawn を呼び出した MCP リクエストの traceparent の子)を、モデルターンごとにトークン数と終了理由を含む chat スパンを、ツール呼び出しごとに execute_tool スパンを生成します。データプレーンはプロキシされたターンごとにスパンを、スケジューラーは luftpause.replica と luftpause.held_ms を含む admit スパンを生成します。W3C の traceparent はデータプレーン、スケジューラー、エンジン、HTTP ツールサーバーへ渡され、ターンイベントにはトレース ID が含まれます。この環境変数が設定されていない場合、何もエクスポートされません |
| 認証 | LUFTPAUSE_TOKEN を設定すると、データプレーンのすべての /v1 ルートと、ランタイムの /mcp および GET /status では Authorization: Bearer <token> が必要になります(定時間比較を使用)。それ以外の場合は JSON エラーとともに 401 を返します。ランタイムは同じベアラートークンをデータプレーンに送信します。クライアントの Authorization は上流に渡されず、エンジンには設定されたキーが渡されます。この環境変数が設定されていない場合、両方のサービスは警告をログに記録し、すべてのリクエストを受け付けます |
失敗時の動作
データプレーン(/v1/chat/completions、/v1/decide)を通るターンで障害が発生した場合の動作です。エラーボディは {"error": {"message": "..."}} です。スケジューラーに接続できない場合を除き、すべてのレスポンスに x-luftpause-run-id が含まれます。失敗したターンはただちにスロットを解放し、実行中の状態をアイドルにします。ツールで一時停止した状態にはならないため、再試行はその実行の次のターンと同じように受け付けられます。同じ x-luftpause-run-id を指定して再試行すると、その実行のアフィニティと公平性の優先順位が維持されます。
| ケース | クライアントに見える内容 | luftpause の動作 | 独自ループのエージェントが行うこと |
|---|---|---|---|
| ターンが保留中にクライアントが切断する | 何もありません(クライアントが切断したため) | スケジューラーへの呼び出しをキャンセルし、ターンを待機キューから削除します(TurnAbandoned)。エンジンリクエストは送信されません。その時点でルーティング中だった場合は、ターンを失敗として報告し、スロットを解放します | クライアント側のタイムアウトで中断した場合は、同じ実行 ID を指定してターンを再送信します。ターンは実行されていません |
| ストリーム中にクライアントが切断する | 何もありません(クライアントが切断したため) | エンジンリクエストを閉じ、生成を停止します。ターンを失敗として報告します | 同じ実行 ID を指定してターンを再送信します。途中までの応答はどこにも保存されません |
| エンジンに接続できない | 502、engine <replica>: <error>。接続が拒否された場合はただちに、応答しないホストの場合は OS の接続タイムアウト後に返されます | ターンの開始と失敗を報告します。ヘルスプローブが失敗するまでは、レプリカはローテーション対象に残ります(周期は 2 s) | 少し待ってから同じ実行 ID で再試行します。プローブでレプリカが停止と判定されるまでは、再試行が同じレプリカに送られる場合があります |
| ストリーム中にエンジンが失敗する | 200 と、ここまでに送信されたチャンク。その後、最後のチャンクや [DONE] がないまま接続が閉じます | ターンの失敗を報告します | 応答は失われたものとして破棄し、同じ実行 ID でターン全体を再試行します |
| エンジンがただちに拒否する(429、5xx) | エンジンのステータスとボディがそのまま返され、Retry-After も含まれます | ターンの開始と失敗を報告します | 429 の場合は Retry-After の時間待つか、それがなければ約 1 s 待ってから、同じ実行 ID で再試行します。5xx の場合はバックオフしながら数回再試行し、その後は諦めます |
| 不明なモデル | 404、no Generative replica serves model '<model>' | キューに入れる前に拒否します。エンジンリクエストは送信されず、ターンも記録されません | 諦めます。モデル名またはエンドポイント設定を修正してください。再試行しても同じ応答になります |
| スケジューラーに接続できない、またはスケジューラーが再起動中 | 何もありません。キューに入っている場合と同様に、スケジューラーが応答するかクライアントが切断するまでターンは保留されます | 同じターン ID で毎秒アドミッションを再試行します。再接続時にはアドミッションを再開する前に、実行と進行中のターンを同期します。それまではエンジンリクエストを送信しません | 何もする必要はありません。障害継続時間より短いクライアント側タイムアウトが発生した場合は、保留中のターンと同様に、同じ実行 ID で再試行します |
定数
luftpause で固定値が使われている箇所はわずかです。キューの期限もターン数の上限もありません。保留中のターンはエンジン自身のキューと同じように待機し、実行はツールの予算を使い切ると終了します。残るのは次のとおりです。
| 定数 | 値 | 内容 | 場所 |
|---|---|---|---|
| 返信の長さ | 1ターンあたり256トークン。実行ごとに spawn(maxTokens) で設定し、0の場合はエンジンに任せます | SGLang は、リクエストが書き込む可能性のある最長の返信に備えてメモリを予約します。上限がないと、バッチ処理できるリクエスト数は7分の1になり(テスト環境では毎分6回の実行に対して42回)、返信が暴走するとLlamaの「返信ごとに1回呼び出す」形式が崩れました | runtime/…/LuftpauseTools.cs |
| ツール呼び出し予算 | 実行あたり8回。実行ごとに spawn(maxToolCalls) で設定します | 予算を使い切るとモデルにはツールが提示されなくなり、回答しなければなりません。これにより、実行のターン数も予算+1回に制限されます | runtime/…/LuftpauseTools.cs |
await のタイムアウト | デフォルトは60秒、最大600秒です | 1回の await 呼び出しが、実行の現在の状態を返すまで待機する時間です。60秒であれば、MCP クライアント独自のリクエストごとの制限(Claude CodeはHTTP経由で60秒)を超えません | runtime/…/LuftpauseTools.cs |
| ターンのタイムアウト | 10分 | 完了が報告されないターン(ターンの途中でデータプレーンがクラッシュした場合)は破棄され、スロットが解放されます | scheduler/…/Expiry.cs |
| 実行のアイドル期限 | 30分 | この時間だけターンがない実行は破棄されます。次のターンはアフィニティなしで開始されます。ランタイムはサブエージェントの終了を報告します。独自のループを持つエージェントは完了を通知しないため、その状態には終了期限が必要です | scheduler/…/Expiry.cs |
| プローブ間隔 | 2秒 | エンジンの健全性と負荷(/metrics)をこの間隔で読み取ります。受付はこの遅延を伴ってエンジン負荷に追従します。また、プローブのタイムアウトにも使われます | scheduler/…/Probing.cs |
| クロックの刻み | 1秒 | 上記2つの期限を制御します | scheduler/…/Clock.cs |
| ランタイムからデータプレーンへのタイムアウト | 10分 | キューの待ち時間を含め、実行が失敗するまでに1回のターンに許される最長時間です。OpenAIおよびAnthropicクライアントのデフォルトと同じです | runtime/…/Program.cs |
| 429の再試行間隔 | レスポンスの Retry-After。ない場合は1秒 | ランタイムは、エンジンが429(ホスト型APIのレート制限)で拒否したターンを、実行がキャンセルされるまで再試行します | runtime/…/LlmClient.cs |
| エンジン接続のタイムアウト | 2秒 | データプレーンはこの時間が経過すると、エンジンへの接続を諦めます | dataplane/…/main.rs |
| レポートの再接続 | 1秒 | データプレーンはこの時間の経過後、スケジューラーへのイベントストリームを再接続します | dataplane/…/report.rs |
| ツール呼び出しIDの記憶期間 | 30分。60秒ごとに整理されます | エージェントがエコーバックする tool_call_id によって、データプレーンが実行を識別できる期間です(実行ヘッダーを送らないエージェントが対象です) | dataplane/…/run_identity.rs |
| 実行ジャーナル | Runtime:StateDir。デフォルトは state、コンテナー内では /var/lib/luftpause です | runs.jsonl の保存場所です。ランタイムが再起動後も保持する唯一の状態です | runtime/…/Program.cs |
| 呼び出し元ヘッダー | x-luftpause-caller。128バイトで切り詰められます | 公平性のために呼び出し元を識別します。指定がない場合は、匿名の呼び出し元として共有されます | dataplane/…/proxy.rs |
実行方法
docker compose up -d --build # scheduler, dataplane, runtime, fake engines, fake MCP tools
dotnet run --project tools/loadgen -- --spawn 10 # fan 10 sub-agents out over MCP and await them
dotnet run --project tools/loadgen -- --agents 8 --duration-s 60 --class batch --caller me # 8 own-loop agents through the proxy, closed loop
tools/check.sh # both .NET test projects, loadgen, cargo test (when cargo is installed), image builds
リリース:tools/release.sh vX.Y.Z は3つのイメージをビルドし、タグ付けして ghcr.io/luftpause にプッシュし(LUFTPAUSE_REGISTRY で上書きできます)、gitタグを作成します。DRY_RUN=1 を指定すると、コマンドのみが表示されます。
トークンを使用する場合:LUFTPAUSE_TOKEN=<token> docker compose up -d を実行し、続けて claude mcp add --transport http luftpause http://localhost:7000/mcp --header "Authorization: Bearer <token>" を実行します。
ホスト上のOllamaを唯一のレプリカとして使う場合(モデルは OLLAMA_MODEL から取得し、デフォルトは llama3.2:1b です):
docker compose -f compose.yaml -f compose.ollama.yaml up -d scheduler dataplane runtime
1台のGPUでLlama-3.2-1B FP8のレプリカを2つ実行する場合(以下のテスト環境で使用):
docker compose -f compose.yaml -f compose.testbed.yaml up -d --build scheduler dataplane runtime fake-mcp sglang-a sglang-b
Jaegerでトレースを確認するには、jaegertracing/all-in-one を起動し、3つのサービスの OTEL_EXPORTER_OTLP_ENDPOINT をJaegerに向ける compose.otel.yaml を追加します。MCP呼び出しで traceparent を送信するハーネスでは、サブエージェント、そのターン、スケジューラーによる受付が、独自のトレース内に表示されます。UIは http://localhost:16686 にあります。
docker compose -f compose.yaml -f compose.otel.yaml up -d --build
ホスト型vLLMポッドを追加のレプリカとして使う場合(キーとポッドIDは環境から取得します):
RUNPOD_POD_ID=<pod-id> RUNPOD_KEY=<key> docker compose -f compose.yaml -f compose.testbed.yaml -f compose.runpod.yaml up -d scheduler runtime
測定
特に記載がない限り、RTX 4060 Laptop 8 GB 搭載ノート PC 1 台で、Llama-3.2-1B-Instruct FP8 の SGLang レプリカ 2 個を使用しています。繰り返し回数は各表に記載しています。ばらつきの範囲内の差はノイズです。負荷試験の表は、tools/bench/flood.sh(MCP 経由のサブエージェント)および tools/bench/proxy-flood.sh(独自ループのエージェント)によるものです。どちらも比較する Git リビジョンを比較対象として受け取り、それぞれ独自のイメージタグにビルドします。
ファンアウト:spawn × 16 + await all と、一度に 1 個のサブエージェントを実行した場合の比較(3 回繰り返し)
サブエージェント 16 個、約 2.8k トークンのタスク、lookup ツールは 2 秒に固定、各エージェントは最大 3 回呼び出します。
| 16 個すべての所要時間 | 順次実行との比較 | |
|---|---|---|
| 順次実行(spawn、await、次へ) | 120.5 s | — |
| luftpause のファンアウト | 22.9 s | 5.3× |
| SGLang ルーターへ直接ファンアウト | 21.5 s | 5.6× |
サブエージェントごとの所要時間は、ファンアウトとルーターで同じです(p50 15–22 s)。1.4 s の差は実行ごとのばらつきの範囲内です。
バッチ負荷中の対話型実行(2 回繰り返し、両方を表示)
レプリカに負荷をかけるバッチ実行 48 件と、その横で実行する対話型実行 4 件を、ウィンドウごとに 3 分間実行します。コンテキストは 8000 文字、実行ごとにツール呼び出しは最大 3 回、クローズドループ方式(実行が終了すると即座に置き換えます)です。スケジューラーなしの比較対象では、両エンジンともリクエストをキューに入れたため、プールは飽和状態でした。すべての行で SGLang を --enable-priority-scheduling 付きで実行していますが、優先度は送信していません。
独自ループを使用するエージェント、luftpause を base_url に指定(tools/loadgen --agents):
| 対話型実行数/分 | 対話型実行時間 p50 | バッチ実行数/分 | |
|---|---|---|---|
| SGLang ルーターへ直接、クラスを区別しない | 6.0 / 6.7 | 38.5 s / 33.6 s | 83.7 / 82.3 |
| luftpause、エンジン負荷に基づくアドミッション、実行の移動を許可(0afb28a) | 8.7 / 9.0 | 23.2 s / 25.9 s | 79.3 / 79.0 |
| luftpause、エンジン負荷に基づくアドミッション、実行の厳密なアフィニティ | 12.0 / 13.3 | 18.5 s / 19.8 s | 75.7 / 76.0 |
| luftpause、KV メモリ推定を追加(削除済み) | 13.0 / 10.7 | 17.8 s / 20.8 s | 76.3 / 76.0 |
MCP 経由のサブエージェント(tools/loadgen --spawn):
| 対話型実行数/分 | 対話型実行時間 p50 | バッチ実行数/分 | |
|---|---|---|---|
| ランタイムから SGLang ルーターへ直接接続 | 3.0 / 3.0 | 56.6 s / 59.5 s | 39.2 / 36.5 |
| luftpause、エンジン負荷に基づくアドミッション、実行の移動を許可(0afb28a) | 4.0 / 5.0、4.3 / 5.6 | 44.4 s / 36.7 s | 35.5 / 36.6 |
| luftpause、エンジン負荷に基づくアドミッション、実行の厳密なアフィニティ | 8.0 / 8.0(ベンチブランチでは 8.0 / 8.0、7.3 / 9.3) | 29.2 s / 25.2 s | 36.2 / 38.5 |
| luftpause、KV メモリ推定を追加(削除済み) | 6.3 / 6.6、6.0 / 7.3 | 29.7 s / 35.4 s | 35.5 / 35.5 |
対話型スループットは、プロキシ経由ではクラスを区別しないルーターの 2 倍、MCP 経由では 2.7 倍です。一方、バッチ処理はプロキシ経由で 9%低下し、MCP 経由では低下しません。各行で異なるのはアドミッションだけです。実行の移動を許可すると、バッチ実行 48 件のうち 39 件が実行中にレプリカ間を移動し、プレフィックスキャッシュが失われました。生成中または一時停止中の実行を同じレプリカに固定することが、改善のすべてです。各ターンの KV メモリ推定値を追加し、エンジンが報告するプールと照合したところ、MCP 経由では対話型スループットが約 5 分の 1 低下し、プロキシ経由では低下しなかったため、この機能は削除されました。負荷が軽い場合(コンテキスト 2000 文字、バッチ実行 24 件)は、エンジンがキューに入れることはなく、すべての行で対話型実行数は約 14 件/分と同程度です。
クライアントとしての Claude Code
6 つの河川について調べるよう指示すると、Claude Code は 6 つの実行を開始し、1 回の呼び出しですべての完了を待ちました。ファンアウト全体の所要時間は、自身のラウンドトリップを含めて 12.5 秒でした。同じ実行を順番に行った場合は 36 秒でした。
新しいセッションで「ローカルのサブエージェントを使って、これら 6 つの河川について調べて」とだけ依頼すると、サブエージェントのツールを一覧表示し、実行を Wikipedia ツールに限定して 6 つを開始し、1 回の呼び出しですべての完了を待ち、回答の質が低いものは再度実行しました。 ツール:wikipedia-mcp(stdio 経由)。
| サブエージェント | Claude による再実行 | 拒否されたツール呼び出し | 結果 |
|---|---|---|---|
| 1B ローカル、引数の型強制前 | 4 | 46 回中 38 回 | 回答の大半はモデルの記憶に基づく |
| 3B ローカル、型強制あり | 2 | 20 回中 0 回 | 長さと河口は正しいが、国の一覧は信頼性に欠ける |
| RunPod vLLM ポッド上の Llama 3.3 70B AWQ | 0 | 6 回中 0 回 | 6 件すべてを Wikipedia から 1 ラウンドで取得(9.3 秒) |
70B ポッドはローカルの 3B レプリカと同じプールに配置されていました。llama-3.3-70b を求めるターンは、API キーを使って https 経由でポッドに送られ、それ以外はすべてローカルにとどまりました。await の failedToolCalls は、回答がツールから得られなかった場合に呼び出し元へ通知します。
ルーティング(実行中のサブエージェント数 N = 8、4 回繰り返し)
| 実行回数/分 | キャッシュからのプロンプトトークン | |
|---|---|---|
| luftpause アフィニティ | 30.1 ± 1.7 | 44.6 % |
SGLang ルーター、cache_aware | 30.1 ± 0.5 | 44.6 % |
SGLang ルーター、round_robin | 28.2 ± 0.5 | 25.7 % |
アフィニティは SGLang ルーターのキャッシュ認識ルーティングと同等で、中程度の負荷ではラウンドロビンを 7~15 % 上回ります。実行中の実行が 16~24 件になるとキャッシュが入れ替わり、ポリシーの差は重要でなくなります。
シングルパス(vLLM 分類器、RoBERTa-base)
luftpause は 1 つのプールで 2 種類のモデルを提供します。生成型エンジン(SGLang、vLLM、ホスト型 API)はエージェントのターンを実行します。トークンを 1 つずつデコードし、実行ごとに KV キャッシュを保持し、ツール呼び出しでターンが終了すると実行を一時停止できます。シングルパスエンジンは 1 回のフォワードパスで応答します。エンコーダーまたは分類器(RoBERTa 形式。vLLM の /classify で提供)は、デコードやストリーム、呼び出し間の状態なしに、確率付きの型付きラベルを返します。エージェントは、リクエストのルーティング、ツール結果のスコア付け、次のステップの選択など、生成された応答を必要としない判断にこれを使い、生成ターンではなく数ミリ秒で処理します。サブエージェントは decide MCP ツール経由で、独自ループのエージェントは POST /v1/decide 経由でアクセスします。シングルパスのターンは専用レプリカに割り当てられ、実行状態には入らないため、生成型のスロットを占有したり、空くのを待ったりしません。分類器が学習していないラベルセットの場合、decide(labels: [...]) は生成型レプリカの最初のトークンの logprobs からラベルをスコア付けします。その代わり、1 トークンのターンが 1 回必要です。1B テスト環境では、「サーバーが 3 回 500 を返し、オンコールの電話が鳴っている」を 0.89 で urgent に、「人口についての質問」を 0.99 で lookup に分類しますが、「2 + 2」と「ありがとう、以上です」も lookup と判定します。仕組みは機能していますが、1B モデルは最初のラベルに偏ります。より大きなモデルを使うか、スコアを調整してください。
| 負荷 | データプレーンでのレイテンシ |
|---|---|
| 一度に 1 回呼び出し | p50 20.6 ms |
| 同時実行エージェント 20 件 | p50 85 ms、p99 180 ms |
ライセンス
Business Source License 1.1 に基づき、各バージョンの公開から4年後に Apache License 2.0 へ移行します。実際には、自由に使用、変更、製品や社内への組み込み、再配布ができ、ご自身のコードにコピーレフトが及ぶこともありません。移行前に禁止されているのは、luftpause 自体をホスト型またはマネージドサービスとして第三者に提供することだけです。つまり、これはソース公開ソフトウェアであり、OSI 認定のオープンソースではありません。その点は明確にお伝えします。理由はインフラストラクチャでよくあるものです。小規模なチームは、プロジェクトをコンソールで包んで販売するクラウドプロバイダーと競争できません。そのような結果になれば、プロジェクトへの資金提供どころか、プロジェクト自体が存続できなくなります。変更日は、「いつかオープンソースになるのか」という問いへの答えです。はい、ライセンスに明記された日付に、バージョンごとに移行します。その前に、この制限が実現したいことの妨げになる場合は、イシューを作成してください。個別の許可は簡単に行えます。コントリビューションも同じ条件で受け付けています(CONTRIBUTING.md を参照してください)。
コメント
AI アプリケーションが外部のツールやデータソースに統一的な方法で接続するためのオープンなプロトコルです。クライアントはモデルとの対話を担い、MCP サーバーはツール・リソース・プロンプトテンプレートを提供します。標準メッセージでやり取りするため、1 つのサーバーを複数のクライアントで再利用できます。