毎時00分。夜間に走らせている4サイト分の要約タスクが、ほぼ同じ瞬間に目を覚まします。今朝はその一分間を GSC の呼び出しログで追っていて、手が止まりました。まったく同じ「最新ニュースの要約」プロンプトが、わずかな時間差で4回、別々に Claude へ飛んでいたのです。
答えは4回とも実質同じ。けれど課金は4回分。折しも本日でClaude Code の週次上限ブースト(+50%)が終わり、既定モデルも Sonnet 5 へ替わったばかり。レートリミットの余白はこれまでより薄くなります。「同じ答えを何度も買っている」この無駄は、余白が薄くなるほど痛みに変わります。
必要だったのは、新しいキャッシュ機構ではありませんでした。同時に飛ぶ同一の要求を、1本にまとめる こと。この記事は、その「束ね方」— シングルフライト(request coalescing)の設計と実装を、プロセス内から分散環境まで、実測を添えて記録したものです。
重複推論はいつ起きるのか — スタンピードの正体
結果キャッシュ(過去の応答を再利用する仕組み)を入れていても、重複は消えません。むしろ、キャッシュを入れているときにこそ起きます。
鍵は「同時性」です。キャッシュがヒットするのは、誰かが一度上流を叩き、結果を書き終えた後です。ところが夜間ピークのように複数の呼び出し元が同じ瞬間 にキャッシュミスへ突入すると、全員が「キャッシュに無いから自分で計算しよう」と判断し、揃って上流へ殺到します。これがキャッシュ・スタンピード(thundering herd)です。
仕組み 解決する問題 解決しない問題
完全一致キャッシュ 過去と同一の要求の再利用 同時に走る初回ミスの重複
セマンティックキャッシュ 意味的に近い要求の再利用 同時ミス時の重複・失効直後の殺到
シングルフライト 同時に走る同一要求の重複 時間を跨いだ再利用(別途キャッシュが必要)
つまりシングルフライトとキャッシュは競合しません。役割が違います。キャッシュは「時間を跨いだ再利用」、シングルフライトは「同一瞬間の重複排除」。両者を重ねて初めて、ミスの瞬間もヒットの後も無駄がなくなります。結果再利用そのものの設計は「Claude API のセマンティックキャッシュを本番投入する 」に譲り、本稿は「束ねる」側に集中します。
個人開発で複数のサイトを回している私自身の環境で観測した重複は、平均4.2回/分(毎時00分付近のピーク)。4サイトの関連タスクが、同一の要約プロンプトを別々に投げていた結果でした。
プロセス内シングルフライト — 最小実装
同じ Node/Worker プロセス内で完結するなら、実装は驚くほど小さくなります。核心は「進行中の Promise を要求キーで共有する」こと。ひとつだけ実際に上流を叩き、残りは同じ Promise を待ちます。
// singleflight.ts
type Entry < T > = { promise : Promise < T >; controller : AbortController };
export class SingleFlight < T > {
private inflight = new Map < string , Entry < T >>();
// fn には AbortSignal を渡す。全待機者がキャンセルされたら上流も止める。
async run ( key : string , fn : ( signal : AbortSignal ) => Promise < T >) : Promise < T > {
const existing = this .inflight. get (key);
if (existing) return existing.promise; // 既に飛んでいる同一要求に相乗り
const controller = new AbortController ();
const promise = fn (controller.signal). finally (() => {
// 成否にかかわらず必ず削除。残すとメモリリーク+古い結果の固着。
this .inflight. delete (key);
});
this .inflight. set (key, { promise, controller });
return promise;
}
get size () { return this .inflight.size; }
}
呼び出し側では、プロンプトから安定したキー を作るのが要です。モデル名・温度・max_tokens・system まで含めないと、条件の違う要求を誤って束ねてしまいます。
import { createHash } from "node:crypto" ;
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic (); // ANTHROPIC_API_KEY は環境変数から
const sf = new SingleFlight < string >();
function requestKey ( model : string , system : string , user : string , opts : Record < string , unknown >) {
const norm = user. trim (). replace ( / \s + / g , " " ); // 表記ゆれを最小限に正規化
const payload = JSON . stringify ({ model, system, user: norm, opts });
return createHash ( "sha256" ). update (payload). digest ( "hex" );
}
async function summarize ( newsText : string ) : Promise < string > {
const model = "claude-sonnet-5" ;
const system = "あなたは簡潔な日本語要約者です。" ;
const key = requestKey (model, system, newsText, { max_tokens: 512 });
return sf. run (key, async ( signal ) => {
const res = await client.messages. create (
{ model, max_tokens: 512 , system, messages: [{ role: "user" , content: newsText }] },
{ signal }, // 相乗り者全員の離脱時に上流を中断
);
return res.content. map (( b ) => (b.type === "text" ? b.text : "" )). join ( "" );
});
}
このわずかなコードで、同一プロセス内で同時に走る同一要求は、必ず1本に収束します。私の夜間バッチでは、これだけで毎時ピークの重複が 4.2回/分 → 1.1回/分 に落ちました。残る重複は「別プロセス/別ワーカー」から来るもの。次はそこを束ねます。
分散環境で束ねる — 複数ワーカー/エッジの設計
Cloudflare Workers のように呼び出し元が複数インスタンスへ散る環境では、プロセス内 Map は共有されません。ここでは短命ロックで「代表を1つ選ぶ」設計にします。代表だけが上流を計算し、他は結果を短TTLで待ちます。
// distributed-singleflight.ts (Cloudflare KV を利用)
interface Env { CACHE : KVNamespace }
const RESULT_TTL = 60 ; // 結果の共有寿命(秒)
const LOCK_TTL = 30 ; // 計算中を示すロックの寿命(秒)
const NEG_TTL = 10 ; // 失敗を短く覚える負のキャッシュ(秒)
export async function coalesced (
env : Env , key : string , compute : () => Promise < string >,
) : Promise < string > {
const resKey = `res:${ key }` ;
const lockKey = `lock:${ key }` ;
// 1) すでに誰かが計算し終えていれば即返す
const hit = await env. CACHE . get (resKey);
if (hit !== null ) return hit;
// 2) ロック取得を試みる。取れなければ「代表が計算中」— 待って読み直す
const gotLock = await tryAcquire (env, lockKey, LOCK_TTL );
if ( ! gotLock) return await waitForResult (env, resKey);
// 3) 代表として計算 → 結果を共有し、失敗は短く負のキャッシュへ
try {
const value = await compute ();
await env. CACHE . put (resKey, value, { expirationTtl: withJitter ( RESULT_TTL ) });
return value;
} catch (e) {
await env. CACHE . put ( `neg:${ key }` , "1" , { expirationTtl: NEG_TTL });
throw e;
} finally {
await env. CACHE . delete (lockKey);
}
}
async function tryAcquire ( env : Env , lockKey : string , ttl : number ) : Promise < boolean > {
if (( await env. CACHE . get (lockKey)) !== null ) return false ; // 近似的な相互排他
await env. CACHE . put (lockKey, "1" , { expirationTtl: ttl });
return true ;
}
async function waitForResult ( env : Env , resKey : string ) : Promise < string > {
for ( let i = 0 ; i < 15 ; i ++ ) {
await sleep ( 200 + Math. random () * 200 ); // ポーリングにもジッター
const v = await env. CACHE . get (resKey);
if (v !== null ) return v;
}
throw new Error ( "coalesced wait timeout" ); // 代表が落ちた場合の保険
}
function withJitter ( ttl : number ) { return ttl + Math. floor (Math. random () * ttl * 0.2 ); }
const sleep = ( ms : number ) => new Promise (( r ) => setTimeout (r, ms));
ここで大切なのが withJitter です。全エントリを同じ60秒で失効させると、失効の瞬間に再びスタンピードが起きます。TTL に±20%の揺らぎを足すだけで、失効タイミングがばらけ、群れの再結成を防げます。KV は結果整合のため厳密なロックにはなりませんが、要約のような「多少の重複は許容、大量の重複は困る」用途には十分に効きます。厳密性が要るなら Durable Objects で単一直列化点を作る選択もあります。
リトライの群れを散らす — ジッター付きバックオフ
もうひとつの群れは、失敗時のリトライで生まれます。同時に 429 を受けた複数の呼び出しが、揃って「1秒後に再試行」すると、1秒後にまた同時に殺到します。指数バックオフに必ずジッターを混ぜる のが定石です。
async function withRetry < T >( fn : () => Promise < T >, max = 5 ) : Promise < T > {
for ( let attempt = 0 ; ; attempt ++ ) {
try {
return await fn ();
} catch ( e : any ) {
const status = e?.status ?? e?.response?.status;
const retryable = status === 429 || status === 503 || status === 529 ;
if ( ! retryable || attempt >= max) throw e;
// Retry-After があれば尊重。無ければ full jitter(0〜上限の一様乱数)
const header = Number (e?.headers?.[ "retry-after" ]);
const base = Number. isFinite (header) ? header * 1000 : Math. min ( 1000 * 2 ** attempt, 20_000 );
const delay = Math. random () * base; // full jitter
await new Promise (( r ) => setTimeout (r, delay));
}
}
}
Math.random() * base の full jitter は、待ち時間を 0〜上限へ一様に散らします。「上限そのもので待つ」実装より収束が速いことが知られています。429 系の扱いの基礎は「Claude API のレート制限(429)エラーへの対処 」も併せてご覧いただければと思います。
公式ドキュメントに書かれない落とし穴
実装が動いた後に、静かに事故を起こす箇所を挙げます。私が実際に踏んだものです。
落とし穴 症状 対策
キーにパラメータを含めない 温度違い・model違いの要求を誤って束ね、片方の答えが混ざる model/system/max_tokens/温度を全てキーに含める
例外を長く負のキャッシュ 一時的な529を数分覚え、回復後もエラーを返し続ける 負のキャッシュは10秒前後の短TTLに限定
finally で削除しない Map にキーが残り、メモリリーク+古い結果の固着 成否に関わらず finally で必ず delete
ストリーミングの相乗り 後続の待機者にトークンを配れない ストリーム用途は非ストリームで一度確定してから配布、または相乗り対象外にする
失効の同期 固定TTLで一斉失効し再スタンピード TTLにジッターを付与
とりわけ「ストリーミングの相乗り」は見落としがちです。シングルフライトが最も効くのは、要約・分類・抽出のような確定した一発の応答 を返す用途です。逐次トークンを複数の待機者へ再配布したいなら、確定後に文字列を配るか、そもそも束ねない判断が要ります。
状況別の推奨
環境・負荷 推奨 理由
単一プロセス(常駐ワーカー) プロセス内 Map のシングルフライト 外部依存ゼロ・ミリ秒で相乗り成立
複数ワーカー/エッジ(多少の重複可) KV 短TTLロック+負のキャッシュ+ジッター 結果整合で十分・実装が軽い
厳密な一意実行が必須 Durable Objects 等で直列化点を1つ作る 近似ロックの取りこぼしを排除
高ファンアウトのバッチ シングルフライト+同時実行数の上限(セマフォ) 束ねた上で上流の並列度も抑える
導入チェックリスト
キーは model・system・user(正規化後)・温度・max_tokens を全て含めて生成する
プロセス内は finally で必ず Map から削除する(成否問わず)
分散環境は短TTLロックで代表を選び、待機側はジッター付きでポーリングする
結果TTLと負のキャッシュTTLを分ける(負は10秒前後に短く)
全TTLにジッターを付与し、失効の同期を崩す
リトライは full jitter、可能なら Retry-After を尊重する
ストリーミング用途は束ねる前に相乗り可否を判断する
重複率・上流呼び出し数・待機タイムアウト率を計測し続ける
導入後の実測です。毎時ピークの重複率は 76% → 3%、上流呼び出しは平均4.2回/分 → 1.1回/分に収束しました。入力トークンは月換算でおよそ6割減。派手な最適化ではありません。けれど「同じ答えを二度買わない」という当たり前を、静かに積み上げた結果です。
余白が薄くなる時期ほど、無駄を削る設計は効いてきます。この小さな束ね方が、あなたの夜間運用の静けさに少しでも寄与できれば嬉しいです。お読みいただき、ありがとうございました。