[ blog · tutorial ]9 min read

Search + URL Extract でリサーチエージェントを作る(Claude tool use、エンドツーエンド)

Sarah Choy公開: 2026年5月3日読了 9 分
Search + URL Extract でリサーチエージェントを作る(Claude tool use、エンドツーエンド)

「AI リサーチエージェント」チュートリアルの多くは「ツール定義はこれ」で止まる。本記事は動くエージェントを最後まで作る:質問を入れると、出典付きの回答が出る。検索・抽出・推論・引用 — すべて Python 120 行未満。

要点

  • 使える最小のリサーチエージェントループは:検索 → リンク選定 → 本文抽出 → モデルにインライン引用付きで回答させる、の 4 段。
  • 2 つのツール — Web Search(15 credits)と URL Extract(URL 1 件あたり 2 credits)— で、根拠付き回答ユースケースの 95% をカバーできる。
  • オーケストレーションは Claude tool use が担う。あなたは tool_use ブロック → API 呼び出し → tool_result ブロックを、モデルが止まるまで往復させるだけ。
  • エンドツーエンドのコスト:標準的な深さで 1 質問あたり約 25 credits + LLM トークン約 $0.02。

作るもの

入力は 1 つ — 自然言語の質問 — で、出典付きの回答を返すリサーチエージェント。アーキテクチャはこうだ:

question → [Claude]
              ↓ tool_use(search)
            [API Pick Web Search] → ranked URLs
              ↓ tool_use(extract)
            [API Pick URL Extract] → cleaned bodies
              ↓ Claude reads, decides if more is needed
              ↓ end_turn
          answer with inline [source: URL] citations

Python 約 120 行。2 つのツール — search と extract — と、Claude が決めることを何でもさばく 1 つのエージェントループ。これを 4 ステップでゼロから作る。

1ツールスキーマを取得する(JSON を手書きしない)

どちらのエンドポイントも、messages.create が期待する形そのままの Claude ツール定義を返すツールスキーマ用ルートを公開している。

import requests

API_KEY = "pk_yourkey"

def fetch_tool(tool_path: str) -> dict:
    """Fetch a Claude tool definition from API Pick's tool-schema endpoint."""
    schema = requests.get(f"https://www.apipick.com{tool_path}/tool-schema").json()
    return schema["claude"]

WEB_SEARCH_TOOL = fetch_tool("/api/search/web")
URL_EXTRACT_TOOL = fetch_tool("/api/extract")

# Cache these once at module load. They don't change between requests.

これでタダで手に入る重要なものが 2 つ:パラメータスキーマ(Claude が querycountry_codestart_date などを受け付けると分かる)と、ツールが何をするかをモデルが理解しやすい明快な説明。

2ツールハンドラを書く

Claude が tool_use ブロックを返したら、あなたの仕事は実際の API を呼び、tool_result ブロックを返すこと。ツールごとに 1 関数、Claude が選んだ名前でルーティングするディスパッチャを 1 つ。

def call_tool(tool_use_block) -> dict:
    """Execute the tool Claude asked for and return a tool_result block."""
    name = tool_use_block.name
    args = tool_use_block.input

    if name == "web_search":
        endpoint = "https://www.apipick.com/api/search/web"
    elif name == "extract_urls":
        endpoint = "https://www.apipick.com/api/extract"
    else:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"Unknown tool: {name}",
            "is_error": True,
        }

    resp = requests.post(
        endpoint,
        headers={"x-api-key": API_KEY},
        json=args,
        timeout=30,
    )

    if resp.status_code != 200:
        return {
            "type": "tool_result",
            "tool_use_id": tool_use_block.id,
            "content": f"HTTP {resp.status_code}: {resp.text[:500]}",
            "is_error": True,
        }

    return {
        "type": "tool_result",
        "tool_use_id": tool_use_block.id,
        "content": resp.text,
    }

注目すべき点が 3 つ。tool_use_id は Claude が結果を先の呼び出しと対応付ける手段で、必ずそのまま返さなければならない。is_error: True は Claude に優雅にリカバリ(多くは別のクエリを試す)するよう伝える。そして生のレスポンステキストをそのまま渡している — Claude は API Pick が返す JSON の形に慣れているので、変換は不要だ。

3エージェントループ

ループは短い:会話を送り、レスポンスの全ブロックを見て、ツールがあれば実行し、Claude の tool_use とあなたの tool_result の両方を会話に追加し、stop_reasonend_turn になるまで繰り返す。

import anthropic

client = anthropic.Anthropic()

SYSTEM_PROMPT = """You are a research assistant. When the user asks a question:

1. Use web_search to find relevant sources. Prefer recent (set start_date) for time-sensitive queries.
2. Use extract_urls to read the body of the top 3-5 most relevant URLs from the search results.
3. Answer the question concisely. After every factual claim, include an inline citation in the form [source: URL].
4. If you don't have enough information, say so — don't fabricate.

Be concise. Aim for 3-5 sentence answers unless the user asks for depth."""

def research(question: str) -> str:
    messages = [{"role": "user", "content": question}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=SYSTEM_PROMPT,
            tools=[WEB_SEARCH_TOOL, URL_EXTRACT_TOOL],
            messages=messages,
        )

        # Append the assistant's response (may contain tool_use blocks)
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            # Pull out the text answer
            return "\n".join(b.text for b in response.content if b.type == "text")

        if response.stop_reason == "tool_use":
            # Run every tool the model asked for in this turn
            tool_results = [
                call_tool(b)
                for b in response.content if b.type == "tool_use"
            ]
            messages.append({"role": "user", "content": tool_results})
            continue

        raise RuntimeError(f"Unexpected stop_reason: {response.stop_reason}")

モデルは 1 ターンで複数のツール呼び出しを発火できる点に注目 — 例えば 5 件の URL をまとめて 1 回の extract_urls 呼び出しでバッチ処理することがある。これは、アシスタントのターンから すべて の tool_use ブロックを集め、次のユーザーターンで すべて の tool_result ブロックを返すことでさばく。

4実行する

if __name__ == "__main__":
    answer = research("What were the major announcements at OpenAI DevDay this year?")
    print(answer)

こんな感じの出力が得られる:

OpenAI's DevDay focused on three themes: cheaper inference (a new gpt-mini
tier at roughly half the prior cost) [source: https://openai.com/devday-2026],
agent infrastructure (a managed agent runtime with persistent memory and
tool sandboxing) [source: https://techcrunch.com/...], and developer
tooling (a new Responses API replacing the legacy Assistants API)
[source: https://platform.openai.com/docs/...].

これがリサーチエージェントだ。約 120 行、2 つのツール、出典付きの出力。

コストはどれくらいか

標準的なリサーチ呼び出し 1 回:

  • 検索 1 回 — 15 credits($0.015)
  • 3〜5 件の URL をカバーする抽出 1 回 — 6〜10 credits($0.006〜$0.010)
  • Claude の入力約 3,000 + 出力 800 トークン — 約 $0.02

ざっくり:リサーチ回答 1 件あたり約 3 セント。1 日 1,000 回のリサーチ呼び出しで約 $30/日 — アナリスト 1 人の朝のコーヒー代くらいだ。予算を組むべきは検索 API ではなく LLM のほう。

本番向けの 3 つの仕上げ

1. 質問単位でキャッシュする

プロダクトが似た質問を扱うなら、(question, today's date) をハッシュ化して最終回答を 1 時間キャッシュする。ユーザー駆動のリサーチの多くは、ロングテールでもヒット率 30% 超になる。

2. 鮮度が重要なときは制約する

「今週」「今日」系の質問では、検索呼び出しの start_date に当日または前日をセットする。これがないと去年の記事が返ってくることがある。最も簡単な版:「質問に today、this week、recent、latest が含まれるときは常に start_date を入れる」 というシステムプロンプトのルール。

3. ハルシネーション対策のガードレール

最も効くルールは 1 つ:「回答に十分な抽出済みコンテンツがなければ、'I couldn't find a reliable source for this.' と返す。推測しない。」 これをシステムプロンプトに足すと、当社のテストでは捏造回答が一桁減った。

次の展開先

自然な拡張が 3 つ:

  • バーティカル化するWeb SearchAcademic Search に差し替えれば文献レビューエージェント、SEC Filings Search ならデューデリジェンス、Clinical Search なら医療リサーチになる。ハンドラは変わらない — 変わるのはシステムプロンプトだけ。
  • 構造化出力を足す:モデルに answercitations[] フィールドを持つ JSON を返させ、UI カードとしてレンダリングする。
  • トークンをストリームするclient.messages.stream() に切り替えて逐次出力に — 回答が長いときに有用。ツール呼び出しループは同じ。

よくある質問

なぜ単一の「answer」エンドポイントではなく 2 つのツールに分けるのか?

検索と抽出を分けると、何件の URL を読むか、いつ止めるか、どれを引用するかを自分で制御できる。単一のホスト型「answer」エンドポイントはこれを隠してしまい、再設計なしに戦略を変えるのが難しい。2 つのツールなら、同じコードがプロンプトを少し変えるだけで、朝のブリーフィングエージェントにも、文献レビューアにも、競合インテリジェンスのスクレイパーにもなる。

OpenAI Assistants / Responses API でも動く?

動く。アーキテクチャは同一で、変わるのはツール呼び出しブロックのパースと結果の提出方法だけ。ハンドラの形が tool_result コンテンツブロックではなく submit_tool_outputs(...) になるが、エージェントループとツール定義は同じ JSON のまま。

エージェントはいつ止まると判断するのか?

Claude は回答に十分な文脈が揃うと stop_reason: end_turn を返し、ツールを呼びたいときは tool_use を返す。ループは end_turn になるまで回り続けるだけ。実際にはモデルは回答前に通常 1〜3 回の検索/抽出サイクルを行う。

本当に出典を引用させるには?

レバーは 2 つ。1 つ目:システムプロンプトで、すべての主張の後にインラインで '[source: URL]' を必ず付けるよう明示的に要求する。2 つ目:URL Extract のレスポンスには URL が含まれるので、モデルが文脈にそれを持っていると自然に引用しやすくなる。引用が抜けるなら、回答を却下してモデルに引用追加を求める最終フォーマットパスを足す。

レイテンシは?

検索 1 往復は約 1 秒、抽出 1 バッチは URL 件数と JS レンダリング次第で 1〜4 秒。標準的な「質問をリサーチする」呼び出しは実時間 5〜15 秒に収まる。もっと速くしたいなら抽出ステップを並列化し(extract はすでに 1 回の呼び出しで URL のリストを取れる)、システムプロンプトの指示でモデルを 1 質問あたりツール 1 ラウンドに制約する。

この記事で使われている API

Sarah Choy
執筆
Sarah Choy
CEO, API Pick

API Pick の CEO。AI エージェントと LLM ワークフロー向けの本番運用可能な API について執筆。