0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数の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を使う場合、モデル単位の上限だけでは不十分です。

モデル単位の上限 × 同時に使うモデル数

この値は、アプリケーションの安全な総量を簡単に超えます。
そのため、共有バックエンドを表すグローバル上限が必要です。

さらに、枯渇をタイムアウトや空返信へ変換しない設計も重要です。
キュー長、待機時間、ビジー通知を可視化すれば、障害は「沈黙」へ化けません。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?