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] citationsPython 約 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 が query、country_code、start_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_reason が end_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 Search を Academic Search に差し替えれば文献レビューエージェント、SEC Filings Search ならデューデリジェンス、Clinical Search なら医療リサーチになる。ハンドラは変わらない — 変わるのはシステムプロンプトだけ。
- 構造化出力を足す:モデルに
answerとcitations[]フィールドを持つ 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
API Pick の CEO。AI エージェントと LLM ワークフロー向けの本番運用可能な API について執筆。