エージェントが返事を返してくるまでの時間が、開発のどこかで急に伸びる瞬間があります。先週まで3秒で済んでいた応答が、今週は20秒かかる。原因はおそらく外部APIの遅延、あるいはマルチエージェントの中の一段が膨らんだだけ。それでもユーザーの体感としては「壊れた」と判断されてしまいます。私自身、2014年からiPhone向けの個人開発を続けてきて、累計5,000万DLを超えるアプリ群を運用するなかで、何度もこの「気づかぬうちに遅くなる」現象に向き合ってきました。
レイテンシ問題を扱うとき、多くの記事はタイムアウトとリトライの話で終わります。けれども本番のエージェントは、もっと複雑な選択を毎ターン迫られています。「あと残り何秒で応答すべきか」「これは課金ユーザーの依頼だから優先すべきか」「重い処理を半分だけ済ませてユーザーに返すべきか」。こうした判断を毎回プロンプトの中に書くのは現実的ではありません。エージェントが自分で時間を知り、残り予算で振る舞いを変えられる設計が必要なのです。
この発想を、私は「時間予算(Time Budget)」と呼んで運用しています。タイムアウトを単なる秒数の上限ではなく、エージェントが意識的に消費していく予算として扱う考え方です。今回は、Antigravityで構築するエージェントにこの時間予算を持たせる具体的な実装と、半年以上運用して気づいた落とし穴をまとめておきます。
なぜタイムアウトだけでは足りないのか
タイムアウトという概念は古くからあります。HTTPクライアントには timeout=30 を渡せますし、Antigravityのエージェント設定でも実行上限を秒で指定できます。それで十分に思えるかもしれません。
ところが本番運用に入ると、タイムアウトを単独で使う設計は段階的に破綻します。具体的には、次の3つの状況で困りました。
ひとつめは、ツール呼び出しが多段に連なるエージェントで、各段が「自分の」タイムアウトしか知らない場合です。たとえばエージェントAがエージェントBを呼び、Bが外部APIを呼ぶ。Aは30秒で打ち切る設定でも、Bが個別に45秒のタイムアウトを持っていると、Bは自分の予算で動いてしまいます。結果として、Aがタイムアウトした後にBの呼び出しだけが宙に浮いて、ログにエラーが残り続けるという状況になりました。
ふたつめは、優先度の異なるタスクを同じタイムアウトで処理してしまう問題です。課金ユーザーからの問い合わせと、無料プランの一括処理を、同じ20秒で打ち切るのは合理的ではありません。とはいえ、毎回プロンプトに「あなたは課金ユーザーなので時間をかけてもよい」と書くのは、エージェントの判断を歪めるだけで筋が悪いと感じます。
みっつめは、途中で諦めるべき場面の判断が遅れることです。残り3秒の状況で、4秒かかると分かっているツール呼び出しを始めるのは無駄です。残り時間を知らないエージェントは、その判断ができません。
ここで必要になるのが、タイムアウトという「外側の制約」を、エージェント内部の「自分が消費していく予算」へと反転させる発想です。残り何秒あって、何にどれだけ使ってきたかが分かれば、エージェントは自分で振る舞いを変えられます。
Time Budgetオブジェクトの最小実装
時間予算は、3つの値だけで表現できます。
- 開始時刻(startedAt)
- 締切(deadline)
- 優先度(priority)
これをひとつのオブジェクトに束ね、エージェントの内部で持ち回します。TypeScriptで書くと、最小実装は以下のような形になります。
// time-budget.ts
export type Priority = "critical" | "high" | "normal" | "low";
export class TimeBudget {
private readonly startedAt: number;
private readonly deadline: number;
readonly priority: Priority;
constructor(opts: { totalMs: number; priority?: Priority }) {
this.startedAt = Date.now();
this.deadline = this.startedAt + opts.totalMs;
this.priority = opts.priority ?? "normal";
}
/** 経過時間(ms) */
elapsed(): number {
return Date.now() - this.startedAt;
}
/** 残り時間(ms) — 締切を過ぎた場合は0 */
remaining(): number {
return Math.max(0, this.deadline - Date.now());
}
/** 締切を過ぎているか */
isExpired(): boolean {
return this.remaining() === 0;
}
/** 指定の処理を許可するか(必要時間 + 安全マージンで判定) */
canAfford(estimatedMs: number, safetyMs = 200): boolean {
return this.remaining() >= estimatedMs + safetyMs;
}
/** 子タスクへ予算を分配する(残り時間の一部を切り出す) */
spawn(ratio = 0.5, priority?: Priority): TimeBudget {
const childMs = Math.floor(this.remaining() * ratio);
return new TimeBudget({ totalMs: childMs, priority: priority ?? this.priority });
}
}このクラスのポイントは canAfford と spawn の2つです。canAfford は「これから始めようとしている処理が予算内に収まるか」を判断するためのもので、エージェントがツール呼び出しを始める前に必ず確認させます。spawn は子エージェントに予算を分配する仕組みで、親が残り20秒なら、子には10秒だけ与えるというように、全体の締切を守りながら下層に伝搬できます。
宮大工だった祖父が「材料は無限に使えるわけじゃないから、最初に図面で配分を決めるんだ」と話していたのを、なぜか思い出します。時間予算という考え方も、これと近いのだと思います。
エージェントツール呼び出しに予算を伝搬する
Antigravityのエージェントは、ツール呼び出しの集合として組み立てるのが基本です。ツールごとに TimeBudget を渡すラッパーを用意しておくと、自然に予算を消費する設計になります。
// budgeted-tool.ts
import { TimeBudget } from "./time-budget";
type ToolFn<I, O> = (input: I, signal?: AbortSignal) => Promise<O>;
export function withBudget<I, O>(
tool: ToolFn<I, O>,
estimatedMs: number,
fallback?: (input: I) => O,
): ToolFn<I & { budget: TimeBudget }, O> {
return async (input, signal) => {
const { budget, ...rest } = input;
if (!budget.canAfford(estimatedMs)) {
if (fallback) return fallback(input);
throw new Error(
`BudgetExceeded: required=${estimatedMs}ms remaining=${budget.remaining()}ms`,
);
}
// AbortControllerで「残り予算」を上限としてキャンセルを連動
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), budget.remaining());
try {
const merged = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
return await tool(rest as I, merged);
} finally {
clearTimeout(timer);
}
};
}このラッパーは2つの仕事をしています。ひとつは「予算が足りなければそもそも呼び出さない」という事前ガード。もうひとつは「呼び出し中に予算を超えたら確実に中断する」という事後ガード。AbortSignal を使ってキャンセルを連鎖させているので、ツールが内部でさらに別のリクエストを発行している場合でも、まとめて止められます。
実運用ではここに、fallback として「キャッシュから返す」「簡易応答に切り替える」などの選択肢を渡すことが多いです。たとえば商品検索ツールなら、本来はベクター検索で30件返すところを、残り予算が少なければキーワード検索で5件だけ返す、といった切り替えが可能になります。
残り予算で判断を変える適応的スケジューラ
時間予算が定義できたなら、次はエージェントのプランニング側で残り時間を見て戦略を切り替える仕組みを入れます。私は「適応的スケジューラ」と呼んでいます。
// adaptive-scheduler.ts
import { TimeBudget } from "./time-budget";
type Step = {
name: string;
estimatedMs: number;
importance: "must" | "should" | "nice";
run: (budget: TimeBudget) => Promise<unknown>;
};
export async function runWithinBudget(
steps: Step[],
budget: TimeBudget,
): Promise<Array<{ name: string; status: "ok" | "skipped" | "expired" }>> {
// 1) must を最優先、次に should、nice は予算次第
const ordered = [...steps].sort((a, b) => {
const w = { must: 0, should: 1, nice: 2 } as const;
return w[a.importance] - w[b.importance];
});
const results: Array<{ name: string; status: "ok" | "skipped" | "expired" }> = [];
for (const step of ordered) {
if (budget.isExpired()) {
results.push({ name: step.name, status: "expired" });
continue;
}
if (!budget.canAfford(step.estimatedMs)) {
// mustだけは無理してでも実行(ただし子予算で必ず制限)
if (step.importance === "must") {
const child = budget.spawn(0.95);
await step.run(child);
results.push({ name: step.name, status: "ok" });
} else {
results.push({ name: step.name, status: "skipped" });
}
continue;
}
const child = budget.spawn(step.estimatedMs / Math.max(budget.remaining(), 1));
await step.run(child);
results.push({ name: step.name, status: "ok" });
}
return results;
}このスケジューラの肝は、ステップごとに「外せない(must)」「できれば実行(should)」「余裕があれば(nice)」というラベルを付けて、残り予算と相談しながら順序と省略を決めることです。完璧主義のエージェントを「諦め上手」に変えてくれます。
たとえばカスタマーサポート用のエージェントを考えると、こんな組み合わせになります。
- must: ユーザーの質問の意図分類(軽い・絶対必要)
- should: 関連FAQの検索(ベクター検索)
- nice: 過去の類似対応履歴の参照(重い・あると親切)
予算20秒なら全部実行できますが、5秒しか残っていない場合はmustだけ確実に終わらせ、残りはスキップしてユーザーに「概要だけお返しします」と伝えるほうが、長く待たせて完璧な応答を返すよりずっと体験が良くなります。
グレースフルカット — タイムアウト時の優雅な切り上げ
予算が尽きたとき、エラーで終わらせるのは最後の手段です。私が運用で大切にしているのは「いま手元にある情報だけで、一段下の品質に下げて返す」というグレースフルカットの設計です。
// graceful-cut.ts
export async function withGracefulCut<T>(
budget: TimeBudget,
primary: () => Promise<T>,
fallback: () => T | Promise<T>,
reservedMs = 500,
): Promise<{ value: T; degraded: boolean }> {
const primaryBudget = budget.spawn(
Math.max(0, budget.remaining() - reservedMs) / Math.max(budget.remaining(), 1),
);
try {
if (primaryBudget.remaining() <= 0) throw new Error("no time for primary");
const value = await primary();
return { value, degraded: false };
} catch {
const value = await fallback();
return { value, degraded: true };
}
}reservedMs で「フォールバック用の予算」をあらかじめ確保しているのがポイントです。これがないと、本来の処理が予算ギリギリまで走り、フォールバックを実行する時間も残らない、というケースに陥ります。
廣川政樹の個人開発で実際にあった失敗の話をします。あるアプリで翻訳エージェントを動かしていたのですが、「APIタイムアウト時に手元のキャッシュから返す」という設計のはずが、本来の翻訳が予算を全部使い切ってしまい、フォールバックの実行時間が残らずクラッシュしていました。reservedMs を入れた瞬間に解消し、その日からエージェントの「諦めかた」が静かに上品になったのを覚えています。
個人開発で本番運用して気づいた3つの落とし穴
机上の設計と現実の運用は別物です。半年ほど時間予算ベースの設計を本番に入れてみて、つまずいた点を挙げておきます。
ひとつめは、見積もり時間(estimatedMs)の更新を忘れる問題です。最初に「このツールは平均800ms」と決めても、半年も運用すれば外部APIのレイテンシは平均1.5秒に伸びていることがあります。古い見積もりのままだと、本来諦めるべき場面で実行に踏み切ってしまい、結果的に予算超過が増えました。対処として、canAfford の判定に使う estimatedMs を、過去24時間のメトリクスから動的に計算する仕組みを入れています。具体的にはP90レイテンシをそのまま見積もりとして使う、というシンプルな運用です。
ふたつめは、子エージェントへの予算分配比率(spawn(ratio))を一律にしてしまう問題です。マルチエージェントで親が3つの子を呼ぶ場合、単純に1/3ずつ配ると、最後の子が「みんなが時間を使い切った後で動く」ことになりがちです。重要度に応じて重み付け配分するか、最後の子に多めに残すという調整が必要でした。AdMob収益が月150万円を超えていた時期に、レコメンド系のマルチエージェントで応答が体感1秒以上伸びる事故があり、原因はまさにこの均等配分でした。
みっつめは、優先度の上書きが暴走する問題です。「課金ユーザーは critical 扱い」というルールを安易に入れると、課金ユーザーの問い合わせが集中したときに、無料ユーザーの体験が極端に劣化します。優先度は応答時間そのものを変える材料に留め、「実行/スキップ」の判断には使わないというガードを入れたほうが安全でした。
観測・チューニング・改善ループ
時間予算ベースの設計は、観測がなければ意味を成しません。私が運用しているメトリクスは以下の3つだけです。
ひとつめは「予算充足率」。各リクエストで (消費時間 / 予算) を集計し、P50・P90・P99のヒートマップを作ります。P99が常に1.0近辺なら予算がきつすぎ、P90が0.3なら緩すぎます。0.6〜0.8あたりに収まるのが、私の経験ではちょうど良いバランスでした。
ふたつめは「ステップ別のスキップ率」。should や nice のステップがどれくらいの頻度でスキップされているかを見ます。スキップ率が30%を超えるステップは、もはや「nice」ではなく「実装が重すぎる」のサインです。実装の見直しか、別の軽い手段への置き換えを検討します。
みっつめは「グレースフルカット率」。フォールバック側に降りた割合を計測します。これが急増したら、外部APIの遅延か、エージェントのプランニング自体が膨らんだ可能性があります。アラートを仕込んでおくと、ユーザーから「最近応答が雑になった」と言われる前に、自分で気づけるようになります。
このあたりの観測の作り込みは、AIエージェントのエラーリカバリ設計 ― 止まらないパイプラインを構築する実践パターンで解説したサーキットブレーカーやフォールバックチェーンの議論と地続きです。リトライとタイムアウトを別々に考えるのではなく、「時間という限られた資源をどう配分するか」という共通の枠組みでとらえると、設計がだいぶシンプルになります。
また、エージェントのコスト最適化に踏み込みたい場合は Antigravityエージェントのコスト最適化 — トークン消費を半減する設計判断 も合わせて読むと、時間とトークンを同じ「予算」として扱う発想が深まります。本番投入前の検証フェーズについては Antigravityエージェントのシャドウモード本番投入ガイド で扱った内容が、時間予算の設計検証にも有効です。
タイムアウト・リトライ・冪等性が一つの章にまとまっており、私もエージェントを設計するときの拠り所のひとつにしています。
次の一歩
エージェントが残り時間を意識して動き始めると、運用者の心理的負荷が静かに下がります。「遅くなった」とユーザーから言われる前に、エージェント自身が「申し訳ありませんが、簡易版でお返しします」と語れるようになるからです。
最初の一歩としておすすめなのは、いま走っているエージェントのうち、もっとも応答時間が不安定な1本に TimeBudget を入れることです。全体に広げる前に、1本でいいので試してみると、自分のプロダクトの中で「時間」がどう使われているのかが見えてきます。私自身、最初の1本に入れてから全体に広げるまでに2か月ほど時間をかけましたが、その遠回りのおかげで、自分のサービスに合った予算配分の感覚をつかめたのだと思います。
同じテーマで悩んでいる方の参考になれば嬉しいです。お読みいただきありがとうございました。