LLM SDK

while True の中でAIが喋り続けます ── ツール実行ループを読む

生成AIに function calling を書かせると、ほぼ確実に while True で始まるループが出てきます。同じコード・同じツールで、モデルを変えただけで4回で終わったり12回でも終わらなかったりしました。止まる保証がどこにも無いことを、実測の反復回数とトークンで見ます。

  • LLM SDK
  • tools
  • tool_choice
  • tool_calls
  • role=tool

ここを見れば気づける

  • while True に反復上限が無い(実測: モデルを変えただけで上限12回に到達し未完了・11,011トークンを消費)
  • ツールのエラー応答が「再試行して」だけだと、モデルは再試行し続ける
  • tool_choice="required" が提供側に無視されても、エラーにならない
  • ツール関数の例外を捕まえていないと、モデルに伝わる前にループごと落ちる

証拠の出し方:同じループ・同じツールでモデルとツール応答の中身だけを変え、反復回数・入力トークンの伸び方・完了したかどうかを並べる

氷河期世代のクラウドエンジニア、terralien です。AIエージェントを作りたいという相談を受けると、たいてい最初に出てくるコードが「function calling の実行ループ」です。

そして、そのコードはほぼ確実に while True: で始まります。止める条件が、モデルの気分以外にどこにも無い状態です。

数字はすべて手元の実測です。MacBook Pro(Apple M5 Pro / 64GB / macOS 26.6.1)+ Ollama 0.32.14、モデルは gemma4:latest(8.0B)と Qwen3.8:27b(27.3B)の2つ。クライアントは Python 3.11.2 + openai 3.6.0。2026-08-29 に計測しました。

用途 ── ツール呼び出しは「関数を実行する機能」ではありません

まずここです。tools を渡しても、API は関数を1つも実行しません。返ってくるのは「この関数を、この引数で呼んでほしい」というお願いです。

段階誰がやるか
どの関数を呼ぶか決めるモデル
引数を組み立てる(JSON 文字列)モデル
関数を実際に実行するあなたのコード
実行結果をモデルに戻すあなたのコード(role: "tool" のメッセージ)
結果を読んで次を決める/答えるモデル

だから「ツールを呼ばせる」は1回の API 呼び出しでは終わらず、必ずループになります。そしてループになるということは、終了条件が要ります。ここが今日の話の全部です。

代表要素 ── 3つ

要素何が返る/何をする罠
tools=[...]使える関数の一覧を JSON Schema で渡すdescription が薄いと、モデルは呼ぶ判断を間違える。説明文が実質のプロンプト
message.tool_calls呼んでほしい関数と引数のリスト。複数同時に返ることがあるarguments は辞書ではなくJSON 文字列。json.loads() が要る。中身がスキーマどおりとは限らない
{"role": "tool", "tool_call_id": ..., "content": ...}実行結果をモデルに戻すcontent は文字列だけ。ここに何を書くかで、ループが止まるか止まらないかが決まる(後述)

tool_choice もありますが、これは後で「効かないことがある」話として出てきます。

AIが書く例 ── 実際に書かせたもの

手元の Qwen3.8:27b に「function calling で天気ツールを使わせる実行ループを書いて」と頼みました。返ってきたループ部分がこれです(一字も直していません)。

def run_agent(user_input: str) -> str:
    messages = [
        {"role": "system", "content": "You are a helpful assistant that can check the weather."},
        {"role": "user", "content": user_input}
    ]

    while True:
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=messages,
            tools=tools
        )
        message = response.choices[0].message

        if message.tool_calls:
            for tool_call in message.tool_calls:
                function_name = tool_call.function.name
                arguments = tool_call.function.arguments

                if function_name == "get_weather":
                    import json
                    args = json.loads(arguments)
                    result = get_weather(args["city"])
                else:
                    result = "Unknown function"

                messages.append(message)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result
                })
        else:
            return message.content

きちんと動きます。「東京の天気は?」と聞けば、ちゃんと答えが返ってきます。

読むところは3つです。while True: に上限が無い。get_weather() に try が無い。messages.append(message) が for の中に入っている。

監査① ── while True: が止まるかどうかは、モデル次第です

上限の無いループがどうなるか、実際に走らせました。

題材は「月次レポートの売上を教えて」。ツールは {"status": "pending", "message": "まだ生成中です。しばらくしてからもう一度呼び出してください"} を必ず返します。永久に完成しないレポートです。

システムプロンプトは、エージェントで普通に書く内容にしました。

回答は必ずツールから取得した実データに基づいてください。推測で答えてはいけません。
ツールがまだ結果を返していない場合は、結果が得られるまでツールを呼び直してください。
モデル反復回数完了したか消費トークン
gemma4(8B)4✅ 諦めてユーザーに報告1,948
Qwen3.8(27B)12(打ち切り)❌ まだ呼び続けていた11,011

コードは1行も変えていません。ツールも同じ、質問も同じです。モデルを差し替えただけで、片方は止まり、片方は止まりませんでした。

12 という数字は、こちらが while steps < 12 を書いたから出た数字です。AIが書いた while True: のままなら、この行は表に現れません。止めたのは上限であって、モデルの良識ではありません。

「席が空くまで待ってください」と言われて待合室に座る客が、5分おきに受付へ聞きに行く状況です。良識のある客は3回目で帰ります。そうでない客もいます。店側が「20時で閉店です」と決めていないなら、閉店時刻は客の性格で決まります。

しかも困ったことに、賢いモデルほど粘る傾向があります。「指示を守る」という方向に賢いからです。モデルを上位版に差し替えたら請求額が跳ねた、という事故はこの形をしています。

監査② ── 入力トークンは、反復ごとに積み上がります

同じ計測の、リクエストごとの入力トークンです。毎回会話履歴を丸ごと送り直すので、こうなります。

反復123…12合計
Qwen3.8 の入力トークン337470575…1,33710,395

1回あたり 84 トークンほどしか増えていないのに、合計は 10,395 です。毎回全部送るので、n 回目までの累計は n の2乗に比例して伸びます。反復が2倍になれば、料金はおよそ4倍になります。

そして今回、この 10,395 トークンから得られた成果物はゼロです。答えは1文字も出ていません。

「上限を 50 くらいにしておけば安心」と書きたくなるところですが、費用は反復回数の2乗で効きます。上限50は上限12の約17倍の入力トークンを許すという意味です。上限は「安全のための大きな数字」ではなく、予算そのものとして決めます。

監査③ ── ループを止めるのは、ツールが返す文章です

では、上限を付ける以外に打つ手はあるのか。同じモデル・同じコードのまま、ツールが返す JSON の中身だけを変えました。

ツールが返した内容反復完了消費トークン
{"status":"pending", "message":"...もう一度呼び出してください"}12(打ち切り)❌11,011
{"status":"failed", "retryable":false, "message":"...再試行しても結果は変わりません。ユーザーにその旨を伝えて終了してください"}2✅1,030

10.7分の1になりました。変えたのはツール関数の return の中身だけです。

role: "tool" の content は、ログではなくモデルへの指示です。ここに「エラーが発生しました」とだけ書くと、モデルは「では条件を変えてもう一度」と判断する材料しか持ちません。

ツールのエラー応答モデルの次の一手
"エラー"情報が無いので、同じ呼び出しを試す
{"error": "timeout"}一時的だと読める。再試行する
{"error":"unknown_city", "available":["東京","大阪"]}引数を直して呼び直せる。1回で収束する
{"status":"failed", "retryable":false, "message":"...ユーザーに伝えて終了"}呼ぶのをやめて報告する

retryable に相当する情報を返しているかどうか。ここがツール実装側の監査ポイントです。

監査④ ── 効いていない安全装置に気づく

「上限の代わりに tool_choice で制御する」という話も出ます。手元で tool_choice="required" を付けて測ったところ、2回目の呼び出しはツールを呼ばずに最終回答を返しました。つまりこの組み合わせでは設定が効いていません。エラーも警告も出ませんでした。

tool_choice に限らず、LLM の API は知らないパラメータを黙って捨てることがあります。手元で通ったから効いている、とは言えません。効いていることを確かめる方法は1つで、効いていたら変わるはずの数字を見ることです(今回なら「ツールを呼ばない応答が返ってくるか」)。

同じ話がもう1つあります。AIが書いたコードの messages.append(message) は for tool_call in ...: の内側にあります。ツールが2つ同時に呼ばれると、assistant メッセージが2回積まれます。並列で2件呼ばれる質問を投げて確かめたところ、手元の Ollama は 200 を返して普通に動きました。

壊れなかったことを報告しておきますが、これは「問題ない」という意味ではありません。メッセージ列の厳しさは提供側ごとに違うので、手元で通ったことが移行先で通る根拠にならない、というだけです。正しい形は、for の外で1回だけ積むことです。

messages.append(message)                    # assistant は1回だけ
for tool_call in message.tool_calls:        # tool 結果は呼び出しの数だけ
    try:
        result = dispatch(tool_call)
    except Exception as e:                  # ★例外をモデルに返す
        result = json.dumps({"status": "failed", "retryable": False,
                             "message": f"{type(e).__name__}: {e}"}, ensure_ascii=False)
    messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": result})

try が要る理由も同じです。get_weather() が例外を投げると、AIが書いたコードではループごと落ちます。モデルは何が起きたか永久に知らないままで、リトライも代替案も出せません。ツールの失敗は、Python の例外ではなくモデルへのメッセージに変換するのが、この構造での正しい扱い方です。

隣で読むもの

記事ここと繋がるところ
JSON で返ってきたから正しい、ではありませんツールの引数は JSON 文字列で返ります。検証しないと同じ穴に落ちます
モデル名の打ち間違いが「接続エラー」で返ってきます「粘るモデル」に差し替えたときの挙動差は、乗り換えの話とセットです
Bedrock AgentCore(AIP-C01 ノート)このループをマネージドで持つとどうなるか

チートシート

見たもの疑うこと直し方
while True:止める条件がモデル任せfor _ in range(MAX_STEPS) にして、超えたら例外
上限が 50 など大きい費用は反復の2乗で効く上限は予算として決める(実測 12回で11,011トークン)
ツール関数に try が無い例外でループごと落ちる捕まえて role: "tool" で返す
エラー応答が "エラー" だけモデルは再試行しか選べないretryable と次の一手を書く
json.loads(arguments) に try が無い引数が壊れていると落ちる捕まえてモデルに返す
messages.append(message) が for の中並列呼び出しで重複するfor の外で1回だけ
tool_choice を設定して安心している黙って無視されることがある効いていたら変わる数字で確かめる
モデルを上位版に差し替えた粘るモデルほど反復が伸びる上限とトークン消費を再計測する
読めるようになったか、ひとつだけ確認

Q. 「手元でエージェントを動かしたら3往復で終わったので、上限は要りません」は妥当ですか?

妥当ではありません。実測では、まったく同じコードとツールで、gemma4 は4回で終わり、Qwen3.8 は12回でも終わりませんでした。反復回数はコードではなくモデルとツール応答が決めます。上限は「終わらなかったとき」のためにあるので、終わった観測は根拠になりません。

ちなみにこの記事のために、ずっと pending を返し続けるツールをわざと書きました。実行中ずっと「これ、課金が発生する本番のAPIだったら今いくら溶けてるんだろう」と考えていて、ローカルで測れることのありがたさが身に沁みました。手元に閉じた環境で先に一度回す、というだけの話なんですが、それをやらずに本番へ出したことが無いとは言えません。

出典