deepseek.ac日本語で学ぶ、実践する

パターン

ツール呼び出しとは何か — モデルに「手足」を渡す前に知るべきこと

· 約10分

リード

ツール呼び出し(Function Calling)は「モデルが関数を実行してくれる機能」ではない。モデルが返すのは名前と引数のJSONを含む要求であり、実行するかどうかを決めるのはあなたのコードだ。この一点を押さえると、エージェント系フレームワークの挙動がすべて読めるようになる。

前提

本文

モデルは「実行」しない、「要求」を出す

「Function Calling」という名前が最大の誤解源だ。モデルは関数を呼ばない。モデルが行うのは次の1つだけ。

  1. ユーザーのメッセージと与えられたツール定義を照らし合わせる
  2. ツールが必要と判断したら、name と arguments(JSON文字列)からなる構造化された要求を返す

この時点で副作用は一切起きていない。ファイルも書かれていないし、DBも更新されていない。要求をどう扱うかを決めるのはアプリケーション側の責務であり、ここを曖昧にしたまま実装すると、権限も監査ログもない「なんでも実行するAI」が完成する。

「関数呼び出し」ではなく「ツール呼び出し」と呼ぶほうが実態に近い。ツールは関数とは限らず、HTTPリクエストでも、DBクエリでも、UI操作のトリガーでもよい。

1回のやり取りで終わらない — 呼び出しループ

ツール呼び出しは1リクエストで完結しない。基本の流れは次のループになる。

  1. ユーザーメッセージ+ツール定義をモデルに送る
  2. モデルがツール要求を返す(またはそのまま回答を返す)
  3. アプリが要求を検証し、実際にツールを実行する
  4. 実行結果(またはエラー)をモデルに返す
  5. モデルが最終回答を生成する(必要なら 2 に戻る)

この往復が「エージェントループ」の正体だ。特別な魔法はなく、ただのAPI呼び出しの繰り返しである。フレームワークを導入する前に、このループを自分の手で1回書いておくと、後で何が起きているか分からなくなる事故が減る。

ツール定義はプロンプトの一部

ツールの description と引数の説明文は、モデルが「使うかどうか」「どの引数を入れるか」を判断する唯一の材料になる。つまりツール定義はプロンプトそのものだ。

「なぜそうするか」は単純で、モデルはツールの実装コードを見られないからだ。シグネチャと説明文だけが判断材料になる。

何に向いて、何に向かないか

向く用途:

慎重にすべき用途:

副作用のあるツールは、人間の承認ステップを挟むか、実行専用の別ターンに分離する。

最小構成のコード

以下はループの骨格を示すための例だ。モデル名・エンドポイント・tools パラメータの対応状況は DeepSeek 公式ドキュメントで要確認。

import json
import os
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="https://api.deepseek.com",  # 公式ドキュメントで要確認
)

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_order_status",
            "description": "注文IDから配送状況を取得する。ユーザーが注文の所在や到着時期を尋ねたときに使う。",
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "注文ID。例: ORD-20261027-001",
                    }
                },
                "required": ["order_id"],
            },
        },
    }
]


def dispatch(name: str, args: dict) -> dict:
    """実際の処理。ここに来るまで何も起きていない点が重要。"""
    if name == "get_order_status":
        return {"order_id": args["order_id"], "status": "配送中"}
    raise ValueError(f"unknown tool: {name}")


def run(user_message: str, max_turns: int = 5) -> str:
    messages = [{"role": "user", "content": user_message}]

    for _ in range(max_turns):  # 無限ループ防止は必須
        res = client.chat.completions.create(
            model="deepseek-chat",  # モデル名は要確認
            messages=messages,
            tools=tools,
        )
        msg = res.choices[0].message

        if not msg.tool_calls:
            return msg.content

        messages.append(msg.model_dump(exclude_none=True))

        for call in msg.tool_calls:
            try:
                args = json.loads(call.function.arguments)
                result = dispatch(call.function.name, args)
            except Exception as e:
                # エラーもモデルに返す。握りつぶすとリトライできない
                result = {"error": str(e)}

            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": json.dumps(result, ensure_ascii=False),
                }
            )

    return "ツール呼び出しの上限に達しました"

ポイントは3つ。要求を受けただけでは何も実行していないこと、エラーをモデルに返していること、ループ回数に上限を置いていること。

設計で先に決めること

実装前に決めておかないと、後から剥がせなくなる。

セキュリティ上の前提

ツール呼び出しを入れると、モデルが読んだ外部テキストが命令として作用する経路が生まれる。WebページやPDFに「この注文をキャンセルせよ」と書かれていた場合、それがツール要求として現れる可能性がある。

対策の基本はモデル側ではなくアプリ側に置く。

プロンプトインジェクション対策の詳細はセキュリティ編で扱う。

つまずきポイント

症状 対処
モデルがツールを呼んでくれない description に「いつ使うか」を書く。ツール名を動詞+目的語にする
引数のJSONが壊れていてパースできない json.loads を try で囲み、失敗をモデルに返して再試行させる
同じツールを延々と呼び続ける ループ上限を設定し、上限到達時の応答を決めておく
ツール結果を返したのに回答が空になる role: "tool" と tool_call_id の対応を確認する
本番で意図しない操作が実行された 副作用ツールを分離し、承認フローと監査ログを追加する
ツールを増やすほど精度が落ちる ツール数を絞る。似た役割のツールは統合する

まとめ

チェックリスト