ANTIGRAVITY LABEN
記事一覧/Agents & Manager
Agents & Manager/2026-06-29上級

並行サブエージェントが同じAPIの上限を奪い合うとき — 共有トークンバケットで集約レートを抑える

Antigravity 2.0 の dynamic sub-agents を並行で走らせると、各エージェントが独立に外部APIを叩いて集約レートが上限を超え、429が連鎖します。共有トークンバケットで集約レートを先回りで抑える設計と、Redis化までを実装コード付きで解説します。

antigravity402agents80rate-limit4token-bucketparallel2

プレミアム記事

Antigravity 2.0 の dynamic sub-agents で「4つの作業を同時に進めてくれる」便利さに慣れてくると、次にぶつかるのは速度ではなく外部APIの上限でした。私自身、個人開発と並行して複数のブログを自動運用していて、各サブエージェントがそれぞれ GitHub にコミットを投げた瞬間に、まとめて 403 secondary rate limit を踏んだことがあります。1つずつ動かしていたときには一度も見なかったエラーでした。

問題の本質は単純です。サブエージェントを N 個並べると、外部から見た送信レートは N 倍になります。各エージェントが「自分は礼儀正しくバックオフしている」と思っていても、上限を共有している相手から見れば、N 個が同時に殺到しているだけなのです。

その N 倍問題を「各エージェントの良心」ではなく「全エージェントが必ず通る1つの蛇口」で解く方法を、動くコードと実測値で示していきます。題材は GitHub の secondary rate limit ですが、Stripe・AdMob レポート API・自前バックエンドなど、1つの上限を複数の実行主体で共有するすべての場面に同じ設計が効きます。

なぜ「各自バックオフ」は並行時に壊れるのか

最初に試したのは、いちばん素直な対処でした。各サブエージェントのHTTP呼び出しを、429を見たら指数バックオフで再送するラッパーで包む、というものです。

// 一見正しいが、並行時に破綻する「各自バックオフ」方式
async function callWithBackoff(fn: () => Promise<Response>): Promise<Response> {
  let delay = 500;
  for (let attempt = 0; attempt < 6; attempt++) {
    const res = await fn();
    if (res.status !== 429 && res.status !== 403) return res;
    await sleep(delay);     // 全エージェントがほぼ同時に同じ delay を待つ
    delay *= 2;
  }
  throw new Error("rate limit: gave up");
}

これは単一エージェントなら機能します。ところが6個のサブエージェントが並行で走ると、次の連鎖が起きます。

  1. 6個がほぼ同時に送信し、集約レートが上限を超える
  2. ほぼ同時に全員が429を受け取る
  3. 全員が同じ初期 delay(500ms)を待つ
  4. 500ms 後、6個が再び同時に再送する。そしてまた全員429

これがいわゆる**サンダリングハード(thundering herd)**です。再送のたびに同じ山が再生され、バックオフは「山の間隔」を広げるだけで「山そのもの」を崩しません。ジッターを足せば多少ばらけますが、それは衝突確率を下げる対症療法であって、集約レートを上限以下に保証する仕組みではないのです。

ここで発想を変えます。送ってから謝る(reactive)のをやめ、送る前に許可を取る(proactive)。 許可を出す主体を1つに集約すれば、集約レートは定義上、その主体の発行ペース以下に必ず収まります。これがトークンバケットの役割です。

単一プロセスの共有トークンバケット

トークンバケットは、容量 capacity のバケツに毎秒 refillPerSec 個のトークンが補充され、API を1回叩くごとにトークンを1つ消費する、という仕組みです。トークンが無ければ補充されるまで待ちます。全サブエージェントが同じバケツ1個を共有する点が肝になります。

// shared-limiter.ts — FIFO 公平な非同期トークンバケット
type Waiter = { cost: number; resolve: () => void };
 
export class TokenBucket {
  private tokens: number;
  private last: number;
  private waiters: Waiter[] = [];
  private timer: ReturnType<typeof setInterval> | null = null;
 
  constructor(
    private readonly capacity: number,    // バースト許容量
    private readonly refillPerSec: number // 定常レート(毎秒の発行数)
  ) {
    this.tokens = capacity;
    this.last = Date.now();
  }
 
  private refill(): void {
    const now = Date.now();
    const elapsed = (now - this.last) / 1000;
    if (elapsed <= 0) return;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec);
    this.last = now;
  }
 
  // 呼び出し前に必ず await する。待たされた分だけ送信が遅延し、集約レートが上限内に収まる
  async acquire(cost = 1): Promise<void> {
    if (cost > this.capacity) {
      throw new Error("cost が capacity を超えています: 永久に取得できません");
    }
    this.refill();
    // 先客がいなければ即時取得(待ち行列を追い越さない=公平性)
    if (this.waiters.length === 0 && this.tokens >= cost) {
      this.tokens -= cost;
      return;
    }
    return new Promise<void>((resolve) => {
      this.waiters.push({ cost, resolve });
      this.startDraining();
    });
  }
 
  private startDraining(): void {
    if (this.timer) return;
    this.timer = setInterval(() => {
      this.refill();
      // 先頭から順に、トークンが足りる限り解放(FIFO)
      while (this.waiters.length > 0 && this.tokens >= this.waiters[0].cost) {
        const w = this.waiters.shift()!;
        this.tokens -= w.cost;
        w.resolve();
      }
      if (this.waiters.length === 0 && this.timer) {
        clearInterval(this.timer);
        this.timer = null;
      }
    }, 50); // 50ms ごとに補充と解放を判定
  }
}

使い方は、外部API呼び出しの直前に acquire() を挟むだけです。

// GitHub のコンテンツ作成系は控えめに「毎秒1.0・バースト5」に絞る
const github = new TokenBucket(5, 1.0);
 
async function commitViaSubAgent(agentId: string, change: FileChange): Promise<void> {
  await github.acquire(1);          // ここで順番待ちが起きる
  await githubApi.createCommit(change);
}
 
// 6個のサブエージェントが同じ github バケツを共有して並行実行
await Promise.all(
  subAgents.map((a) => commitViaSubAgent(a.id, a.pendingChange))
);

ポイントは3つあります。第一に、acquire() を待っている間も他のサブエージェントの計算は進むので、スループットの実害は「上限に張り付いた分」だけです。第二に、待ち行列を FIFO にしてあるので、特定のエージェントが永久に後回しにされる**飢餓(starvation)**が起きません。第三に、容量 capacity がバースト許容量を表すので、ここを小さくするほど瞬間的な突出が抑えられます。

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
並行サブエージェントが同じ外部APIの上限を食い合って429が頻発していた状況を、集約レートを上限内に収める共有トークンバケットで解消できる
リトライ+バックオフだけがなぜ「再送の山」を生むのかを理解し、先回り型の協調スロットリングへ置き換えられる
単一プロセスのトークンバケットを Redis + Lua の原子的 acquire へ拡張し、別プロセス・別マシンで動くサブエージェントまで1つの上限で協調させられる
Stripe による安全な決済 · いつでもキャンセル可能

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

Agents & Manager2026-06-19
並列にするか、順番に残すか — 複数エージェントを束ねるときの損益分岐
複数のエージェントを並列で走らせるべきか、直列のまま残すべきか。調整コストと待ち時間短縮の損益分岐をざっくり見積もる式と、4サイト運用での実際の線引き、判定を自動化する小さなスクリプトまでまとめました。
Agents & Manager2026-06-28
頼んでいないことまでやられた — エージェントのタスク範囲を契約で縛る
ボタンの色を直してと頼んだら、リファクタとリネームと依存更新まで入っていた。権限ではなくタスク範囲の問題です。範囲外に触れたら止まって尋ねる契約を設計します。
Agents & Manager2026-06-28
処理させる記事の本文が、そのままエージェントへの指示になっていた話
Antigravity CLI で無人のコンテンツ整形パイプラインを回していると、処理対象ファイルの本文に紛れた指示文がエージェントの動作を乗っ取ります。指示チャネルとデータチャネルを構造的に分離し、出力スコープの受け入れゲートで逸脱を弾く設計をまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →