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?

請求書より先にアプリが守り始めた — AIO HelperのAI cost-cap spend guard

0
Last updated at Posted at 2026-10-06

請求書より先にアプリが守り始めた — AIO HelperのAI cost-cap spend guard

上原正吉(EarthLink Network Co., Ltd.)。Claude Codeを開発の主体に据え、20を超えるプロダクトを1人で同時に開発・運用しています。これは、その現場の実測記です。

請求書より先にアプリが守り始めた — AIO HelperのAI cost-cap spend guard

結論

2026年4月16日、SEO分析SaaS「AIO Helper」のAPIサーバーに、AI APIの月次利用額がサイトごとの上限(既定$50)を超える前に、処理を止める仕組みを入れました。見積額が残額を超える要求は、AIを呼ぶ前に止めます。利用額の記録は、AI呼び出しが成功した後です。各用語は本文の初出で説明します。

  • 利用額の記録と上限確認を1つのINSERTにまとめ、2つの処理へ分けない形にしました。ただし、同時実行時の厳密な上限保証は実際のPostgresで未確認です。また、見積もりを超えた実費や同時実行による超過は、事後の記録では止められません。
  • 4月23日には残額が10%未満になった時点で警告する機能を追加しました(実装後に240件のテストが通過・当時の実行記録)。
  • 確認を入れたのは、文章を生成するAIを呼ぶ3つの機能(ページ目標の自動生成・タイトルと説明文の提案・一括提案)です。一括提案では、ページごとに呼ぶ直前で確認し直します。検索用のベクトルを作るAI呼び出しは、この時点では対象外でした。

本文(読了 約8分)

何を作っていたか

対象は、AIO Helperという自社のSaaS(Software as a Service、インターネット経由で利用するソフトウェア)です。SEO(Search Engine Optimization、検索結果で見つけられやすくする最適化)の運用を支援します。Google Search Consoleなどからサイトのデータを取り込み、ページごとの改善案を出します。構成は、利用者が操作する管理画面と、処理を受け持つAPIサーバー(seo-api)の2つです。製品の全体像はAIO Helperの紹介記事にまとめています。

AI(Artificial Intelligence、学習済みモデルで推論や生成を行う技術)を使うのはAPIサーバー側で、2026年4月の時点で、文章を生成するAIを呼ぶ機能は次の3つでした。いずれも生成AIのAPI(Application Programming Interface、システム間で機能を呼び出す窓口)を呼びます。

  • ページ目標の自動生成(POST /v1/page-goals/auto-generate)。ページのタイトルや本文から、狙うキーワード・ページの目的・想定読者を下書きします。
  • タイトルと説明文の提案(POST /v1/suggest)。1ページ分の改善案を出します。
  • 一括提案(POST /v1/suggest/batch)。クリック数の多いページから順に、まとめて提案を出します。

このほかに、関連する文書を探すための検索用ベクトル(embedding、文章を数値の並びに変換したもの)を作る呼び出しがあります。提案の2機能が内部で使うものと、ベクトルを作り直すAPI(POST /v1/embeddings/rebuild)です。4月の仕組みは、これらを対象にしていません(後の「確認できていないこと」で説明します)。

使う人にとっては、分析が早く終わり、手作業で調べる時間が減るのが価値です。

ただし、AI APIは呼ぶたびに費用が増えます。利用者が増えたときだけでなく、バグで同じ処理を繰り返したときにも請求は増えます。管理画面に利用額を表示するだけでは、気づいた時点ではすでに費用が発生しています。

そこで、次の2つを作りました。

  • 使う前に止める仕組み。サイトごとに月次上限を持ち、残額が足りない要求はAI APIへ送りません。
  • 使った後に追える台帳。サイト、モデル、呼び出し先、トークン数、費用を、文章生成の呼び出し1回ごとに残します。

AIO Helperのどこに上限確認が入っているか(2026年4月の実装をもとにした再構成)

サイトごとの月次上限は、4月16日の時点ではAPI(PUT /v1/sites/:siteId/budget)で変更し、4月23日には管理画面に「AI Budget」タブも加えました。上限と利用記録はAPIサーバーのデータベースにあり、文章を生成するAIを呼ぶ3つの機能は、AIを呼ぶ直前に同じテーブルを見ます。

目的は、単に月$50で止めることではありません。どの機能がどれだけ費用を使ったかを追えるようにし、使い方を変えた後に本当に減ったかを確認することです。「止める」と「改善する」を同じ記録から行える状態を目指しました。

1回の要求をどこで止めるか

処理の順番は次のとおりです。

  1. APIサーバーが、文章を生成するAIを呼ぶ3つの機能のどれかの要求を受け取ります。
  2. 使うモデルと入出力の見込みから費用を計算します。
  3. 当月の利用額と見込み費用をデータベース内で比べます。
  4. 残額が足りなければ、外部のAI APIは呼びません。ページ目標の自動生成と1ページの提案は、HTTP(Hypertext Transfer Protocol、Web通信の規約)402を返します。一括提案は残額が足りないページを飛ばし、飛ばしたページの件数を付けて、HTTP 200で結果を返します。
  5. 残額が足りればAI APIを呼び、成功した後に実績を利用台帳へ記録します。記録の時にも、同じSQL文の中で上限を確認します。

AI APIを呼ぶ前に月次上限を確認する処理フロー(実装をもとにした再構成)

この順番で重要なのは、止める判断を「AI APIがエラーを返した後の後処理」にしないことです。外部へ送った後では費用を取り消せません。アプリの入口で止めることで、初めて上限が制御として機能します。

一方で、この記録は請求書の代わりではありません。アプリは自分が把握しているトークン数と価格表から見積もります。プロバイダ側の最終的な請求と完全に一致するとは限りません。アプリ内台帳は早く止めるための数字、請求書は最終的に支払う数字と役割を分けます。

きっかけは「表示はあるのに上限がない」

発端はテスト監査でした。AIO Helperの管理画面には「今月のAI利用額」を表示するパネルがありましたが、上限を強制するテストは0件でした。利用額を計算できても、上限を超える前に止められなければ、従量課金の生成AIモデルを呼び続けてしまいます。

そこで最初にやったのは、料金計算とデータモデルの分離です。価格表は外部 API に問い合わせず、コードに静的に持つと決めました。理由は3つあります。

  • AI APIの応答に費用が含まれません。AIO HelperはOpenAIのAPIを直接呼んでいますが、応答に含まれるのはトークン数だけです。
  • 価格はそう頻繁には変わりません。静的な価格表でも保守が現実的です。
  • 課金判断を外部呼び出しに依存させたくありません。コストを知るための API 呼び出しが失敗したら課金判断ができなくなる、という循環を避けます。
// ai-cost.ts — 未知モデルはわざと高く見積もる
const FALLBACK_PRICE = { input: 0.02, output: 0.08 };

// calcCost() は 6 桁に丸め、負値は 0 にクランプし、決して throw しない
// estimateCostFromBytes() は 3 bytes/token(英語 ~4・日本語 ~1.5-2 の中間)
// 出力は入力の 25% と仮定してプリフライト見積もりに使う

FALLBACK_PRICE を意図的に高く($0.02/$0.08 per 1K)したのがポイントです。新しいモデルを追加したのに価格表への登録を忘れると、コストが 0 と見なされて上限がすり抜けます。だから未知モデルは「高い」と扱うのが安全側です。

上限確認と利用記録を1文で行う

課金を止める本体は budget.ts に置きました。テーブルは2つです。

-- seo_site_budgets: サイトごとの月次上限(デフォルト $50)
--   monthly_usd_cap NUMERIC(10,2)
-- seo_ai_usage: 1コールごとの追記専用台帳
--   cost_usd NUMERIC(10,6), created_at timestamptz

金額をFLOATではなくNUMERICにしたのは、数百万回積み上がったときの丸め誤差を避けるためです。一番悩んだのは、同時に2つの要求が上限ぎりぎりに来た場合の制御でした。SELECT FOR UPDATEとトランザクションを使う案ではなく、上限確認を含む単一のINSERT文を選びました。

INSERT INTO seo_ai_usage (site_id, model, endpoint, input_tokens, output_tokens, cost_usd)
SELECT $1::text, $2::text, $3::text, $4::int, $5::int, $6::numeric
 WHERE (
   COALESCE((SELECT monthly_usd_cap FROM seo_site_budgets WHERE site_id = $1), $7::numeric)
   - COALESCE((
       SELECT SUM(cost_usd) FROM seo_ai_usage
        WHERE site_id = $1
          AND created_at >= date_trunc('month', NOW())
     ), 0)
 ) >= $6::numeric
RETURNING id;

$7は、サイトに予算行が無いときの既定上限($50)です。事前の残額確認はcheckBudget()が行い、不足なら上流の AI fetch を呼ばずに止めます(1ページ単位の2機能は HTTP 402 Payment Required を返します)。AI呼び出し成功後のrecordUsage()は、この INSERT が 0 行を返したら{ ok: false }を返します。この時点では費用がすでに発生しているため、要求は失敗にせず、警告ログを出します。台帳には記録されないので、その呼び出しの費用は全額が台帳から抜けます。台帳の利用額が増えないため、その後の上限確認にもこの費用は反映されません。cap = 0 は「1円も使わせない」停止設定として機能させました。

単一のINSERT文で確認できる範囲

上限チェックを別のSELECT文にせず、INSERTのWHERE句へ相関サブクエリとして含めています。これにより、1つの文の中では集計と書き込みの間にアプリケーション側の別処理が入りません。

ただし、独立に確認できたのはこのSQLの構造までです。同時に始まった2つの文が必ず先行行を集計へ含めることは、実際のPostgresを使った競合テストで確認できていません。厳密な上限保証が必要なら、予算行のロック、直列化可能な分離レベル、またはアドバイザリーロックなどで、サイト単位の処理順を明示する必要があります。

この設計では、テスト環境にも問題がありました。テストはpg-mem(メモリ内で動くPostgres)で回していましたが、date_trunc('month', NOW())が未実装だったため、当時の記録では18件中12件が500で失敗しました。

12 out of 18 tests in integration-ai-cost-cap.test.ts failed
Only the 3 pure calcCost unit tests pass

記録の時点で通っていたのは、データベースへ触れないcalcCostの単体テストだけでした。500とは別の要因で失敗したテストもあり、記録に残っているのは、PUT budgetが入力検証の問題で400を返した件です。

当時のテスト記録では、db.public.registerFunction()でdate_truncを登録すると失敗は5件に減りました。残りはINSERT...SELECTの中で値の型を解決できない問題です。node-postgresは型を指定していない値を文字列として送るため、$1::textのように明示的な型変換を足して解消しています。

当時のテスト記録では、意図的に date_trunc の WHERE 句を壊す実験も行っています。18件中17件は月次の抽出条件が壊れても通り、「先月分は数えない」テストだけが失敗しました。前月の行を60日前の日付で追加し、集計されないことを確認するテストです。月次フィルタを誤って削除した場合、この1件しか検知できません。重要な条件を検証するテストが1件に集中していると分かりました。

4月23日、残額10%未満の警告を加える

上限で止めるだけでは、使う側には突然 402 が返り、なぜ止まったのか分かりません。そこで残額が 10% を切ったら警告フラグを立てる soft-warning を追加しました。

export const WARNING_REMAINING_PCT = 0.1;
function isNearLimit(cap: number, remaining: number): boolean {
  if (cap <= 0) return false;              // キルスイッチは「警告」ではない
  return remaining / cap < WARNING_REMAINING_PCT;
}

cap <= 0を明示的に除外したのは、「利用を停止している」状態と「上限が近い」状態を別の通知として扱うためです。当時の実行記録では、失敗するテスト4件を先に書き、警告条件の実装後に240件のテストが通っています。変更はfeat: AI budget soft-warning flag at <10% remainingとしてコミットしました。

月次上限に対する利用状態の分け方(当時の実装記録をもとにした再構成)

警告と停止を分けたことで、画面や通知の文言も分けられます。残額が少ないなら、利用者は処理量を減らすか、管理者へ上限変更を依頼できます。上限を0にした場合は、管理者が意図的に止めた状態です。同じ黄色い警告で見せると、利用者は「待てば戻る」と誤解します。

台帳を「止める仕組み」だけで終わらせない

上限に達したことだけを通知しても、次に何を直すかは分かりません。運用で必要なのは、少なくとも次の5つです。

  • 今月いくら使ったか。月次上限と比べるための基本数字です。
  • 今日どれだけ増えたか。急な増加は、利用者増だけでなく、再試行ループやバグの可能性もあります。
  • どの機能が使ったか。呼び出し先を残しておかないと、削減候補を選べません。
  • 1回あたりと成果物1件あたりの単価。呼び出し回数が増えても、提案の件数が同じ比率で増えているなら意味が異なります。
  • 変更の前後でどう変わったか。モデルや入力量を変えた日を残し、その後の単価と比べます。

4月のseo_ai_usageは、このうち上の3つを出せる列(サイト・モデル・呼び出し先・トークン数・費用・日時)を持っています。日別の増え方や変更前後の比較を見る画面は、まだありません。それでも1回ごとの記録が残っていれば、後から集計して削減を「気分」ではなく数字で比べられます。

この設計で確認できていないこと

この記事を「同時実行でも絶対に予算を超えない完成版」とは書けません。理由は4つあります。

  • 実際のPostgresで競合テストを終えていません。単一のSQL文にまとめたことと、サイト単位の完全な順序制御は同じではありません。
  • 台帳に残らない呼び出しがあります。記録はAI呼び出しが成功した後だけで、失敗した呼び出しは記録しない設計です。ただし、ページ目標の自動生成では、AIが応答した後に応答の読み取りで失敗するとエラーを返し、台帳に記録しません。費用は発生しているのに、台帳には残りません。
  • 検索用ベクトルの作成は上限の外でした。提案の2機能が内部で作るembeddingと、ベクトルを作り直すAPIは、上限確認も台帳への記録もしていませんでした。2026年9月に、作り直すAPIには上限確認と記録を、提案内部のembeddingには記録を加えています。
  • 予算用データベースが使えないときの動作も未確認です。処理を続ければ費用上限を守れず、止めれば利用者の作業を止めます。どちらを優先するかを決め、障害時のテストと通知を用意する必要があります。

だから、この実装は終点ではなく、費用を制御対象として扱い始めた最初の段階です。後続の記事で、請求データとの照合、固定費の特定、変更後の再計測へ進みます。この順番を飛ばすと、削減効果を数字で説明できません。

その後

この利用上限制御と台帳は、5月以降のコスト改善で使う計測・上限・記録の原形になりました。翌月にはCost Explorerが $0 を返す問題を調べ、その後「測定→修正→再測定」を記録に残す運用へ進みます。その考え方は、この4月にすでにありました。

一方、この仕組みが対象にするのはAIO HelperのAI API利用額です。Elastic Container Serviceの常時起動やNAT(Network Address Translation、内部ネットワークから外部への通信を中継する仕組み) Gatewayの固定費など、クラウド全体の請求は止められません。アプリケーションの外で発生する費用には、Cost Explorerや予算監視を別に用意する必要があります。

転用できる教訓

  • 未知は「高い」と見積もります。価格表に無いモデルを 0 円扱いにすると上限がすり抜けます。フォールバック価格は意図的に高くします。
  • 集計と書き込みを1文にまとめても、同時実行時の保証は別に検証します。厳密な上限が必要なら、実際のPostgresによる競合テストと、処理順を保証する仕組みが必要です。
  • 停止設定と残額警告は分けます。cap = 0(止まっている)を「もうすぐ」警告に混ぜません。
  • 請求書を読む前から1回ごとの費用を残します。呼び出し先とトークン数を添えてアプリ側で記録しておくと、後の削減戦略が数字で立てられます。

このクラウドコストとの戦いについての記事は、帰属・実測・構造ガード・定点観測の方法論を、順次シリーズとして公開していきます。
興味のある方は、ぜひ「いいね」と記事の購読をお願いいたします。

この方法論をもとにしたコスト管理サービス Costwary を https://costwary.com で提供しています。
そのほかの自社プロダクトは https://www.eln.ne.jp/products にまとめています。

筆者について

上原正吉。EarthLink Network Co., Ltd. でAI開発をしています。2025年からClaude Codeを開発の主体に据え、今は20を超えるプロダクトを1人で同時に開発・運用しています。この連載では、その現場で実際に起きたこと(うまくいったことも、失敗も)を、数字と一緒に書いていきます。

また、AIで業務や開発を組み替えたい会社・チーム向けに、AI活用のコンサルティングも受け付けています。ご相談は www.eln.ne.jp からどうぞ。


EarthLink Network は、会社の全業務を AI で回すために、必要になったものを自社で作っています。いま作っているプロダクトの一覧と概要は、こちらにまとめています。

→ EarthLink Network が自社でつくっている18のプロダクト

会社と各プロダクトの詳細は、公式サイト www.eln.ne.jp をご覧ください。


🔗 この記事は note に掲載した記事の再掲です。正規版(canonical)はこちら: https://note.com/chooser/n/nbc8f02432bcc

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?