複数のAIエージェントやバックグラウンドワーカーから、用途別ローカルモデルを同じOllamaへ送る構成で、返信が突然止まりました。
画面にはエラーが出ません。
ログを追うと、1回の処理に数十分かかっています。
モデル性能に問題はありませんでした。
同時実行数の制御方法に問題がありました。
モデル単位の上限では「合計」を制限できない
当初は、モデルごとに同時実行数を制限していました。
MODEL_MAX_CONCURRENT=2
説明用として、次の3モデル構成を考えます。
- ルーティング用モデル
- 会話用モデル
- コーディング用モデル
各モデルへ2件ずつ許可すると、アプリケーションは最大6件を同時に送信できます。
モデル単位の上限 2 × ローカルモデル 3種類 = 最大6件
Ollama側には、複数の制約があります。
ここが重要です。
-
OLLAMA_MAX_LOADED_MODELS: 同時に常駐できるモデル数 -
OLLAMA_NUM_PARALLEL: 各モデルが並列処理するリクエスト数 -
OLLAMA_MAX_QUEUE: ビジー時に保持する待機リクエスト数
新しいモデルの読み込みに必要なメモリが不足すると、Ollamaはリクエストを待機列へ入れます。
既存モデルのアイドル化後、必要に応じたアンロードが始まります。
ただし、アプリケーション側のタイムアウトは別に進みます。
ロード待ちが長引けば、先にタイムアウトへ到達します。
その後、フォールバックが追加の呼び出しを発生させます。
結果として、混雑がさらに悪化します。
LOCAL_LLM_MAX_CONCURRENTはアプリケーション独自の設定です。
Ollama側には存在しません。
計測結果から決めた、アプリケーション側の総量制限です。
共有リソースで総量を制限する
対策として、ローカルLLM向けリクエストへ共有リソースを追加しました。
const resources: QueueResource[] = [
{
key: `model:${input.modelKey}`,
limit: modelLimitFor(input.modelKey, env.MODEL_MAX_CONCURRENT),
},
{
key: `consumer:${input.consumerKey}`,
limit: env.CONSUMER_MAX_CONCURRENT,
},
];
if (input.provider === 'local') {
resources.push({
key: 'local-llm:backend',
limit: env.LOCAL_LLM_MAX_CONCURRENT,
});
}
ローカルバックエンド以外の処理には、共有上限を適用しません。
各リクエストは、必要な全リソースを取得できた場合だけ実行されます。
const acquired = states.every((state) => state.eligible);
モデル枠に空きがあっても、バックエンド全体の上限へ達していれば待機します。
Ollamaやプロキシのキューだけでは足りない理由
OLLAMA_MAX_QUEUEで決められるのは、Ollamaが保持する待機件数です。
リバースプロキシが接続数を制限しても、アプリケーション内部の論理リソースは扱えません。
今回必要だったのは、次の枠をまとめて確保する仕組みです。
モデル枠 ∧ コンシューマー枠 ∧ バックエンド全体の枠
いずれか1つでも取得できない場合、処理は開始しません。
単一の接続カウンターでは、このall-or-nothing条件を表現できません。
複数プロセスをDBキューで調停する
同一プロセス内だけなら、JavaScriptのセマフォでも制御できます。
複数プロセスが同じバックエンドへ接続する場合、その方法では総量を守れません。
そこで、共有DBを使ったFIFOキューにしました。
判定と取得は、同じトランザクション内で実行します。
const tryAcquire = async (
requestId: string,
resources: QueueResource[],
): Promise<boolean> =>
db.transaction(async (transaction) => {
await deleteExpiredLeases(transaction);
await lockResources(transaction, resources);
await enqueue(transaction, requestId, resources);
const states = await readResourceStates(
transaction,
requestId,
resources,
);
if (!states.every((state) => state.eligible)) {
return false;
}
await markAcquired(transaction, requestId);
return true;
});
モデル枠だけ確保し、バックエンド枠を待つ状態は発生しません。
全リソースを取得できないリクエストは、待機列へ残ります。
取得済みレコードには有限のリースを付与します。
処理中はハートビートで期限を延長します。
正常終了時にはfinallyからレコードを削除します。
プロセスが異常終了しても、後続処理は期限切れリースを回収できます。
グローバル上限と個別上限は役割が違う
説明用として、設定例を示します。
| 制約 | 例 | 目的 |
|---|---|---|
| バックエンド全体 | 4 | 総リクエスト数を安全枠へ収める |
| モデルごと | 1 | モデル横断の負荷集中を抑える |
| コンシューマーごと | 2 | 単一処理元による占有を防ぐ |
グローバル上限は、超えてはいけない安全装置です。
モデル単位やコンシューマー単位の制限は、待機列に生じる偏りを抑える調整値です。
安全枠には固定の正解がありません。
モデルサイズ、コンテキスト長、利用メモリ、タイムアウト値を含めて計測します。
混雑しても沈黙させない
同時実行制御とは別に、次の防御も追加しました。
- 生成処理へ明示的なタイムアウトを設ける
- 一時的な通信エラーだけを限定的に再試行する
- 全フォールバックが失敗した場合、混雑中であることを返信する
タイムアウトを長くするだけでは改善しません。
混雑した処理がスロットを保持する時間まで延びるため、後続リクエストはさらに遅くなります。
まとめ
複数モデルや複数ワーカーから同じローカルLLMを使う場合、モデル単位の上限だけでは不十分です。
モデル単位の上限 × 同時に使うモデル数
この値は、アプリケーションの安全な総量を簡単に超えます。
そのため、共有バックエンドを表すグローバル上限が必要です。
さらに、枯渇をタイムアウトや空返信へ変換しない設計も重要です。
キュー長、待機時間、ビジー通知を可視化すれば、障害は「沈黙」へ化けません。