ある晩、4つのブログサイトの更新をまとめてスケジュール実行に乗せたところ、翌朝のクォータ画面が想定の3倍近く消費されていました。ジョブ自体は成功しているのに、消費だけが膨らんでいる。ログを追うと、親エージェントが立てた dynamic sub-agent が、さらに別のサブエージェントを生やし、その子がまた孫を生やしていました。誰も無限ループには入っていません。ただ「ちょっと込み入っているから、もう一段分けよう」という判断が、枝の各ノードで静かに繰り返されていただけです。
Antigravity 2.0 の dynamic sub-agents は、実行時に親エージェントが必要に応じて子エージェントを動的に立てられる仕組みです。あらかじめ並列数を決め打ちする静的な並列実行とは、運用上の性質がまったく違います。便利な反面、「いつ・どれだけ枝を増やすか」をエージェント自身の判断に委ねるため、放っておくと木が深く広く茂ります。私自身がオフピークの自動運用で踏んだこの落とし穴を起点に、深さ・幅・キャンセルの3点を制御する具体的な設計を、以下で順に共有します。
静的な並列と dynamic sub-agents は別物として扱う
これまでの「並列エージェント」は、こちらが Promise.all で5本のタスクを同時に投げるような、幅が固定された構造でした。最大同時実行数は人間が事前に決めます。木で言えば、深さ1・幅5の浅い茂みです。
dynamic sub-agents はここが根本的に違います。子エージェントが自分の作業の途中で「このリファクタは独立した3つのモジュールに分けられる」と判断すれば、その場で3つの孫エージェントを立てられます。孫もまた同じ判断をします。結果として木の深さも幅も実行時に決まり、事前には読めません。
この性質を理解せずに「並列だから速いはず」とだけ捉えると、消費とレイテンシの両方で読み違えます。深さ3・各ノードのファンアウト3なら、末端のノード数は単純計算で 3 の 3 乗、つまり27本です。各サブエージェントがそれぞれモデルを呼ぶので、トークン消費は本数に比例して膨らみます。私が一晩で3倍近く溶かしたのは、まさにこの指数的な広がりを見落としていたからでした。
だから最初に置くべき前提はひとつです。dynamic sub-agents を使うときは、木が勝手に茂る前提で、深さと幅に明示的な天井を設けます。
3つのガード:深さ予算・ファンアウト上限・キャンセル伝播
制御すべき軸は3つに整理できます。
第一に「深さ予算(depth budget)」です。親から数えて何段までサブエージェントを生やせるかの上限です。深さが浅ければ木は決して指数的に茂りません。
第二に「ファンアウト上限(fan-out cap)」です。木全体で同時に走るサブエージェントの本数の上限です。深さを許しても、同時本数を絞れば消費のピークは抑えられます。
第三に「キャンセル伝播(cancellation propagation)」です。ある枝が止まった・不要になったとき、その配下の子孫すべてに停止を伝える仕組みです。これがないと、親が諦めた後も孫が黙々とモデルを呼び続けます。
この3つは独立しているようで、運用上は1つのオーケストレーション層にまとめると扱いやすくなります。以下、それぞれの実装を Node.js(TypeScript)で示します。Antigravity SDK のサブエージェント起動を薄くラップする前提です。
深さ予算をコンテキストに埋め込む
深さ予算の肝は、「現在の深さ」をサブエージェントに渡すコンテキストの一部として持ち回ることです。親が子を立てるとき、自分の深さに1を足して渡します。上限に達したら、もう子を立てずに自分でやり切らせます。
type SpawnContext = {
depth: number; // 現在の深さ(ルートは 0)
maxDepth: number; // 深さ予算
traceId: string; // 木全体を追跡するための ID
};
// サブエージェント起動の薄いラッパ
async function runSubAgent(
prompt: string,
ctx: SpawnContext,
spawnChildren: (childCtx: SpawnContext) => Promise<void>,
) {
// 深さ上限に達したら、子を立てずに単独で完結させる指示を足す
const canSpawn = ctx.depth < ctx.maxDepth;
const guardedPrompt = canSpawn
? prompt
: `${prompt}\n\n[制約] これ以上サブエージェントを起動しないこと。` +
`この作業はあなた自身で完結させてください。`;
const result = await antigravity.agents.run({
prompt: guardedPrompt,
metadata: { traceId: ctx.traceId, depth: ctx.depth },
});
if (canSpawn) {
// 子を立てるときは必ず depth+1 を渡す
await spawnChildren({ ...ctx, depth: ctx.depth + 1 });
}
return result;
}
ここで重要なのは、深さ上限に達したことをエラーにしないことです。最初の実装では上限超過を例外で弾いていましたが、それだと「あと一段分けたかっただけ」の正当な作業まで失敗扱いになりました。代わりに、上限に達したサブエージェントには「ここから先は自分で完結させてください」という制約をプロンプトに足します。木の末端では分割をやめて手を動かす、という挙動に倒すわけです。
私の運用では maxDepth は 2 に落ち着いています。ルート(0)が大枠を分け、その子(1)が個別タスクを分け、孫(2)は実作業に専念する。3段目を許した実験もしましたが、得られる並列度の利得に対してデバッグのつらさが見合いませんでした。
ファンアウト上限とキューイング
深さを絞っても、各ノードが一斉に大量の子を立てれば同時本数は跳ね上がります。そこで木全体で「いま走っている本数」を共有のカウンタで管理し、上限を超える分はキューで待たせます。
class FanOutLimiter {
private running = 0;
private queue: (() => void)[] = [];
constructor(private readonly maxConcurrent: number) {}
async acquire(): Promise<void> {
if (this.running < this.maxConcurrent) {
this.running++;
return;
}
// 空きが出るまで待つ
await new Promise<void>((resolve) => this.queue.push(resolve));
this.running++;
}
release(): void {
this.running--;
const next = this.queue.shift();
if (next) next();
}
get inFlight(): number {
return this.running;
}
}
// 木全体で1つのリミッタを共有する
const limiter = new FanOutLimiter(6);
async function runGuarded(prompt: string, ctx: SpawnContext) {
await limiter.acquire();
try {
return await runSubAgent(prompt, ctx, async (childCtx) => {
// 子の起動も同じリミッタを通す
await runGuarded(childPrompt, childCtx);
});
} finally {
limiter.release();
}
}
ポイントは、リミッタを木全体で1つだけ共有することです。ノードごとにリミッタを持たせると、各ノードは上限を守っていても全体では青天井になります。同時本数の天井は「木に1つ」が正解です。
maxConcurrent の値は、深さ予算と並んでコストの主要なつまみになります。私はモデルのレート制限とクォータ残量から逆算して 6 を基準にしています。日中の手動作業中は 4 に絞り、誰も見ていない深夜のバッチでは 8 まで上げる、といった時間帯別の出し分けも、この1箇所をいじるだけで効きます。
キャンセル伝播 — 止まった枝が全体を道連れにしないために
3つのうち、実運用でいちばん効いたのがキャンセル伝播でした。あるサブエージェントが外部 API のタイムアウトで詰まったとき、その配下の孫たちが停止指示を受け取れず、親が諦めた後も走り続けてクォータを食う、という事故が起きていたためです。
解決は、ルートに1つの AbortController を置き、その signal を木全体に配り、各サブエージェント起動でその signal を尊重させることです。どこか1点で abort を呼べば、子孫すべてに伝播します。
type SpawnContext = {
depth: number;
maxDepth: number;
traceId: string;
signal: AbortSignal; // 木全体で共有する停止シグナル
};
async function runSubAgent(prompt: string, ctx: SpawnContext, spawn) {
// すでにキャンセル済みなら即座に降りる
if (ctx.signal.aborted) return { skipped: true };
const result = await antigravity.agents.run({
prompt,
signal: ctx.signal, // SDK に停止シグナルを渡す
metadata: { traceId: ctx.traceId, depth: ctx.depth },
});
return result;
}
// ルートで木を起動する
const controller = new AbortController();
// 木全体の上限時間(例: 20分)を超えたら全枝を止める
const deadline = setTimeout(() => controller.abort("deadline"), 20 * 60 * 1000);
try {
await runGuarded(rootPrompt, {
depth: 0,
maxDepth: 2,
traceId: crypto.randomUUID(),
signal: controller.signal,
});
} finally {
clearTimeout(deadline);
}
ここで気をつけたい落とし穴が2つあります。ひとつは、signal.aborted のチェックをサブエージェント起動の直前に毎回入れること。キューで待っている間に木全体が abort されることがあり、待ち明けに何もチェックせず走ると、止めたはずの枝が1本だけ動いてしまいます。もうひとつは、abort 後のクリーンアップを finally に必ず置くこと。リミッタの release を呼び忘れると、カウンタがずれて以降の本数管理が壊れます。本番では abort のたびにこの2点を疑うのが習慣になりました。
木全体に1つのデッドラインを置けるのも、この構造の利点です。私のスケジュールタスクは20分を上限にしていて、それを超えた枝は理由を問わず全部止めます。「どこかが詰まっても、最悪20分でクォータ流出は止まる」という安心感が、無人で夜間に回す前提では何より効きました。
実運用で効いた閾値と観測ポイント
数字は環境で変わりますが、出発点として私が落ち着いた値を共有します。あくまで個人開発で、4つのブログと App Store・Google Play に出しているアプリ群を1人で回す規模感での話です。
| つまみ | 値(基準) | 意図 |
| maxDepth(深さ予算) | 2 | 指数的な増殖を構造的に封じる。末端は分割せず手を動かす |
| maxConcurrent(ファンアウト) | 6(夜間8 / 日中4) | 同時本数の天井。クォータとレート制限から逆算 |
| 木全体のデッドライン | 20分 | 詰まった枝によるクォータ流出を最悪20分で止める |
| 1ノードの再試行 | 1回まで | 再試行も1本のサブエージェント。木の本数として数える |
観測で必ず見ているのは、ジョブごとの「立ったサブエージェントの総数」と「同時本数のピーク」です。traceId を全ノードの metadata に通しておけば、後から木を再構成して「どのノードが何本生やしたか」を一覧できます。総数が普段の2倍に跳ねた日は、たいてい特定のノードが想定外に枝を増やしています。そこだけプロンプトに「この作業は分割せず単独で進めてください」と制約を足すと、翌日には総数が戻ります。
逆に言えば、深さ・幅・デッドラインの3つを数値として握っていれば、消費が膨らんでも原因の切り分けは速いということです。私が最初の晩に3倍溶かしたときは、この3つのどれも観測していませんでした。観測できないものは制御できない、という当たり前を、クォータ画面の数字で思い知らされた格好です。
次の一手
もし dynamic sub-agents をこれから自動運用に組み込むなら、まず maxDepth を 1 か 2 で固定し、木全体で共有する FanOutLimiter を1つだけ通すところから始めてみてください。キャンセル伝播はその後でも足せますが、深さとファンアウトの天井だけは最初の1本目から入れておくことを推奨します。私はこの場合は、迷ったら maxDepth を 1 に倒す方を選びます。それでも私が踏んだ「一晩で3倍」の事故はまず起きません。便利な仕組みほど、放っておくと静かに茂ります。天井を先に決めてから走らせる、という順番だけは崩さないのが、無人運用を続けるうえでの私なりの結論です。