ツール呼び出し:モデルに手を動かさせる
モデルは自分ではリアルタイムのデータを調べられず、何の操作も実行できません。PyPI でパッケージの最新バージョンを調べる本物のツールを与え、並列呼び出し、エラー処理、思考モードでの注意点まで含めて、ツール呼び出しの流れを一通りたどります。
- 約 45 分
- 難易度:中級
- 検証:2026-09-14 deepseek-flash
コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。
モデルに「httpx の最新バージョンは?」と聞いても、学習データに頼って答えるしかなく、1 年前のバージョン番号かもしれないし、でっち上げかもしれません。「カレンダーに会議を入れて」と頼んでも、「はい、追加しました」と答えるだけで、実際には何も起きません。
モデルが出力できるのは文字だけです。リアルタイムのデータを調べさせたり、本当に何かをさせたりするには、ツールを与える必要があります。あなたが関数を書き、どんな関数が使えるかをモデルに伝えます。モデルは必要だと判断すると「この関数をこの引数で呼びたい」と出力します。あなたのプログラムが実際にその関数を実行し、結果をモデルに伝えます。モデルはその結果をもとにユーザーに答えます。この仕組みをツール呼び出し(tool calling)、または関数呼び出し(function calling)と呼びます。
これはモジュール 05 のエージェントの土台です。この課でその各段階をはっきり理解しましょう。
流れ
まず全体像を見ましょう。ツール呼び出しを伴う質疑応答では、モデルを少なくとも 2 回呼び出します。
你的程序 模型
│ 1. 用户问题 + 工具说明书 │
│ ────────────────────────────────────────────▶ │
│ 2. "请调用 get_pypi_info(httpx)" │
│ ◀──────────────────────────────────────────── │
│ 3. 程序自己执行 get_pypi_info("httpx") │
│ 拿到结果 {"version": "0.28.1", ...} │
│ 4. 之前的全部消息 + 工具结果 │
│ ────────────────────────────────────────────▶ │
│ 5. "httpx 的最新版本是 0.28.1" │
│ ◀──────────────────────────────────────────── │
肝心なのは第 2 ステップと第 3 ステップです。モデルはどんなコードも決して実行しません。構造化された「呼び出しの要求」を出力するだけで、実行する権限は完全にあなたのプログラムが握っています。何を呼ぼうとしているのか、引数が正しいかをチェックし、実行するか拒否するかを決められます。この点はセキュリティにとって非常に重要で、モジュール 05 第 8 課で詳しく扱います。
第一歩:ツールと説明書を書く
ツールはただの Python 関数です。ここでは実際に使えるものを書きます。PyPI の公開 API を呼んで、パッケージの最新バージョンを調べるものです。
import httpx
def get_pypi_info(package: str) -> dict:
"""真正干活的函数:调用 PyPI 的公开接口。"""
r = httpx.get(f"https://pypi.org/pypi/{package}/json", timeout=10)
if r.status_code == 404:
return {"error": f"PyPI 上没有叫 {package} 的包"}
info = r.json()["info"]
return {"name": info["name"], "version": info["version"], "summary": info["summary"],
"requires_python": info["requires_python"]}
ちなみに、ここで HTTP リクエストを送るのに使っているのが、まさに httpx です。openai をインストールしたときに依存関係として入っています。
次に、このツールの存在をモデルに伝える「説明書」を書きます。モデルには関数のコードは見えず、見えるのはこの説明書だけです。
TOOLS = [
{
"type": "function",
"function": {
"name": "get_pypi_info",
"description": "查询一个 Python 包在 PyPI 上的最新版本、简介和支持的 Python 版本。",
"parameters": {
"type": "object",
"properties": {
"package": {"type": "string", "description": "PyPI 上的包名,例如 httpx"},
},
"required": ["package"],
},
},
}
]
FUNCTIONS = {"get_pypi_info": get_pypi_info}
name はツール名、description は何ができるかの説明、parameters は JSON Schema で引数を記述したものです。モデルは description を見ていつこのツールを使うべきかを判断し、parameters を見て引数をどう埋めるかを決めます。説明書の出来が、モデルがツールを使うかどうか、正しく使えるかどうかを直接左右します。書き方はモジュール 05 第 3 課で詳しく扱います。
FUNCTIONS はツール名から本物の関数への対応表で、プログラムは呼び出しの要求を受け取ると、これを使って実行すべき関数を見つけます。
第二歩:ループ
messages = [{"role": "user", "content": "httpx 和 requests 在 PyPI 上的最新版本分别是多少?各自要求什么 Python 版本?"}]
for step in range(1, 6): # 最多 5 轮,防止意外的死循环
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
extra_body={"thinking": {"type": "enabled" if THINKING else "disabled"}},
)
message = response.choices[0].message
print(f"第 {step} 轮:finish_reason={response.choices[0].finish_reason}")
if not message.tool_calls:
print("最终回答:", message.content)
break
# 把模型的这条消息原样放回历史。开思考时,里面的 reasoning_content 也必须带上
messages.append(message.model_dump(exclude_none=True))
for call in message.tool_calls:
args = json.loads(call.function.arguments)
print(f" 模型要求调用 {call.function.name}({args})")
try:
result = FUNCTIONS[call.function.name](**args)
except Exception as e: # 工具出错也要告诉模型,而不是让程序崩掉
result = {"error": f"{type(e).__name__}: {e}"}
print(f" 返回:{result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result, ensure_ascii=False)})
各ラウンドで、tools を付けてモデルを呼び出します。回答に tool_calls がなければ、モデルは最終回答を出したということなので終了です。あれば一つずつ実行し、結果を role が tool のメッセージとして履歴に加えて、もう一度モデルを呼び出します。
注意すべき点がいくつかあります。
- モデルの呼び出し要求を履歴に戻す。
messages.append(message.model_dump(exclude_none=True))で、tool_callsを含むモデルのこのメッセージをそのまま戻します。これがないと、モデルは次のラウンドでツールの結果の山を見ても、誰が頼んだものかわかりません。 tool_call_idを対応させる。各呼び出し要求にはidがあり、対応するツールの結果には同じtool_call_idを付けます。モデルが一度に複数のツールを呼び出したとき、この ID でどの結果がどの要求に対応するかを知ります。- 引数は文字列の JSON。
call.function.argumentsは'{"package": "httpx"}'のような文字列なので、json.loadsで解析します。 - ツールのエラーでプログラムを落とさない。エラーメッセージを結果としてモデルに返せば、モデルはたいていそれに応じて調整します。たとえば引数を変えてもう一度試したり、見つからなかったとユーザーに正直に伝えたりします。
- ラウンド数の上限を設ける。モデルがツールを呼び出し続けて止まらなくなることがあるので、上限は最後の安全装置です。
実行結果
第 1 轮:finish_reason=tool_calls
模型要求调用 get_pypi_info({'package': 'httpx'})
返回:{'name': 'httpx', 'version': '0.28.1', 'summary': 'The next generation HTTP client.', 'requires_python': '>=3.8'}
模型要求调用 get_pypi_info({'package': 'requests'})
返回:{'name': 'requests', 'version': '2.34.2', 'summary': 'Python HTTP for Humans.', 'requires_python': '>=3.10'}
第 2 轮:finish_reason=stop
最终回答: 两个包在 PyPI 上的最新信息如下:
| 包名 | 最新版本 | 要求 Python 版本 | 简介 |
|---|---|---|---|
| **httpx** | 0.28.1 | >=3.8 | The next generation HTTP client. |
| **requests** | 2.34.2 | >=3.10 | Python HTTP for Humans. |
几点说明:
- **httpx** 支持范围更宽,Python 3.8 及以上都能用,兼容性更好。
- **requests** 这边要求 Python 3.10 及以上,门槛更高一些。
- 光看"最低版本要求"的话,httpx 覆盖的老版本 Python 更多;但如果你跑在 3.10+ 环境上,两者都没问题。
如果你告诉我项目所用的 Python 版本,我可以帮你判断具体该选哪个。
(これは 2026 年 9 月 14 日に調べたバージョン番号で、あなたが実行するときには PyPI のバージョンが更新されているかもしれません。)
1 ラウンド目の finish_reason は tool_calls で、モジュール 00 第 3 課の表の 3 番目のケースにあたります。しかもモデルは同じラウンドで 2 回の呼び出しを要求しています。httpx の照会と requests の照会です。これを並列ツール呼び出しと呼びます。モデルは二つの照会が互いに依存しないと判断して一緒に出し、往復を一回省いたのです。2 ラウンド目では、モデルは二つの本物のデータを受け取って最終回答を出しました。バージョン番号はどちらも PyPI から得たもので、でっち上げではありません。
思考モードをオンにしているとき
DeepSeek のモデルは既定で思考がオンになっています。思考モードでツールを使うときには一つルールがあります。それまでの各ラウンドの reasoning_content を、そのまま API に送り返さなければならない。ツールを使わないときは送っても送らなくてもよく、サーバーは無視しますが、ツールを使うなら必須です。
上のコードは message.model_dump(exclude_none=True) でモデルのメッセージ全体を辞書に変えて履歴に戻しており、そこには当然 reasoning_content も含まれるので、思考オンでも正常に動きます。
python tool_calling.py --think
第 1 轮:finish_reason=tool_calls
模型要求调用 get_pypi_info({'package': 'httpx'})
返回:{'name': 'httpx', 'version': '0.28.1', 'summary': 'The next generation HTTP client.', 'requires_python': '>=3.8'}
模型要求调用 get_pypi_info({'package': 'requests'})
返回:{'name': 'requests', 'version': '2.34.2', 'summary': 'Python HTTP for Humans.', 'requires_python': '>=3.10'}
第 2 轮:finish_reason=stop
最终回答: 两个包在 PyPI 上的最新信息如下:
(后面的回答内容和不开思考时相近,这里省略)
よくある書き方として、content と tool_calls だけを取り出し、手で辞書を組み立てて履歴に戻すものがあります。これは思考オフなら問題ありませんが、思考をオンにすると reasoning_content が失われます。model_dump でそのまま戻すのが一番手間がかかりません。
モデルがおかしな引数を渡したら
モデルが埋める引数は正しいとは限りません。存在しないパッケージ名かもしれず、必須の引数が欠けているかもしれず、型が違うかもしれません。防御線はいくつかあります。
- 説明書をはっきり書く。引数の
descriptionに形式と例を書きます。「包名」(パッケージ名)とだけ書くより、「PyPI 上のパッケージ名。例:httpx」のほうがよいです。 - 関数の中でチェックする。引数が必ず正しいと仮定しないでください。パッケージ名におかしな文字がないか、数値が妥当な範囲にあるかなどです。
- エラーをモデルに返す。上のコードでは、関数が投げた例外はすべて捕まえて
{"error": "..."}にして返しています。PyPI に存在しないパッケージなら、関数自体もエラーの説明を返します。モデルはエラーを見れば、たいてい自分で修正するか、ユーザーに正直に伝えます。 - strict モード。モジュール 02 第 4 課で扱った DeepSeek の strict モード(
strict: true)を使えば、引数がスキーマの構造に従うことは保証できますが、内容が正しいことまでは保証できません。
よくある問題
ツールを呼ぶべきなのに呼ばず、答えをでっち上げた:ツールの description で何ができるかがはっきり説明されているか確認してください。system メッセージに「パッケージのバージョン情報に関わるときは必ず get_pypi_info で調べ、記憶で答えないこと」と書くのも手です。特定のツールを必ず呼ばせたいなら、tool_choice で強制できます。
メッセージの順番が正しくないというエラーが出る:たいていは、tool メッセージの前に対応する tool_calls 付きの assistant メッセージがないか、tool_call_id が合っていないのが原因です。上のコードのとおり、先にモデルのメッセージを置き、それからツールの結果を一つずつ置きます。
練習問題
- PyPI に存在しないパッケージについて聞いてみてください。たとえば「httpxx の最新バージョンは?」。ツールが返すエラーメッセージと、モデルがユーザーにどう答えるかを見てください。
- ツール
get_github_stars(repo)を追加し、GitHub の公開 APIhttps://api.github.com/repos/{repo}を呼んでリポジトリのスター数を調べてください(キーは不要ですが、1 時間あたりの回数制限があります)。「httpx の最新バージョンと GitHub のスター数は?」と聞き、モデルが二つの異なるツールを同時に呼び出すか見てください。 messages.append(message.model_dump(exclude_none=True))を、contentとtool_callsだけを入れた手書きの辞書に変えて、--think付きで実行し、何が起きるか見てください。
確認テスト
1. ツール呼び出しで、get_pypi_info 関数を実行したのはモデルですか?
違います。モデルは「get_pypi_info を httpx という引数で呼びたい」という構造化された要求を出力しただけです。関数を実際に実行したのはあなたのプログラムです。実行する権限は完全にプログラムにあり、モデルの呼び出し要求をチェックしたり、修正したり、拒否したりできます。
2. モデルが一度に二つのツールの呼び出しを要求しました。二つの結果をどうやって別々に伝えますか?
各呼び出し要求には一意の id があります。呼び出しごとに role が tool のメッセージを 1 件ずつ加え、tool_call_id に対応する要求の id を入れます。モデルはこれで結果と要求を対応づけます。
3. 思考モードをオンにしてツールを使うとき、何に注意すべきですか?
それまでの各ラウンドのモデルのメッセージにある reasoning_content を、そのまま API に送り返す必要があります。最も手間がかからないのは、message.model_dump(exclude_none=True) でモデルのメッセージ全体を履歴に戻すことで、content と tool_calls だけを手で取り出さないようにします。
質問と議論
このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。
質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。
議論を読み込んでいます…