0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude API の Web 検索ツール(web_search)で最新情報に答えさせる実装手順 — エラーは HTTP 200 で返る・content[0].text が前置きだけ・allowed_domains と blocked_domains の併用で 400、3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

Claude API に「今日時点の情報」を答えさせたくて、サーバーツールの Web 検索ツール(web_search) を組み込んだ。リクエストに1行足すだけで動くが、レスポンスの読み方を間違えると「検索はしてるのに答えが空」「エラーなのに例外が出ない」という地味な事故が起きる。自分が実際に踏んだ3つをまとめておく。

  • 想定読者:Claude API を Python から叩いたことがあり、ツール呼び出しの基本(tool_use / tool_result)は分かる人
  • 環境:Python 3.12 / anthropic(Python SDK、2026年10月時点の最新版)/ モデル claude-opus-5-5
  • ツール:web_search_20260209(Opus 4.6 以降・Sonnet 4.6 以降で使える版。古いモデルは web_search_20250305)
  • 前提:Console の組織設定で Web 検索が有効になっていること(無効だと使えない)

TL;DR

  • tools に {"type": "web_search_20260209", "name": "web_search"} を足すだけで、検索は Anthropic のサーバー側で実行 される。自前でツール実行ループを書く必要はない
  • 答えは 複数の text ブロックに分割 されて返る。content[0].text だけ読むと前置きしか取れない
  • 検索エラーは HTTP 200 のまま web_search_tool_result の中に入って返る。例外にならないので自分で分岐する

手順 / 動かし方

1. 最小構成

import anthropic

client = anthropic.Anthropic()  # ANTHROPIC_API_KEY を環境変数から読む

response = client.messages.create(
    model="claude-opus-5-5",
    max_tokens=16000,
    tools=[{
        "type": "web_search_20260209",
        "name": "web_search",
        "max_uses": 3,  # 1リクエスト内の検索回数の上限
    }],
    messages=[{"role": "user", "content": "Python 3.14 の主な新機能を出典付きで3つ教えて"}],
)

for block in response.content:
    print(block.type)
print(response.usage.server_tool_use)

出力例(抜粋):

thinking
server_tool_use
web_search_tool_result
text
text
text
...
ServerToolUsage(web_search_requests=2)

ブロックの流れはこうなっている。

tool_use ではなく server_tool_use で、stop_reason も通常は end_turn。自前ツールのように tool_result を返し直す必要はない。検索回数は usage.server_tool_use.web_search_requests に出るので、コスト監視はここを見る(検索1回ごとにトークン料金とは別の課金がある)。

2. 出典付きで本文を組み立てる

回答テキストは文単位で分割され、検索結果に基づく部分には citations が付く。

def render(response) -> str:
    parts, sources = [], {}
    for block in response.content:
        if block.type != "text":
            continue
        parts.append(block.text)
        for c in block.citations or []:
            if c.type == "web_search_result_location":
                n = sources.setdefault(c.url, len(sources) + 1)
                parts.append(f"[{n}]")
    refs = "\n".join(f"[{n}] {url}" for url, n in sources.items())
    return "".join(parts) + "\n\n" + refs

citations の各要素には url / title / cited_text(引用元の該当箇所)が入っている。エンドユーザーに出すなら出典リンクは必ず併記しておく。

3. 検索先を絞る・地域を指定する

tools=[{
    "type": "web_search_20260209",
    "name": "web_search",
    "max_uses": 5,
    "allowed_domains": ["docs.python.org", "peps.python.org"],
    "user_location": {
        "type": "approximate",
        "country": "JP",
        "timezone": "Asia/Tokyo",
    },
}]

allowed_domains にはスキーム無しのホスト名を書く。サブドメインも対象に含まれる。user_location を入れると「近くの〜」「今日の〜」系の質問が日本基準になる。

ハマりどころ

ハマり1:検索エラーなのに例外が出ない

症状:検索結果を for で回していたら TypeError: 'WebSearchToolResultError' object is not iterable 的なエラーで落ちた。try: ... except anthropic.APIError では捕まらない。

原因:サーバーツールのエラーは HTTP 200 で返り、web_search_tool_result の content が リストではなくエラーオブジェクト になる。

{
  "type": "web_search_tool_result",
  "tool_use_id": "srvtoolu_...",
  "content": {
    "type": "web_search_tool_result_error",
    "error_code": "max_uses_exceeded"
  }
}

error_code は max_uses_exceeded / too_many_requests / invalid_input / query_too_long / unavailable など。

回避策:content の型で分岐する。

for block in response.content:
    if block.type != "web_search_tool_result":
        continue
    if isinstance(block.content, list):
        for r in block.content:
            print(r.title, r.url)
    else:
        print("search error:", block.content.error_code)

max_uses_exceeded は「上限に達したので、それまでの結果で答えた」状態なので致命的ではない。一方 unavailable が続くなら回答の鮮度が怪しいので、ログに残して人が見られるようにしておく。

ハマり2:content[0].text が「調べてみます」だけ

症状:返ってきた答えを response.content[0].text で取り出していたら、「最新情報を検索します。」の一文だけ。あるいは AttributeError で落ちる。

原因:Web 検索を使うと content は「(thinking)→ server_tool_use → web_search_tool_result → text × N」と 多数のブロック になる。回答本体は後ろの複数の text ブロックに分割されている。Opus 5.5 ではツール呼び出しの合間の文章が thinking ブロックとして返るので、先頭が text ですらないこともある。

回避策:type == "text" のブロックを全部連結する(上の render() がそれ)。インデックス決め打ちはやめる。会話を続ける場合も、テキストだけ抜いて履歴に積むのではなく response.content をそのまま assistant として積み直す。

ハマり3:allowed_domains と blocked_domains を両方書いて 400

症状:「公式ドキュメント中心、ただしまとめサイトは除外」のつもりで両方指定したら 400 invalid_request_error。

原因:2つは どちらか片方しか指定できない。

回避策:許可リストで足りるなら allowed_domains だけにする。広く検索させたいなら blocked_domains だけ。ドメインに https:// を付けた場合も弾かれるので、ホスト名だけを書く。

(補足)pause_turn で止まったとき

検索を何度も繰り返す重い質問だと、サーバー側のループが上限に達して stop_reason: "pause_turn" で返ってくることがある。このときは「続けて」と追加で言う必要はなく、受け取った assistant の内容をそのまま履歴に積んで再リクエストすれば続きから再開する。

messages = [{"role": "user", "content": question}]
while True:
    resp = client.messages.create(model="claude-opus-5-5", max_tokens=16000,
                                  tools=tools, messages=messages)
    if resp.stop_reason != "pause_turn":
        break
    messages.append({"role": "assistant", "content": resp.content})

無限ループ防止のため、実運用では再開回数に上限(自分は3回)を付けている。

まとめ

  • web_search_20260209 を tools に足すだけで、検索から引用付き回答までサーバー側で完結する
  • 回答は複数の text ブロックに分かれて返る。content[0] 決め打ちをやめ、type == "text" を全部連結する
  • 検索エラーは HTTP 200 の中に入っている。content がリストかオブジェクトかで分岐する
  • allowed_domains と blocked_domains はどちらか片方だけ。pause_turn は履歴を積み直して再送する
  • コストは usage.server_tool_use.web_search_requests と max_uses で管理する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?