Antigravity でマルチエージェントを書いていると、デモでは快調に動いていたのに、本番投入直後に「同じツールを 10 回、20 回と呼び続ける」「サブゴールを永遠に繰り返す」という現象に出くわすことがあります。私もこれを何度か経験しました。最初は「モデルが悪いのか」と疑いましたが、実際には設計やプロンプトに原因が潜んでいることがほとんどです。
ここではAntigravity のエージェントが暴走・ループしてしまう代表的な原因と、私が実装レベルで入れている「止める仕組み」をお伝えします。原因の特定ができれば、プロンプトのちょっとした調整やコード数十行で、驚くほど安定します。
エージェントのループは4種類に分けられる
私の経験上、エージェントのループは次の4種類に整理できます。原因ごとに対処が違うので、まずはどれに当たっているかを切り分けることが出発点です。
- A. ツール呼び出しループ: 同じツールを同じ引数で繰り返す
- B. サブエージェント再帰ループ: エージェントが自分と同じ種類のサブエージェントを呼び続ける
- C. 計画の書き直しループ: Plan → 実行 → 失敗 → Plan の書き直し を延々と繰り返す
- D. タスク完了判定ループ: 終了条件が曖昧で「まだやることがある」と判断し続ける
A と D は特に多く、B は設計ミス、C はプロンプト設計の問題が多いです。
A. ツール呼び出しループ — 最も起きやすい型
典型パターンは次です。ツールの戻り値が空、あるいは失敗を示すが、エージェントが「もう一度同じ引数で呼べばいい」と判断してしまうケース。
agent: search_docs(query="auth flow")
-> []
agent: search_docs(query="auth flow") ← 同じ引数で再試行
-> []
agent: search_docs(query="auth flow") ← 無限に続く原因は主に2つあります。まず、ツールの戻り値が曖昧で「何もない」のか「失敗」のかモデルが判別できていありません。次に、エージェントに「同じツールを同じ引数で呼ぶのは禁止」というガードレールがありません。対処はツール側とガードレールの両方から入れます。
対処1: ツールの戻り値を明示的にする
def search_docs(query: str) -> dict:
"""ドキュメント検索。結果の状態を明示的に返す。"""
results = vector_search(query)
if not results:
return {
"status": "empty",
"query": query,
"suggestion": "Try a more specific or different keyword.",
"results": [],
}
return {
"status": "ok",
"results": [{"title": r.title, "excerpt": r.excerpt[:500]} for r in results],
}status: "empty" と suggestion を返すだけで、モデルは「同じクエリでは結果が出ないので変えよう」と判断しやすくなります。「空配列を返して終わり」だと、モデルは「運が悪かっただけだから、もう一度試そう」と解釈しがちです。
対処2: 同一呼び出しのガードレール
エージェント側に「直近 N 回で同じ引数で同じツールを呼んでいたら止める」仕組みを入れます。
from collections import deque
from typing import Any
class ToolCallGuard:
"""直近の呼び出しを記憶し、同一呼び出しの繰り返しを検出する。"""
def __init__(self, window: int = 5, max_repeats: int = 2):
self.window = window
self.max_repeats = max_repeats
self.recent = deque(maxlen=window)
def check(self, tool_name: str, args: dict) -> bool:
"""同一呼び出しの繰り返し検出。繰り返しなら False を返す。"""
key = (tool_name, json.dumps(args, sort_keys=True))
count = sum(1 for k in self.recent if k == key)
if count >= self.max_repeats:
return False
self.recent.append(key)
return True
# 使用例
guard = ToolCallGuard()
def safe_call(tool_name, args, tools):
if not guard.check(tool_name, args):
return {"status": "blocked",
"reason": f"Tool {tool_name} was called with identical args {args} too many times. Please try a different approach."}
return tools[tool_name](**args)ガードがブロックしたときは、モデルに「同じ呼び出しを繰り返しています。別のアプローチを試してください」というメッセージを返します。これだけでループの大半は止まります。
B. サブエージェント再帰ループ
Antigravity の強力さは「サブエージェントを呼べる」点ですが、エージェントが自分と同じタイプのサブエージェントを呼んでしまうと、理論上は無限に深くなります。
対処は2つあります。まず、サブエージェント呼び出しに深さ制限をつける。次に、呼び出し時に「どのタイプのエージェントは呼んではいけないか」を明示します。
class AgentContext:
"""エージェントの実行文脈に深さと呼び出し履歴を持たせる。"""
def __init__(self, max_depth: int = 3, call_chain: tuple = ()):
self.max_depth = max_depth
self.call_chain = call_chain
def can_invoke(self, agent_type: str) -> tuple[bool, str]:
if len(self.call_chain) >= self.max_depth:
return False, f"max agent depth {self.max_depth} reached"
if agent_type in self.call_chain:
return False, f"recursion detected ({agent_type} already in chain)"
return True, ""
def child(self, agent_type: str) -> "AgentContext":
return AgentContext(self.max_depth, self.call_chain + (agent_type,))実装上は「設計者が意図した以上の深さでサブエージェントが動くことはない」状態を作ることが目標です。3段で十分実用的なエージェントは組めます。それ以上の深さが必要なら、分業ではなく共同作業を検討すべきというサインです。
C. 計画書き直しループ
これは「タスクを分解して実行 → うまくいかず → 計画を書き直す」を無限に繰り返すパターンです。プロンプト設計の問題が多く、次のような状況で起きやすいです。
- 計画の評価基準が曖昧で「うまくいった/失敗した」の判断がモデル任せ
- 失敗したサブタスクのエラー情報が次の計画に引き継がれていない
- 「計画を書き直す」オプションが常に選択可能で、明確な撤退条件がない
対処として、私は計画の書き直し回数を明示的にカウントし、上限を設けています。
class PlanController:
"""計画と実行のサイクルを管理し、書き直し上限を守る。"""
def __init__(self, max_replans: int = 2):
self.max_replans = max_replans
self.replan_count = 0
self.failure_history: list[dict] = []
def can_replan(self) -> bool:
return self.replan_count < self.max_replans
def record_failure(self, step: str, error: str):
self.failure_history.append({"step": step, "error": error})
def replan_prompt(self) -> str:
"""書き直し上限に達したら「部分達成で終わる」ことを促す指示を返す。"""
if not self.can_replan():
return ("書き直し上限に達しました。現時点で完了している成果物を元に、"
"未完了部分を明示した上で最終報告を作成してください。")
self.replan_count += 1
past = "\n".join(f"- {h['step']}: {h['error']}" for h in self.failure_history)
return f"以下の失敗を踏まえて計画を改訂してください:\n{past}"「完璧を目指して無限にやり直す」のではなく「2回失敗したら部分達成で締めくくる」という撤退条件を与えるのがポイントです。
D. タスク完了判定ループ
「終わりの条件」が曖昧だと、エージェントは「まだやることがある気がする」と言い続けて終わりません。対処はシンプルで、明示的な完了条件を最初に定義し、そこへ帰結するプロンプトにします。
def build_agent_prompt(task: str, acceptance_criteria: list[str]) -> str:
"""受入基準を明示した上でエージェントプロンプトを組み立てる。"""
criteria_str = "\n".join(f"- {c}" for c in acceptance_criteria)
return f"""タスク: {task}
このタスクは次の条件をすべて満たしたときに完了です:
{criteria_str}
重要:
- 完了条件を満たしたら直ちに report_complete ツールで終了してください
- すべてを完璧にする必要はありません。上記条件を満たせば十分です
- 15 ステップを超えても完了していなければ、現状を report_partial で報告して終了してください
"""acceptance_criteria を明示するだけで「これ以上何をすればよいか分からない」状態を避けられます。また「ステップ数の上限」も実装上のセーフティネットとして入れておきます。
本番で効いている3つのガードレール
設計・プロンプトの調整だけでは完全には防げないので、私は実行層で次の3つを入れています。
- 総ステップ数上限: 1タスクあたり最大 25 ステップ。超えたら強制終了してエラー報告
- 総実行時間上限: 5分を超えたタスクはキャンセル
- 同一ツール呼び出し上限: 同じツールを 8 回以上呼んだら警告を出し、10 回目でブロック
これらをログに残しておくと、ループが発生したときに「どの条件で止まったか」が分かり、原因追跡が早くなります。
よくあるアンチパターン
- 無限に自動リトライする: ネットワークエラーを無限に再試行するコードは、ループの温床です。必ず指数バックオフと上限を設ける
- 同じモデルでジャッジさせる: 「このタスクは完了したか?」の判断を、タスクを実行したのと同じモデルにさせると過信が生じやすい。可能なら別モデル or 構造化チェックでゲート
- エラー情報を握りつぶす: ツール実行エラーを「空文字列」として返すと、モデルは何が起きたか分からずループします。必ずエラーメッセージを構造化して返す
全体を振り返って
エージェントの暴走・ループは、モデルの能力不足ではなく「設計の制約が足りない」ことで起きるケースが大半です。まずは ToolCallGuard を1つ入れて、同じツールが連続して呼ばれていないか観測してみてください。ログを見るだけで、あなたのエージェントが何を何回やっているかが可視化され、そこから改善の打ち手が見えてきます。完璧なエージェントを目指すのではなく、上手に止められるエージェントを目指すのが近道です。