プログラミング・スキルアップ

AIエージェントのエラーハンドリング設計|リトライ実装2026

読了時間: 約18分

深夜3時、社内Slackにエラー通知が9件並んだ。見積書チェックを任せていたAIエージェントが、同じAPI呼び出しを延々と再試行していたのだ。原因は単純だった。「失敗したら1秒待ってもう一度」というロジックしか組んでいなかった。相手のサービスが過負荷で落ちている最中に、律儀に1秒おきの追い打ちをかけ続けていたことになる。

AIエージェントは自律的に動く分、人間が横で見ていない時間帯にこそ失敗が起きる。落ちること自体は避けられない。避けられるのは、AIエージェントのエラーハンドリングをリトライ・サーキットブレーカー・冪等性・フォールバックの4層でどう設計しておくかだけだ。

この記事の要点
  • 固定間隔のリトライは「サンダリングハード問題」を悪化させる。指数バックオフ+ジッターが実装の最低ラインになる
  • サーキットブレーカーは依存先ごとに独立させる。1つのAPIの不調で全ツール呼び出しを止めない設計が要る
  • リトライを安全にするのは冪等性設計が先。ここを飛ばすと二重課金・二重実行のリスクの方が大きくなる

なぜAIエージェントにエラーハンドリングが要るのか

従来のWebアプリと比べて、AIエージェントの失敗は種類が多い。ネットワークタイムアウトのような従来型のインフラ障害に加えて、LLMの応答そのものが壊れているケースが混ざってくる。この2つを同じ「エラー」として一括りにリトライすると、対処法を間違える。

実行エラー

ツール呼び出し先のAPIタイムアウト・5xx・接続断。従来のインフラ障害と同じ扱いでよく、リトライで解決する見込みが高い。

セマンティックエラー

LLMが期待したJSON形式を崩す、存在しない関数名を呼ぼうとする等。同じプロンプトを再送しても同じ壊れ方をしやすく、単純リトライは効きにくい。

依存先の過負荷

レート制限(429)や下流サービスの輻輳。ここで間隔を詰めて再試行すると、相手の回復を遅らせる加害側に回る。

ツール呼び出しの一時的失敗

エージェントが外部APIやDBを呼ぶ工程は、単発のスクリプトより試行回数が桁違いに多い。1タスクの中で数十回ツールを呼ぶエージェントも珍しくなく、1回あたりの失敗確率が0.5%でも、タスク全体で見れば失敗はほぼ毎回どこかで起きる計算になる。

LLM応答のセマンティックエラー

構造化出力を強制していても、モデルは時々JSONの閉じ忘れや型違いを返す。この失敗は「もう一度同じプロンプトを送る」だけでは再現性高く直らない。プロンプトの温度設定を変える、スキーマ検証を挟んで失敗内容をエラーメッセージとしてモデルに突き返す、といった別のリトライ戦略が必要になる。

下流サービスの過負荷・429

エージェントは並列でツールを呼びがちなので、レート制限に引っかかりやすい。ここで各呼び出しが個別に「失敗したら即リトライ」を繰り返すと、リクエスト数が指数関数的に膨らみ、相手のサービスをさらに追い込む。次の章のバックオフ設計は、この状況を前提にしている。

リトライ設計の基本形:指数バックオフ+ジッター

固定間隔リトライが引き起こす問題

「1秒待って再試行」を全呼び出しが横並びで実行すると、失敗の波が同期する。100個のエージェントタスクが同時にタイムアウトした場合、1秒後にその100個が一斉に再試行する。これがサンダリングハード問題だ。相手のサービスが復旧しかけた瞬間に、また全力で殴りにいく構図になる。

対策は間隔を試行ごとに広げる指数バックオフと、各エージェントの再試行タイミングを散らすジッターの組み合わせだ。理屈は単純だが、効果を数字で確認しておく方が実装の判断がぶれない。

実装:バックオフ付きリトライ関数

def with_retry(fn, max_attempts=4, base_delay=0.2, max_delay=2.0):
    attempt = 0
    while True:
        attempt += 1
        try:
            return fn(), attempt
        except TransientError as e:
            if attempt >= max_attempts:
                raise
            delay = min(max_delay, base_delay * (2 ** (attempt - 1)))
            jitter = random.uniform(0, delay * 0.3)
            sleep_for = delay + jitter
            print(f"  [retry] attempt={attempt} failed: {e} -> {sleep_for:.2f}s待機して再試行")
            time.sleep(sleep_for)

ポイントは2つ。1つ目はTransientErrorだけを対象にしている点だ。認証エラーのような何度呼んでも直らない失敗まで巻き込むと、無駄な待ち時間が利用者を待たせるだけになる。2つ目はjitterを遅延の0〜30%の範囲でランダムに足している点。この幅が同期を崩す。

実行結果で見る挙動の違い

4回叩くと、2回失敗して3回目で成功するケースと、約1.5秒かけて4回とも失敗し例外を送出するケースの2パターンが出た。

=== ケース1: 2回失敗して3回目で成功 ===
  [retry] attempt=1 failed: call#1: 一時的なタイムアウト(模擬) -> 0.22s待機して再試行
  [retry] attempt=2 failed: call#2: 一時的なタイムアウト(模擬) -> 0.41s待機して再試行
結果: call#3: OK (総試行回数=3)

=== ケース2: 全試行(4回)失敗して例外送出 ===
  [retry] attempt=1 failed: call#1: 一時的なタイムアウト(模擬) -> 0.21s待機して再試行
  [retry] attempt=2 failed: call#2: 一時的なタイムアウト(模擬) -> 0.51s待機して再試行
  [retry] attempt=3 failed: call#3: 一時的なタイムアウト(模擬) -> 0.81s待機して再試行
最終的に失敗: call#4: 一時的なタイムアウト(模擬)
総試行回数=4, 総経過時間=1.53秒

待機時間は0.22秒→0.41秒→0.81秒とほぼ倍々で伸びる。ジッターを入れているので厳密な倍数にはならない。ここが固定間隔リトライとの違いで、同時に失敗した他のタスクとタイミングがずれていく。

ここは見落としがちだが、リトライ回数の上限は「待ってもいいユーザー体験の限界」から逆算するべきで、技術的にきりのいい数字を選ぶ話ではない。4回・最大遅延2秒という設定は、エージェントがバックグラウンドで動くバッチ用途を想定した値だ。チャット応答のようにユーザーが画面の前で待つ用途では、上限は2回・数百ミリ秒単位まで縮める必要がある。

サーキットブレーカーで無駄な再試行を止める

バックオフだけでは足りない場面がある。下流サービスが完全にダウンしている間、律儀に待って再試行を繰り返すこと自体が無駄なコストになるケースだ。ここで使うのがサーキットブレーカー。家庭のブレーカーと同じで、異常な電流(失敗)を検知したら回路を強制的に切る。

3つの状態:Closed / Open / Half-Open

サーキットブレーカーは3状態を行き来する。平常時はClosedで全呼び出しを素通しする。失敗が閾値を超えるとOpenに切り替わり、以降の呼び出しはネットワークへ発信せず即座にブロックする。一定時間が経つとHalf-Openになり、1回だけ試験的に呼び出しを通す。成功すればClosedに戻り、失敗すれば再びOpenに戻る。

実装とテスト結果

class CircuitBreaker:
    def __init__(self, fail_threshold=3, reset_timeout=1.0):
        self.fail_threshold = fail_threshold
        self.reset_timeout = reset_timeout
        self.fail_count = 0
        self.state = "closed"
        self.opened_at = None

    def call(self, fn):
        if self.state == "open":
            if time.time() - self.opened_at >= self.reset_timeout:
                self.state = "half-open"
            else:
                raise CircuitOpenError("circuit open: 呼び出しをブロック中")
        try:
            result = fn()
        except Exception:
            self.fail_count += 1
            if self.state == "half-open" or self.fail_count >= self.fail_threshold:
                self.state = "open"
                self.opened_at = time.time()
            raise
        else:
            self.state = "closed"
            self.fail_count = 0
            return result

この設定(閾値3回・復旧待機0.5秒)では、3回連続の失敗でOpenへ切り替わり、約0.5秒の復旧待機を経た5回目の呼び出しでHalf-Openから成功へ戻った。

=== 連続失敗でcircuitがopenするまで ===
  call#1: 実呼び出し失敗 -> 下流サービス応答なし(模擬) (state=closed)
  call#2: 実呼び出し失敗 -> 下流サービス応答なし(模擬) (state=closed)
  [circuit] closed/half-open -> open (fail_count=3)
  call#3: 実呼び出し失敗 -> 下流サービス応答なし(模擬) (state=open)
  call#4: ブロックされた -> circuit open: 呼び出しをブロック中

=== reset_timeout経過後、half-openで復旧確認 ===
  [circuit] open -> half-open (試験的に1回だけ通す)
  [circuit] half-open -> closed (試験呼び出し成功)
  call#5: 成功 -> OK (state=closed)

call#4がネットワークに一切出ずに即座にブロックされている点が、リトライ単体との違いだ。相手が完全ダウンしている間は、待つのをやめて素早く失敗を返す方がエージェント全体のスループットは上がる。

リトライと組み合わせる時の注意

サーキットブレーカーは依存先(APIエンドポイント・DB・外部モデル)ごとに個別のインスタンスを持たせる。1つの共有ブレーカーで全ツールをまとめて管理すると、無関係なAPIの不調で全機能が止まる。決済APIが落ちても検索APIは動くべきで、この粒度分けを誤ると障害の影響範囲が本来より広がる。

もったいないと感じるのが、サーキットブレーカーとリトライを両方とも「1回失敗したら即カウント」で運用してしまうケースだ。リトライで吸収できる一時的な失敗まで即座にブレーカーの失敗カウントに加算すると、ブレーカーが過敏になり本来動くはずの呼び出しまで止めてしまう。ブレーカーのカウントは、リトライを使い切った末の最終失敗だけを対象にする。

冪等性設計:リトライを安全にする土台

ここまでの2つは「失敗にどう反応するか」の話だった。しかし反応の前に確認すべきことがある。そもそもその処理は、2回実行しても安全なのか。

なぜリトライで二重実行が起きるのか

タイムアウトの厄介な点は、リクエストが届いていないのか、処理は成功したのに応答だけ届かなかったのかを呼び出し側から区別できないことだ。後者の状態でリトライすると、決済なら二重課金、メール送信なら二重送信、DB更新なら不整合が起きる。エージェントは失敗時に自動でリトライする設計が前提なので、この確認を飛ばしたまま本番投入するのは危険度が高い。

idempotency keyの実装

processed_keys = set()
side_effect_log = []

def charge_customer(idempotency_key, amount):
    if idempotency_key in processed_keys:
        return f"skip: {idempotency_key} は処理済み(重複実行を防止)"
    processed_keys.add(idempotency_key)
    side_effect_log.append((idempotency_key, amount))
    return f"charged: {idempotency_key} amount={amount}"

エージェントの1タスク実行に一意なキーを発行し、それを外部APIの冪等性キーとして渡す。同じキーで2回リクエストが来ても、外部サービス側(または自前の記録)で「処理済み」を検知して2回目はスキップする。同じキーで2回呼ぶテストでは、副作用(課金処理)が発生したのは1件だけだった。

1回目(初回実行): charged: agent-run-8841-step3 amount=5000
2回目(タイムアウト後の自動リトライ): skip: agent-run-8841-step3 は処理済み(重複実行を防止)
実際に副作用が発生した回数: 1件 (期待値: 1件)

エージェントのツール呼び出しにどう組み込むか

キーの発行元は呼び出し側、つまりエージェントのオーケストレーション層にする。ツール自体にキー生成を任せると、リトライのたびに新しいキーが発行されて意味がなくなる。タスクIDとステップ番号を組み合わせた文字列(agent-run-8841-step3のような形)を1回だけ生成し、そのステップに属するすべての再試行で使い回す。決済・メール送信・DB書き込みなど副作用を伴うツールにはこのキーを必須パラメータにしておくと、後から「このツールはリトライ安全か」を毎回考えずに済む。

同時実行制御とレート制限対策

冪等性でリトライ自体は安全になっても、大量の呼び出しが同時に飛べばレート制限には引っかかる。マルチエージェント構成では、複数のエージェントが同じ外部APIを並行して叩く。1体ずつは行儀よくリトライしていても、全体として見ると同時アクセス数が相手のレート制限を超えてしまう。この種の失敗はバックオフだけでは防げない。呼び出し側の並列数そのものを絞る必要がある。

同時実行数の制限

セマフォで同時実行数の上限を設け、上限に達したツール呼び出しはキューで待たせる。API側のレート制限(例: 秒間10リクエスト)より確実に低い値に設定しておくと、429自体の発生頻度が下がり、結果的にリトライの総数も減る。429を出してからリトライで対処するより、そもそも出さない設計の方がコストが低い。

Retry-Afterヘッダーを優先する

多くのAPIは429応答にRetry-Afterヘッダーを付けて、次にいつ叩いていいかを教えてくれる。この値が来ている場合は自前の指数バックオフより優先する。サーバー側が「あと何秒で空く」と申告しているのに、クライアント側の推測値で早く再試行すれば、また同じ429が返ってくるだけだ。

対策効く失敗実装コスト
指数バックオフ+ジッター単発のタイムアウト・5xx
同時実行数の制限429を未然に防ぐ
Retry-After優先サーバー側の明示的な指示がある429
サーキットブレーカー依存先の長時間ダウン

フォールバック設計:落ちても止まらない仕組み

リトライもサーキットブレーカーも、最終的には「諦める」瞬間が来る。その先で何をするかを決めていないエージェントは、タスク全体がそこで停止する。フォールバックは、諦めた後の代替手段を段階的に用意しておく設計だ。

モデル・プロバイダ切替

メインモデルが応答不能なら、別プロバイダの同等モデルに切り替える。速度や精度は落ちても、タスクを完走させられる可能性が残る。

定型応答への降格

全モデルが不通なら、事前に用意したテンプレート文言やキャッシュ済み結果を返す。ゼロよりは価値のある応答を返す最終防衛ライン。

Human-in-the-Loop

金額が大きい・取り消せない操作は、自動フォールバックさせず人間に判断を委ねるキューへ回す。エージェントのガバナンス設計と接続する部分。

モデル切替の実務ポイント

プロバイダを跨いだフォールバックは、プロンプトのフォーマットやツール呼び出しのスキーマがモデルごとに微妙に異なる点が壁になる。LangGraphやCrewAIのようなフレームワークはこの差異を吸収するアダプタ層を持っているため、自前で全部書くより導入コストが低いことが多い。Claude Agent SDKのように単一プロバイダに最適化されたSDKを使う場合は、フォールバック先を別プロバイダのAPIとして自前でラップする必要がある。

エスカレーション基準の決め方

「どこまで自動で頑張らせ、どこから人間を呼ぶか」の線引きは技術的な話に見えて、実際は業務ルールの話だ。決済金額の上限、取り消し可能かどうか、顧客への影響範囲といった軸で事前にリストアップしておく。エージェントのガバナンス設計と合わせて決めておかないと、失敗のたびに現場判断が割れる。

本記事で扱ったパターンは、それぞれ以下の一次情報を参照している。

パターン比較:いつ何を使うか

4つのパターンは競合ではなく、失敗の段階ごとに積み重ねるものだ。どの順番で効いてくるかを整理すると、実装の優先順位が決めやすくなる。

パターン役割導入の優先度
冪等性設計リトライそのものを安全にする前提条件最優先(副作用のあるツールは必須)
指数バックオフ+ジッター一時的な失敗を自動で吸収する高(全ツール呼び出しの標準装備)
サーキットブレーカー長時間ダウン中の無駄な再試行を止める中(依存先が増えるほど効果大)
フォールバック諦めた後もタスクを完走させる中〜高(ユーザー影響が大きい機能ほど優先)

自分なら、新しいエージェント機能を作るときは冪等性とバックオフの2つを最初のスプリントで必ず入れる。サーキットブレーカーとフォールバックは、依存先が2つ以上に増えた時点、あるいは障害を1回経験した時点で追加する順番にしている。最初から全部盛り込もうとすると設計が重くなり、リリースが遅れる方が実害として大きい。

AIエージェントのエラーハンドリングでよくある失敗パターン

仕組みを理解していても、実装段階で踏みやすい落とし穴がいくつかある。

401認証エラーや400バリデーションエラーまで無差別にリトライするコードを見かけることがある。直らない失敗に何秒も無駄に待たされた上で、結局失敗するだけだ。リトライ対象は「retryableなエラー」に限定する。HTTPステータスコードなら4xx(429を除く)は基本リトライしない、5xxとタイムアウトはリトライする、という線引きをエラーハンドリングの最初に決めておく。
  • ログに再試行回数が残らない: バックオフが効いているのか単に遅いのか区別がつかず、障害調査が長引く。attempt番号とsleep時間は必ずログに出す
  • タイムアウト設定そのものがない: リトライ設計だけ作り込んでも、1回の呼び出しが無限に応答を待てば意味がない。ツール呼び出しには個別にタイムアウトを設定する
  • エージェントのトークン消費が想定外に増える: リトライのたびに会話履歴へ失敗ログを積むと、次のLLM呼び出しの入力トークンが膨らむ。失敗ログは要約してから履歴に載せる工夫が要る

複数エージェントが連携する構成では、1体の失敗が他のエージェントへ連鎖しないよう、エラーハンドリングをエージェント単位でカプセル化しておく必要もある。1体の再試行ループが親エージェント全体をブロックする設計は、規模が大きくなるほど障害の影響範囲を広げる。

よくある質問

Q. リトライ回数は何回が適切か

用途で変える。バックグラウンドは3〜5回、チャット応答は1〜2回が目安だ。

Q. LangGraphやCrewAIはリトライ機構を標準で持っているか

両者ともノード単位・ツール単位でリトライやタイムアウトを設定する仕組みを持つ。OpenAI Agents SDKのような他フレームワークも同様の機能を用意している場合が多いが、サーキットブレーカーや冪等性キーの管理まで面倒を見てくれるわけではない。フレームワーク任せにできる範囲と自前で設計すべき範囲を切り分けて把握しておく必要がある。

Q. セマンティックエラー(LLMの応答崩れ)にもリトライは有効か

同じプロンプトをそのまま再送するだけでは効果が薄い。有効なのは、スキーマ検証で検出したエラー内容を次のプロンプトに含めて「この形式で失敗したので直して送り返してほしい」と伝える修正リトライだ。単純な再試行と修正リトライは別ロジックとして実装する。

まとめ

AIエージェントのエラーハンドリングは、リトライを足すだけでは完成しない。冪等性で安全性を確保し、バックオフで一時的な失敗を吸収し、サーキットブレーカーで無駄打ちを止め、フォールバックで完走率を上げる。この4層を順番に積む設計が、落ちても止まらないエージェントの実体だ。

新しくツール呼び出しを実装するなら、まず冪等性キーとバックオフ付きリトライの2つを標準装備にする。そこから先は、実際の障害を1つ経験するたびにサーキットブレーカーとフォールバックを足していけばいい。全部を最初から作り込む必要はない。