[ blog · tutorial ]9 min read

Einen Research-Agenten mit Search + URL Extract bauen (Claude tool use, Ende zu Ende)

Sarah ChoyVeröffentlicht am 3. Mai 20269 Min. Lesezeit
Einen Research-Agenten mit Search + URL Extract bauen (Claude tool use, Ende zu Ende)

Die meisten 'AI-Research-Agent'-Tutorials hören bei 'hier ist eine Tool-Definition' auf. Dieses hier liefert einen lauffähigen Agenten: Frage rein, Antwort mit Quellen raus. Suchen, extrahieren, schlussfolgern, zitieren — alles in unter 120 Zeilen Python.

Auf einen Blick

  • Die kleinste nützliche Research-Agent-Schleife lautet: suchen → Links auswählen → Inhalte extrahieren → das Modell um eine Antwort mit inline-Quellenangaben bitten.
  • Zwei Tools — Web Search (15 Credits) und URL Extract (2 Credits pro URL) — decken 95 % der Anwendungsfälle für quellengestützte Antworten ab.
  • Claude tool use übernimmt die Orchestrierung; du reichst nur tool_use-Blöcke → API-Calls → tool_result-Blöcke durch, bis das Modell stoppt.
  • Kosten Ende zu Ende: ~25 Credits + ~$0.02 an LLM-Tokens pro Frage bei typischer Tiefe.

Was wir bauen

Einen Research-Agenten, der einen Input entgegennimmt — eine natürlichsprachige Frage — und eine quellengestützte Antwort zurückgibt. Architektonisch:

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

Rund 120 Zeilen Python. Zwei Tools — Search und Extract — und eine Agent-Schleife, die alles abwickelt, was Claude beschließt zu tun. Wir bauen ihn in 4 Schritten von Grund auf.

1Die Tool-Schemata abrufen (kein JSON von Hand)

Beide Endpoints stellen eine Tool-Schema-Route bereit, die eine Claude-Tool-Definition in genau der Form zurückgibt, die messages.create erwartet.

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.

Zwei wichtige Dinge, die du dabei gratis bekommst: das Parameter-Schema (damit Claude weiß, dass du query, country_code, start_date usw. akzeptierst) und eine klare, modellfreundliche Beschreibung dessen, was das Tool tut.

2Den Tool-Handler schreiben

Wenn Claude einen tool_use-Block zurückgibt, ist es deine Aufgabe, die eigentliche API aufzurufen und einen tool_result-Block zurückzugeben. Eine Funktion pro Tool, ein Dispatcher, der anhand des von Claude gewählten Namens routet.

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

Drei Dinge sind erwähnenswert. tool_use_id ist, wie Claude das Ergebnis mit seinem vorherigen Call korreliert — du musst es zurückspiegeln. is_error: True signalisiert Claude, sich elegant zu erholen (oft, indem es eine andere Query probiert). Und wir geben den rohen Response-Text weiter — Claude kommt mit der JSON-Form, die API Pick zurückgibt, gut zurecht, also ist keine Transformation nötig.

3Die Agent-Schleife

Die Schleife ist kurz: Sende die Konversation, sieh dir jeden Block in der Antwort an, führe alle Tools aus, hänge sowohl Claudes tool_use als auch deinen tool_result an die Konversation an, wiederhole, bis stop_reason gleich end_turn ist.

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}")

Beachte, dass das Modell in einer Runde mehrere Tool-Calls absetzen kann — zum Beispiel könnte es einen einzelnen extract_urls-Call mit fünf URLs absetzen, um sie zu bündeln. Wir behandeln das, indem wir alle tool_use-Blöcke aus der Assistant-Runde sammeln und alle tool_result-Blöcke in der nächsten User-Runde zurückgeben.

4Ausführen

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

Du wirst etwa Folgendes sehen:

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

Das ist ein Research-Agent. ~120 Zeilen, zwei Tools, Output mit Quellen.

Was das kostet

Ein typischer Research-Call:

  • 1 Search-Call — 15 Credits ($0.015)
  • 1 Extract-Call über 3–5 URLs — 6–10 Credits ($0.006–$0.010)
  • ~3.000 Input- + 800 Output-Claude-Tokens — ~$0.02

Über den Daumen: ~3 Cent pro recherchierter Antwort. Bei 1.000 Research-Calls pro Tag sind das ~$30/Tag — etwa das Budget für den morgendlichen Kaffee eines Analysten. Kalkuliere das LLM ein, nicht die Such-APIs.

Drei Verfeinerungen für die Produktion

1. Pro Frage cachen

Wenn dein Produkt ähnliche Fragen stellt, hashe (question, today's date) und cache die finale Antwort für eine Stunde. Die meiste nutzergetriebene Recherche hat Long-Tail-Trefferraten über 30 %.

2. Einschränken, wenn Aktualität zählt

Für 'diese Woche'- / 'heute'-Fragen setze start_date im Search-Call auf das aktuelle Datum oder gestern. Ohne das kann die Suche letztjährige Artikel zurückgeben. Die einfachste Variante: eine System-Prompt-Regel wie 'füge start_date immer hinzu, wenn die Frage today, this week, recent oder latest enthält'.

3. Guardrails gegen Halluzination

Die mit Abstand wirkungsvollste Regel: 'Wenn du nicht genug extrahierten Inhalt für eine Antwort hast, gib zurück: Ich konnte keine verlässliche Quelle dafür finden. Rate nicht.' Das in den System-Prompt aufzunehmen senkt erfundene Antworten in unseren Tests um eine Größenordnung.

Wohin man es als Nächstes bringt

Drei naheliegende Erweiterungen:

  • Vertikalisieren: Tausche Web Search gegen Academic Search für einen Literatur-Review-Agenten, SEC Filings Search für Due Diligence oder Clinical Search für medizinische Recherche. Der Handler ändert sich nicht — nur der System-Prompt.
  • Strukturierten Output hinzufügen: Bitte das Modell, JSON mit den Feldern answer und citations[] zurückzugeben, sodass du sie als UI-Card rendern kannst.
  • Tokens streamen: Wechsle zu client.messages.stream() für inkrementellen Output — nützlich, wenn die Antwort lang ist. Die Tool-Call-Schleife bleibt dieselbe.

Häufig gestellte Fragen

Warum zwei Tools statt eines einzigen 'Answer'-Endpoints?

Search und Extract zu trennen gibt dir Kontrolle darüber, wie viele URLs gelesen werden, wann gestoppt wird und welche zitiert werden. Ein einzelner gehosteter 'Answer'-Endpoint verbirgt das — du kannst die Strategie nicht einfach ändern, ohne neu zu architektieren. Mit zwei Tools wird derselbe Code allein durch Anpassen des Prompts zum Morning-Briefing-Agenten, zum Literatur-Reviewer oder zum Competitive-Intel-Scraper.

Funktioniert das auch mit der OpenAI Assistants / Responses API?

Ja. Die Architektur ist identisch — das Einzige, was sich ändert, ist, wie du die Tool-Call-Blöcke parst und die Ergebnisse übermittelst. Die Handler-Form wird zu submit_tool_outputs(...) statt eines tool_result-Content-Blocks, aber die Agent-Schleife und die Tool-Definitionen sind dasselbe JSON.

Wie entscheidet der Agent, wann er stoppt?

Claude liefert stop_reason: end_turn, wenn es genug Kontext für eine Antwort hat, und tool_use, wenn es ein Tool aufrufen will. Die Schleife läuft einfach weiter, bis end_turn kommt. In der Praxis macht das Modell meist 1–3 Such-/Extract-Zyklen, bevor es antwortet.

Wie stelle ich sicher, dass er tatsächlich Quellen zitiert?

Zwei Hebel. Erstens: Im System-Prompt explizit '[source: URL]' inline nach jeder Aussage verlangen. Zweitens: Die URL-Extract-Antwort enthält die URL, sodass das Modell, wenn es sie im Kontext hat, von selbst zum Zitieren neigt. Wenn Quellenangaben durchrutschen, füge einen abschließenden Formatierungsdurchlauf hinzu, der die Antwort ablehnt und das Modell auffordert, Quellen zu ergänzen.

Was ist mit der Latenz?

Jeder Such-Round-Trip dauert ~1 s; jeder Extract-Batch 1–4 s, je nach Anzahl der URLs und JS-Rendering. Ein typischer 'recherchiere eine Frage'-Call landet bei 5–15 s Wall-Clock-Zeit. Wenn du es schneller brauchst, parallelisiere den Extract-Schritt (Extract nimmt ohnehin eine Liste von URLs in einem Call entgegen) und beschränke das Modell per System-Prompt auf eine Tool-Runde pro Frage.

APIs in diesem Artikel

Sarah Choy
Geschrieben von
Sarah Choy
CEO, API Pick

Sarah Choy ist CEO von API Pick. Sie schreibt über produktionsreife APIs für KI-Agenten und LLM-Workflows.