自律型 AI エージェントの設計では、ReAct パターンや、生成と検証を繰り返すレビュー・修正ループを組み込む構成が定着しています。エージェントにタスクを委任する開発者は、安全弁として反復回数の上限(リトライ制限やステップ制限)を設けるのが通例です。

しかし運用を続けると、多くの現場で共通の課題に直面します。「エージェントが進展のないまま同じ失敗を繰り返し、上限に達するまで止まらない」という時間とコストの浪費です。主要なフレームワークのコミュニティでも、行動パターンの反復によるスタックや無限ループに関する問題が継続的に報告されています12345。さらにエージェントループが長引くと、コンテキストの肥大化に伴いキャッシュ読み込みコストが二次関数的に増大していくため6、無駄な反復を早期に打ち切る重要性は一層高まっています。

回数上限以外に、何を監視すればエージェントの無駄なループを早期に止められるのでしょうか。

結論から言えば、欠陥クラス に着目した停止装置が有効でした。ここでいう 欠陥クラス とは、表現や指摘箇所が変わっても、閉じるべき欠陥の本質的な種類が同じであるならば同一と見なす判定単位です。

当開発基盤(meetsource.work)に蓄積された検証記録 68 件のうち、停止装置の稼働記録を持つログを突き合わせた実測調査をもとに、回数カウンタより先に効いた停止装置の正体と、その実装パターンを解説します。

検証環境と調査スコープ

本調査では、当サイトの技術ブログ執筆パイプラインにおいて、記事設計書(design.md)のファクトチェックを行う品質検証エージェント(/blog-verify)の実行履歴を対象としました。

検証環境の詳細は以下のとおりです。

  • 実行環境 OS: macOS Sequoia 15.3 (Apple Silicon)
  • ランタイム: Node.js v22.14.0
  • 調査対象データ: specs/blog-posts/*/verify-history/*.md(計 68 レコード / 36 slug、期間: 2026-07-19 〜 2026-08-20)
  • 集計スクリプト: node scripts/verify-history-stats.mjs
  • ロジック検証テスト: node tests/verify-history-stats.test.mjsok: verify-history-stats、正常終了)

1 レコード = 1 検証ラウンド です。品質検証エージェントは 1 周走るたびに 1 ファイルを追記するため、以下に出てくる母数と発火率の分母はいずれも記事の本数ではなくラウンド数を指します。検証ラウンド 欄を持つ 49 レコードの内訳は、1 周目 30 / 2 周目 17 / 3 周目 2 です。

集計にあたっては、パイプライン運用の改善に伴い検証記録の固定欄が段階的に後付けされた点に留意する必要があります。初出以降の欠落はいずれもゼロですが、固定欄ごとに有効な母数が異なります7

固定欄初出レコード (UTC)有効母数記載あり欠落
検証ラウンド20260726T164023Z49490
Hard Stop20260730T034410Z43430
中断条件4(再発)20260804T080214Z38380

なお、本調査の母数は履歴ディレクトリを保持している 36 slug に限られます(全 76 slug 中、初期の 40 slug は履歴書き出し導入前)。また、後述の Hard Stop 0 件という結果は「回数上限が不要である」ことを意味しません。同期間中に判定基準の緩和など運用改善が並行して行われており、対照実験は未実施である点にご留意ください。

2つの停止装置とは何か

当パイプラインには、差し戻しループを打ち切るための停止装置が 2 つ組み込まれています。

  1. 周回数ベースの停止装置(Hard Stop): レビューと修正の反復回数が規定上限(3周目)に達した時点でループを強制的に打ち切る停止装置。
  2. 欠陥クラスベースの停止装置(中断条件4): 表現や箇所が変わっても同一の欠陥クラスが連続して発生した時点でループを打ち切る停止装置。

主要フレームワークにおける設計の非対称性

主要なエージェントフレームワークを見渡すと、そのほぼすべてが「反復回数の上限」を一級市民の API として標準提供しています。

  • LangGraph: config={"recursion_limit": 25} による superstep 制限。超過時は GraphRecursionError を送出8
  • AutoGen: MaxMessageTermination(max_messages=N) によるメッセージ数制限。超過時は StopMessage を返却9
  • CrewAI: Agent(max_iter=25) による最大反復回数制限。超過時は _handle_max_iterations_exceeded により最終回答の生成を強制10
  • OpenAI Agents SDK: Runner.run(..., max_turns=10) によるターン数制限。超過時は MaxTurnsExceeded を送出11

このように、フレームワーク標準の安全弁は「回数を数えること」に特化しています。一方で、「エージェントが同じ失敗を繰り返しているか」を検知する仕組みはフレームワーク組み込みではなく、利用者が条件付きエッジやカスタムロジックとして後付けしなければならないという非対称性が存在します。

サーキットブレーカーとしての欠陥クラス検知

同一欠陥クラスの再発でループを止める機構は、ソフトウェアエンジニアリングにおける サーキットブレーカー(Circuit Breaker) パターン12と構造が一致します。

サーキットブレーカーは、外部サービスの呼び出しにおいて連続する障害回数が閾値を超えた際、回路を「Open」にして以降の呼び出しを即座に遮断(フェイルファスト)する機構です。周回数上限がタイムアウト的な打ち切りであるのに対し、欠陥クラスの再発検知は「同じ障害パターンが連続した時点で直ちに回路を開く」サーキットブレーカーとして機能します。

先行研究においても同様の知見が示されています。Alexander 氏による 200 タスクの ReAct エージェント実験では、リトライの 90.8% が「存在しないツール名の呼び出し」という復旧不能な恒久エラーに浪費されていたと報告されています13(※90.8% はツール名幻覚1回につき3スロットを消費するシミュレーション定数に依存した値です)。Alexander 氏はグローバルな回数カウンタを廃止し、エラー分類に基づくサーキットブレーカーの導入を提唱しました。

この先行例が対象としたのは「機械的に判定可能なツール実行エラー」であり、当パイプラインで再発していたのは「自然言語によるレビュー指摘の意味的な欠陥」です。ドメインは異なりますが、「回数を数えるより、エラーの分類と再発検知が先に効く」という結論において軌を一にしています。

それぞれの停止装置は何回発火したか

同一の検証記録について、2 つの停止装置が実際に何回発火したかを数えた結果が以下の表です。母数が 68 ではなく 43 / 38 になるのは、前節のとおり固定欄ごとに初出時期が異なるためです。

停止装置判定単位有効母数発火件数発火率
Hard Stop(周回数 = 3周目)検証ラウンド / Hard Stop4300.0%
中断条件4(欠陥クラスの再発)中断条件4(再発)38410.5%

周回数ベースの Hard Stop は、記録を持つ 43 レコード中、一度も発火しませんでした(全件が「対象外」)。期間中に 3 周目に達したレコードは 2 件存在しましたが、いずれも 2026年7月28日の記録であり、Hard Stop 欄が導入された 7月30日より前のため欄自体を持ちません。

一方、欠陥クラスの再発を見る中断条件4は、母数 38 件中 4 件(10.5%)で発火していました。

特筆すべきは、中断条件4 が発火した 4 件のすべてが「2 周目」「Hard Stop: 対象外」の時点だった という事実です。エージェントが差し戻しを修正できず、ループが打ち切られた事例では、3 周目の回数上限カウンタが作動する前に、すべて 2 周目の欠陥クラス再発検知によって決着していました。

この数字の読み方には 2 つ注意が要ります。1 つは、4 件が「中断条件4 が該当と記録されたレコード」として抽出されている以上、その 4 件が中断条件4 で決着したこと自体は抽出条件の帰結 だという点です。観測として意味を持つのは決着した装置の名前ではなく、4 件がいずれも 3 周目に到達しないまま終わったという到達周回のほうです。もう 1 つは、中断条件4 が 2 周目時点、Hard Stop が 3 周目に置かれている以上、内側のゲートが先に鳴れば外枠のカウンタは構造的に鳴り得ない という配置上の交絡です。中断条件4 を無効化した場合に 3 周目へ到達していたかは測っていません。したがって Hard Stop 0 / 43 は「回数上限が不要である」証拠ではなく、早期ゲートのほうが先に働いた記録 として読むべき数字です。

周回数で止める場合と欠陥クラスで止める場合の違い

反復回数による停止と欠陥クラスによる停止は、それぞれ異なるトレードオフを持っています。反復回数による停止は実装が極めて容易である反面、タスクが収束に向かっている場合でも画一的に打ち切る過剰停止(False Positive)を招きやすい特性があります。これに対し欠陥クラスによる停止は、実装コストを要するものの、本質的な停滞を早期に捉えて無駄な周回を防ぐことができます。

両者の特性を 5 つの軸で比較した結果が以下のとおりです。

比較軸周回数で止める場合(Hard Stop)欠陥クラスで止める場合(中断条件4)
1. 判定単位単純な試行回数(カウンタ整数値)指摘内容のセマンティクス(欠陥の分類体系)
2. 発火タイミング上限到達時(遅い。本パイプラインでは 3 周目)同一欠陥の再発検知時(早い。本実測の発火全 4 件は 2 周目)
3. 実装コスト極小(フレームワーク標準の引数を指定するのみ)中〜高(過去の指摘履歴の保持+モデルによる分類突き合わせ)
4. 誤検出の向きFalse Positive(過剰停止): 前進・収束しつつある複雑タスクを画一的に止めてしまうFalse Negative と False Positive の双方: 言い換えを見落として通すリスクと、前進した修正を過剰一般化して誤認し、打ち切ってしまうリスクの双方を抱える
5. 機械判定の可否完全自動・決定論的(コード1行、例外ハンドリングで確定)ハイブリッド(判定はLLMの意味読解、記録は固定スキーマ)

したがって、両者は二者択一ではありません。安全弁としての回数上限(Safety Net)を外枠に敷いた上で、早期打ち切りのサーキットブレーカーとして欠陥クラス再発検知を内側に配置する多層防御が合理的な設計となります。

欠陥クラス判定をどう実装するか

欠陥クラス判定をエージェントに任せる際、プロンプトへの指示だけに頼ると、その指示が遵守されないリスクが残ります。当パイプラインでは、判定自体は LLM の意味読解に委ねつつ、判定結果の記録側を「追記専用ログ」と「必須固定フォーマット(スキーマ)」で縛るハイブリッド構成を採用しています。

ここで重要なのは、当パイプラインは欠陥クラスの固定リスト(taxonomy)を持っていない という点です。中断条件4 が見るのは「一度撤回したはずの記述が再発したか」であり、クラス名はラウンド間で指摘同士を突き合わせた結果としてその場で言語化されます。前掲の「ヘッジ不足」「過剰一般化」「参照 URL ルール違反」も、事前に定義された選択肢ではなく事後の記述です。固定リストを持たない分だけクラス名は揺れますが、揺れても記録は固定欄に残るため、事後に人が読んで判定の当否を検証できます。読者の系で事前に列挙できる分類(テスト ID、例外型、ツール名など)があるなら、そちらを Literal で固定するほうが再現性は上がります。

この知見のエッセンスを抽出し、一般的な Python エージェントループで利用可能な最小のサーキットブレーカー実装例としてまとめたコードが以下です。

実装コード: agent_circuit_breaker.py

構造化出力のパースヘルパー client.chat.completions.parse() を使うため、依存は openaipydantic の 2 つです。ヘルパーの置き場所は SDK の版で変わっている(旧 v1 系では client.beta.chat.completions.parse())ので、バージョンを固定してから実行してください。

pip install 'openai>=3.8' 'pydantic>=2'
export OPENAI_API_KEY='sk-...'
from typing import List, Optional
from pydantic import BaseModel, Field
from openai import OpenAI

class DefectAnalysis(BaseModel):
    is_recurring: bool = Field(description="今回の指摘が前回の指摘と同一の欠陥クラスに該当するか")
    defect_class: str = Field(description="分類された欠陥クラスの名称(例: 過剰主張、根拠不整合、ルール違反)")
    reason: str = Field(description="同一クラスと判定した、または異なるクラスと判定した論理的根拠")

class DefectClassCircuitBreaker:
    """同一欠陥クラスの再発を検知してループを遮断するサーキットブレーカー"""
    def __init__(self, client: OpenAI, model: str = "gpt-4o"):
        self.client = client
        self.model = model
        self.history: List[str] = []

    def check_and_record(self, current_feedback: str) -> DefectAnalysis:
        if not self.history:
            self.history.append(current_feedback)
            return DefectAnalysis(
                is_recurring=False,
                defect_class="初回指摘",
                reason="前回の指摘が存在しないため初回ラウンドとして記録"
            )

        previous_feedback = self.history[-1]
        prompt = f"""あなたは品質検証エージェントです。
前回の指摘事項と今回の指摘事項を比較し、同一の「欠陥クラス」が繰り返されていないか判定してください。

【重要な判定基準】
指摘箇所の見出し、段落、具体的な言い回しが修正されていても、
「閉じるべき欠陥の本質的理由」(例: 根拠不足の断定、小規模データからの過剰一般化、参照URLルールの不遵守)
が共通している場合は同一の欠陥クラス(再発)と判定してください。

--- 前回の指摘 ---
{previous_feedback}

--- 今回の指摘 ---
{current_feedback}
"""

        response = self.client.chat.completions.parse(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            response_format=DefectAnalysis,
            temperature=0.0,
        )

        analysis = response.choices[0].message.parsed
        self.history.append(current_feedback)
        return analysis

if __name__ == "__main__":
    # 動作確認の最小シミュレーション
    client = OpenAI()
    breaker = DefectClassCircuitBreaker(client)

    # 1周目の指摘(本文中の過剰主張)
    round1_defect = "第2章本文において『あらゆるエージェントで必ず発生する』と断定しているが、提示されたデータは単一環境のものであり過剰主張である。"
    result1 = breaker.check_and_record(round1_defect)
    print(f"Round 1: 再発={result1.is_recurring} (クラス: {result1.defect_class})")

    # 2周目の指摘(本文の表現を修正したが、見出しに過剰主張が移動した場合)
    round2_defect = "本文の断定は和らげられたが、章見出しが『すべてのフレームワークで不可避な停止障害』となっており、過剰一般化の欠陥が解消されていない。"
    result2 = breaker.check_and_record(round2_defect)
    print(f"Round 2: 再発={result2.is_recurring} (クラス: {result2.defect_class})")
    print(f"判定理由: {result2.reason}")

    if result2.is_recurring:
        print(">> サーキットブレーカー発火: 同一欠陥クラスの再発を検知したため、ループを早期打ち切りします。")

想定される出力

上記の __main__ を実行したとき、2 周目で再発が検知されると次のような出力になります(判定文は LLM の生成結果なので、文面はそのつど変わります)。

$ python agent_circuit_breaker.py
Round 1: 再発=False (クラス: 初回指摘)
Round 2: 再発=True (クラス: 過剰主張・過剰一般化)
判定理由: 第1ラウンドでは本文中の単一環境データに基づく過剰な断定が指摘され、第2ラウンドでは本文が修正されたものの章見出しに同等の過剰一般化表現が残っており、解消すべき本質的な欠陥クラス(過剰一般化)が同一であるため。
>> サーキットブレーカー発火: 同一欠陥クラスの再発を検知したため、ループを早期打ち切りします。

実装のポイント

  1. セマンティックな同一性判定: 単純な文字列比較ではなく、LLM の構造化出力(response_format=DefectAnalysis)を用いて「本質的な誤りの種類」を突き合わせています。埋め込みベクトルの類似度のような、より安価な判定手段でどこまで代替できるかは検証していません。
  2. 履歴の追記: 判定結果を追記のみで積み、過去の指摘を書き換えずに残します。ただし不変性を担保しているのは当パイプライン側のファイル追記+固定スキーマであり、このサンプルの self.history は同等の保証を持たない最小版です。
  3. フェイルファストの実現: 回数上限(本パイプラインでは 3 周目、フレームワーク既定値なら 10〜25 ターン)を待つことなく、2 ターン目で同一欠陥の再発が検知された瞬間にループを中断できます。

なお、このサンプルは意図的に範囲を絞っています。比較するのは直前 1 件のみで、クラス単位の集合比較は行いません(実測の replace-estimate-tacit-knowledge のように 3 クラスが同時に再発する事例は、クラスごとの突き合わせに拡張する必要があります)。「1 周目に出て 2 周目で消え 3 周目に戻る」非連続の再発も検知できず、サーキットブレーカーの Closed / Open 状態や連続回数の閾値も持ちません。実運用では、前ラウンドのクラス集合との積集合を取る形へ広げてください。

実際に再発した4件の中身

実際に中断条件4が発火した 4 件の検証記録を掘り下げると、エージェントが修正時にどのような振る舞いを見せていたのかが具体的に分かります。

記事識別子 (slug)記録された欠陥クラス(生ログ要約)「移動」の有無
ai-memory-multiple-curves『主張が自分の提示する論証より強い』というヘッジ不足あり
replace-estimate-tacit-knowledge転換点の主張不整合、事実外挿、ヘッジ不足の3クラスあり
agent-option-label-duplication参照URL指定ルール違反(個別パーマリンクではなくトップURLを指定)なし
gemini-pro-flash-fact-synthesis-ab-test小規模データ・単一事例からの過剰一般化パターンの再発なし

欠陥が「別の場所へ移動する」現象

ここで注目すべきは、発火した 4 件中 2 件(50%)において、欠陥が別の場所へ移動した とログに明記されていた点です。

  • ai-memory-multiple-curves の記録:

    「『主張が自分の提示する論証より強い』というヘッジ不足が、本文から看板主張・見出しへ移動しただけで解消していない。過去に中断に至った同型の過剰主張パターンと同型」

  • replace-estimate-tacit-knowledge の記録:

    「3クラスとも1周目と同一で、修正により別の文へ移動した」

エージェントに「この文の断定を修正せよ」と指示した場合、エージェントはその文自体を素直に削除・緩和します。しかし、プロンプトのコンテキスト内に保持された前提構造や全体最適化のバイアスが解消されないまま生成を続けると、同じ主張を別の場所(見出し、まとめ、あるいは前後の段落)に再生成してしまいます。

人間から見れば「同じ欠陥が別の文へ移動しただけ」であっても、行番号や差分しか見ない単純なツールでは新規の文と見なされてすり抜けてしまいます。欠陥クラスという概念で意味の連続性を捉えていたからこそ、この移動を確実に検知して打ち切ることができました。

なお、残り 2 件は移動ではなく単純な修正漏れや同一ルールの連続違反でした。「欠陥の移動」は 4 件中 2 件の観測であり、すべての再発事例が移動を伴うわけではない点には留意が必要です。

まとめ

本調査の要点を命題として整理します。

  • 反復回数の上限は必要条件だが、十分条件ではない: 回数制限は無限ループを防ぐ最低限の安全弁ですが、停滞した反復を早期に止めることはできません。
  • 実測では、回数上限に到達する前に決着した事例が 4 件あり、いずれも引き金は欠陥クラスの再発だった: 差し戻しループが打ち切り(中断)となった全 4 件は、回数上限(3 周目)に達する前の 2 周目の時点で同一欠陥クラスの再発により決着していました。ただし前述のとおり、早期ゲートが先に置かれている以上、これは「欠陥クラス判定が周回数より優れている」ことの証明ではなく、先に鳴った装置の記録です。
  • 欠陥クラス判定は LLM と固定スキーマのハイブリッドで組む: 指示プロンプトだけに頼らず、追記専用ログと必須フォーマットによる決定論的な制約を組み合わせることで実用的な監査が可能になります。
  • 安全弁(周回数)と早期停止(欠陥クラス)の二重構造にする: 外枠に回数上限(Safety Net)を敷き、内側にサーキットブレーカーを配置するのが現実的な最適解です。

よくある質問(FAQ)

Q: AI エージェントのリトライは何回で打ち切るべきでしょうか?
A: 回数上限だけでなく、同一欠陥クラスの再発を見る早期停止(サーキットブレーカー)を併用すべきです。
一般に 3〜5 回の上限が設定されますが、自社ログの実測で差し戻しループが打ち切りとなった全 4 件においては、回数上限(3 周目)が発火する前に、すべて 2 周目の時点で同一欠陥クラスの再発によって決着していました(中断条件4 の判定対象 38 ラウンドのうち、同一欠陥クラスの再発が記録されなかった=そのまま次工程へ進んだものが 89.5%。残る 4 ラウンドが早期停止の側です)。

Q: エージェントが同じ失敗を繰り返しているかを機械的に判定できますか?
A: 構文エラーを除き、意味的な欠陥を完全に機械判定することは困難です。
判定自体は LLM に意味読解を行わせつつ、判定結果を「追記専用ログ」と「必須固定スキーマ」という決定論的フォーマットに縛るハイブリッド構成が実用的です。

エージェント運用のリトライ設計や、品質ゲートの自動化・パイプライン設計についてのご相談は、コンタクトフォーム よりお気軽にお問い合わせください。

Footnotes

  1. [FEATURE] Agent Loop Detection Middleware CrewAI コミュニティ (GitHub Issue)、2026年3月3日。自律ループにおける同一行動パターンの反復検知とミドルウェアによるループ打ち切り提案。

  2. RFC: Production Reliability Improvements for ReAct Agents LangGraph コミュニティ (GitHub Issue)、2025年12月22日。本番 ReAct エージェントにおけるコンテキスト溢れ、サイレント停止、スタックループの防止改善提案。

  3. Infinite Loops AutoGen コミュニティ (GitHub Issue)、2023年10月4日。空メッセージ連続送信による無限ループ障害の実在確認。

  4. Agent Performance Degradation and Infinite Reasoning Loop LangChain コミュニティ (GitHub Issue)、2026年7月14日。enable_thinking=true での同一推論無限出力障害の報告。

  5. Agent delegation seems to be broken OpenHands コミュニティ (GitHub Issue)、2025年4月30日。エージェント委任時の無限スタック障害の実在確認。

  6. Expensively Quadratic: the LLM Agent Cost Curve Philip Zeyliger (exe.dev)、2026年2月3日。ループ継続に伴うコンテキスト肥大とキャッシュ読み込みコストの二次関数的増大モデル。

  7. 一次計測ログ「差し戻しループの停止装置は、周回数と欠陥クラスのどちらで発火したか」当開発基盤(meetsource.work)、2026年9月3日。Hard Stop 0/43(0%)、中断条件4 4/38(10.5%)、該当4件が全件2周目で発火した実測データ、欠陥移動2件。

  8. Graph API overview - Docs by LangChain LangChain, Inc.、公式ドキュメント(2026年確認)。config={"recursion_limit": 25} のデフォルト仕様、超過時の GraphRecursionError 送出仕様。

  9. Termination — AutoGen Microsoft AutoGen Team、v0.4 公式チュートリアル。MaxMessageTermination による終了条件と論理演算子による合成仕様。

  10. Agents - CrewAI CrewAI, Inc.、公式ドキュメント(2026年確認)。Agent(max_iter=25) のデフォルト仕様と _handle_max_iterations_exceeded による強制終了挙動。

  11. Running agents - OpenAI Agents SDK OpenAI、公式ドキュメント(2026年確認)。Runner.runmax_turns 超過時に MaxTurnsExceeded を送出する仕様は「Runner lifecycle and configuration」節、例外の定義は「Exceptions」節。デフォルト値 10 は本文には現れず、実装側の定数 DEFAULT_MAX_TURNSsrc/agents/run_config.py)で確認した値。

  12. CircuitBreaker Martin Fowler、2014年3月6日。ソフトウェアにおけるサーキットブレーカーの基本定義、Closed / Open / Half-Open の状態遷移と閾値による即時遮断パターン。

  13. Your ReAct Agent Is Wasting 90% of Its Retries — Here’s How to Stop It Emmimal P. Alexander (Towards Data Science)、2026年4月12日。200タスクでリトライの90.8%が恒久エラー(ツール名幻覚)に浪費された実測、エラー分類とサーキットブレーカーの提唱。※90.8% はシミュレーション定数 HALLUCINATION_RETRY_BURN = 3 に依存する旨の注記。