ツール呼び出し(Tool Calling)を実装する:4ステップの往復
ツール呼び出しでいちばん誤解されているのは、「モデルが関数を実行してくれる」という点です。 モデルは実行しません。モデルがするのは「この関数を、この引数で呼んでほしい」というJSONを返すことだけで、実行するのは自分のコードです。
この構造さえ掴めば実装は単純な往復になります。この記事では、動くコードで4ステップのループを組み立てます。概念的な説明はFunction callingとはにあるので、ここでは実装に集中します。
4ステップの往復
1. tools を付けてリクエスト
↓
2. モデルが tool_calls を返す(finish_reason: "tool_calls")
↓
3. 自分のコードで関数を実行
↓
4. 結果を role: "tool" で返し、もう一度リクエスト → 最終的な文章が返る
ポイントは、2回リクエストを送ることです。1回目でモデルに「何を呼びたいか」を聞き、2回目で「結果を踏まえた答え」をもらいます。
ステップ1:ツールを定義する
tools にJSON Schemaで関数を宣言します。
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "指定した都市の現在の天気を取得する",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "都市名"},
},
"required": ["city"],
},
},
}
]
description は飾りではありません。モデルは説明文を読んで呼ぶかどうかを判断します。「いつ使う関数なのか」が伝わる文にしてください。引数の description も同じで、ここが曖昧だと引数の取り違えが起きます。
ステップ2:tool_callsを受け取る
from openai import OpenAI
client = OpenAI(
api_key="<FASTMETAL_API_KEY>",
base_url="https://api.fastmetal.ai/v1",
)
messages = [{"role": "user", "content": "東京の天気を調べて"}]
resp = client.chat.completions.create(
model="gemini-flash-lite-free",
max_tokens=300,
messages=messages,
tools=tools,
)
print(resp.choices[0].finish_reason)
print(resp.choices[0].message.tool_calls)
返ってくるのはこの形です(2026年8月29日に実測した応答から、要点を抜粋)。
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "call_247545",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"東京\"}"
}
}
]
}
}
見るべきは3つです。
finish_reasonがtool_calls— これがツールを呼びたいという合図です。stopならモデルはそのまま答えているので、ツールの出番はありませんargumentsは文字列 — オブジェクトではなくJSON文字列なので、json.loads()でパースしますid— ステップ4で結果を返すときに、このidで対応付けます
ステップ3:自分のコードで実行する
ここはただのディスパッチです。
import json
def get_weather(city: str) -> dict:
# 実際には天気APIを呼ぶ
return {"city": city, "temp_c": 28, "condition": "晴れ"}
AVAILABLE = {"get_weather": get_weather}
モデルが返した関数名と引数をそのまま信用しないでください。 存在しない関数名が返ることも、必須の引数が欠けることもあります。呼び出す前に名前が既知かを確認し、引数を検証してください。これはプロンプトインジェクション対策としても重要です(関連記事)。
ステップ4:結果を返して、もう一度呼ぶ
assistant のメッセージ(tool_calls を含むもの)を履歴に積んでから、role: "tool" で結果を返します。
msg = resp.choices[0].message
if msg.tool_calls:
messages.append(msg) # tool_calls を含む assistant メッセージ
for call in msg.tool_calls:
fn = AVAILABLE.get(call.function.name)
if fn is None:
result = {"error": f"unknown function: {call.function.name}"}
else:
args = json.loads(call.function.arguments)
result = fn(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id, # ← 1回目の id と一致させる
"content": json.dumps(result, ensure_ascii=False),
})
final = client.chat.completions.create(
model="gemini-flash-lite-free",
max_tokens=300,
messages=messages,
tools=tools,
)
print(final.choices[0].message.content)
tool_call_id の対応付けを間違えると、モデルはどの結果がどの呼び出しのものか分からなくなります。tool_calls が複数返ることもあるので、必ずループで全件返してください。1件でも欠けるとエラーになるモデルがあります。
ループにまとめる
実務では、モデルがツールを呼ばなくなるまで繰り返します。
def run(user_message: str, model: str = "gemini-flash-lite-free", max_turns: int = 5):
messages = [{"role": "user", "content": user_message}]
for _ in range(max_turns):
resp = client.chat.completions.create(
model=model, max_tokens=500, messages=messages, tools=tools,
)
msg = resp.choices[0].message
if not msg.tool_calls:
return msg.content
messages.append(msg)
for call in msg.tool_calls:
fn = AVAILABLE.get(call.function.name)
result = fn(**json.loads(call.function.arguments)) if fn else {"error": "unknown"}
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
return "ツール呼び出しが上限に達しました"
max_turns は必ず入れてください。 ツールが期待した結果を返さないと、モデルが同じ呼び出しを繰り返して止まらなくなることがあります。上限がないと課金が延々と積み上がります。
このループは model を差し替えるだけで別のモデルでも動きます。それがOpenAI互換のゲートウェイを挟む利点です。どのモデルがツール呼び出しに対応しているかはモデルカタログで確認できます。
ツール定義もトークンを消費します
見落とされがちですが、tools はプロンプトの一部として毎回送られます。
上の get_weather(引数1つ)で実測すると、同じ質問文に対して次の差が出ました(2026年8月29日、gemini-flash-lite-free)。
| リクエスト | prompt_tokens |
|---|---|
tools なし | 6 |
tools あり(関数1つ) | 59 |
ツール定義1つで +53トークン です。関数が10個あれば数百トークンが毎リクエスト乗り、ループが5往復すればそのぶん掛け算になります。
対策は単純で、その場面で使い得るツールだけを渡すことです。全ツールを常に渡す実装は、動きはしますがコストが線形に増えます。コスト全般の考え方はLLM APIのコストを下げる7つの方法にまとめています。
つまずきやすい点
arguments をオブジェクトとして扱ってしまう
JSON文字列です。json.loads() が必要です。
assistant メッセージを履歴に積み忘れる
role: "tool" を返す前に、tool_calls を含む assistant メッセージを履歴に入れる必要があります。これを飛ばすと、モデルから見て「呼んでいない関数の結果」が届くことになります。
tool_call_id を返していない
必須です。複数呼び出しの対応付けができなくなります。
上限を付けずにループさせる
無限ループは残高が尽きるまで止まりません。max_turns を必ず入れてください。
引数を検証していない モデルの出力はユーザー入力と同じ扱いで検証してください。ファイル削除やメール送信のような副作用のある関数は特に慎重に。
次のステップ
ツールを使わず出力の形だけ固定したい場合は、構造化出力(JSON)のほうが簡単です。「関数を呼びたいのか、JSONが欲しいだけなのか」を最初に切り分けてください。
既製のツール群をエージェントに渡したい場合は、MCPという選択肢もあります。FastMetalはMCPサーバーを提供しています。
よくある質問
Q. モデルが関数を実行してくれるのですか?
いいえ。モデルは「この関数をこの引数で呼びたい」というJSONを返すだけです。実行するのは自分のコードで、結果を role: "tool" で返して初めて最終的な回答が得られます。
Q. ツール呼び出しは無料モデルでも使えますか?
使えます。この記事のコードは gemini-flash-lite-free で実際に動作を確認しています。ただし無料モデルにはレート制限があり、ループは往復ぶんリクエストを消費します。
Q. tools を渡すとコストは増えますか?
増えます。ツール定義は毎リクエストのプロンプトに含まれます。引数1つの小さな関数で+53トークンを実測しました。使う可能性のあるツールだけを渡してください。
Q. どのモデルがツール呼び出しに対応していますか?
主要なモデルは概ね対応しています。モデルカタログで確認してください。model を差し替えるだけで同じコードが動きます。
まとめ
ツール呼び出しは「モデルが呼びたい関数を教える → 自分で実行する → 結果を返す」という往復です。finish_reason が tool_calls かを見て、arguments をパースし、tool_call_id を対応させて返す。この3点を押さえれば実装は終わります。
上限付きのループにしておくこと、渡すツールを絞ることが実務上の勘所です。まずAPIキーを取得して、無料モデルで往復を1回動かしてみてください。