メインコンテンツへスキップ

AIエージェントのツール呼び出しループを止める:5層ガードの実装

同じツールの反復は、次の実行を拒否し、呼び出し元にも停止結果を返して止めます。進捗の確認、完了済み操作の再利用、結果不明の書き込みを再送しない復旧手順まで説明します。

LaoZhang AI Team公開更新 29 分で読めます
目次
実行前に予算と検証できる進捗を確認し、成果が増えない反復を止めて途中結果を渡す図

AIエージェントが同じツールを呼び続けるときは、次のツール実行を止め、呼び出し元の処理も終了させます。「もう繰り返さないで」と指示するだけでは、再試行、子エージェント、別の実行経路から呼び出しが続くことがあります。

まず新規実行と自動再試行を止め、最後に確認できた結果と実行中の操作を保存してください。メール送信、注文作成、決済などの書き込みが含まれるなら、完了済み・未実行・結果不明を分けます。タイムアウトした操作を、そのまま失敗扱いで再送してはいけません。

本記事の5層ガードは、呼び出し予算、結果の分類、進展なしの検知、業務操作の重複防止、途中結果を伴う終了です。後半のPython例は、外部サービスを呼ばない直列実行の原型です。停止後の実行拒否などをローカルで確認していますが、実際のモデルや決済サービスの安全性を検証したものではありません。

今すぐ止める3手

  1. 次の実行を閉じる。 ツールを呼ぶ直前の共通経路で拒否します。モデルへの新規リクエスト、自動再試行、子処理の起動も同じ停止状態を参照させます。画面の出力を止めるだけでは、バックエンドの処理は終了しません。
  2. 実行済みと実行中を記録する。 ツール名、呼び出しID、実際に実行した回数、結果、業務操作の識別子、最後の確認済み進捗を残します。ログに認証情報や顧客データをそのまま出さないようにします。
  3. 停止理由と途中結果を返す。 呼び出し元に、完了、部分結果、要確認のどれかを返し、その分岐で実行ループを抜けます。停止を普通のツールエラーに置き換えてモデルへ戻すと、モデルが再び同じツールを要求する余地が残ります。

実行中の処理については、停止要求が受け付けられたことと、処理が実際に終わったことを分けて確認します。キャンセルは、すでに外部へ反映された変更を元に戻しません。タイムアウト監視で先に応答を返しても、その裏で動いている書き込みまで消えるわけではありません。

Amazon Bedrock AgentCore Runtimeを利用している場合は、現在の公式APIとしてStopRuntimeSessionがあります。対象の実行ARN、セッションID、権限を確認して利用する停止機構です。ここでの冪等な停止リクエストは、業務ツールの書き込みを取り消す仕組みとは別です。

Serverworksの2025年の事例では、ツール計算の反復と早期終了の実装が紹介されています。当時の権限変更による対応を、現在の一般的な停止手順として使う必要はありません。利用中のランタイムにある停止機構と、アプリケーションの実行ゲートを先に確認します。

同じ呼び出しでも、進捗があれば継続できる

ツール名が同じという理由だけで止めると、ページ送りやジョブの状態確認まで止まります。判定したいのは「同じ要求を、成果が増えないまま繰り返しているか」です。

見えている症状確認するもの次の処置
同じ検索や読み取りを繰り返す有効な引数と新しく取得した情報新情報がない反復を上限で止める
引数のキー順だけが変わる意味を保って正規化した引数同じ要求として数える
読み取りと確認を交互に繰り返す業務状態が前へ進んだかツール横断の進展なし上限で止める
同じジョブをポーリングするサーバーの処理段階、完了状態、期限間隔と総回数を守り、進捗があれば続ける
成功した作成処理をもう一度要求する同じ業務操作の記録と入力確認済みの結果を再利用する
読み取りが拒否され、同じ要求を繰り返す権限、対象の存在、要求の条件同じ条件で再試行せず、途中結果か要確認で終了する

進捗は、アプリケーションが検証できる事実から作ります。例えば、新しいページの取得を確認した、未処理件数が減った、対象ジョブの処理段階が進んだ、目的のレコードと内容を照合できた、といった変化です。単にレスポンスの文字列や更新時刻が変わっただけでは足りません。

モデルが「順調です」と書いたこと、待機時間が経過したこと、ログの行数が増えたことは業務の進捗ではありません。バックオフは再試行の時期を制御しますが、成果が増えた証拠にはなりません。進捗なしで待つ必要があるジョブには、期限と有限のポーリング回数を別途設定します。

引数の正規化でも意味を削らないことが大切です。ページ番号、カーソル、対象テナント、宛先、バージョンは残します。文字列の空白を一律に削ると、検索文や書き込み内容が変わる場合があります。以下のコードではJSONのキー順だけをそろえます。ログ用のマスキングと、制御用の同一性判定は分けてください。ハッシュ化だけで機密情報が安全になるとは限りません。

確認できる処理段階が進むポーリングと、同じ結果の反復で停止する呼び出しを対比した模式図。

成功、待機、再試行、要確認を分ける

ツールが返す「成功」は、そのツールの操作が成功したという意味です。ユーザーの仕事全体が終わったとは限りません。逆に、目的を達成しているのに終了条件を確認しないと、成功済みツールを繰り返すことがあります。

n8nの2025年の報告にも、更新成功後に同じツールを再実行する例があります。投稿者は指示の明確化で改善したとしていますが、それだけで重複実行を防ぐ保証にはなりません。指示の修正と、実行側の終了判定を組み合わせます。

検証済みの結果同じ要求を続けてよいか呼び出し元の扱い
成功、仕事全体は未完了必要な次の操作へ進む確認済み成果を保存して継続
成功、仕事全体も完了続けない完了結果を返す
処理中定めた間隔・期限・回数内で状態確認する待機後に有限回のポーリング
一時的失敗、未反映が確認済み再試行条件を満たす場合のみ全体予算内で再試行
権限不足、承認待ち、入力不正同じ条件で続けない要確認または修正待ちで終了
不明な結果、書き込み後の応答消失そのまま再送しない操作記録と外部状態を照合

読み取りが止められた場合でも、すでに得た情報で答えられるなら、その範囲を示した部分結果を返せます。答えに不可欠な情報がないなら、欠けている情報と取得条件を返します。呼び出し上限を上げたり、別の実行を開始してカウンターを消したりすることは、情報が取得できない原因への対処にはなりません。

また、ドライバーが毎回ツール使用を強制していないか確認してください。OpenAIのtool choiceの仕様では、requiredは1つ以上の関数呼び出しを要求し、特定関数の強制指定はその関数を呼ばせます。すでに必要な仕事が終わっているのに、この条件を毎回指定していれば、通常の最終回答を出せない原因になり得ます。残作業に応じてアプリケーションが選択する設定であり、すべてのループの原因という意味ではありません。

直列実行のガードをPythonで実装する

次のコードは、標準ライブラリだけを使う同期・直列実行の例です。model_gate()をモデル要求の前、dispatch()をツール実行の前に置きます。実サービス向けのSDKコードではありません。設定値も説明用で、推奨の本番上限ではありません。

verify(call, raw_result)は、ツールの応答と必要な外部状態をアプリケーション側で照合し、kind、単調な整数progress、仕事全体の完了を表すdoneを返します。書き込み成功にはcommitted=Trueも必要です。writeの区分と業務操作キーは、アプリケーションのツール定義と依頼記録から設定し、モデルに決めさせません。モデルの自由文をそのままこの形式へ変換して承認してはいけません。後述のローカル検証では、外部状態の代わりに固定の模擬結果を使いました。

python
import copy
import json

class Guard:
    def __init__(self, now, verify, max_models=8, max_tools=6,
                 duration=60, repeat_limit=2, stall_limit=3):
        self.now, self.verify = now, verify
        self.deadline = now() + duration
        self.max_models, self.max_tools = max_models, max_tools
        self.repeat_limit, self.stall_limit = repeat_limit, stall_limit
        self.models = self.tools = self.progress = self.stalled = 0
        self.repeats, self.operations = {}, {}
        self.ready_at = now()
        self.terminal = None

    def end(self, reason, state="PARTIAL"):
        if self.terminal is None:
            self.terminal = {"state": state, "reason": reason,
                             "model_calls": self.models,
                             "tool_calls": self.tools,
                             "progress": self.progress}
        return dict(self.terminal)

    def boundary(self):
        if self.terminal is not None:
            return dict(self.terminal)
        if self.now() >= self.deadline:
            return self.end("deadline")
        return None

    def model_gate(self):
        stopped = self.boundary()
        if stopped:
            return stopped
        if self.now() < self.ready_at:
            return {"state": "WAIT", "until": self.ready_at}
        if self.models >= self.max_models:
            return self.end("model_budget")
        self.models += 1
        return {"state": "ALLOW_MODEL"}

    def dispatch(self, call, execute):
        stopped = self.boundary()
        if stopped:
            return stopped
        if self.now() < self.ready_at:
            return {"state": "WAIT", "until": self.ready_at}
        try:
            name, args, write = call["tool"], call["args"], call["write"]
            if not isinstance(name, str) or not isinstance(args, dict):
                raise ValueError("invalid call")
            if type(write) is not bool:
                raise ValueError("invalid write flag")
            fingerprint = json.dumps([name, args], sort_keys=True,
                                     ensure_ascii=False, allow_nan=False)
            key = call.get("operation_key") if write else None
            if write and (not isinstance(key, str) or not key):
                raise ValueError("missing business operation key")
        except (KeyError, TypeError, ValueError):
            return self.end("invalid_call", "FAILED")

        if key in self.operations:
            record = self.operations[key]
            if record["fingerprint"] != fingerprint:
                return self.end("operation_input_conflict", "NEEDS_HUMAN")
            if record["state"] != "COMMITTED":
                return self.end("commit_unknown", "NEEDS_HUMAN")
            return {"state": "CACHED", "result": copy.deepcopy(record["result"])}
        if self.tools >= self.max_tools:
            return self.end("tool_budget")
        if self.repeats.get(fingerprint, 0) >= self.repeat_limit:
            return self.end("same_call_without_progress")
        self.tools += 1
        self.repeats[fingerprint] = self.repeats.get(fingerprint, 0) + 1
        if write:
            self.operations[key] = {"fingerprint": fingerprint, "state": "UNKNOWN"}
        try:
            result = self.verify(call, execute(call))
            if result["kind"] not in {"SUCCESS", "PENDING", "RETRYABLE", "BLOCKED", "FATAL"}:
                raise ValueError("unknown result")
            if type(result["progress"]) is not int or result["progress"] < self.progress:
                raise ValueError("invalid progress")
            if type(result["done"]) is not bool:
                raise ValueError("invalid completion")
            wait = result.get("wait", 0)
            if type(wait) not in (int, float) or not 0 <= wait <= 30:
                raise ValueError("invalid wait")
            if result["done"] and result["kind"] != "SUCCESS":
                raise ValueError("invalid completion kind")
            if write and (result["kind"] != "SUCCESS" or result.get("committed") is not True):
                return self.end("commit_unknown", "NEEDS_HUMAN")
        except Exception:
            return self.end("commit_unknown" if write else "unverified_result", "NEEDS_HUMAN")
        if write:
            self.operations[key] = {"fingerprint": fingerprint, "state": "COMMITTED",
                                    "result": copy.deepcopy(result)}
        if result["progress"] > self.progress:
            self.progress, self.stalled = result["progress"], 0
            self.repeats.clear()
        else:
            self.stalled += 1
        if result["kind"] in {"BLOCKED", "FATAL"}:
            return self.end(result["kind"].lower(), "NEEDS_HUMAN")
        if result["done"]:
            return self.end("verified_task_complete", "COMPLETED")
        if self.stalled >= self.stall_limit:
            return self.end("no_progress")
        self.ready_at = self.now() + wait
        return {"state": "CONTINUE", "result": result}

呼び出し元は、返されたstateを必ず分岐に使います。ALLOW_MODELの場合だけモデルを呼び、CONTINUEなら必要な次の処理へ進みます。WAITなら指定時刻まで待ちますが、待機で期限や累積予算は増やしません。CACHEDなら保存済み結果を使い、仕事全体の完了条件を確認します。COMPLETED、PARTIAL、NEEDS_HUMAN、FAILEDが返ったら、結果を保存して呼び出し元から返ります。

呼び出し元の終了分岐も、模擬提案で動かせます。上のクラスの後に次を置いて実行すると、同じ読み取りを2回実行した後、3回目の実行前にPARTIALを返してrun()を抜けます。ここだけのverifyは、信頼できる固定の模擬結果をそのまま返しています。実際のツールでこの検証を省略してよいという意味ではありません。

python
t = [0]
guard = Guard(lambda: t[0], lambda call, raw: raw)
proposals = iter([{"tool": "read", "args": {"page": 1}, "write": False}] * 4)
executed = [0]

def fake_execute(call):
    executed[0] += 1
    return {"kind": "RETRYABLE", "progress": 0, "done": False, "wait": 1}

def run():
    while True:
        gate = guard.model_gate()
        if gate["state"] == "WAIT":
            t[0] = gate["until"]
            continue
        if gate["state"] != "ALLOW_MODEL":
            return gate
        call = next(proposals, None)  # 模擬提案。モデルは呼びません。
        if call is None:
            return guard.end("no_more_proposals")
        decision = guard.dispatch(call, fake_execute)
        if decision["state"] not in {"CONTINUE", "CACHED", "WAIT"}:
            return decision

report = run()
assert report["reason"] == "same_call_without_progress"
assert executed[0] == 2
print(report)

停止後に例外を握りつぶしてもう一度dispatch()を呼んでも、このオブジェクトは同じ終端結果を返します。すでに発火した停止をモデルに解除させないためです。一方、このクラスを作り直すと記録は消えます。本番では、仕事の識別子にひもづく累積カウンター、停止状態、期限、操作記録を永続化し、新しいターン、子処理、再開後も同じ予算を引き継いでください。

ここでの実行時間チェックは、次の実行を止めるためのものです。同期execute()が返ってこなければ、コード自体はその呼び出しを中断できません。長時間処理のキャンセルは、実行基盤やツール側で別に実装し、終了したことを確認する必要があります。

書き込みの二重実行は、業務操作の記録で防ぐ

operation_keyは、呼び出し元が確定した同じ業務操作の識別子です。モデルが呼び出すたびに作るIDや、再試行ごとに変わるIDではありません。例えば、ある注文の一度の確定処理、ある障害に対する一度の通知を区別できる値を使います。

例では書き込み前にUNKNOWNを保存し、反映を確認できた場合にだけCOMMITTEDへ変えます。同じキー・同じ入力が再び来たら、実行せず結果を返します。同じキーで入力が変わったら、自動で別操作として扱わず停止します。応答が失われた場合は、書き込みが完了した可能性を残し、再送を止めます。

ただし、これは同じプロセスで直列実行するときの説明です。メモリー上の辞書は再起動で失われ、複数workerからの同時要求を原子的に予約できません。「記録を検索し、なければ書く」という2段階処理だけでは、両方のworkerが未実行と判断できます。

本番では、永続ストアの原子的な操作予約と一意制約、外部サービスが提供する冪等性キー、結果照合を組み合わせます。外部への送信とローカル記録の間で障害が起きる可能性も残るため、予約だけで「必ず1回だけ」を保証したと考えないでください。結果不明の操作を検索・照合できる設計が必要です。

応答が失われた書き込みを業務操作キーで照合し、完了済み、未反映、結果不明に分けて扱う復旧の模式図。

結果不明の操作を確認してから再開する

  1. 最後の安全なチェックポイント、業務操作キー、入力、送信時点を読みます。
  2. その操作が外部で完了したか、記録や対象データを照合します。完了済みなら結果を回収し、再実行しません。
  3. 未反映を確認できた場合は、残り予算と承認条件を確認し、同じ業務操作として再試行します。宛先や内容を変更するなら、別操作にする根拠を記録します。
  4. 完了・未反映のどちらも確認できない場合は、要確認のまま止めます。新しいキーで送ると、以前の操作と重複する可能性があります。
  5. 未完了の仕事だけを再開します。会話履歴を引き継ぐことと、完了済み外部操作の記録を引き継ぐことは別です。

OpenAIのエージェント実行ガイドは、履歴やセッションによる継続、承認待ちの中断と再開を説明しています。会話の継続だけで、外部の書き込みが再実行されないことまで証明できるわけではありません。

ローカルで確認した停止と継続の境界

掲載コードを、固定の模擬実行関数と手動で進める時計で確認しました。モデル、API、クラウドのセッション、実際の書き込みは呼んでいません。確認したのは、指定した入力に対するこの原型の制御です。

入力した状況確認できた挙動
1回目で仕事全体の完了を確認終端結果を返し、その後のモデル・ツール要求は実行されない
キー順だけが違う同じ読み取りが、進捗なしで続く2回実行後、3回目は実行前に拒否される
状態確認が待機し、その後に処理段階が進む待機中はモデル要求を許可せず、確認済み進捗の後は同じツールを継続できる
読み取りと確認が交互に続き、成果は不変進展なし上限で部分結果として止まる
同じ業務操作キーで成功済み書き込みを要求直列の2回目は保存結果を使い、入力を変更した要求は要確認になる
書き込み実行関数が応答消失を表す例外を返す操作記録を結果不明として残し、その後の再送を実行しない
モデル上限、ツール上限、期限へ到達それぞれの境界で新規実行を拒否する
未知の結果または拒否結果を返す要確認で終了し、次の実行へ進まない

停止後に模擬実行関数のカウンターが増えないことも確認しています。これは本番の誤検知率、モデルの収束、費用削減率、並列トランザクションの正しさを示すベンチマークではありません。

自分のシステムで検証するときは、呼び出し回数だけでなく「停止後に実行されたか」を数えます。再起動や同時実行の検証は永続台帳を含む別の範囲です。読み取りの正常なページ送り、期限まで進捗がないジョブ、成功後の通信断など、実際に持つツールの性質に合わせて確認してください。

SDKの上限は、数える単位と超過後の動きを確認する

モデルの実行回数、個々のツール呼び出し回数、1回のワークフロー起動、同じ会話全体の予算は別です。1回のモデル応答から複数ツールが呼ばれる場合もあります。「最大10回」という設定だけでは、何を10回に制限したか分かりません。

利用環境公式文書で確認する設定追加で必要な制御
OpenAI Agents SDK実行ループと最大ターン超過の扱い仕事全体の完了、外部操作記録、呼び出し元の終了分岐
LangChain JavaScript/TypeScriptモデル呼び出し制限、ツール呼び出し制限、再試行超過時に継続するか終了するか、会話をまたぐ予算と永続化
n8n Tools AgentMax Iterationsモデル実行回数とは別のツール上限、外側のワークフロー再起動対策
Google ADKLoopAgentの反復と終了信号サブエージェント内のモデル・ツール回数、業務の完了判定

LangChainの現在の文書では、toolCallLimitMiddlewareの既定の超過動作continueは、上限を超えたツールを拒否しても、モデルや他のツールの処理を続けます。errorによる停止も、呼び出し元が捕捉して同じ仕事を起動し直せば全体の終端にはなりません。endには、単一ツールで他に保留中の呼び出しがないなどの条件があります。threadLimitの継続には同じ会話IDとチェックポインターが必要で、runLimitは新しいユーザーターンでリセットされます。適用条件を確認してください。

同じ文書のtoolRetryMiddlewareは、既定のmaxRetries=2なら最初の実行に加えて最大2回再試行します。失敗後の既定動作continueは、エラーメッセージをモデルへ返して処理を続けるものです。モデルが再度提案した呼び出しや、アプリケーション側の再試行と組み合わさると、実行が増えます。現在の文書にあるバージョンごとの再試行対象の扱いも確認し、全体の予算へ合算します。

n8nのMax Iterationsの既定値10は、回答を生成するモデル実行回数です。ツール操作全体や別ワークフローからの再起動回数の上限とは同じではありません。Google ADKのLoopAgentの反復回数も、サブエージェントの一巡を数えます。ADK 2.0のPython/Goでは、旧ワークフローテンプレートはグラフや動的ワークフローで置き換えられる位置づけになっています。すべての現行ADKで同じ実装が必須だとは考えず、使っているバージョンと構成に合わせます。

金額の上限まで止めたい場合は、LLMエージェントAPIの費用キルスイッチの、プロバイダーへ送る前の予算予約と停止も必要です。ツールの反復検知と、金額に基づく送信拒否はそれぞれの実行境界に置きます。

よくある質問

最大反復回数を設定すれば、無限ループは解決しますか?

その設定が対象とする反復を有限にできます。ただし、正常完了、進展なし、重複書き込み、外側からの再起動までは同じ問題ではありません。数える単位と超過後の動作を確認し、仕事全体の累積予算と終了結果も管理します。

ツールが成功しているのに、なぜ同じ処理を繰り返すのですか?

ツールの成功と仕事全体の終了が結びついていない可能性があります。完了条件、残作業、毎回のツール強制指定を確認してください。成功済みの書き込みは、業務操作キーから確認済み結果を返し、モデルが再提案しても再実行しない設計にします。

タイムアウトした書き込みは再試行してよいですか?

未反映を確認できるまでは、そのまま再送しません。外部で完了した後に応答だけ失われた可能性があります。操作記録と外部状態を照合し、完了済みなら結果を回収し、不明なら要確認として止めます。

停止後に、新しいrunを作れば再開できますか?

新しい実行を作るだけでは、安全な再開条件を満たしません。原因の変更、未完了の仕事、結果不明の操作、残り予算を確認します。同じ仕事の累積予算と操作記録を引き継ぎ、未完了部分だけを再開してください。

参考資料8

本文で参照している外部ページを、登場順に並べています。最終更新日:2026年10月7日。

  1. 1.StopRuntimeSessiondocs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_StopRuntimeSession.html
  2. 2.Serverworksの2025年の事例blog.serverworks.co.jp/agentic-infinity-loops
  3. 3.n8nの2025年の報告community.n8n.io/t/agent-goes-into-infinite-tool-call-loop/174472
  4. 4.tool choiceの仕様developers.openai.com/api/docs/guides/function-calling
  5. 5.OpenAIのエージェント実行ガイドdevelopers.openai.com/api/docs/guides/agents/running-agents
  6. 6.モデル呼び出し制限、ツール呼び出し制限、再試行docs.langchain.com/oss/javascript/langchain/middleware/built-in
  7. 7.Max Iterationsdocs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/tools-agent
  8. 8.LoopAgentの反復と終了信号adk.dev/agents/workflow-agents/loop-agents
さらに読む: API ガイド
OpenClawとClaude Codeの用途を比較する図
Claude Code

OpenClawとClaude Codeの違い:用途・料金・連携方法で選ぶ

コード修正から始めるならClaude Code、チャット経由の依頼や自前の常駐運用をまとめるならOpenClawが判断の出発点です。Claude Codeにも記憶・チャット連携・クラウド定期実行があるため、実行場所と必要な権限、認証に対応する費用で選びます。

36 分