前提

RIKKA M&A は事業やWebサイトを売買するプラットフォームで、LLMを使った機能が複数、本番で動いています。技術デューデリジェンス、事業の健全性評価、本人確認、サポート、通報の処理。いずれもClaude APIを叩きます。

これらは全部、ユーザーの操作を起点に外部の有料APIを呼ぶという共通の性質を持っています。つまり、放っておくとコストが青天井になります。

この記事は、その止め方をどう設計したかと、その設計が別のリスクと引き換えだった話です。

素朴な対策には穴がある

まず思いつくのはプロバイダ側の上限設定です。ただしこれには粒度の問題があります。

上限はアカウントやプロジェクト単位でしか引けません。機能ごとに分けられないので、到達した瞬間に全機能が同時に死にます。「匿名の誰でも使える公開ツール」と「認証済みユーザーのコア機能」が同じ予算を共有していると、公開ツールが荒らされただけでコア機能まで止まります。

そして最大の問題は、気づいたときには請求が立っていることです。上限は事後の防波堤であって、事前の制御ではありません。

設計: guard() と record() の2フック

各API呼び出しの直前に guard() を、成功後に record() を呼びます。これだけです。

// 呼ぶ側はこの2行を挟むだけ
$this->usageGuard->guard('tech_dd_analyze', $nid, $uid);
$result = $this->callClaude($payload);
$this->usageGuard->record('tech_dd_analyze', $costEstimate, $nid, $uid);

guard() は違反時に例外を投げます。呼ぶ側は判定ロジックを一切持ちません。

5層のガード

guard() の中には5つの層があります。

  1. Kill Switch — ONなら即時拒否
  2. クールダウン — 同一アクション(+対象ノード)の連打抑止
  3. 日次回数上限 — サブシステム全体の直近24時間の件数
  4. ユーザー別日次上限 — 登録ユーザー単位。匿名は対象外
  5. 月次予算到達Kill Switch を自動でONにして以後を全停止

5層目だけ性質が違います。他が「この呼び出しを拒否する」なのに対し、これはサブシステム全体を止めに行くからです。

$monthlyBudget = (float) ($config->get($this->subsystem . '_budget_monthly_jpy') ?? 0);
if ($monthlyBudget > 0) {
  $monthQuery = $this->database->select('rikka_core_api_usage', 'u')
    ->condition('subsystem', $this->subsystem)
    ->condition('created', $now - 2592000, '>');
  $monthQuery->addExpression('SUM(cost_estimate)', 'total');
  $monthCost = (float) $monthQuery->execute()->fetchField();

  if ($monthCost >= $monthlyBudget) {
    $this->logger->critical('月次予算到達: subsystem=@s cost=@c (Auto Kill Switch ON)', [
      '@s' => $this->subsystem,
      '@c' => $monthCost,
    ]);
    // 個別の拒否では止まらない。以後を全部止める。
    $this->killSwitch->enable(sprintf('ApiUsageGuard 月次予算到達: subsystem=%s cost=%.2f', $this->subsystem, $monthCost));
    throw new ApiUsageLimitException('budget', 'AI 機能は月次予算到達のため一時停止しています。');
  }
}

4層目で「匿名は対象外」にしているのは、匿名にはユーザーIDが無いからです。そこは前段のレート制限(識別子単位)が見ます。ガードで全部やろうとしない、という線引きです。

設計判断1: 1クラス、サブシステム別インスタンス

このクラスは1つですが、サービスとしては複数生えていますconfigNamesubsystem をコンストラクタで注入し、サービス定義側で別インスタンスとして定義します。

  # 認証済みユーザー向けのコア機能用
  rikka_listing.ai_usage_guard:
    class: Drupal\rikka_core\Service\ApiUsageGuard
    arguments:
      - '@database'
      - '@datetime.time'
      - '@config.factory'
      - '@rikka_listing.ai_kill_switch'          # kill switch も別
      - '@logger.channel.rikka_listing'
      - 'rikka_listing.settings'                 # 閾値を置く config
      - 'ai'                                     # subsystem 名

  # 匿名の公開ツール用。上とは独立した予算・独立した kill switch
  rikka_listing.tech_dd_public_usage_guard:
    class: Drupal\rikka_core\Service\ApiUsageGuard
    arguments:
      - '@database'
      - '@datetime.time'
      - '@config.factory'
      - '@rikka_listing.tech_dd_public_kill_switch'
      - '@logger.channel.rikka_listing'
      - 'rikka_listing.tech_dd_public_settings'
      - 'tech_dd_public'

狙いは爆発半径の分離です。匿名の公開ツールが荒らされて予算に到達しても、止まるのは公開ツールだけで、認証済みユーザーのコア機能は生きています。kill switch を共有していないからです。

現在この形で5インスタンスが4モジュールに散っています。それぞれ独立した予算と独立した停止スイッチを持ちます。

:::message 「分離しているつもり」は当てになりません。これはKernelテストで固定しています。

testBudgetKillSwitchIsolatedAcrossSubsystems() ── あるサブシステムの予算到達が、別サブシステムの Kill Switch をONにしないこと。 :::

設計判断2: 緊急停止パスはキャッシュを通さない

Kill Switch の状態は config に持っています。ただし読むときは config テーブルを直接SELECTします

public function isEnabled(): bool {
  return $this->isEnabledFromDb();
}

/**
 * config テーブルを直接 SELECT し、config キャッシュをバイパスして最新値を読む。
 *
 * 自動 Kill Switch ON 後の同一リクエスト内処理でも最新値を保証するため。
 */
private function isEnabledFromDb(): bool {
  $raw = $this->database->select('config', 'c')
    ->fields('c', ['data'])
    ->condition('name', $this->configName)
    ->execute()
    ->fetchField();

  if ($raw === FALSE) {
    return FALSE;
  }
  $decoded = @unserialize($raw, ['allowed_classes' => FALSE]);
  if (!is_array($decoded)) {
    return FALSE;
  }
  return !empty($decoded['kill_switch_enabled']);
}

Drupalのconfigにはキャッシュ層があります。普通はそれで良いのですが、緊急停止だけは事情が違います

5層目が自動でKill SwitchをONにしたとき、同じリクエストの中で後続の処理が走ることがあります。そこでキャッシュ済みの古い値を読むと、**「たった今止めたのに、まだ動いている」**が起きます。止める仕組みが遅延したら、止める仕組みとしての意味がありません。

なので、このパスだけは遅い代わりに確実な経路を通します。フレームワークが用意した速い道を、ここでは意図的に使いません。

正直な話: これはDoSと引き換えです

ここまでが設計で、ここからが正直な話です。

月次予算に到達すると自動で止まるということは、裏返すと攻撃者はコストを積むだけで機能を落とせるということです。金を使わせれば止められる。予算上限は、可用性を金で買える状態を作ります。

これは分かった上で選んでいます。天秤にかけたのはこの2つでした。

  • 上限なし: コストが青天井。請求は取り消せない。個人でも法人でも致命傷になりうる
  • 上限あり: 機能が一時的に止まる。攻撃者に落とされる面ができる

「請求が青天井」は回復できませんが、「機能が一時的に止まる」は回復できます。だから後者を選びました。緩和として、前段にレート制限とOAuth認可を置いて、そもそもコストを積むこと自体のハードルを上げています。

強調したいのはここです。この判断を明示的にしないまま上限を入れると、無自覚に攻撃面を作ることになります。 上限は直感的には「安全側」に見えます。コストの観点では確かにそうです。しかし可用性の観点では、外部から引ける停止レバーを増やしています。安全側に倒したつもりが、別の軸では危険側に倒れている。どちらの軸で見ているかを意識しないと、この反転は見えません。

限界

正直に書いておくべきことが2つあります。

record() が積んでいるのは推定コストであって、実際の請求ではありません。したがって予算ガードは推定の上に立っています。推定が過小に倒れていれば、予算を超えてから止まります。守れる精度は推定の精度を超えません。

もうひとつ、「月次」は暦月ではなく直近30日のローリングです(created > now - 2592000)。月初にリセットされません。実装としては単純ですが、「今月あといくら使えるか」を人間が数えるときに直感と食い違います。

まとめ

  • LLMを本番に出すなら、コストの止め方を機能ごとに分ける。 プロバイダ側の上限は粒度が粗すぎて、荒らされた機能が無関係な機能を道連れにする
  • 爆発半径を分けたなら、分かれていることをテストで固定する。 「別インスタンスにしたから大丈夫」は確認していない仮説
  • 緊急停止のパスはキャッシュを通さない。 止めたのに止まらない窓は、止める仕組みを無意味にする
  • 予算上限は可用性と引き換え。 無自覚に入れると攻撃面になる。どちらの軸で安全側なのかを言語化してから入れる
  • 推定コストで守るガードは、推定がズレた分だけ守れない

この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

技術DDは誰でも無料で試せます(GitHubリポジトリをその場で診断)。 RIKKA M&A の技術デューデリジェンスを試す