氷河期世代のクラウドエンジニア、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時で閉店です」と決めていないなら、閉店時刻は客の性格で決まります。
しかも困ったことに、賢いモデルほど粘る傾向があります。「指示を守る」という方向に賢いからです。モデルを上位版に差し替えたら請求額が跳ねた、という事故はこの形をしています。
監査② ── 入力トークンは、反復ごとに積み上がります
同じ計測の、リクエストごとの入力トークンです。毎回会話履歴を丸ごと送り直すので、こうなります。
| 反復 | 1 | 2 | 3 | … | 12 | 合計 |
|---|---|---|---|---|---|---|
Qwen3.8 の入力トークン | 337 | 470 | 575 | … | 1,337 | 10,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だったら今いくら溶けてるんだろう」と考えていて、ローカルで測れることのありがたさが身に沁みました。手元に閉じた環境で先に一度回す、というだけの話なんですが、それをやらずに本番へ出したことが無いとは言えません。