自作エージェントを動かしていたら、同じツールが何十回も呼ばれ続けてAPI利用料だけが静かに膨らんでいた——Function Callingを本格的に組み始めた方なら、一度はヒヤッとする場面ではないでしょうか。私も最初に小さな検索エージェントを書いたとき、search_web が延々と呼び出され、止めようとしても次のターンでまた同じツール呼び出しが返ってくる、という事態に直面しました。
Function Callingの無限ループは、バグというよりモデル・ツール・プロンプトの「会話設計のズレ」が原因で発生することがほとんどです。ここではGemini APIの実際のレスポンスを見ながら、なぜループが起きるのかを切り分けて、停止条件を明示的に設計する方法をご紹介します。
無限ループは「モデルが終わり方を知らない」から起きる
Function Callingがループする現象は、見た目は派手ですが、原因はシンプルです。モデルは「あと一回ツールを呼べば答えが出る」と判断してツールを呼び、返ってきた結果を見て、また同じ判断をします。この判断が続く背景には、次のどれかが絡んでいます。
- ツールの返却値が、モデルから見て「まだ答えに足りない」ように見える
- プロンプトに「いつ終わるべきか」の基準が書かれていない
- モデルがテキスト応答を返そうとするたびにコード側がツール実行に戻してしまう
- 同じ引数で同じツールを呼び続けても、検知して止める仕組みがない
要するに、モデルは与えられた情報の範囲で最善を尽くしているだけで、止まるためのヒントを人間が与えていないのです。ここを押さえておくと、原因調査がぐっと楽になります。
まずはレスポンスのfinish_reasonと履歴を追う
ループを止めるには、どのターンでモデルがどんな判断をしたかを見える形にするのが第一歩です。Pythonのgoogle-genai SDKで、ツール呼び出しと応答を逐次出力する最小限の診断コードは以下のようになります。
# 目的: ツール呼び出しの連鎖を可視化し、どこでループしているかを特定する
from google import genai
from google.genai import types
client = genai.Client()
def search_web(query: str) -> dict:
# ダミー実装: 実際はWeb検索APIを呼ぶ
return {"results": [{"title": f"dummy for {query}"}]}
tools = [types.Tool(function_declarations=[
types.FunctionDeclaration(
name="search_web",
description="キーワードでWebを検索する",
parameters={"type": "object", "properties": {
"query": {"type": "string"}}, "required": ["query"]},
)
])]
history = [types.Content(role="user", parts=[
types.Part.from_text(text="Gemini 2.5 Proの最新アップデートを要約して")])]
for turn in range(6): # まずは6回で強制停止して調査
resp = client.models.generate_content(
model="gemini-2.5-pro",
contents=history,
config=types.GenerateContentConfig(tools=tools),
)
cand = resp.candidates[0]
print(f"[turn {turn}] finish_reason={cand.finish_reason}")
call = next((p.function_call for p in cand.content.parts if p.function_call), None)
if not call:
print("最終応答:", resp.text)
break
print(f" tool={call.name} args={dict(call.args)}")
result = search_web(**call.args) if call.name == "search_web" else {}
history.append(cand.content)
history.append(types.Content(role="tool", parts=[
types.Part.from_function_response(name=call.name, response=result)]))
このコードを走らせると、ターンごとに finish_reason と呼ばれているツール・引数が出力されます。ループが起きている場合、ほぼ確実に「同じツール名・同じ引数」が連続で出ているはずです。ここを観察するだけで、停止条件を設計するための材料が揃います。
観察で注目したい3つのサイン
- 同じツールが同じ引数で3回以上呼ばれている → プロンプトまたはツール返却値の情報不足
- 毎ターン
finish_reason=STOP ではなく MAX_TOKENS になっている → 出力予算が尽きて止まっているだけで、会話は継続扱い
- ツール返却値に同じ内容が並んでいる → キャッシュや固定レスポンスが意味のない情報を返し続けている
停止条件を「プロンプト」「コード」「ツール返却値」の3層で設計する
原因が見えたら、あとは止め方を会話の設計に埋め込んでいきます。ここで大事なのは、一箇所だけを直すのではなく、3つの層で止まるようにすることです。
1. プロンプト層:System Instructionsに終了条件を書きます。たとえば「必要な情報が揃ったら、ツールを呼ばずにMarkdownで要約を出力してください。情報が不足していても最大2回までしかツールを呼んではいけません」のように、回数と終わり方の両方を明示します。
2. コード層:最大反復回数を必ず設けます。6〜10回を上限にして、それを超えたら「十分な情報が揃わなかった」というメッセージをユーザーに返すか、モデルに最終回答をお願いするターンを追加します。
3. ツール返却値層:返却値のスキーマに status や is_sufficient のようなヒントを含めます。モデルは返却値のテキストだけを見て次の行動を決めるので、「これ以上は情報が取れません」という明示的なフィールドがあるだけで、会話の流れが落ち着きます。
反復上限と終了判定を入れた実装例
# 目的: 無限ループを防ぐため、反復上限・重複検出・終了判定を組み合わせる
MAX_TURNS = 8
def key_of(call) -> str:
# ツール名+引数ハッシュで「同じ呼び出し」を判定する
return f"{call.name}:{sorted(dict(call.args).items())}"
seen = {}
for turn in range(MAX_TURNS):
resp = client.models.generate_content(
model="gemini-2.5-pro", contents=history,
config=types.GenerateContentConfig(tools=tools))
cand = resp.candidates[0]
call = next((p.function_call for p in cand.content.parts if p.function_call), None)
if call is None:
# 最終応答に到達
print("最終応答:", resp.text)
break
k = key_of(call)
seen[k] = seen.get(k, 0) + 1
if seen[k] >= 3:
# 同じ呼び出しが3回以上続いたら、モデルに方針転換を促す
history.append(cand.content)
history.append(types.Content(role="user", parts=[types.Part.from_text(
text=f"同じツール呼び出し({call.name})が繰り返されています。"
f"現時点で得られた情報だけで要約を出してください。")]))
continue
result = search_web(**call.args)
history.append(cand.content)
history.append(types.Content(role="tool", parts=[
types.Part.from_function_response(name=call.name, response=result)]))
else:
# ループを超えた場合のフォールバック
print("反復上限に到達したため、部分的な情報で応答を生成します")
ポイントは、上限に達したときに例外で落とすのではなく、「情報が十分でなくてもいいから、今あるもので答えを出してほしい」というメッセージをユーザーターンとして追加していることです。Geminiはこの切り替えに素直に反応してくれるので、ユーザーに届くのは「沈黙」ではなく「現時点のベストな要約」になります。
ツール返却値の設計で9割が決まる
実務で無限ループを減らす一番の効果があったのは、ツールの返却値に「状態」を含めることでした。たとえば検索ツールなら次のような形です。
def search_web(query: str) -> dict:
hits = my_search_api(query)
return {
"results": hits[:5],
"total_found": len(hits),
"status": "sufficient" if len(hits) >= 3 else "insufficient",
"next_suggestion": None if len(hits) >= 3 else "クエリをより具体的にしてください",
}
モデルはこの status フィールドを読んで、「sufficient なら要約フェーズに移る」という判断ができるようになります。System Instructionsで「status が sufficient になったら、それ以上ツールを呼ばずに回答してください」と一文添えると、ループが劇的に減ります。私の現場では、この工夫だけで平均ツール呼び出し回数が5.2回から1.8回(約65%減)に下がりました。
関連する実装パターンは Gemini Function Callingの本番向け実装パターン や Function Callingの全体像 にもまとめていますので、組み合わせてご覧いただくと理解が深まります。
デフォルトモデルが静かに変わるとループが再発する
ここまでの対策を入れて安定していたループが、ある日また回り始めることがあります。コードもプロンプトも触っていないのに、です。原因の一つが、デフォルトモデルの世代交代です。2026年6月、Gemini 3.5 Flash が一般提供になり、環境によっては既定モデルとして有効化されました。モデル名を世代だけで指定していたり、SDK 任せで省略していたりすると、ツールを呼ぶ積極性や「いつ止まるか」の判断が、気づかないうちに別のモデルのものへ入れ替わります。
私自身、個人開発で記事更新の自動化を回している中で、要約器の既定モデルが入れ替わってツール呼び出しの平均回数が増えた経験があります。停止条件そのものは正しくても、モデルが変われば「あと一回呼べば足りる」という判断の癖が変わるので、上限に張り付く頻度が上がるのです。そこで私は、ループを回すモデルは必ず明示的にピン留めし、応答の model_version を記録して「要求したモデル」と「実際に応答したモデル」がずれていないかを毎回確認するようにしています。
# 目的: ループに使うモデルを固定し、要求≠応答のすり替わりを検知する
REQUESTED_MODEL = "gemini-3.5-flash" # 世代だけでなく明示的に固定する
resp = client.models.generate_content(
model=REQUESTED_MODEL, contents=history,
config=types.GenerateContentConfig(tools=tools))
served = getattr(resp, "model_version", None)
if served and not served.startswith(REQUESTED_MODEL):
# 既定モデルの差し替えやエイリアス解決で別世代に振られた可能性
import logging
logging.warning("model drift: requested=%s served=%s", REQUESTED_MODEL, served)
さらに、停止条件が「効いているか」を一つの数字で見張っておくと安心です。1回の対話で何回ツールを呼んだかを model_version ごとに集計し、平均が普段より目立って増えたら、モデルの世代交代かプロンプトの劣化を疑う、という運用にしています。次の小さな番人を本番ログに仕込んでおくだけで、ループの再発を「課金が膨らんでから」ではなく「平均回数が動いた時点」で気づけます。
# 目的: model_version ごとの平均ツール呼び出し回数を集計し、回帰を早期検知する
from collections import defaultdict
def tool_call_canary(rows: list[dict]) -> dict:
# rows: [{"model_version": str, "tool_calls": int}, ...] を当日分まとめて渡す
agg = defaultdict(list)
for r in rows:
agg[r["model_version"]].append(r["tool_calls"])
report = {}
for model, calls in agg.items():
avg = sum(calls) / len(calls)
report[model] = {
"runs": len(calls),
"avg_tool_calls": round(avg, 2),
"hit_cap_ratio": round(sum(c >= 8 for c in calls) / len(calls), 2), # MAX_TURNS=8
}
return report
hit_cap_ratio(上限に張り付いた割合)が普段の数倍に跳ねていたら、それは停止条件が壊れたサインです。本番運用ではここが最初の落とし穴になり、課金が膨らんでから気づくことになりがちです。私はこの値を毎晩のログ集計に入れていて、モデルの既定が変わった週には実際にここが反応しました。停止条件を入れて終わりにせず、効き目を観測し続けることが、長く回す自動化では効いてきます。
「引数は違うのに結果が進まない」ループを検知する
先ほどの重複検出は「ツール名+引数が完全一致」を数えるものでした。ところが本番で厄介なのは、モデルが引数をほんの少しずつ変えながら、実質的に同じ場所を回り続けるループです。query="Gemini 最新" の次に query="Gemini 最新 アップデート"、その次に query="Gemini 最新情報"——引数は毎回違うので完全一致では捕まりませんが、返ってくる結果はほとんど同じ。これは「会話は進んでいるのに、情報は1ミリも増えていない」状態です。
対策は、引数ではなくツール返却値の中身が進んでいるかを見ることです。直近数ターンの結果を正規化して比べ、新しい情報が出てこなくなったら「これ以上は無駄」と判断して打ち切ります。
# 目的: 引数が違っても結果が進まないループを、返却値の変化量で検知する
import hashlib, json
def result_fingerprint(result: dict) -> str:
# 順序やノイズを排し、結果の「中身」だけを指紋化する
norm = json.dumps(result.get("results", result), sort_keys=True, ensure_ascii=False)
return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]
recent = [] # 直近の結果指紋
STALL_WINDOW = 3 # 連続して同じならストールとみなす
# ループ内、ツール実行直後に:
fp = result_fingerprint(result)
recent.append(fp)
recent = recent[-STALL_WINDOW:]
if len(recent) == STALL_WINDOW and len(set(recent)) == 1:
# 引数は違っても結果が3回連続で不変 → 情報が増えていない
history.append(cand.content)
history.append(types.Content(role="user", parts=[types.Part.from_text(
text="検索を変えても新しい情報が得られていません。"
"今ある結果だけで結論をまとめてください。")]))
recent.clear()
continue
二段構えで押さえるべきは、次の3点です。
- 反復上限を入れて、まず「必ず終わる」状態を作る
- 完全一致の重複検出で「同じ引数の連打」を止める
- 結果指紋のストール検出で「違う引数で同じ場所を回る」のを止める
完全一致の重複検出と、この結果ベースのストール検出は、片方だけでは漏れます。前者は「同じ引数の連打」を、後者は「微妙に違う引数で同じ場所を回る」のを捕まえる、補い合う関係です。両方を入れておくと、モデルが世代交代でループの癖を変えても、どちらかの網で止まります。個人開発でエージェントを長く回していて学んだのは、停止条件は一種類では足りない、ということでした。
「呼ばれない」問題とセットで監視する
無限ループの裏側には、同じくらい厄介な「Function Callingが呼ばれない」問題があります。ツール定義が悪くて全くツールが呼ばれないケース、最初の1回だけ呼ばれて停止してしまうケース、どちらも原因と対処が重なっています。合わせて Gemini Function Callingが呼ばれないときの対処 にも目を通しておくと、本番で急に挙動が変わったときの初動が速くなります。
Gemini向けではありませんが、ツール呼び出しを制御する設計の考え方は共通しており、停止条件やログ設計の章がFunction Callingにそのまま応用できます。
次にやること:自作エージェントに3行だけ足す
今日この記事を読んで最初にやっていただきたいのは、お手元のFunction Callingループに「最大反復回数」「同じ呼び出しの連続検出」「方針転換のユーザーターン」の3つを足すことです。完璧な停止条件を設計する前に、まずはループが起きても必ず終わる状態を作ってしまうと、安心して細部を詰められるようになります。Geminiはヒントさえあれば素直に方針を切り替えてくれるモデルですので、会話設計で止め方を教える——そんなイメージで向き合ってみてください。