ある朝、請求ダッシュボードを開いて、前月のトークン費が普段の3倍を超えていることに気づきました。原因はすぐには分かりませんでした。どのエージェントが、どのタスクで、どれだけ消費したのか、記録がどこにもなかったからです。並行で10本近いエージェントを夜間に走らせていて、そのうちの1本が想定外のループに入り、延々と巨大なコンテキストを投げ続けていたのでした。
廣川政樹です。2014年から個人開発で iOS / Android アプリを作り続けていて、累計5,000万DLの壁紙アプリ群と、Lab 4サイト・Blog 2サイトの合計6サイトを一人で運営しています。記事の生成やリンク監査、AdMob まわりの定型作業の多くを、Antigravity の並列エージェントに任せるようになってから、運用は確かに楽になりました。けれど同時に、「気づいたら費用が膨らんでいる」という新しい種類の事故も抱え込むことになりました。
その晩のジョブは、4サイトぶんの記事生成と、各サイトの内部リンク監査をまとめて流すものでした。普段なら1時間ほどで静かに終わります。けれどその日は、1本のエージェントが監査の途中で「リンク先が見つからない→再探索→また見つからない」という小さなループに入り、毎回ほぼ全記事のインデックスをコンテキストに積み直していました。1回あたりの入力トークンが通常の数十倍に膨らみ、それが数百回繰り返されたのです。個人開発で全部を一人で見ている身からすると、こういう「静かに膨らむコスト」は、夜中に走っているぶんだけ気づくのが遅れます。
このノートは、その事故を二度と起こさないために組んだ「予算ガバナンス層」の設計と実装の記録です。トークン数を減らすチューニングの話ではありません。消費そのものを予算という単位で監視し、限度を超えたら自動で止める仕組みの話です。同じように複数エージェントを並行運用していて、コストの不透明さに不安を感じている方の参考になればと思います。
トークンを減らす最適化では止血にならない
最初に試したのは、プロンプトを短くする、コンテキストを圧縮する、安いモデルに寄せる、といった「トークンを減らす」方向の改善でした。これはこれで効果がありますし、やる価値はあります。実際、定型タスクのプロンプトを見直しただけで、1タスクあたりの平均消費は2割ほど落ちました。
ただ、これは平常時の単価を下げる話であって、暴走を止める話ではありません。私が遭遇した事故は「1本のエージェントが想定の50倍のトークンを使った」というものでした。単価を2割下げても、50倍の暴走が起きれば請求書は40倍に膨らみます。最適化は分母を小さくしますが、分子が跳ねたときの天井を作ってはくれません。
言い換えると、最適化は「平均」を良くする取り組みで、ガバナンスは「最悪」を抑える取り組みです。個人開発の予算は薄いので、月に一度の最悪が平均の改善を簡単に食い潰します。私が事故月に余計に払った金額は、その前後の最適化で何ヶ月もかけて節約したぶんを、たった一晩で上回りました。だから優先順位としては、まず天井を作り、そのあとで単価を下げる、という順番が正しいと考えています。
必要だったのは、平常時の節約ではなく、異常時の遮断でした。エンジニアリングでいえば、コードを速くするチューニングではなく、無限ループを検知して殺すウォッチドッグに相当するものです。この発想の切り替えが、設計の出発点になりました。
ガバナンスを三層で考える
整理してみると、コストを制御するという課題は、性質の違う三つの層に分かれていました。
最初の層は配分 です。誰が、何に、どれだけ使ってよいかを事前に決める層です。タスクごと、エージェントごと、あるいは1回の実行全体に対して上限を割り当てます。配分がないと、後続の計測も遮断も基準を持てません。
二つ目の層は計測 です。実際にどれだけ消費したかを、リアルタイムに近い粒度で積算する層です。事後の請求ダッシュボードでは遅すぎます。走っている最中に「いま累計いくら使ったか」が分からなければ、止める判断ができません。
三つ目の層は遮断 です。配分した上限に対して、計測値が近づいた、あるいは超えたときに、実際に手を打つ層です。ここをいきなり「即停止」にすると、あと少しで終わるタスクまで殺してしまって本末転倒になります。だから縮退は段階的にします。
この三層を、Antigravity の並列エージェントに後付けで差し込めるよう、薄いラッパーとして実装していきます。以下のコードはそのまま動く TypeScript の最小実装です。
タスク単位で予算を割り当てる
まず配分です。私は「実行(run)」を最上位の単位に置き、その中に複数の「タスク(task)」がぶら下がる構造にしました。実行全体の上限と、タスク個別の上限を別々に持たせるのがポイントです。実行全体だけだと1本の暴走を止められず、タスク個別だけだと並行数が増えたときの合計が青天井になるからです。
// budget.ts — 予算の割り当て定義
export type Money = number ; // USD 換算。内部はすべて USD で統一する
export interface BudgetPolicy {
runCeiling : Money ; // この実行全体で使ってよい上限
taskCeiling : Money ; // 1タスクが使ってよい上限
softRatio : number ; // この割合を超えたら縮退を始める(例: 0.8)
}
// モデルごとの単価(per 1M tokens, USD)。実際の請求値に合わせて更新する
export const PRICE_TABLE : Record < string , { input : Money ; output : Money }> = {
"fast" : { input: 0.30 , output: 1.50 },
"balanced" : { input: 3.00 , output: 15.0 },
"deep" : { input: 8.00 , output: 40.0 },
};
export function costOf ( model : string , inTok : number , outTok : number ) : Money {
const p = PRICE_TABLE [model] ?? PRICE_TABLE [ "balanced" ];
return (inTok / 1_000_000 ) * p.input + (outTok / 1_000_000 ) * p.output;
}
USD で内部を統一しているのにも理由があります。私はサイトによって日本円で考えがちなのですが、モデルの単価はドル建てで提示されることが多く、為替で換算式が散らばると、どこかで桁を間違えます。実際、最初の実装では一部を円、一部をドルで持ってしまい、上限の比較がまるで意味をなさない状態になっていました。比較する値の単位は、一箇所に固定するのが安全です。
単価テーブルは必ず外出しにしておきます。モデルの価格は改定されますし、サイトごと・用途ごとに使うモデルも違うからです。私の場合、記事生成は balanced、リンク監査のような単純作業は fast、設計レビューだけ deep というふうに、タスクの性質で割り当てを変えています。
実消費をリアルタイムに積算する
次が計測です。ここが今回の肝でした。事故の本質は「消費を後からしか知れなかった」ことなので、走行中に累計を持つ台帳(ledger)を用意します。
// ledger.ts — 走行中のトークン消費をリアルタイムに積算する台帳
import { costOf, Money } from "./budget" ;
interface Entry {
taskId : string ;
model : string ;
inTok : number ;
outTok : number ;
cost : Money ;
at : number ;
}
export class TokenLedger {
private entries : Entry [] = [];
private byTask = new Map < string , Money >();
private runTotal : Money = 0 ;
record ( taskId : string , model : string , inTok : number , outTok : number ) : Money {
const cost = costOf (model, inTok, outTok);
this .entries. push ({ taskId, model, inTok, outTok, cost, at: Date. now () });
this .byTask. set (taskId, ( this .byTask. get (taskId) ?? 0 ) + cost);
this .runTotal += cost;
return cost;
}
taskSpend ( taskId : string ) : Money {
return this .byTask. get (taskId) ?? 0 ;
}
total () : Money {
return this .runTotal;
}
// あとで突き合わせ(reconciliation)するための明細を吐く
dump () : Entry [] {
return [ ... this .entries];
}
}
ここで大事なのは、record をLLM 呼び出しの直後に必ず通す ことです。並列で走っていても、台帳のインスタンスを一つ共有していれば、runTotal には全エージェントの消費が合算されます。私は最初、エージェントごとに台帳を分けてしまい、実行全体の上限を見られないという間抜けな実装をしていました。合算したい値は一箇所に集める、という当たり前のことが、並行になると意外と崩れます。
リアルタイムである必要性は、最初は過剰に思えるかもしれません。けれど、並行で10本走っているとき、合計がいつ上限に届くかは誰にも予測できません。あるタスクが急に重くなれば、ほかが軽くても全体は跳ねます。事後の集計では、跳ねたあとの数字しか手に入りません。走りながら積算しているからこそ、「いま全体で上限の8割まで来た」という判断が成り立ち、まだ止められる余地のあるうちに手を打てます。私はこの「走行中に累計が見える」という一点が、ガバナンス層のいちばんの価値だと感じています。
そして dump() の明細は、走行後に必ず保存します。冒頭の事故で一番つらかったのは「どのタスクが犯人か分からない」ことでした。明細さえ残っていれば、翌朝5分で原因が特定できます。これは保険として効きます。
ハードキャップと段階的に縮退する遮断層
三層目の遮断です。配分(budget)と計測(ledger)がそろったので、両者を突き合わせて判断を下す BudgetGuard を実装します。判断は三段階にしました。
第一段階は通常 。ソフト閾値(例: 上限の80%)に達するまでは何もしません。第二段階は降格 。ソフト閾値を超えたら、以降の呼び出しを安いモデルに自動で切り替えます。仕上げの品質は少し落ちますが、止めるよりはマシな状況が多いからです。第三段階は停止 。ハードキャップに達したら、そのタスク(あるいは実行全体)に対してこれ以上の呼び出しを許可しません。
この段階分けは、一見すると過剰に思えるかもしれません。けれど「即停止」だけの設計を一度試して、痛い目を見ました。記事生成のように、序盤で大量に下調べをして終盤で仕上げるタスクは、消費の8割が前半に寄ります。前半で閾値に触れて即停止すると、仕上げ前の中途半端な状態で打ち切られ、その晩のトークンがまるごと無駄になります。降格という中間段階を挟むと、「仕上げは安いモデルでもいいから最後まで走らせる」という選択ができます。完璧な成果物は得られませんが、ゼロよりはずっとマシです。コストを守ることと、成果をゼロにしないことは、別々に考える価値がある二つの目的だと感じています。
// guard.ts — 予算と台帳を突き合わせ、縮退判断を下す
import { BudgetPolicy } from "./budget" ;
import { TokenLedger } from "./ledger" ;
export type Decision =
| { action : "proceed" ; model : string }
| { action : "downgrade" ; model : string }
| { action : "halt" ; reason : string };
export class BudgetGuard {
constructor (
private policy : BudgetPolicy ,
private ledger : TokenLedger ,
) {}
// 呼び出し前に必ず通す。返ってきた action に従う
check ( taskId : string , requestedModel : string ) : Decision {
const run = this .ledger. total ();
const task = this .ledger. taskSpend (taskId);
// ハードキャップ: 実行全体 or タスク個別のどちらか超過で停止
if (run >= this .policy.runCeiling) {
return { action: "halt" , reason: `run ceiling $${ this . policy . runCeiling } reached` };
}
if (task >= this .policy.taskCeiling) {
return { action: "halt" , reason: `task ${ taskId } ceiling reached` };
}
// ソフト閾値: 実行全体の消費が softRatio を超えたら降格
if (run >= this .policy.runCeiling * this .policy.softRatio) {
return { action: "downgrade" , model: "fast" };
}
return { action: "proceed" , model: requestedModel };
}
}
check は呼び出しの前 に通します。record は呼び出しの後 です。この前後の対称性を守るのがコツで、どちらかを忘れると予算が漏れます。私は「ガードしてから呼び、呼んだら記録する」を一つの関数にまとめて、エージェント側から個別に触れないようにしました。
// guarded-call.ts — check と record を必ずセットで通す唯一の入口
import { BudgetGuard } from "./guard" ;
import { TokenLedger } from "./ledger" ;
export async function guardedCall (
guard : BudgetGuard ,
ledger : TokenLedger ,
taskId : string ,
model : string ,
invoke : ( model : string ) => Promise <{ inTok : number ; outTok : number ; text : string }>,
) : Promise <{ text : string ; halted : boolean }> {
const decision = guard. check (taskId, model);
if (decision.action === "halt" ) {
console. warn ( `[budget] halt ${ taskId }: ${ decision . reason }` );
return { text: "" , halted: true };
}
const useModel = decision.action === "downgrade" ? decision.model : decision.model ?? model;
const res = await invoke (useModel);
ledger. record (taskId, useModel, res.inTok, res.outTok);
return { text: res.text, halted: false };
}
これで、エージェントがどう実装されていても、LLM を叩く経路がこの guardedCall を通る限り、予算は必ず効きます。
逆に言えば、抜け道を一つでも残すと、そこから予算が漏れます。私はエージェントのコードをレビューするとき、invoke に相当する生の呼び出しが guardedCall の外に書かれていないかだけは必ず確認します。入口を一つに絞るのは地味ですが、ガバナンスを「気をつける運用」から「構造で守られる運用」に変える、いちばん効くルールだと思っています。
Antigravity の並列エージェントに差し込む
ここまでの三層を、Antigravity の並列エージェントに組み込みます。Antigravity では複数のサブエージェントを同時に走らせられますが、それぞれが独立して LLM を叩くため、放っておくと合算消費が見えません。そこで、台帳とガードを実行スコープで一つだけ生成し、全サブエージェントに同じインスタンスを渡します 。
// orchestrate.ts — 並列エージェントに共有ガードを配る
import { TokenLedger } from "./ledger" ;
import { BudgetGuard } from "./guard" ;
import { guardedCall } from "./guarded-call" ;
async function runDailyJobs ( tasks : { id : string ; model : string ; run : Function }[]) {
const ledger = new TokenLedger ();
const guard = new BudgetGuard (
{ runCeiling: 4.0 , taskCeiling: 0.8 , softRatio: 0.8 },
ledger,
);
// 全タスクが同じ ledger / guard を共有するのが要点
const results = await Promise . allSettled (
tasks. map (( t ) =>
t. run (( model : string , invoke : any ) =>
guardedCall (guard, ledger, t.id, model, invoke),
),
),
);
// 走行後に明細を保存しておく(事故時の原因特定が5分で済む)
await persistLedger (ledger. dump (), new Date ());
console. log ( `[budget] run total: $${ ledger . total (). toFixed ( 3 ) }` );
return results;
}
Antigravity の並列エージェントは、サブエージェントごとに作業ログとアーティファクトを残してくれます。私はそこに、台帳の run total と、降格や停止が発動したかどうかを書き出すようにしました。こうしておくと、翌朝アーティファクトを開いた時点で、その晩のコストと、ガードが何回働いたかが一目で分かります。エージェントの成果物とコストの記録が同じ場所に並ぶので、品質とコストを切り離さずに振り返れるのが気に入っています。
ここで Promise.allSettled を使っているのは意図的です。Promise.all だと1本が halt で失敗扱いになったとき、他の正常なタスクの結果まで巻き込んで捨ててしまいます。予算超過で止まったタスクがあっても、残りは完走させて結果を回収したいので、allSettled が実用的です。
実行スコープの runCeiling を $4.0、タスク個別を $0.8 に置いているのは、私の6サイト運用の1晩分のジョブ規模に合わせた値です。最初に事故ったときの請求差分から逆算して決めました。みなさんの規模に合わせて調整してください。
本番で踏んだ落とし穴
設計どおりに動かすまでに、いくつか痛い目を見ました。記録しておきます。
第一に、ストリーミング応答の計測漏れ です。トークンの確定値は応答完了後にしか出ないので、ストリーミング中に巨大な出力が走っていても、record が呼ばれるのは最後です。つまりガードが効くのは「次の呼び出しから」になります。1回の呼び出しで暴走されると素通りします。対処として、タスク個別の上限を、平均1呼び出しの想定消費よりも十分大きく、しかし事故レベルよりは小さく置く必要があります。私は「想定の3〜5倍」を目安にしています。
第二に、リトライによる二重消費 です。ネットワークエラーでリトライすると、失敗した呼び出し分のトークンも実際には課金されることがあります。リトライ前の消費を record し忘れると、台帳が実請求と食い違います。私はリトライのたびに記録するよう直しました。走行後に請求ダッシュボードと dump() の合計を突き合わせて、誤差が5%以内に収まっているかを毎週確認しています。最初は20%以上ずれていて、その差のほとんどがリトライの記録漏れでした。
第三に、降格の品質劣化を見逃す 問題です。fast モデルへ降格したタスクの成果物は、当然ながら品質が落ちます。記事生成で降格が発動すると、薄い記事が出てきます。なので私は、降格が一度でも発動した実行については、成果物に「降格フラグ」を立てて、人間のレビュー対象に回すようにしました。コストを守った代わりに品質を黙って落とすのは、別の事故の入口になります。
第四に、共有台帳への並行書き込み です。同じ TokenLedger インスタンスを全サブエージェントが触るので、record が同時に呼ばれます。Node の単一スレッドなら同期的な Map 更新は競合しませんが、ワーカーを分けたり別プロセスにすると話が変わります。私は当面シングルプロセスで運用すると決めて、その前提を台帳のコメントに明記しました。前提を破る変更を入れるなら、原子的な加算に置き換える必要があります。前提を言葉で残しておくと、半年後の自分が事故を起こしにくくなります。
これらはどれも、設計図だけ見ていると気づけない類の問題でした。実際に本番で1晩回してみて初めて表に出てきます。だからこそ、最初は上限をかなり低めに設定して、わざと halt を踏ませながら挙動を確かめるのをお勧めします。
素朴な並列実装と、ガードを通した実装の違い
導入前後の差を、コードで並べてみます。事故を起こしていた頃の素朴な実装は、おおよそこうでした。
// Before — ガードなし。消費は走行後の請求でしか分からない
async function runDailyJobs ( tasks : { id : string ; run : Function }[]) {
// 各タスクが好き勝手に LLM を叩く。合算も上限もない
return Promise . all (tasks. map (( t ) => t. run ()));
}
一見すると何の問題もなく、実際ふだんは静かに動きます。けれど、上限がどこにもないので、1本でも暴走すると誰も止められません。請求が確定して初めて異常に気づく構造です。
これを、これまでに作った三層に通すとこうなります。
// After — 共有台帳とガードを全タスクに配り、走行後に明細を残す
async function runDailyJobs ( tasks : { id : string ; model : string ; run : Function }[]) {
const ledger = new TokenLedger ();
const guard = new BudgetGuard (
{ runCeiling: 4.0 , taskCeiling: 0.8 , softRatio: 0.8 },
ledger,
);
const results = await Promise . allSettled (
tasks. map (( t ) =>
t. run (( model : string , invoke : any ) =>
guardedCall (guard, ledger, t.id, model, invoke),
),
),
);
await persistLedger (ledger. dump (), new Date ()); // ← 明細を残すのが効く
return results;
}
差分は行数にすれば数行です。けれど、この数行が「請求書を見て青ざめる」運用から「毎朝ログで把握できる」運用への分かれ目になりました。大掛かりな監視基盤を組む必要はなく、既存の並列実行をこの薄いラッパーで包むだけで足ります。
予算の数字を実績から逆算する
runCeiling や taskCeiling に入れる金額は、勘で決めるとだいたい外します。高すぎればガードが効かず、低すぎれば正常なタスクまで止まります。私は、台帳の明細を1〜2週間ためてから、実績の分布を見て決めました。
具体的には、こういう順序で詰めていきました。
まず遮断を一切かけず、TokenLedger の dump() だけを2週間ぶん集めます。
タスクごとに1回の実行コストを並べ、中央値と最大値を見ます。私の記事生成タスクは中央値 $0.12、たまに $0.30 まで伸びる程度でした。
タスク個別の上限は、その最大値の3〜5倍に置きます。私は $0.8 にしました。正常範囲には十分余裕があり、事故レベル(過去に $6 超え)はしっかり止まる値です。
実行全体の上限は、1晩に走る全タスクの合計の中央値に、安全率1.5倍ほどを掛けます。私の1晩は合計 $2.5 前後だったので、$4.0 に置きました。
この決め方の良いところは、根拠が自分の実績データにあることです。一般論の数字ではなく、自分のエージェントが現に消費している分布から逆算するので、止めるべきものは止まり、通すべきものは通る確率が高くなります。3ヶ月運用した範囲では、正常タスクを誤って止めてしまった例はゼロでした。
数字は一度決めて終わりにせず、月に一度は明細を見直して調整するのが良いと感じています。扱う記事の本数やモデルの単価は変わっていくので、上限も静かに合わせ続ける必要があります。
導入して変わったこと
このガバナンス層を入れてから、月のトークン費は事故前の水準に戻り、何より「いくらかかるか読めない」という不安が消えました。毎晩の実行ログに run total: $X が必ず残るので、異常があればその日のうちに気づけます。冒頭のような「翌月の請求書で初めて知る」事故は、それ以来一度も起きていません。
数字をひとつ挙げると、事故月に約3.2倍まで膨らんだトークン費は、ガード導入後の3ヶ月はいずれも基準値の±10%以内に収まっています。最適化で単価を下げたこと以上に、天井を持ったことの安心感が大きいと感じています。
次の一歩としておすすめしたいのは、まず TokenLedger だけを既存のエージェントに差し込んで、1週間ぶんの明細を取ってみることです。遮断はその次で構いません。自分のエージェントが実際に何にいくら使っているかを数字で見るだけで、どこに上限を置くべきかが自然と見えてきます。私自身、台帳を取り始めて初めて、犯人だと思っていたタスクが実は無実で、地味な定型作業のほうが累計で効いていたと気づきました。
同じようにコストの不透明さと向き合っている方の、設計の足がかりになれば嬉しいです。