毎晩2時、LangChainエージェントに外部API照会をさせる夜間バッチを動かしています。ある朝、ログには「Recursion limit of 25 reached」の1行だけ。タスク120件のうち完了は0件でした。エージェントは失敗したTool呼び出しを、引数を少し変えながら何度も繰り返し続け、25回という上限で力尽きていたのです。この記事では、同じ事故を最小コードで再現した上で、①同一呼び出しガード ②final_answerの強制 ③recursion_limit固定の3つのガードで断つ、コピペで動く完全な実装を書きます。
何が起きたか: 25回のTool呼び出しと「Recursion limit of 25 reached」
| 時刻 | 起きたこと |
|---|---|
| 02:00 | 夜間バッチ開始。タスク120件をエージェントへ投入 |
| 02:04 | 1件目で照会APIが404エラーを返す |
| 02:04〜02:21 | エージェントが似た引数で同じToolを呼び続ける(推論ステップごとに課金が消化される) |
| 02:21 | GraphRecursionError「Recursion limit of 25 reached」で1件目が停止 |
| 06:00 | 残り117件が未着手のまま朝を迎え、バッチ全体が未完了 |
エージェントの設計どおり「答えが得られるまでToolを呼ぶ」こと自体は正しい動きです。事故の根っこは、失敗が確定している呼び出しを自動で諦める仕組みがどこにもなかったことでした。
失敗した設計: エラー文字列を返すだけのTool
事故のエージェントでは、3つの条件が揃っていました。
-
Toolが失敗を文字列で返す: 照会ツールは見つからない対象に
"ERROR: not found"を返すだけ。LLMから見ると「引数を直せばまだいける」という挑戦文にしか読めない - 終了条件がプロンプトにない: 「不明なら調査を終える」という1行がなかったので、エージェントには「諦める」という選択肢が存在しない
- ストッパーがrecursion_limit(既定25)だけ: 最後の安全網しかない設計では、安全網を厚くするか、ループの元を断つしかない
先に結論: 3つのガードの使い分け
| ガード | 防げる事故 | 副作用 | 実装コスト |
|---|---|---|---|
| ① 同一呼び出しガード(引数ハッシュ) | 同じToolを連呼して、課金だけ増えたまま停止する | ガード文面がコンテキストに数行乗る | Tool1本あたり2行 |
| ② final_answer Tool+終了手順 | 終了条件がなく彷徨い続ける | ほぼゼロ | プロンプト2行+Tool1本 |
| ③ recursion_limitは25固定+例外の握りつぶし | どんな原因でも最大25回で必ず停止 | fallback回答の品質がそのまま体験になる | try/except 5行 |
recursion_limitを上げるのは対策ではありません。 上限を25→100にすると、ループの原因が残ったまま最大100回まで回り続けます(後述の失敗パターン)。
コピペで動く完全なコード(1ファイル)
依存のインストール:
pip install -U langgraph langchain-openai
export OPENAI_API_KEY=sk-...
python3 agent_recursion_guard.py
agent_recursion_guard.py の中身(1ファイルで完結します):
# LangGraphエージェントの無限ループを3つのガードで断つ
import json
import hashlib
from typing import Dict
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
from langgraph.errors import GraphRecursionError
# ── ガード①: 同一呼び出し検出 ─────────────────────
# キーは「Tool名 + 正規化した引数」のハッシュ。
# 正規化(lower / sort_keys)をしないと、引数の表記ゆれで
# 同じ呼び出しが別ハッシュになり、ガードが効かなくなる。
_CALL_LOG: Dict[str, int] = {}
def _repeat_guard(name: str, args: dict, max_repeat: int = 1):
"""同一呼び出しを検出したらLLMへの指針文を返す。未検出なら None。"""
key = name + ":" + json.dumps(args, ensure_ascii=False, sort_keys=True).lower()
h = hashlib.sha256(key.encode()).hexdigest()[:16]
_CALL_LOG[h] = _CALL_LOG.get(h, 0) + 1
if _CALL_LOG[h] > max_repeat:
return (
f"[GUARD] {name} を同一引数で{_CALL_LOG[h]}回呼びました。"
"同じ呼び出しから新しい情報は得られません。"
"調査できない点は「不明」と明記し、final_answerで終了してください。"
)
return None
# ── Tool群 ────────────────────────────────────────
@tool
def weather(city: str) -> str:
"""指定した都市の現在の天気を返す。実在しない都市はエラー文字列になる。"""
g = _repeat_guard("weather", {"city": city})
if g:
return g
if city == "nowhere": # デモ用: 存在しない都市が来たらエラーを返す
return "ERROR: city not found"
return f"{city}: 晴れ、気温12度、降水確率10%"
@tool
def final_answer(answer: str) -> str:
"""調査を終え、ユーザーへの最終回答を返す。これを呼んだら他のToolは二度と呼ばない。"""
return answer
SYSTEM = (
"あなたは天気アシスタント。手順は必ず3ステップまで。"
"1) weatherで都市を確認 2) 結果を要約 3) final_answerで回答して終了。"
"同じToolを同じ引数で二度呼ぶことは禁止。"
"エラーが返ったら、分かった範囲の回答をfinal_answerで報告する。"
)
# ── ガード③: 再実行しないfallback ────────────────
def run(question: str, recursion_limit: int = 25) -> str:
"""1発で終わらせる。GraphRecursionErrorは握りつぶし、再実行しない。"""
agent = create_react_agent(
ChatOpenAI(model="gpt-4o-mini", temperature=0),
tools=[weather, final_answer],
prompt=SYSTEM,
)
try:
result = agent.invoke(
{"messages": [("user", question)]},
config={"recursion_limit": recursion_limit},
)
return result["messages"][-1].content
except GraphRecursionError as e:
return (
f"[FALLBACK] ループ上限に到達({type(e).__name__})。"
"手動確認へエスカレーションしてください。質問: " + question
)
if __name__ == "__main__":
_CALL_LOG.clear() # バッチで使い回すときはタスク開始ごとに必ず1回
print(run("nowhere の天気を教えて"))
print("ガード対象Tool(weather)の呼び出し回数:", sum(_CALL_LOG.values()))
動かすと、1回目の weather("nowhere") はエラーを返しますが、2回目の同一引数呼び出しでガードが介入し、エージェントは final_answer に分岐して終了します(実測の一例です。LLMの挙動により細部は前後します):
nowhere の天気は調査できませんでした。実在する都市名をご指定ください。
ガード対象Tool(weather)の呼び出し回数: 2
事故そのものを手元で再現したい場合は、weather 内の if g: return g をコメントアウトしてください。ガードなしで「Recursion limit of 25 reached」がそのまま発生します。コストを抑えたい実験では run(question, recursion_limit=8) を使ってください。
動作確認環境は langgraph 0.2系・langchain-openai 0.2系です。旧バージョンの create_react_agent はプロンプト引数の名前が state_modifier なので、エラーが出たら pip で最新へ上げてください。
同一呼び出しガードの設計メモ(4点)
- ハッシュ対象は「Tool名+正規化した引数」。表記ゆれ(大小文字・余分な空白)は
.lower()とsort_keysで吸収する -
_CALL_LOGはプロセス内で共有するグローバル。夜間バッチで使い回すときは、タスクの開始時に_CALL_LOG.clear()を呼ぶ(前タスクの履歴で誤発動しない) -
max_repeatは目的別に変える。実照会系なら1、APIの完了待ちなどのポーリング系なら3〜5まで緩める - ガード発動時は、ただのエラーではなく「次に何をすべきか」を文面でLLMへ返す。エラー文のまま返すと、今度はそれ自体がリトライの火種になる
失敗パターン: recursion_limitを100に上げて、課金だけ3〜4倍
私は最初、原因分析に時間を取られるのが嫌で、config={"recursion_limit": 100} に上げました。結果は最悪です。ループの原因が消えていないので、同じタスクが最大100回まで回り続けるようになり、タスク640本のバッチなら最悪で6.4万回のTool呼び出しに膨れ上がりました。「止まる回数」を増やすことは「止まる条件」を増やすのではありません。この変更は数時間で取り消し、上限を既定の25に戻して、代わりにガード①と②を入れました。
まとめ
自分のエージェントに、次の順番で3つ入れてください。
- 照会系のToolに同一呼び出しガードを足す(引数ハッシュは
sort_keys+正規化で作る) - system promptに**「同じToolを同じ引数で二度呼ぶな」「失敗したらfinal_answerで報告しろ」**の2行を書く
-
recursion_limitは25固定のまま、GraphRecursionErrorは再実行せず握りつぶし、手動確認へエスカレーションする
最後に注意をひとつ。ガードは「同じことを繰り返すな」という人間の習慣をコードにしたものですが、逆に意図した再試行(APIの結果待ち、レート制限明けの再送)も阻害します。ポーリング系のToolは max_repeat を個別に緩めてください。
今日から使えるチェックリスト(保存版)
| 症状 | 最初に疑う原因 | 最初に打つ手 |
|---|---|---|
| Recursion limit of 25 reached で朝まで止まっていた | 同一呼び出しの連打 or 終了条件なし | ①を入れ、プロンプトに3ステップ手順を書く |
| Toolの呼び出し回数だけが増えて回答が来ない | エラー文字列をリトライ指示として解釈 | Toolのエラー応答に「次の指針」を1行添える |
| ガードを入れたのに止まらない | 引数の表記ゆれでハッシュが別物に | 正規化(lower / sort_keys)が効いているか確認する |
| 止めたはずのタスクが夜間に再発する | 例外後の再実行が無限に走っている | ③で握りつぶし、次タスクへ進める |
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
関連記事(同テーマ)
- LangChainの構造化出力がnullを返して夜間バッチが全滅した — OutputParserExceptionを防ぐ3つのガードと完全実装
- LangChainエージェント入門 — AIが自律的にツールを選ぶ仕組み
📗 LangChain × AI実践入門 — Pythonで構築するAIアプリケーション(Kindle・Kindle Unlimited読み放題対象)