深夜、無人で回している生成パイプラインのコストが、前日の3倍に跳ねていました。
落ちていたわけではありません。むしろ逆で、すべての処理が「最終的に成功」と記録されていました。原因を追うと、ある一本の処理が同じ出力を27回作り直していました。検証に通らない出力を、ループが律儀に「もう一度」と投げ続けていたのです。
LLMの自己修復ループには、静かな落とし穴があります。「直せば通る」という前提でループを書くと、直しようのないエラーに対しても永遠に直そうとします。本番で効くのは、修復の巧みさよりも、いつ修復をやめるかの判断です。
ここでは、エラーを4つに分類し、クラスごとに再試行予算を割り当てる設計を扱います。コードはコピーして動く形で示します。
なぜ「素朴な再試行」は本番で壊れるのか
最初に書きがちなのは、こういうループです。
// アンチパターン: 通るまで直し続ける
async function generateUntilValid(prompt: string) {
while (true) {
const out = await callClaude(prompt);
if (validate(out).ok) return out;
prompt = `${prompt}\n\n前回の出力は検証に失敗しました。修正してください。`;
}
}
このコードは、対話的に人が見ている場面では問題になりません。数回で諦めて手で直すからです。
壊れるのは無人運用です。検証ロジックのバグ、満たせない制約、モデル側の一時的な不調 — これらはすべて「修正してください」では解決しません。それでもループは回り続け、トークンを焼き続けます。
問題の本質は、再試行を「一律」に扱っていることにあります。一時的な過負荷(429や529)と、構造的に満たせない制約とでは、取るべき行動がまったく違います。前者は待てば直り、後者は何度投げても直りません。
エラーを4つに分類する
実運用で再試行の判断に効くのは、次の4分類です。原因の所在ではなく「どう対処すべきか」で分けるのがポイントです。
| クラス | 典型例 | 正しい対処 | 再試行予算の目安 |
| transient(一時的) | 429 / 529 / タイムアウト / ネットワーク断 | 指数バックオフで待って再送。プロンプトは変えない | 5〜7回(バックオフ込み) |
| repairable(修復可能) | JSON崩れ / スキーマ不一致 / 必須フィールド欠落 | エラー内容を添えて1〜2回だけ作り直す | 2回まで |
| semantic-invalid(意味的に不正) | 事実誤り / 制約違反 / 品質ゲート不合格 | 同じ依頼を繰り返さない。アプローチ自体を変える | 1回(別戦略で) |
| hard-fail(恒久的失敗) | 401 / 400(入力不正)/ モデル未存在 / 満たせない制約 | 即座に中断。人手かフォールバックへ | 0回 |
この4分類の価値は、「諦める条件」がクラスごとに自然に決まることです。hard-fail は0回、semantic-invalid は別戦略で1回。同じ依頼の単純リトライが意味を持つのは、実は transient だけです。
repairable と semantic-invalid の違いが、設計上いちばん大切です。repairable は「形が壊れている」だけなので、エラーを見せれば直ります。semantic-invalid は「中身が要件を満たしていない」ので、同じ頼み方を繰り返しても堂々巡りになります。冒頭の27回は、semantic-invalid を repairable と取り違えていた典型でした。
エラー分類器を実装する
まず、例外やレスポンスをクラスへ写像する分類器を用意します。ここを一箇所に集約しておくと、対処方針の変更が一点で済みます。
type ErrorClass = "transient" | "repairable" | "semantic-invalid" | "hard-fail";
interface Classified {
cls: ErrorClass;
reason: string;
}
// API例外・検証結果を4クラスへ写像する
function classify(err: unknown, validation?: { ok: boolean; kind?: string }): Classified {
// 1) 検証由来の不合格を先に判定する
if (validation && !validation.ok) {
if (validation.kind === "schema" || validation.kind === "json")
return { cls: "repairable", reason: `形式不正: ${validation.kind}` };
// 事実誤り・品質ゲート不合格など、形ではなく中身の問題
return { cls: "semantic-invalid", reason: "内容が要件を満たさない" };
}
// 2) HTTPステータスで分類する
const status = (err as { status?: number })?.status;
if (status === 429 || status === 529) return { cls: "transient", reason: `過負荷 ${status}` };
if (status === 408 || status === 500 || status === 503)
return { cls: "transient", reason: `一時障害 ${status}` };
if (status === 401 || status === 403) return { cls: "hard-fail", reason: `認証 ${status}` };
if (status === 400) return { cls: "hard-fail", reason: "入力不正(400)" };
if (status === 404) return { cls: "hard-fail", reason: "モデル/リソース未存在(404)" };
// 3) ネットワーク系の例外
const code = (err as { code?: string })?.code;
if (code === "ETIMEDOUT" || code === "ECONNRESET" || code === "ENOTFOUND")
return { cls: "transient", reason: `ネットワーク ${code}` };
// 4) 判別できないものは安全側(即中断)に倒す
return { cls: "hard-fail", reason: "未分類のため安全側で中断" };
}
最後の「未分類は hard-fail」が地味に効きます。分からないものを transient 扱いにすると、未知の恒久エラーで延々と再送してしまうからです。安全側はあくまで「止まる」側です。
クラスごとに予算を割り当てるループ
次に、分類結果に応じて行動を変えるループを組みます。鍵は、クラスごとに独立した残予算を持つことです。「全部で何回」ではなく「transient は何回、repairable は何回」と別々に数えます。
interface Budget {
transient: number; // 例: 6
repairable: number; // 例: 2
semanticInvalid: number; // 例: 1(別戦略で)
hardCostCeilingUsd: number; // 例: 0.50(このタスクのコスト天井)
}
interface Attempt { n: number; cls?: ErrorClass; reason: string; costUsd: number; }
async function repairLoop(
task: { build: (hint?: string) => string; altBuild?: () => string },
validateFn: (out: string) => { ok: boolean; kind?: string; detail?: string },
budget: Budget
): Promise<{ ok: boolean; out?: string; attempts: Attempt[] }> {
const left = { ...budget };
const attempts: Attempt[] = [];
let spent = 0;
let prompt = task.build();
let usedAlt = false;
for (let n = 1; ; n++) {
// コスト天井は全クラス共通の最終ストッパー
if (spent >= budget.hardCostCeilingUsd) {
attempts.push({ n, reason: `コスト天井 $${budget.hardCostCeilingUsd} 到達`, costUsd: 0 });
return { ok: false, attempts };
}
let out: string, cost: number;
try {
const res = await callClaude(prompt); // { text, costUsd } を返す想定
out = res.text; cost = res.costUsd; spent += cost;
} catch (e) {
const c = classify(e);
attempts.push({ n, cls: c.cls, reason: c.reason, costUsd: 0 });
if (c.cls === "transient" && left.transient-- > 0) {
await sleep(backoff(n)); continue; // 待って同じプロンプトで再送
}
return { ok: false, attempts }; // transient以外は即中断
}
const v = validateFn(out);
if (v.ok) { attempts.push({ n, reason: "成功", costUsd: cost }); return { ok: true, out, attempts }; }
const c = classify(undefined, v);
attempts.push({ n, cls: c.cls, reason: `${c.reason}: ${v.detail ?? ""}`, costUsd: cost });
if (c.cls === "repairable" && left.repairable-- > 0) {
prompt = task.build(`前回は ${v.kind} で失敗しました(${v.detail})。その点だけ直してください。`);
continue;
}
if (c.cls === "semantic-invalid" && left.semanticInvalid-- > 0 && task.altBuild && !usedAlt) {
usedAlt = true; prompt = task.altBuild(); // 同じ頼み方を繰り返さず戦略を替える
continue;
}
return { ok: false, attempts }; // 予算切れ・別戦略なし → 諦める
}
}
const backoff = (n: number) => Math.min(1000 * 2 ** (n - 1), 30_000) + Math.random() * 250;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
このループには「無限に回る経路」が存在しません。各クラスの残予算は減る一方で、コスト天井という全体ストッパーも別に効きます。どの分岐をたどっても、有限回で必ず ok: false か ok: true に到達します。
semantic-invalid の分岐で altBuild() を呼んでいる点に注目してください。同じプロンプトを言い換えるのではなく、別の組み立て方(例: 出力を分割する、参照を増やす、より小さなモデルで下書きしてから清書する)へ切り替えます。「諦める」は「別の手を打つ」とセットにして初めて実用的になります。
コスト天井をなぜクラス予算と別に持つのか
クラス予算だけでも回数は有限になります。それでもコスト天井を別に持つのは、1回あたりのコストが一定ではないからです。
長い入力や拡張思考を使う処理では、1回の試行が数十倍に振れます。回数で縛っても、たまたま高コストな試行が重なると想定を超えます。回数の予算は「暴走の段数」を、コストの天井は「金額の総量」を守ります。役割が違うので、両方を持つのが安全です。
実運用では、コスト天井をタスクの価値に紐づけて決めると破綻しません。個人開発で複数サイトの記事生成を無人運用している私自身の環境でも、1本あたりの天井を「その記事が生む価値の数分の一」に置き、超えたら静かに諦めて翌回に回す方針にしています。直前まで粘って予算を使い切るより、潔く畳んで次へ進むほうが、一日の総産出は安定しました。
試行ログを必ず構造化して残す
無人運用で最も怖いのは、冒頭のように「成功と記録されているが実は異常」という状態です。これを検知できるかは、試行ログの質で決まります。
各試行について、最低でも次を残します。
| 項目 | 用途 |
| 試行回数とクラス内訳 | 「1本に repairable が何回出たか」の異常検知 |
| 各クラスの reason | どの検証で落ちたかの事後分析 |
| 累積コスト | 天井到達の有無、コスト異常の早期発見 |
| 最終結果と諦めた理由 | 「予算切れ」「コスト天井」「hard-fail」の区別 |
ここで効くのが「成功なのに試行が多い」ことを警告する集計です。例えば repairable が1本で3回以上出るなら、検証ロジックかプロンプトに構造的な問題があります。成功していても、それは「たまたま通った」のサインです。
// 成功してもクラス内訳が異常なら警告する(無音の劣化を捕まえる)
function inspect(attempts: Attempt[]) {
const count = (c: ErrorClass) => attempts.filter((a) => a.cls === c).length;
const total = attempts.reduce((s, a) => s + a.costUsd, 0);
if (count("repairable") >= 3)
log.warn("repairable過多: プロンプトか検証に構造的問題の疑い", { attempts });
if (count("semantic-invalid") >= 1)
log.warn("semantic-invalid発生: 題材かアプローチの見直しを推奨", { attempts });
return { total, repaired: count("repairable") };
}
成功/失敗の二値だけを見ていると、この層の異常は永遠に見えません。クラス内訳まで残すからこそ、「成功しているのに健全でない」を捕まえられます。
フォールバックの段を用意する
諦めたあとに何もしないと、無人パイプラインは静かに穴が空きます。クラスに応じて「次の手」を決めておきます。
transient で予算切れなら、次回スケジュールへ持ち越すのが素直です。待てば直る性質なので、無理にその場で粘りません。
semantic-invalid で別戦略も尽きたら、難易度を下げた代替成果物に切り替えます。私の場合は、新規記事の生成に失敗したら既存記事の加筆へ自動で切り替える段を入れています。ゼロ本で終わるより、価値のある別の仕事に振り替えるほうが運用として健全です。
hard-fail は、原則として人手の確認に上げます。401や400は設定や入力の問題なので、再試行ではなく通知が正解です。ここを自動リトライに混ぜると、根本原因が隠れたまま障害が長引きます。
まず入れるべき一手
既存の素朴な再試行ループがあるなら、最初の一歩は分類器の導入です。classify() を一箇所に置き、429/529 と検証不合格と 4xx を別経路に分けるだけで、無限ループの大半は止まります。
そのうえで、repairable と semantic-invalid を取り違えていないかを点検してください。「修正してください」と投げ続けている経路があれば、それは高い確率で semantic-invalid を repairable と誤認しています。同じ依頼の繰り返しをやめ、別戦略か諦めに倒すこと。これが、夜中にコストが3倍になる事故を防ぐいちばん確実な一手です。
同じ無人運用で悩んでいる方の設計の足場になれば嬉しいです。