[ blog · tutorial ]9 min read

Zbuduj agenta badawczego z Web Search + URL Extract (tool use w Claude, od początku do końca)

Sarah ChoyOpublikowano 3 maja 20269 min czytania
Zbuduj agenta badawczego z Web Search + URL Extract (tool use w Claude, od początku do końca)

Większość poradników o 'agencie badawczym AI' kończy się na 'oto definicja narzędzia'. Ten dostarcza działającego agenta: na wejściu pytanie, na wyjściu odpowiedź z cytowaniami. Szukaj, ekstrahuj, rozumuj, cytuj — wszystko w niecałych 120 liniach Pythona.

TL;DR

  • Najmniejsza użyteczna pętla agenta badawczego to: wyszukaj → wybierz linki → wyekstrahuj treści → poproś model o odpowiedź z cytowaniami w tekście.
  • Dwa narzędzia — Web Search (15 kredytów) i URL Extract (2 kredyty za URL) — pokrywają 95% przypadków odpowiedzi opartych na źródłach.
  • Tool use w Claude zajmuje się orkiestracją; ty tylko przerzucasz bloki tool_use → wywołania API → bloki tool_result, aż model się zatrzyma.
  • Koszt od początku do końca: ~25 kredytów + ~$0.02 w tokenach LLM na pytanie przy typowej głębokości.

Co budujemy

Agenta badawczego, który przyjmuje jedno wejście — pytanie w języku naturalnym — i zwraca odpowiedź ze źródłami. Architektonicznie:

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

Około 120 linii Pythona. Dwa narzędzia — search i extract — oraz jedna pętla agenta, która obsługuje cokolwiek Claude zdecyduje się zrobić. Zbudujemy to od zera w 4 krokach.

1Pobierz schematy narzędzi (żadnego JSON-a ręcznie)

Oba endpointy udostępniają trasę tool-schema, która zwraca definicję narzędzia Claude w dokładnie takim kształcie, jakiego oczekuje messages.create.

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.

Dwie ważne rzeczy, które dostajesz za darmo: schemat parametrów (aby Claude wiedział, że akceptujesz query, country_code, start_date itd.) oraz jasny, przyjazny dla modelu opis tego, co robi narzędzie.

2Napisz handler narzędzia

Gdy Claude zwraca blok tool_use, twoim zadaniem jest wywołać właściwe API i zwrócić blok tool_result. Jedna funkcja na narzędzie, jeden dispatcher kierujący na podstawie nazwy wybranej przez Claude.

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,
    }

Trzy rzeczy warte uwagi. tool_use_id to sposób, w jaki Claude koreluje wynik ze swoim wcześniejszym wywołaniem — musisz go odesłać z powrotem. is_error: True mówi Claude, by odzyskał równowagę z gracją (często próbując innego zapytania). A my przekazujemy surowy tekst odpowiedzi — Claude czuje się swobodnie z kształtem JSON zwracanym przez API Pick, więc żadna transformacja nie jest potrzebna.

3Pętla agenta

Pętla jest krótka: wyślij konwersację, przejrzyj każdy blok w odpowiedzi, uruchom wszelkie narzędzia, dołącz do konwersacji zarówno tool_use Claude, jak i swój tool_result, i powtarzaj, aż stop_reason będzie równe 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}")

Zauważ, że model może wystrzelić wiele wywołań narzędzi w jednej turze — na przykład może wydać jedno wywołanie extract_urls z pięcioma adresami URL, aby przetworzyć je wsadowo. Obsługujemy to, zbierając wszystkie bloki tool_use z tury asystenta i zwracając wszystkie bloki tool_result w następnej turze użytkownika.

4Uruchom to

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

Zobaczysz coś w stylu:

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/...].

Oto agent badawczy. ~120 linii, dwa narzędzia, wynik ze źródłami.

Ile to kosztuje

Typowe wywołanie badawcze:

  • 1 wywołanie wyszukiwania — 15 kredytów ($0.015)
  • 1 wywołanie ekstrakcji obejmujące 3–5 adresów URL — 6–10 kredytów ($0.006–$0.010)
  • ~3,000 tokenów wejściowych + 800 wyjściowych Claude — ~$0.02

W zaokrągleniu: ~3 centy za zbadaną odpowiedź. Przy 1,000 wywołań badawczych dziennie to ~$30/dzień — mniej więcej tyle, ile budżet na poranną kawę jednego analityka. Budżetuj LLM, a nie API wyszukiwania.

Trzy usprawnienia produkcyjne

1. Buforuj według pytania

Jeśli twój produkt zadaje podobne pytania, policz hash z (question, today's date) i buforuj końcową odpowiedź przez godzinę. Większość badań napędzanych przez użytkowników ma współczynniki trafień długiego ogona powyżej 30%.

2. Ogranicz, gdy liczy się świeżość

Dla pytań o 'ten tydzień' / 'dzisiaj' ustaw start_date w wywołaniu wyszukiwania na bieżącą datę lub wczoraj. Bez tego wyszukiwanie może zwrócić zeszłoroczne artykuły. Najprostsza wersja: reguła w system prompcie typu 'zawsze dołączaj start_date, gdy pytanie zawiera dzisiaj, ten tydzień, niedawny lub najnowszy'.

3. Zabezpieczenia przed halucynacją

Reguła o największej dźwigni: 'Jeśli nie masz wystarczającej wyekstrahowanej treści, by odpowiedzieć, zwróć: Nie udało mi się znaleźć wiarygodnego źródła dla tego. Nie zgaduj.' Dodanie tego do system promptu zmniejsza liczbę zmyślonych odpowiedzi o rząd wielkości w naszych testach.

Dokąd zabrać to dalej

Trzy naturalne rozszerzenia:

  • Zwertykalizuj to: zamień Web Search na Academic Search dla agenta do przeglądu literatury, SEC Filings Search dla due diligence lub Clinical Search dla badań medycznych. Handler się nie zmienia — zmienia się tylko system prompt.
  • Dodaj ustrukturyzowany wynik: poproś model o zwrócenie JSON-a z polami answer i citations[], abyś mógł wyrenderować je jako kartę UI.
  • Strumieniuj tokeny: przełącz się na client.messages.stream(), aby uzyskać wynik przyrostowy — przydatne, gdy odpowiedź jest długa. Pętla tool-call jest taka sama.

Najczęściej zadawane pytania

Dlaczego dwa narzędzia zamiast jednego endpointu 'answer'?

Rozdzielenie wyszukiwania i ekstrakcji daje ci kontrolę nad tym, ile adresów URL czytać, kiedy przestać i które cytować. Pojedynczy hostowany endpoint 'answer' to ukrywa — nie da się łatwo zmienić strategii bez przebudowy architektury. Mając dwa narzędzia, ten sam kod staje się agentem porannego briefingu, recenzentem literatury lub scraperem wywiadu konkurencyjnego — wystarczy zmienić prompt.

Czy to działa też z OpenAI Assistants / Responses API?

Tak. Architektura jest identyczna — zmienia się tylko sposób, w jaki parsujesz bloki tool-call i przesyłasz wyniki. Kształt handlera staje się submit_tool_outputs(...) zamiast bloku treści tool_result, ale pętla agenta i definicje narzędzi to ten sam JSON.

Jak agent decyduje, kiedy się zatrzymać?

Claude zwraca stop_reason: end_turn, gdy ma wystarczający kontekst, aby odpowiedzieć, oraz tool_use, gdy chce wywołać narzędzie. Pętla po prostu działa aż do end_turn. W praktyce model zwykle wykonuje od 1 do 3 cykli wyszukiwania/ekstrakcji przed udzieleniem odpowiedzi.

Jak mam się upewnić, że faktycznie cytuje źródła?

Dwie dźwignie. Po pierwsze: w system prompcie wyraźnie wymagaj '[source: URL]' w tekście po każdym stwierdzeniu. Po drugie: odpowiedź URL Extract zawiera URL, więc gdy model ma go w kontekście, ma tendencję do naturalnego cytowania. Jeśli cytowania się gubią, dodaj końcowy krok formatowania, który odrzuca odpowiedź i prosi model o dodanie cytowań.

A co z opóźnieniem?

Każda runda wyszukiwania to ~1s; każda partia ekstrakcji to 1–4s w zależności od liczby adresów URL i renderowania JS. Typowe wywołanie 'zbadaj pytanie' mieści się w 5–15s rzeczywistego czasu. Jeśli potrzebujesz szybciej, zrównolegl krok ekstrakcji (extract już przyjmuje listę adresów URL w jednym wywołaniu) i ogranicz model do jednej rundy narzędzi na pytanie za pomocą instrukcji w system prompcie.

API użyte w tym artykule

Sarah Choy
Autor
Sarah Choy
CEO, API Pick

Sarah Choy jest CEO API Pick. Pisze o budowaniu produkcyjnych API dla agentów AI i przepływów pracy z LLM.