Claude Media
Tool Searchを自作する — tool_referenceを返す埋め込み検索の実装

Tool Searchを自作する — tool_referenceを返す埋め込み検索の実装

組み込みのregex版・BM25版ではなく、埋め込み検索でツールを探すTool Searchを自作する手順。tool_resultにtool_referenceを返す形と、defer_loadingの置き方を扱います。

自作のTool Searchは「普通のツール」がtool_referenceを返すだけで成り立つ

自作のTool Searchとは、検索ロジックを自分のコードで持ち、ヒットしたツール名をtool_referenceブロックとしてClaudeに返す実装です。検索の中身は埋め込みでもキーワードでも構いません。

仕組みは公式ドキュメントの「Custom tool search implementation」に書かれています。カスタムツールを1つ用意し、Claudeがそれを呼んだら、標準のtool_resultのcontent配列にtool_referenceを入れて返します。

{
  "type": "tool_result",
  "tool_use_id": "toolu_your_tool_id",
  "content": [{ "type": "tool_reference", "tool_name": "discovered_tool_name" }]
}

返した名前に対応する定義は、トップレベルのtoolsパラメータに必要です。通常はdefer_loading: trueを付けて置きます。ここまで守れば、組み込みの検索と同じ経路でAPIが完全な定義に展開します。

組み込み版との違いは、誰が検索を実行するかです。

項目組み込み(regex / BM25)自作
検索を実行する側組み込み(regex / BM25)Anthropicのサーバー自作自分のアプリ
Claudeの呼び出し組み込み(regex / BM25)server_tool_use自作通常のtool_use
結果の形組み込み(regex / BM25)tool_search_tool_result自作標準のtool_result + tool_reference
tool_resultを返す組み込み(regex / BM25)返さない(返すとAPIが拒否)自作自分で返す
検索方式組み込み(regex / BM25)正規表現かBM25自作自由(埋め込みなど)

組み込みの2変種の書き分けはTool Search Toolのregex版とBM25版の記事にあります。この記事は、その外側にある「自分で検索する」側の手順です。

全体の構成は3種類のツール定義

自作する場合、リクエストのtools配列には次の3種類が並びます。

構成

toolsに並べる定義

  • 検索ツール(非遅延)

    Claudeが最初から見える唯一のツールです。defer_loadingは付けません。

  • 参照先ツール(遅延)

    実際に使う業務ツールです。defer_loading: trueを付け、全定義を毎回送ります。

  • よく使うツール(任意・非遅延)

    検索なしで呼べるよう、3〜5個ほど遅延させずに残します。

全ツールを遅延させると400になります。メッセージはAt least one tool must have defer_loading=false. All tools cannot be deferred.です。自作では検索ツール自身が非遅延なので、この条件は自然に満たされます。

もう1つ、遅延と送信量の関係に誤解が多い点があります。defer_loadingが制御するのは「Claudeの文脈に最初から載るか」で、リクエストに含めるかどうかではありません。遅延したツールも、完全な定義を毎回toolsに入れます。プロンプトキャッシュとの関係はdefer_loadingとキャッシュの解説で掘り下げています。

手順1: 参照先ツールをdefer_loading: trueで定義する

検索対象のツールは、通常のツール定義にdefer_loading: trueを足すだけです。ここでは天気と株価の2つを例にします。

CATALOG = [
    {
        "name": "get_weather",
        "description": "Get the current weather in a given location",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "The city and state, e.g. San Francisco, CA",
                },
            },
            "required": ["location"],
        },
        "defer_loading": True,
    },
    {
        "name": "get_stock_price",
        "description": "Get the latest stock price for a ticker symbol",
        "input_schema": {
            "type": "object",
            "properties": {
                "ticker": {"type": "string", "description": "e.g. AAPL"},
            },
            "required": ["ticker"],
        },
        "defer_loading": True,
    },
]

cache_controlは遅延ツールに付けられません。defer_loading: trueのツールにcache_controlを載せると400になるため、キャッシュのブレークポイントは非遅延のツールに置きます。自作では検索ツールの定義がその置き場所の候補です。

手順2: 検索ツールを定義する

検索ツールは普通のカスタムツールです。入力は自然文のクエリと、返す件数にします。

TOOL_SEARCH = {
    "name": "tool_search",
    "description": (
        "Search for available tools that can help with a task. "
        "Use this when you need a tool you do not have yet."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "What kind of tool you need, in plain language",
            },
            "top_k": {"type": "number", "description": "Number of tools (default 5)"},
        },
        "required": ["query"],
    },
}

descriptionは検索ツールの使いどころをClaudeに伝える唯一の手がかりです。公式の最適化のヒントにも、システムプロンプトで「SlackやGitHubのツールを検索できる」のようにカテゴリを伝える方法があります。自作でも同じ考え方が使えます。

手順3: 埋め込みを作ってコサイン類似度で検索する

検索のエンジンは、ツールの名前・説明・引数をテキストにして埋め込み、クエリとの類似度で並べるだけです。公式クックブックはsentence-transformersのall-MiniLM-L6-v2(384次元、ローカル実行)を使っています。同じ構成で書くと次の形になります(クックブックの考え方に沿った例です)。

pip install anthropic sentence-transformers numpy
import numpy as np
from sentence_transformers import SentenceTransformer
 
embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
 
 
def tool_text(tool: dict) -> str:
    parts = [f"Tool: {tool['name']}", f"Description: {tool['description']}"]
    props = tool.get("input_schema", {}).get("properties", {})
    if props:
        args = ", ".join(
            f"{k} ({v.get('type', '')}): {v.get('description', '')}"
            for k, v in props.items()
        )
        parts.append(f"Parameters: {args}")
    return "\n".join(parts)
 
 
INDEX = embedder.encode(
    [tool_text(t) for t in CATALOG], normalize_embeddings=True
)
 
 
def search_tools(query: str, top_k: int = 5) -> list[str]:
    q = embedder.encode(query, normalize_embeddings=True)
    scores = INDEX @ q  # 正規化済みなので内積 = コサイン類似度
    order = np.argsort(scores)[::-1][:top_k]
    return [CATALOG[i]["name"] for i in order]

組み込みの検索がツール名・説明・引数名・引数の説明を対象にしているのと同じ範囲を、tool_textでテキスト化しています。埋め込みの対象を絞ると、検索はその分だけ弱くなります。

クックブックの出力例では、"I need to check the weather"というクエリに対してget_weather(類似度0.560)、get_forecast(0.508)、get_air_quality(0.401)が上位に並んでいます。キーワードが一致しなくても意味で拾えるのが、正規表現やBM25に対する埋め込みの利点です。

手順4: tool_referenceをtool_resultに入れて返す

Claudeがtool_searchを呼んだら、ヒットしたツール名をtool_referenceにして返します。それ以外のツールの呼び出しは、通常どおり実行して結果を返します。

import anthropic
 
client = anthropic.Anthropic()
TOOLS = [TOOL_SEARCH] + CATALOG
KNOWN = {t["name"] for t in CATALOG}
 
 
def run_tool(name: str, args: dict) -> str:
    ...  # 実際のAPI呼び出しなど
 
 
def chat(user_message: str, max_turns: int = 6):
    messages = [{"role": "user", "content": user_message}]
    for _ in range(max_turns):
        resp = client.messages.create(
            model="claude-sonnet-5-5",
            max_tokens=1024,
            tools=TOOLS,
            messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})
        if resp.stop_reason != "tool_use":
            return resp
        results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            if block.name == "tool_search":
                names = search_tools(
                    block.input["query"], int(block.input.get("top_k", 5))
                )
                content = [
                    {"type": "tool_reference", "tool_name": n}
                    for n in names
                    if n in KNOWN
                ]
            else:
                content = run_tool(block.name, block.input)
            results.append(
                {"type": "tool_result", "tool_use_id": block.id, "content": content}
            )
        messages.append({"role": "user", "content": results})

ポイントは3つあります。

手順

ループで外せない3点

  1. 1

    アシスタントの出力をそのまま履歴に戻す

    messages.append({"role": "assistant", "content": resp.content})で、返ってきたブロックを変更せずに積みます。

  2. 2

    tool_resultを先頭に置く

    ユーザーメッセージのcontentでは、tool_resultブロックが先で、テキストは後ろです。上のコードはtool_resultだけを入れています。

  3. 3

    毎回同じtoolsを送る

    遅延したツールを含む全定義を、毎リクエストで送り直します。

if n in KNOWNの絞り込みは、埋め込み索引とtools配列がずれたときの保険です。tool_referenceの指す名前がtoolsに無いと、Tool reference 'unknown_tool' not found in available toolsの400が返ります。索引をファイルやDBに永続化している構成では、ツールを削除したのに古い索引が残る場面で起きやすい失敗です。

公式クックブックを動かす前に知っておきたい2点

クックブックは自作の実例として便利ですが、そのまま読むと誤解しやすい箇所が2つあります。

1つ目は、デモのTOOL_LIBRARYにdefer_loadingが付いていないことです。全ツールが非遅延で毎回toolsに載るため、掲載されている実行例では最初のターンでClaudeがget_weatherを直接呼んでいます。tool_searchは呼ばれていません。検索を実際に通すには、手順1のように参照先にdefer_loading: trueを付けます。

2つ目は、リクエストのbetaヘッダです。クックブックはanthropic-beta: advanced-tool-use-2025-11-20を付け、「ツール結果にツール定義を載せるためのヘッダ」とコメントしています。一方、Tool search toolのページには、このヘッダの説明が載っていません。クックブックの方式で動かないときは、まずヘッダの有無を試す価値があります。ただしどちらが必須かは、ページ同士の記述が一致していません。

動かないときの切り分け

症状原因の候補直し方
400: All tools cannot be deferred原因の候補検索ツールにもdefer_loadingを付けた直し方検索ツールの定義から外す
400: Tool reference ... not found原因の候補返した名前がtoolsに無い直し方名前をtoolsの集合と突き合わせる
400(キャッシュ関連)原因の候補遅延ツールにcache_controlがある直し方非遅延ツールに移す
検索が呼ばれない原因の候補参照先が遅延されていない直し方defer_loading: trueを確認
欲しいツールが出ない原因の候補埋め込み対象の説明文が薄い直し方説明に利用者の言葉を足す

ヒットが0件のときの扱いは、自作では自分で決める部分です。組み込みの検索は、一致なしでもtool_referencesが空配列の結果を返し、エラーにしません。自作のtool_resultで空のcontentを返したときの挙動について、公式ページには記載がありません。確実に動かすなら、「該当するツールはありません」というテキストを返し、Claudeにクエリの言い換えを促す設計が扱いやすくなります。

検索自体が失敗した場合は、tool_resultのis_error: trueにエラーメッセージを入れて返せます。

自作するときに先に決めておく設計点

検索ロジックの外側にも、決めておくと後で迷わない点があります。

発見したツールの再利用です。APIは会話履歴の中のtool_referenceを、履歴全体にわたって展開します。一度発見したツールは、以降のターンで再検索しなくても呼べます。履歴を切り詰めるアプリでは、tool_referenceを含むtool_resultを落とさないようにします。落とした場合は、そのツールを再検索で見つけ直す必要が出ると考えられます。

次に、モデルの対応です。Tool searchの対応表には、Claude Opus 4.5以降とSonnet 4.5、Haiku 4.5などが載っていて、Claude Opus 4.1以前は非対応とされています。自作でもtool_referenceの展開はAPI側の機能なので、この表が前提になります。

さらに、ツール名の付け方です。公式の最適化のヒントは、github_やslack_のようにサービスごとの接頭辞を付けると、1回の検索でグループ全体に当たると説明しています。埋め込み検索でも、tool_textの先頭にツール名が入るため、接頭辞はそのまま意味の手がかりになります。説明文には、利用者が仕事を頼むときの言い方を入れておくと、クエリとの距離が縮まります。

最後に、索引の保存です。クックブックは次の改善として、埋め込みをディスクに保存して起動のたびの再計算を避ける方法と、より大きなモデル(all-mpnet-base-v2など)を試す方法を挙げています。ツールの定義を更新したら索引も作り直す、という運用を最初から決めておくと、前節の400エラーを避けられます。

組み込みで足りるか、自作するか

公式が示す自作の目的は、組み込みの2変種にない検索方式の導入です。典型は埋め込み検索ですが、次のような場面でも自作が選択肢に入ります。

  • 検索対象に、ツール定義にない情報(利用統計、コスト、信頼度など)を混ぜて順位付けしたい。クックブックの「次のステップ」に同趣旨の提案があります
  • BM25と埋め込みを組み合わせたハイブリッド検索にしたい。これもクックブックが挙げています
  • 利用者や権限に応じて、検索結果に出すツールを絞りたい

逆に、ツールが10個に満たない、または全ツールを毎回使うなら、ツール検索自体が不要です。公式は、ツールが10個以上、定義が1万トークン超、MCPサーバーを束ねて200個以上、といった場合にツール検索を勧めています。

自作の場合は、検索の精度・遅延・索引の保守がすべて自分の責任になります。組み込みの正規表現とBM25で当たりが取れているうちは、自作のコストに見合わないことが多いはずです。MCPのツール定義がなぜ文脈を圧迫するのかは、MCPのツール定義とコンテキストの記事に整理があります。Agent SDKから使う場合はAgent SDKのTool Searchが対象で、そちらはSDK側の既定動作を扱っています。

まとめ

自作のTool Searchに必要なのは、非遅延の検索ツール、defer_loading: trueの参照先ツール、そしてtool_resultに入れたtool_referenceの3つだけです。埋め込みの作り方は自由で、API側の仕様として守るのは「返した名前の定義がtoolsにある」ことに尽きます。

最初の一歩は、ツール数個の小さな索引で、検索ツールが実際に呼ばれるところまで通すことです。クックブックのデモでは検索が呼ばれていないので、参照先のdefer_loadingを確かめるのが先決です。

この記事を共有:XはてブLinkedIn