# LLMエージェントAPIの費用キルスイッチ：プロバイダー呼び出し前に止める

> LLMエージェントの費用を止めるには、送信前に確定費用と予約中の費用を合算して判定します。ローカルで動く予約例を使い、同時要求、費用不明、再試行、停止後の再開条件まで設計します。

- URL: https://blog.laozhang.ai/ja/posts/llm-agent-api-spend-kill-switch
- Published: 2026-06-14
- Updated: 2026-10-07
- Author: LaoZhang AI Team (https://blog.laozhang.ai/ja/about)
- Topic: API ガイド
- Tags: LLMエージェント, API費用管理, AI Gateway, 予算上限

---
LLMエージェントのAPI費用を止めるには、**次の有料要求を送る前に、その要求の最大費用を予約する**仕組みが必要です。判定は「確定費用＋予約中の費用＋次の要求の最大費用が上限以下か」。残高確認と予約を一つの操作として実行し、予約が取れた要求だけを提供事業者へ送ります。

完了後は実際の使用量で予約額を精算します。タイムアウトなどで費用が分からなければ予約を残し、上限を超える要求には再試行しない停止結果を返します。これで、複数の処理が同じ残高を見て一斉に使い切る事故や、停止後に別モデルへ迂回して費用が増える事故を防ぐ設計にできます。

以下の実装例は、1つのNode.jsプロセス内で動く予算台帳です。外部APIを呼ばずに動かせます。複数サーバーで共有する台帳や実際の請求額の保証には、後述する永続化、料金の上限見積もり、全有料経路の統制が別途必要です。

![有料APIへの送信前に予算を判定し、予約または停止へ進むLLMエージェントの費用制御の概念図](https://blog.laozhang.ai/posts/ja/llm-agent-api-spend-kill-switch/img/cover.webp)

## 回数上限・費用上限・ツール予算を分ける

まず、何を止めたいかを決めます。エージェントの最大ステップ数を設定しても、1回の呼び出しが高額なら金額の上限にはなりません。反対に費用上限だけでは、安い要求を延々と繰り返す時間の浪費を止められません。

| 制御 | 判定するもの | 主な用途 |
| --- | --- | --- |
| 回数上限 | モデル呼び出し数、ツール実行数、反復数 | 終わらない処理の停止 |
| 費用上限 | 確定費用と実行中の予約額 | 次の有料要求を予算内で受け付ける |
| ツール予算 | 検索、画像生成、外部処理などの回数や料金 | モデル料金以外の費用を抑える |
| レート制限 | 一定時間内の要求数やトークン数 | 同時負荷や処理量を抑える |
| 予算アラート | 集計された費用と通知しきい値 | 運用担当者が増加を把握する |

API費用のキルスイッチには費用上限を使い、回数上限や実行時間の制限を併用します。ツールが内部でモデルを呼ぶなら、その呼び出しも費用台帳に含めます。検索サービスなどの別料金がある場合は、それも上限を見積もるか、専用の予算枠を設けます。

## 予算判定をすべての有料経路に置く

予算を確認する場所は、エージェントが次の操作を考える場所ではなく、**有料要求を実際に送信する直前**です。モデルが「まだ予算があります」と判断するだけでは強制力がありません。

予算の所有者、上限、期間、使えるモデル、料金表は、サーバー側の信頼できる設定から取得します。モデルの出力やツール引数で予算IDを変えられると、別のIDを作るだけで上限を回避できます。利用者の識別子も認証済みの情報から決め、モデルが渡した任意の名前をそのまま信用しません。

送信経路を確認するときは、通常のモデル要求に加えて次の処理を追います。

- SDKやHTTPクライアントが内部で行う再試行。
- 代替モデルや別の提供事業者への切り替え。
- 子エージェント、要約処理、評価処理。
- ツール内部のモデル呼び出しや画像生成。

再試行で新しい有料要求が生じるなら、その都度予約が必要です。元の要求の費用が不明なまま再試行する場合は、元の予約を残した上で追加の予約を取ります。1回分だけ予約して、SDKに何度でも再試行させる構成では足りません。

子エージェントに個別の上限を設定する場合も、親の総予算を同時に確認します。親が1米ドル、子がそれぞれ0.50米ドルなら、子を何体作っても親の1米ドルを共有します。複数の予算枠に予約する実装では、すべての判定と加算をまとめて成功または失敗させる必要があります。

## 次の要求にいくら予約するか

予約するのは平均費用ではなく、許可した要求が発生させる費用の上限です。まず提供事業者とモデルを確定し、入力の上限、出力の上限、料金の計量単位をそろえます。料金が分からない経路や上限を決められない操作は、予約額を0として通さず、送信を止めます。

テキストだけの要求なら、基本形は次の計算です。

~~~text
予約額 = 入力トークン上限 × 入力単価
       + 出力トークン上限 × 出力単価
       + その他の課金項目の上限
~~~

入力にはシステム指示、会話履歴、ツール定義、ツールの返却内容なども含まれます。文字数をそのままトークン数とみなさず、実際の送信内容と対象モデルに合う計数方法で確認します。画像、音声、検索、キャッシュ作成、課金対象の追加トークンなどがあるなら、それぞれの料金体系も必要です。キャッシュが必ず効くと確認できない場合は、割引を前提に上限を下げません。

次のコードでは料金計算を説明するため、入力1トークンを2単位、出力1トークンを8単位、1単位を0.000001米ドルと仮定します。入力上限1,000トークン、出力上限500トークンなので、1要求の予約は6,000単位、つまり0.006米ドルです。**これらは架空の単価で、現在のモデル価格ではありません。**

最大出力をリクエストに設定しただけで、すべての課金項目を制限できるとは限りません。実サービスへ接続する際は、実際の経路がこの入力・出力上限を守ることと、予約から漏れる料金がないことを確認します。

## 原子的な予約をローカルで実行する

次の例を `budget-gate.mjs` として保存し、Node.jsで `node budget-gate.mjs` を実行できます。外部ライブラリ、APIキー、ネットワーク通信は使いません。

`reserve()` は途中に `await` を挟まず、残高判定と予約額の加算を同期的に終えます。同じプロセスの処理は、その途中へ割り込めません。`sent()` を記録してからfake providerを呼び、費用が確定したときだけ `settle()` で精算します。要求IDが同じなら再送せず、内容や予約額が変わっていればエラーにします。

コード内の `policy` は信頼できる設定の代役です。fake providerはトークンを計数しないため、このコード単体で入力・出力の上限を強制するものではありません。実接続時には、その上限を満たすことを確認した送信内容だけを渡し、提供事業者から得た使用量を同じ料金単位に換算してください。

~~~js
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';

const money = n => {
  if (typeof n !== 'bigint' || n < 0n) throw Error('invalid_amount');
  return n;
};
class Budget {
  constructor(scope, period, cap) {
    this.scope = scope; this.period = period; this.cap = money(cap);
    this.actual = 0n; this.held = 0n; this.frozen = false;
    this.entries = new Map();
  }
  reserve(id, binding, ceiling) {
    money(ceiling);
    if (!id || !binding || ceiling === 0n) throw Error('invalid_request');
    const old = this.entries.get(id);
    if (old) {
      if (old.binding !== binding || old.ceiling !== ceiling)
        throw Error('request_id_conflict');
      return { ok: false, reason: 'duplicate_request' };
    }
    if (this.frozen) return { ok: false, reason: 'budget_frozen' };
    if (this.actual + this.held + ceiling > this.cap)
      return { ok: false, reason: 'over_budget' };
    const ticket = Object.freeze({ scope: this.scope, period: this.period, id });
    this.entries.set(id, { ticket, binding, ceiling, state: 'reserved' });
    this.held += ceiling;
    return { ok: true, ticket };
  }
  entry(ticket) {
    const e = this.entries.get(ticket.id);
    if (!e || e.ticket !== ticket) throw Error('ticket_mismatch');
    return e;
  }
  sent(ticket) {
    const e = this.entry(ticket);
    if (e.state !== 'reserved') throw Error('already_dispatched');
    e.state = 'sent';
  }
  unknown(ticket) {
    const e = this.entry(ticket);
    if (e.state !== 'settled') e.state = 'unknown';
  }
  settle(ticket, amount) {
    money(amount);
    const e = this.entry(ticket);
    if (e.state === 'settled') {
      if (e.amount !== amount) {
        this.frozen = true;
        throw Error('settlement_conflict');
      }
      return; // 同じ確定額の再通知では二重計上しない
    }
    this.actual += amount; this.held -= e.ceiling;
    e.state = 'settled'; e.amount = amount;
    if (amount > e.ceiling || this.actual + this.held > this.cap)
      this.frozen = true;
  }
}

// 仮の単価。1単位=0.000001米ドル、入力2/トークン、出力8/トークン。
const policy = Object.freeze({ route: 'offline-text', inputMax: 1000,
  outputMax: 500, inputRate: 2n, outputRate: 8n });
const ceiling = 1000n * policy.inputRate + 500n * policy.outputRate;
async function guarded(budget, id, payload, provider) {
  const binding = createHash('sha256').update(JSON.stringify({
    scope: budget.scope, period: budget.period, policy, payload
  }, (_, v) => typeof v === 'bigint' ? v.toString() : v)).digest('hex');
  const r = budget.reserve(id, binding, ceiling);
  if (!r.ok) return { status: r.reason, retryable: false };
  budget.sent(r.ticket);
  try {
    const result = await provider(payload, policy);
    if (result.amount === undefined) {
      budget.unknown(r.ticket);
      return { status: 'billing_unknown', retryable: false,
        data: result.data, ticket: r.ticket };
    }
    budget.settle(r.ticket, result.amount);
    return { status: budget.frozen ? 'budget_frozen' : 'completed',
      retryable: false, data: result.data, ticket: r.ticket };
  } catch {
    budget.unknown(r.ticket);
    return { status: 'billing_unknown', retryable: false, ticket: r.ticket };
  }
}

let calls = 0;
const fake = async () => { calls++; return { amount: 4000n, data: '部分結果' }; };
const b = new Budget('run-A', 'run-A-v1', 12000n);
const results = await Promise.all(['a', 'b', 'c'].map(id => guarded(b, id, {}, fake)));
assert.equal(calls, 2);
assert.equal(results[2].status, 'over_budget');
assert.equal(b.actual, 8000n); assert.equal(b.held, 0n);
assert.equal((await guarded(b, 'a', {}, fake)).status, 'duplicate_request');
b.settle(results[0].ticket, 4000n); assert.equal(b.actual, 8000n);
assert.throws(() => b.settle(results[0].ticket, 3000n));
assert.equal(b.frozen, true);
assert.throws(() => b.reserve('a', 'changed', ceiling));
const u = new Budget('run-U', 'run-U-v1', 6000n);
const pending = await guarded(u, 'u', {}, async () => { throw Error('timeout'); });
assert.equal(pending.status, 'billing_unknown'); assert.equal(u.held, 6000n);
assert.equal((await guarded(u, 'next', {}, fake)).status, 'over_budget');
u.settle(pending.ticket, 2000n); assert.equal(u.held, 0n);
const o = new Budget('run-O', 'run-O-v1', 6000n);
await guarded(o, 'o', {}, async () => ({ amount: 7000n }));
assert.equal(o.actual, 7000n); assert.equal(o.frozen, true);
assert.equal((await guarded(o, 'next', {}, fake)).status, 'budget_frozen');
assert.equal(calls, 2);
console.log('offline assertions passed; fake provider calls=2');
~~~

実行時は `offline assertions passed; fake provider calls=2` と表示されます。例の上限は12,000単位、1要求の予約は6,000単位です。3件を同時に受け付けようとしても、2件で予約が埋まり、3件目は送信前に拒否されます。先に完了した要求の実費が4,000単位だったからといって、精算前にその差額を使うことはできません。

ここで示す原子性は、1つのプロセス内の台帳に限ります。複数workerがそれぞれ `new Budget()` を作れば、別々の残高になってしまいます。複数プロセスでは、同じ予算・期間の行をロックするデータベーストランザクションなどで、判定と予約を一つの操作に置き換えます。

RedisのLuaスクリプトも原子的な処理に使えますが、その保証はRedis内のスクリプト実行に関するものです。有料APIへのHTTP送信まで一緒にトランザクションになるわけではありません。永続化、障害復旧、クラスタでのキー配置、スクリプトの再読み込みも別に設計します。単独の `GET` で残高を読み、後から `SET` するだけでは原子的な予約になりません。[Redis公式のスクリプト実行の説明](https://redis.io/docs/latest/develop/programmability/eval-intro/)

![費用の見積もり、予約、API呼び出し、使用量の照合を順番に示す概念図](https://blog.laozhang.ai/posts/ja/llm-agent-api-spend-kill-switch/img/reserve-call-reconcile.webp)

## 費用不明の要求を解放しない

タイムアウト、ストリーム切断、応答解析の失敗は、「料金が発生していない」という証明にはなりません。要求が提供事業者に届いたか、処理が続いたかを確認できなければ、予約を残した `unknown` 状態にします。

上のコードでは、例外が起きた要求も、応答に費用がない要求も `billing_unknown` を返します。その予約は後続要求の残高判定に含まれます。後から確定額が得られた場合は、返された `ticket` を使って `settle(ticket, amount)` を呼びます。同じ額の再通知は二重計上せず、異なる額の再通知は矛盾として停止します。

| 状況 | 台帳での扱い | 次の操作 |
| --- | --- | --- |
| 実費が確定した | 確定費用へ移し、予約との差額を解放 | 残高が足りれば新しい要求を予約 |
| 応答は得たが使用量が不明 | 予約を保持 | 部分結果を保存し、利用記録と照合 |
| 送信後のタイムアウト・切断 | 予約を保持 | 提供事業者の要求IDなどで照合 |
| 送信されていないことを確実に確認した | 根拠を記録して予約を解放可能 | 新しい要求として再判定 |
| 実費が予約額を超えた | 実費を正しく計上し、予算を凍結 | 料金見積もりと経路を修正 |

この例は「確実に未送信」の予約解放を実装していません。送信済みか曖昧な状態を、便宜上未送信に変えないためです。実装する場合は、送信前に終了した記録などの根拠を台帳に結び付け、解放も一度だけ行えるようにします。

実費が予約より高かった場合、超過分を切り捨てて帳尻を合わせてはいけません。例では7,000単位の実費をそのまま記録し、上限6,000単位を超えた台帳を凍結します。この時点で「請求額を上限以内に抑えた」とは言えません。防止できるのは、その後に新しい要求を送って超過を広げることです。

本番の台帳には、予算ID、期間ID、要求ID、料金表の版、経路、予約額、状態、確定額、提供事業者の要求IDを保存します。日付が変わっても未確定要求は元の期間に残し、期間切り替えを予約解放の理由にしません。月次上限だけでは月をまたぐ実行の総費用は制限できないので、必要なら実行全体の上限も併用します。

台帳へ接続できないときや予約・精算の記録に失敗したときは、有料要求の新規送信を止めます。プロセスが落ちる前に予約が永続化されていること、復旧後に未確定予約を再照合できることを確認してください。メモリだけの例を再起動すると台帳が消えるため、そのまま本番用にはできません。

## 上限到達は終端として返し、部分結果を残す

予算超過を通常の一時的な通信エラーに変換すると、エージェントが自動で再試行してしまいます。呼び出し元へは、同じ条件では再開できないことと、完了済みの範囲を返します。

~~~json
{
  "status": "stopped_budget",
  "retryable": false,
  "completed": ["資料3件の要約"],
  "pending": ["追加検索", "最終比較表の生成"],
  "budget": {
    "scope": "run-A",
    "period": "run-A-v1",
    "actual_units": "8000",
    "reserved_units": "0",
    "next_request_units": "6000",
    "cap_units": "12000"
  }
}
~~~

これはアプリケーション側で組み立てる返却例です。未実行操作を完了扱いにしたり、比較表が未完成なのに最終回答として返したりしません。安いモデルへ変更するなら、その変更が品質や利用者への約束に影響するかを確認し、変更後の経路でも新しく予約します。

停止後は、plannerが新しい有料処理を作らず、待機中の子処理も送信前の判定で拒否されるようにします。すでに送信済みの処理は費用を確定させる必要があるため、予算を止めたことを理由に台帳から消しません。

再開できるのは、信頼できる権限で予算方針が変更された、正当な期間更新が起きた、または未確定費用の照合で残高が戻り、次の要求を新しく予約できた場合です。予算枠を勝手に作り直したり、別キーへ切り替えたりするのは再開条件になりません。凍結状態なら、料金の誤りや台帳の矛盾を解消してから明示的に解除します。

## 提供事業者・Gatewayの上限も併用する

提供事業者の上限は有効な防御です。ただし、用途、集計のタイミング、適用範囲が違います。2026年10月6日時点の公式説明に基づく違いは次のとおりです。

| サービス | 確認できる制御 | 費用キルスイッチでの注意点 |
| --- | --- | --- |
| [OpenAI API](https://developers.openai.com/api/docs/guides/spend-limits) | 組織・プロジェクトで `Enforce a hard limit` を有効にすると、該当要求を429で拒否 | 通知だけのSpend alertとは別。反映に時間がかかり、記録費用が少し超過する場合がある |
| [Anthropic Claude API](https://platform.claude.com/docs/en/api/rate-limits) | 組織の利用ティア上限と、利用者が設定する費用上限 | 到達時のステータスが異なる。429だけで再試行可否を決めない |
| [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/features/spend-limits/) | 適用される費用ルールの上限到達後に429で拒否 | 費用の加算は要求完了後。結果整合性と同時要求による超過がある |
| [LiteLLM Proxy](https://docs.litellm.ai/docs/proxy/users) | 全体、チーム、メンバー、仮想キー、利用者などの予算 | 範囲と利用バージョンを確認。予算設定の存在だけで原子的な予約を証明できない |
| [LiteLLMのエージェント予算](https://docs.litellm.ai/docs/a2a_iteration_budgets) | セッションの呼び出し回数・費用上限 | 費用は応答後に加算。既定のカウンタTTLは1時間で、実行全体の永続台帳とは異なる |

OpenAIの組織上限は組織内の全プロジェクトに、プロジェクト上限はそのプロジェクトに計上される要求に適用されます。到達時のコードはそれぞれ `organization_spend_limit_exceeded` と `project_spend_limit_exceeded` です。OpenAI側で承認された月次利用上限は別の制御です。過去の「プロジェクト予算は警告のみ」という説明を、そのまま現在の仕様として使わないでください。[OpenAI公式Spend limits](https://developers.openai.com/api/docs/guides/spend-limits)

Anthropicでは、利用ティアの費用上限に達すると `429 rate_limit_error` となり、Messages APIの詳細に `enforced_spend_limit_reached` が入ります。この場合は `retry-after` がなく、待ち時間を少し増やす再試行では解決しません。一方、利用者が設定した組織・ワークスペースの費用上限は `400 invalid_request_error` です。Claude Code向けワークスペースの扱いには別の条件があるため、すべてのワークスペースエラーをこの分類に当てはめないでください。[Anthropic公式Limits](https://platform.claude.com/docs/en/api/rate-limits)

Cloudflareの費用はベストエフォートの推定で、厳密な請求額ではありません。既知の料金で計算できる要求が対象となり、並列の要求が完了する前に次の要求が通る余地があります。利用者別ルールでは識別子も確認します。`cf.user_id` はAccessで保護されたカスタムドメインの有効な利用者情報に依存し、サービストークンでは付きません。独自の利用者IDを使うなら、アプリ側で認証した値を渡します。[Cloudflare公式Spend limits](https://developers.cloudflare.com/ai-gateway/features/spend-limits/)

LiteLLMでは、誰の予算を確認するかと、いつリセットするかをそろえます。公式文書ではv1.95以降、チームのキーに対する個人予算の適用が変わっているため、個人・チームメンバー・キーを同じ意味で扱えません。また、セッションIDを変えたり、カウンタがTTLで消えたりしても親の総予算が増える設計にはしません。以下の文書は機能確認の資料であり、実際に導入したバージョンや同時実行時の厳密な上限を確認した結果ではありません。[Proxyの予算範囲](https://docs.litellm.ai/docs/proxy/users)、[エージェントの反復・セッション予算](https://docs.litellm.ai/docs/a2a_iteration_budgets)

## この例で確認したことと、導入前に確認すること

掲載コードは外部通信なしで実行し、fake providerへの到達数と台帳の値をassertionで確認しました。確認したのは、同一プロセス内の3件の同時受付で2件だけ送信処理へ進むこと、同じ要求IDでは再送しないこと、重複精算で二重計上しないこと、費用不明なら予約を残すこと、後からの精算で予約を解放すること、予約を超えた実費を記録して凍結することです。

ストリームの実通信、SDKの自動再試行、提供事業者の請求、Redis・データベースの競合や障害復旧は、この検証に含まれません。タイムアウトはfake providerが例外を返す形で再現しています。実際の接続に進む前は、次のように成功条件を具体化します。

| 確認対象 | 成功条件 |
| --- | --- |
| 全有料経路 | 予約拒否の後に送信アダプタへ到達しない。通常・再試行・代替経路・ツールで同じ条件 |
| 複数workerの競合 | 共有台帳で「確定＋予約」の上限判定と予約追加が不可分 |
| ストリーム中断 | 使用量不明の予約が残り、後から同じ要求へ精算される |
| 台帳障害・再起動 | 記録不能時は送信を止め、復旧後も未確定要求を保持 |
| 料金や経路の変更 | 新しい料金表・課金項目で上限を再計算し、漏れた料金がない |
| 停止結果の伝播 | planner・子処理が有料作業を増やさず、部分結果と未実行操作を表示 |

![予約拒否の後に送信回数が増えないことと、費用不明の呼び出しの予約が残ることを分けて確認する図](https://blog.laozhang.ai/posts/ja/llm-agent-api-spend-kill-switch/img/no-provider-call-test.webp)

図は処理や確認項目を説明するための概念図です。実際の通信回数や請求額を測定した画面ではありません。

## よくある質問

### OpenAIのプロジェクト予算だけでエージェントを止められますか？

現在のOpenAI APIには、プロジェクトのハード上限を有効にして要求を拒否する機能があります。ただし反映は即時ではなく、少額の超過が起こり得ます。実行ごとの予算や複数提供事業者をまとめた予算は、送信前の予約で管理します。警告通知の設定とハード上限の有効化は別です。[OpenAI公式説明](https://developers.openai.com/api/docs/guides/spend-limits)

### 429なら自動再試行してよいですか？

ステータスだけでは決められません。要求数のレート制限と、費用上限到達を区別します。OpenAIの費用上限コードやAnthropicの `enforced_spend_limit_reached` が出たら、同じ予算条件での自動再試行を止めます。通常の再試行が許される場合も、新しい有料要求の予約が必要です。[OpenAIの費用上限](https://developers.openai.com/api/docs/guides/spend-limits)、[Anthropicの上限とエラー](https://platform.claude.com/docs/en/api/rate-limits)

### タイムアウトした要求の予約はいつ戻せますか？

確定した費用と照合したとき、または未送信・無課金を十分な根拠で確認したときです。クライアントの切断だけで全額を戻してはいけません。不明な間は予約を残し、要求IDと元の予算期間に結び付けて確認します。

### 安いモデルに切り替えれば予算超過でも続けられますか？

変更後の要求を新しく予約でき、品質や出力への約束を守れる場合だけです。代替経路も同じ総予算を使います。先の要求の費用が不明なら、その予約を残したまま判断します。モデル変更や新しいセッションIDを、予算判定を通さず続けるために使いません。

## 参考資料

本文で参照している外部ページを、登場順に並べています。最終更新日：2026-10-07。

- [Redis公式のスクリプト実行の説明](https://redis.io/docs/latest/develop/programmability/eval-intro/) (redis.io)
- [OpenAI API](https://developers.openai.com/api/docs/guides/spend-limits) (developers.openai.com)
- [Anthropic Claude API](https://platform.claude.com/docs/en/api/rate-limits) (platform.claude.com)
- [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/features/spend-limits/) (developers.cloudflare.com)
- [LiteLLM Proxy](https://docs.litellm.ai/docs/proxy/users) (docs.litellm.ai)
- [LiteLLMのエージェント予算](https://docs.litellm.ai/docs/a2a_iteration_budgets) (docs.litellm.ai)
