個人で 5,000万DL 規模のアプリ事業と4つの技術ブログを回していると、エージェントに任せたい仕事はどんどん増えていきます。記事の下書き生成、多言語リリースノートの整合、AdMob のメディエーション設定の比較、Crashlytics で見つかった同型クラッシュのまとめ修正。2014年からほとんど手で回してきた作業ほど、自動化したい気持ちが強くなります。
ところが、Antigravity のエージェントを「数を増やせば速くなるはず」と素朴に並列で走らせ始めた頃、私は速さではなく詰まりに悩まされました。4サイトそれぞれに記事生成エージェントを立て、同時に6本のアプリ運用エージェントも動かしたところ、GitHub への push、Cloudflare のデプロイ、AdMob コンソールの操作、Stripe の参照が一斉に集中し、下流が次々と 429 を返し始めたのです。エージェント自体は正しく動いていました。壊れたのは、エージェントが仕事を生み出す速さに、下流が捌ける速さが追いつかなかったことでした。
このとき痛感したのは、エージェント運用で先に効いてくるのは賢さではなく 流量(throughput/単位時間あたりに流れる仕事の量) の制御だということです。賢いエージェントを10体並べても、下流の処理能力が毎秒1件なら、残りの9体分の仕事はどこかに溜まるか、捨てられるか、エラーになります。ここでは、流量を構造的に制御して本番を壊さないための設計を、実装とともに整理します。
なぜ「賢さ」より先に流量が問題になるのか
エージェントを1体で動かしているうちは、流量はまず問題になりません。1体が直列に仕事をするので、下流が受け取る仕事も自然と直列になります。問題が顔を出すのは「速くしたい」と思って並列度を上げた瞬間です。
並列化はエージェントの数を増やすだけで簡単に実現できてしまいます。けれども下流——外部 API、データベース、課金システム、コンソール操作——の処理能力は、こちらの都合で増えてくれません。AdMob コンソールは1秒間に何十回も操作できませんし、GitHub の API にはレート制限があり、Stripe にも秒間リクエスト上限があります。エージェントの数を2倍にしても、下流が同じなら、捌けない仕事が2倍のスピードで積み上がるだけです。
私が最初にぶつかったのは、まさにこの非対称でした。記事生成エージェントを4体に増やした日、生成そのものは速くなったのに、push 段階で 429 が多発し、リトライが重なって結果的に1体で回していた頃より遅くなりました。流量を制御しないまま並列度だけ上げると、速くするつもりが遅くなる——これは直感に反しますが、待ち行列理論が予測する通りの結果です。
流量を3つの数字で捉える
「詰まって困る」は感想であって設計にはなりません。流量制御を始める前に、流れを測る物差しを3つ決めます。私は次の3軸で考えています。
- 到着レート(arrival rate): 単位時間あたりにエージェントが生み出す仕事の件数。記事生成なら「毎分何本の push 要求が発生するか」
- 処理能力(service rate): 下流が単位時間あたりに捌ける件数。GitHub API なら「秒間いくつのリクエストまで 200 が返るか」
- 同時実行数(concurrency): いま下流に対して同時に進行している仕事の数。「いま何件の push が in-flight か」
この3つの関係はシンプルです。到着レートが処理能力を継続的に上回ると、待っている仕事は無限に増えます。 一時的に上回るだけなら、待ち行列が吸収してくれます。だから流量制御の本質は「到着レートを処理能力以下に整える」ことと、「一時的な超過を安全に吸収する」ことの2点に尽きます。
数字の取り方は難しくありません。到着レートはエージェントのスケジューラが知っています。処理能力は下流のレート制限ドキュメントに書かれているか、429 が返り始める閾値を実測すれば分かります。同時実行数は、後で作るキューが数えてくれます。
素朴な並列実行が本番を壊す瞬間
まず、私が最初に書いてしまった「壊れるコード」を見てください。エージェントが生成したタスクを、ただ Promise.all で一斉に流すだけの実装です。
// Before: 到着した仕事を全部いっぺんに下流へ流す
async function runAgentTasks(tasks: AgentTask[]): Promise<void> {
// tasks.length が 4 でも 400 でも、全部同時に走り出す
await Promise.all(
tasks.map(async (task) => {
const result = await agent.run(task); // 生成は速い
await pushToGitHub(result); // ここで下流が詰まる
}),
);
}
このコードは tasks が数件のうちは問題なく動きます。けれども、4サイト分のタスクがほぼ同時に到着し、それぞれが複数本の記事を持っていると、pushToGitHub が数十件同時に走り出します。GitHub は途中から 429 を返し始め、失敗したタスクはここでは握り潰されます(Promise.all は1つでも reject すると全体が reject しますが、それ以前に成功した push は取り消せません)。結果として「半分だけ反映されて、どれが失敗したか分からない」という最悪の状態に陥りました。
問題は3つあります。第一に、同時実行数に上限がありません。第二に、到着レートを処理能力に合わせる仕組みがありません。第三に、あふれた仕事の行き先がありません。この3つを順番に埋めていきます。
待ち行列で「到着」と「処理」を切り離す
最初の一手は、到着と処理のあいだに 待ち行列(キュー) を挟むことです。エージェントは仕事をキューに入れるだけ、下流への送り出しはキューから取り出すワーカーだけが行う。こうすると、到着の波が処理側に直接ぶつからなくなります。
// 上限つきの待ち行列。詰まったら enqueue 側を待たせる
class BoundedQueue<T> {
private items: T[] = [];
private waiters: Array<() => void> = [];
constructor(private readonly maxLength: number) {}
// 満杯なら、空きが出るまで待たせる(ここがバックプレッシャーの起点)
async enqueue(item: T): Promise<void> {
while (this.items.length >= this.maxLength) {
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
this.items.push(item);
}
dequeue(): T | undefined {
const item = this.items.shift();
const waiter = this.waiters.shift();
if (waiter) waiter(); // 空きが出たので待っている enqueue を1つ起こす
return item;
}
get length(): number {
return this.items.length;
}
}
ポイントは maxLength です。キューに上限を設けないと、到着レートが処理能力を上回り続けたとき、キューがメモリを食い尽くすまで膨らみます。上限を設けておけば、満杯になった時点で enqueue が待たされ、その「待たされる」こと自体が後で説明するバックプレッシャーになります。
セマフォで同時実行数を上限で抑える
キューから仕事を取り出して下流へ送るワーカーは、何体動かしてもよいわけではありません。下流の処理能力に合わせて、同時実行数の上限 を決めます。これを担うのがセマフォです。
// 同時に通れる本数を N に制限するセマフォ
class Semaphore {
private available: number;
private queue: Array<() => void> = [];
constructor(permits: number) {
this.available = permits;
}
async acquire(): Promise<void> {
if (this.available > 0) {
this.available -= 1;
return;
}
await new Promise<void>((resolve) => this.queue.push(resolve));
}
release(): void {
const next = this.queue.shift();
if (next) {
next(); // 待っていた1件を通す(permit は移譲)
} else {
this.available += 1;
}
}
}
セマフォの permit 数が、下流に対する同時実行数の上限になります。GitHub への push を同時に3本までにしたければ permit を3にします。この値は感覚で決めず、後述する実測で決めるのが大切です。permit を大きくしすぎれば 429 が戻り、小さくしすぎれば下流の能力を使い切れません。
トークンバケットで下流のレート制限に合わせる
同時実行数を抑えても、まだ足りません。下流の多くは「同時に何本か」ではなく「単位時間に何回か」でレート制限をかけてきます。秒間10回まで、と決まっている API に対しては、同時実行数が1でも、短時間に連続して叩けば 429 になります。ここで効くのが トークンバケット です。
// 一定レートで補充されるトークンを消費して通す。バーストもある程度許容する
class TokenBucket {
private tokens: number;
private lastRefill = Date.now();
constructor(
private readonly capacity: number, // バケットの最大容量(バースト許容量)
private readonly refillPerSec: number, // 1秒あたりの補充トークン数
) {
this.tokens = capacity;
}
private refill(): void {
const now = Date.now();
const elapsedSec = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillPerSec);
this.lastRefill = now;
}
// トークンが貯まるまで待ってから1つ消費する
async take(): Promise<void> {
this.refill();
while (this.tokens < 1) {
const waitMs = ((1 - this.tokens) / this.refillPerSec) * 1000;
await new Promise((r) => setTimeout(r, Math.ceil(waitMs)));
this.refill();
}
this.tokens -= 1;
}
}
refillPerSec を下流のレート制限よりわずかに低く設定するのがコツです。たとえば秒間10回まで許される API なら、refillPerSec を8〜9に置いておくと、計測誤差やリトライぶんの余裕が残り、429 をほぼ踏まなくなります。capacity はバースト(瞬間的な集中)の許容量です。普段は余裕があるが時々まとまって到着する、という運用なら容量を少し大きめにします。
バックプレッシャー:詰まったら受け付けを止める
ここまでの部品を組み合わせると、ワーカーは「キューから取り出す → セマフォを取る → トークンを取る → 下流へ送る」という流れになります。下流が遅れると、トークンが貯まらず、セマフォが空かず、ワーカーはキューから取り出せなくなります。するとキューが満杯になり、enqueue が待たされます。
この「enqueue が待たされる」ことこそが バックプレッシャー(backpressure/下流の詰まりを上流へ伝えて受け付けを絞る仕組み) です。下流が詰まっているという事実が、キューを通じてエージェント側に伝わり、エージェントは新しい仕事を投入できずに待つ。結果として、到着レートが自動的に処理能力へ寄っていきます。
// 部品を組み立てた流量制御つきパイプライン
class FlowControlledPipeline<T> {
private readonly queue: BoundedQueue<T>;
private readonly semaphore: Semaphore;
private readonly bucket: TokenBucket;
private running = true;
private inFlight = 0;
constructor(opts: {
maxQueueLength: number;
maxConcurrency: number;
refillPerSec: number;
burstCapacity: number;
handle: (item: T) => Promise<void>;
onOverflow: (item: T, reason: string) => Promise<void>;
}) {
this.queue = new BoundedQueue<T>(opts.maxQueueLength);
this.semaphore = new Semaphore(opts.maxConcurrency);
this.bucket = new TokenBucket(opts.burstCapacity, opts.refillPerSec);
this.opts = opts;
void this.loop();
}
private opts: {
handle: (item: T) => Promise<void>;
onOverflow: (item: T, reason: string) => Promise<void>;
} & Record<string, unknown>;
// エージェントはこれを呼ぶだけ。満杯ならここで待たされる(=バックプレッシャー)
async submit(item: T): Promise<void> {
await this.queue.enqueue(item);
}
private async loop(): Promise<void> {
while (this.running) {
const item = this.queue.dequeue();
if (item === undefined) {
await new Promise((r) => setTimeout(r, 20));
continue;
}
await this.semaphore.acquire();
await this.bucket.take();
this.inFlight += 1;
void this.process(item);
}
}
private async process(item: T): Promise<void> {
try {
await this.opts.handle(item);
} catch (err) {
await this.opts.onOverflow(item, String(err));
} finally {
this.inFlight -= 1;
this.semaphore.release();
}
}
}
submit を呼ぶだけのインターフェースにしておくと、エージェント側はバックプレッシャーの存在を意識する必要がありません。詰まれば自然と待たされ、空けば自然と進む。この「意識しなくていい」状態が、運用を楽にします。
あふれた仕事はデッドレターへ
バックプレッシャーで受け付けを絞っても、下流が長時間落ちていれば、待ち続けるだけでは前に進めません。一定回数リトライして駄目だった仕事は、握り潰さずに デッドレター(dead-letter/処理に失敗した仕事を退避させる置き場) へ送ります。捨てるのではなく、後で人が見て拾えるようにしておくのが肝心です。
async function handleWithDeadLetter(item: AgentTask): Promise<void> {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await pushToGitHub(item.result);
return;
} catch (err) {
if (attempt === maxAttempts) {
// 退避先(KV や R2、ログ)に理由つきで残す
await deadLetterStore.put(item.id, {
item,
reason: String(err),
failedAt: new Date().toISOString(),
});
return;
}
// 指数バックオフ。下流が回復する時間を与える
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
}
}
}
私が Promise.all で痛い目を見たときの一番の問題は「どれが失敗したか分からない」ことでした。デッドレターを置くようにしてからは、朝にまとめて退避先を確認し、下流が回復していれば再投入する、という運用に落ち着きました。失敗そのものは避けられませんが、失敗が見えなくなることは避けられます。
実数値でしきい値を決める
最後に、ここまでのパラメータをどう決めるかです。感覚で決めると、過剰防御で遅くなるか、防御が甘くて 429 を踏むかのどちらかになります。私は次の手順で実測して決めています。
第一に、処理能力の実測。下流に対して同時実行数を1から少しずつ増やし、エラー率が上がり始める手前の値を見つけます。私の運用では、GitHub への push は同時3本・秒間8回あたりが安定点でした。Cloudflare のデプロイはそもそも直列でよく、同時1本に絞っています。
第二に、キュー上限の決定。キューは「一時的な超過を吸収するバッファ」です。長くしすぎると、詰まったときに古い仕事が延々と残って鮮度が落ちます。私は「処理能力 × 許容遅延」を目安にしています。秒間8件捌けて、最大2分の遅延を許すなら、上限は約960件。記事生成のように1日数十件規模なら、もっと小さくて十分です。
第三に、バックオフとデッドレターの線引き。私は「3回リトライして駄目なら退避」を基本にしています。これより多くリトライすると、下流が本当に落ちているときに復旧を遅らせ、コストも無駄に積み上がります。
数字はサイトやアプリごとに違いますが、決め方の手順は同じです。まず実測し、エラーが出る手前に閾値を置き、超過分はキューで吸収し、吸収しきれない分はデッドレターへ逃がす。 この順序を守るだけで、並列度を上げても本番が壊れなくなります。
流量制御は地味な仕組みですが、複数のエージェントを本番で走らせるなら、賢さを足すよりも先に入れる価値があります。次に試すなら、いま Promise.all で一斉に流しているところを1つ選び、BoundedQueue とセマフォを挟むところから始めてみてください。その1箇所だけでも、429 の頻度がはっきり下がるのを実感できるはずです。
同じように複数のエージェントを本番で回している方の参考になれば幸いです。お読みいただきありがとうございました。