夜間に4サイト分の記事候補を集める処理を、長いあいだ1サイトずつ順番に回していました。1サイトあたり40秒前後。4本直列で約2分半。動いてはいるのですが、3サイト目の途中でネットワークが切れた日は、そこから先がまるごと巻き添えになって落ちます。私自身、個人開発で4サイトを一人で回している身として、朝のログを開いて「3サイト目で止まっていた」と気づくたびに、設計の素朴さを思い知らされていました。
順番に回す必要は、本当はどこにもありませんでした。サイトごとの候補集めは互いに独立しています。それなら並列に投げて、返ってきたものから受け取り、落ちたものだけ後で拾い直せばいい。いわゆる fan-out / fan-in です。ここでは、その骨格と、見落としやすい予算・結果の固定・部分失敗の三点を、実装に落としていきます。
直列をやめると、何が壊れるか
並列化そのものは難しくありません。難しいのは、並列にした瞬間に表面化する三つの落とし穴です。
ひとつ目は予算です。直列なら「全体で○トークンまで」と素朴に数えられましたが、並列だと複数のブランチが同時にトークンを食います。レート上限にぶつかると、全ブランチが同時に 429 を返し始めます。
ふたつ目は結果の形です。直列なら1件ずつ目視に近い感覚で扱えましたが、並列では返り順がばらつき、1件でも壊れた JSON が混じると集約処理が静かに崩れます。
みっつ目が部分失敗です。これがいちばん効きます。4本中1本が落ちたとき、残り3本の成果まで捨ててしまっては、並列化した意味が薄れます。
fan-out / fan-in の骨格
まずワーカーを1つ定義します。サイト1つを受け取り、候補リストを返す単純な関数です。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
type Site = { id: string; domain: string; maxTokens: number };
async function collectCandidates(site: Site): Promise<string> {
const res = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: site.maxTokens,
system: "あなたは技術ブログの編集者です。JSON配列のみを返してください。",
messages: [
{ role: "user", content: `${site.domain} 向けの記事候補を5件、JSON配列で。各要素は {title, angle} 。` },
],
});
const block = res.content.find((b) => b.type === "text");
return block && block.type === "text" ? block.text : "[]";
}
fan-out 側は、このワーカーを全サイトに対して同時に起動し、Promise.allSettled で待ち合わせます。Promise.all ではなく allSettled を選ぶのが要点です。前者は1本でも reject すると全体を reject しますが、後者は成功と失敗を区別したまま全件返してくれます。
const sites: Site[] = [
{ id: "cl", domain: "claudelab.net", maxTokens: 1024 },
{ id: "gl", domain: "gemilab.net", maxTokens: 1024 },
{ id: "ag", domain: "antigravitylab.net", maxTokens: 1024 },
{ id: "rl", domain: "rorklab.net", maxTokens: 1024 },
];
const settled = await Promise.allSettled(
sites.map((s) => collectCandidates(s))
);
ここまでで「待ち時間が直列の合計から最遅ブランチ1本ぶんに縮む」効果は得られます。ただし、このままでは壊れた応答も成功扱いで通ってしまいます。次の二つでそこを締めます。
ブランチごとにトークン予算を切る
並列化の最初の事故は、たいてい予算の取り合いです。アカウント全体のレート上限は共有資源なので、ブランチ数を増やすほど1本あたりに割ける余地は減ります。私の場合、max_tokens をブランチ側で固定し、同時実行数に上限を設けることで安定しました。
目安として使っている配分です。
| 同時ブランチ数 | 1本あたり max_tokens | 体感の安定度 |
| 2 | 2048 | 安定。余裕あり |
| 4 | 1024 | 常用域。429 はほぼ出ない |
| 8 | 512 | 上限が近い。要モニタ |
同時実行数そのものに天井を設けるには、軽量なセマフォを挟みます。外部ライブラリを足さずに書ける範囲です。
function pLimit(concurrency: number) {
let active = 0;
const queue: (() => void)[] = [];
const next = () => {
active--;
if (queue.length > 0) queue.shift()!();
};
return async function <T>(fn: () => Promise<T>): Promise<T> {
if (active >= concurrency) await new Promise<void>((r) => queue.push(r));
active++;
try {
return await fn();
} finally {
next();
}
};
}
const limit = pLimit(4);
const settled = await Promise.allSettled(
sites.map((s) => limit(() => collectCandidates(s)))
);
ブランチを8本に増やしても、同時に走るのは常に4本まで。残りは順番待ちになります。レート上限を共有資源として扱う、という発想がここでの肝です。
結果コントラクト—子が返す形を固定する
並列集約が静かに壊れる原因の大半は、子が返す JSON の揺れです。1件でもキーが欠けたり配列でなかったりすると、後段の for ループが例外を投げ、せっかくの成功ブランチまで巻き添えにします。親側でスキーマ検証をはさみ、壊れたものは「失敗」として明示的に隔離します。
import { z } from "zod";
const Candidate = z.object({
title: z.string().min(1),
angle: z.string().min(1),
});
const CandidateList = z.array(Candidate).min(1);
function parseCandidates(raw: string): z.infer<typeof CandidateList> {
const json = JSON.parse(raw); // 例外は呼び出し側で捕捉する
return CandidateList.parse(json); // 形が違えば throw
}
ポイントは、検証を子のなかではなく親の集約フェーズで行うことです。子は素直にテキストを返すだけにしておき、信頼境界を親に集めます。こうすると「どのブランチが、なぜ弾かれたか」が1か所のログに集約され、原因の切り分けが一気に楽になります。
1つのブランチが落ちても全体を止めない
ここが設計の中心です。allSettled の結果を、成功・スキーマ違反・通信失敗の三つに仕分けます。
type Outcome =
| { site: string; ok: true; items: z.infer<typeof CandidateList> }
| { site: string; ok: false; reason: "schema" | "network" | "unknown"; detail: string };
const outcomes: Outcome[] = settled.map((r, i) => {
const site = sites[i].id;
if (r.status === "rejected") {
return { site, ok: false, reason: "network", detail: String(r.reason) };
}
try {
return { site, ok: true, items: parseCandidates(r.value) };
} catch (e) {
return { site, ok: false, reason: "schema", detail: String(e) };
}
});
const fulfilled = outcomes.filter((o): o is Extract<Outcome, { ok: true }> => o.ok);
const failed = outcomes.filter((o) => !o.ok);
console.log(`成功 ${fulfilled.length} / 失敗 ${failed.length}`);
この時点で、成功した3サイトぶんの候補はすでに手元にあります。落ちた1サイトは failed に隔離されているだけで、全体の処理は前へ進みます。直列時代の「3本目で全部巻き添え」は、構造的に起きなくなりました。
失敗を、どこへ流すか
失敗を一律に再試行すると、レート上限超過のような「待てば直る失敗」と、スキーマ違反のような「待っても直らない失敗」が混ざり、無駄なリトライでさらに上限を圧迫します。理由ごとに行き先を変えるのが安全です。
| 失敗理由 | 性質 | 行き先 |
| network(429含む) | 一過性。待てば回復しうる | 指数バックオフで最大2回再試行 |
| schema | プロンプト起因。即再試行は無駄 | 1回だけ再生成、駄目ならデッドレター |
| unknown | 未分類 | 再試行せずデッドレターへ記録 |
デッドレターと言っても大仰なものは要りません。Dolice Labs の夜間バッチでは、失敗したサイト ID と理由と生応答を1行の JSON でログファイルへ追記し、翌朝それだけを拾い直すバッチを別に持たせています。すべてをその場で完璧に回収しようとせず、「いま取れたぶんを確定させ、取り逃しは記録して後で拾う」と割り切ることが、夜間の自動運用では結局いちばん壊れません。
直列と並列で、どれだけ変わったか
手元の4サイト構成で、同じ候補集めを直列・並列それぞれ20回ずつ走らせたときのおおよその値です。環境差はありますが、傾向の参考にはなります。
| 方式 | 平均所要 | 1本失敗時の取得数 | 429 遭遇率 |
| 直列 | 約 152 秒 | 失敗地点以降を喪失 | 低い |
| 並列(上限なし) | 約 44 秒 | 3/4 を確保 | やや高い |
| 並列(同時4・予算固定) | 約 48 秒 | 3/4 を確保 | ほぼゼロ |
注目したいのは、同時実行に上限を入れた行が、上限なしより数秒遅いのに、429 遭遇率はほぼゼロに落ちている点です。直列の約152秒に対して並列は約48秒、およそ3倍・率にして約68%の短縮です。わずかな所要時間と引き換えに、再試行で失う時間と上限超過のリスクを消せている。夜間バッチでは、ピーク速度より「事故らない速度」のほうが価値が高いと感じています。
公式ドキュメントに書かれていない運用上の注意
実際に数か月回して見えてきた、細かな勘所をいくつか共有します。
最遅ブランチをタイムアウトで切る
Promise.allSettled は全ブランチの完了を待つため、1本だけ極端に遅いブランチがいると全体がそれに引きずられます。各ワーカーに AbortController でタイムアウトを噛ませ、遅いブランチは自前で network 失敗に倒すと、最遅1本に全体が人質に取られる事態を回避できます。
ログは成功も失敗も同じ形式で
ログは「成功も失敗も同じ1行フォーマット」で出すと、後から集計しやすくなります。成功だけ饒舌で失敗が素っ気ないと、肝心の失敗分析でフォーマットが揃わず手こずります。
同時実行数は環境変数へ
同時実行数は、コードに直書きせず環境変数で外に出しておくことをお勧めします。レート上限はプランやアカウント状況で動きます。「上限が変わったら数字を1つ変えるだけ」にしておくと、深夜に慌てずに済みます。私自身、一度この値をハードコードしていて、上限引き上げの恩恵をしばらく取りこぼしていました。
次の一歩
まずは、いま直列で回している独立な処理を1つだけ選び、Promise.allSettled と同時実行2本に置き換えてみてください。予算固定とスキーマ検証は、その1つが安定してから足せば十分です。小さく並列化して、部分失敗の扱いに体が慣れてから本数を増やす——この順番が、自動運用をいちばん穏やかに育てます。
同じように夜間バッチの素朴さに悩んでいる方の、設計の手がかりになれば幸いです。