モジュール 03 · 第 3 回

ツール呼び出し:モデルに手を動かさせる

モデルは自分ではリアルタイムのデータを調べられず、何の操作も実行できません。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 がなければ、モデルは最終回答を出したということなので終了です。あれば一つずつ実行し、結果を roletool のメッセージとして履歴に加えて、もう一度モデルを呼び出します。

注意すべき点がいくつかあります。

  • モデルの呼び出し要求を履歴に戻すmessages.append(message.model_dump(exclude_none=True)) で、tool_calls を含むモデルのこのメッセージをそのまま戻します。これがないと、モデルは次のラウンドでツールの結果の山を見ても、誰が頼んだものかわかりません。
  • tool_call_id を対応させる。各呼び出し要求には id があり、対応するツールの結果には同じ tool_call_id を付けます。モデルが一度に複数のツールを呼び出したとき、この ID でどの結果がどの要求に対応するかを知ります。
  • 引数は文字列の JSONcall.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_reasontool_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 上的最新信息如下:
(后面的回答内容和不开思考时相近,这里省略)

よくある書き方として、contenttool_calls だけを取り出し、手で辞書を組み立てて履歴に戻すものがあります。これは思考オフなら問題ありませんが、思考をオンにすると reasoning_content が失われます。model_dump でそのまま戻すのが一番手間がかかりません。

モデルがおかしな引数を渡したら

モデルが埋める引数は正しいとは限りません。存在しないパッケージ名かもしれず、必須の引数が欠けているかもしれず、型が違うかもしれません。防御線はいくつかあります。

  1. 説明書をはっきり書く。引数の description に形式と例を書きます。「包名」(パッケージ名)とだけ書くより、「PyPI 上のパッケージ名。例:httpx」のほうがよいです。
  2. 関数の中でチェックする。引数が必ず正しいと仮定しないでください。パッケージ名におかしな文字がないか、数値が妥当な範囲にあるかなどです。
  3. エラーをモデルに返す。上のコードでは、関数が投げた例外はすべて捕まえて {"error": "..."} にして返しています。PyPI に存在しないパッケージなら、関数自体もエラーの説明を返します。モデルはエラーを見れば、たいてい自分で修正するか、ユーザーに正直に伝えます。
  4. strict モード。モジュール 02 第 4 課で扱った DeepSeek の strict モード(strict: true)を使えば、引数がスキーマの構造に従うことは保証できますが、内容が正しいことまでは保証できません。

よくある問題

ツールを呼ぶべきなのに呼ばず、答えをでっち上げた:ツールの description で何ができるかがはっきり説明されているか確認してください。system メッセージに「パッケージのバージョン情報に関わるときは必ず get_pypi_info で調べ、記憶で答えないこと」と書くのも手です。特定のツールを必ず呼ばせたいなら、tool_choice で強制できます。

メッセージの順番が正しくないというエラーが出る:たいていは、tool メッセージの前に対応する tool_calls 付きの assistant メッセージがないか、tool_call_id が合っていないのが原因です。上のコードのとおり、先にモデルのメッセージを置き、それからツールの結果を一つずつ置きます。

練習問題

  1. PyPI に存在しないパッケージについて聞いてみてください。たとえば「httpxx の最新バージョンは?」。ツールが返すエラーメッセージと、モデルがユーザーにどう答えるかを見てください。
  2. ツール get_github_stars(repo) を追加し、GitHub の公開 API https://api.github.com/repos/{repo} を呼んでリポジトリのスター数を調べてください(キーは不要ですが、1 時間あたりの回数制限があります)。「httpx の最新バージョンと GitHub のスター数は?」と聞き、モデルが二つの異なるツールを同時に呼び出すか見てください。
  3. messages.append(message.model_dump(exclude_none=True)) を、contenttool_calls だけを入れた手書きの辞書に変えて、--think 付きで実行し、何が起きるか見てください。

確認テスト

1. ツール呼び出しで、get_pypi_info 関数を実行したのはモデルですか?

違います。モデルは「get_pypi_info を httpx という引数で呼びたい」という構造化された要求を出力しただけです。関数を実際に実行したのはあなたのプログラムです。実行する権限は完全にプログラムにあり、モデルの呼び出し要求をチェックしたり、修正したり、拒否したりできます。

2. モデルが一度に二つのツールの呼び出しを要求しました。二つの結果をどうやって別々に伝えますか?

各呼び出し要求には一意の id があります。呼び出しごとに roletool のメッセージを 1 件ずつ加え、tool_call_id に対応する要求の id を入れます。モデルはこれで結果と要求を対応づけます。

3. 思考モードをオンにしてツールを使うとき、何に注意すべきですか?

それまでの各ラウンドのモデルのメッセージにある reasoning_content を、そのまま API に送り返す必要があります。最も手間がかからないのは、message.model_dump(exclude_none=True) でモデルのメッセージ全体を履歴に戻すことで、contenttool_calls だけを手で取り出さないようにします。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…