[ blog · tutorial ]9 min read

Construire un agent de recherche avec Search + URL Extract (Claude tool use, de bout en bout)

Sarah ChoyPublié le 3 mai 20269 min de lecture
Construire un agent de recherche avec Search + URL Extract (Claude tool use, de bout en bout)

La plupart des tutos « agent de recherche IA » s'arrêtent à « voici une définition d'outil ». Celui-ci livre un agent qui tourne : une question en entrée, une réponse sourcée en sortie. Chercher, extraire, raisonner, citer — le tout en moins de 120 lignes de Python.

L'essentiel

  • La plus petite boucle d'agent de recherche réellement utile, c'est : chercher → choisir les liens → extraire le corps des pages → demander au modèle de répondre avec des citations en ligne.
  • Deux outils — Web Search (15 crédits) et URL Extract (2 crédits par URL) — couvrent 95 % des cas d'usage de réponse sourcée.
  • Claude tool use gère l'orchestration ; vous vous contentez de faire transiter les blocs tool_use → appels API → blocs tool_result jusqu'à ce que le modèle s'arrête.
  • Coût de bout en bout : ~25 crédits + ~$0.02 de tokens LLM par question à une profondeur typique.

Ce qu'on construit

Un agent de recherche qui prend une seule entrée — une question en langage naturel — et renvoie une réponse sourcée. Côté architecture :

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

Environ 120 lignes de Python. Deux outils — search et extract — et une seule boucle d'agent qui gère tout ce que Claude décide de faire. On va le construire à partir de zéro en 4 étapes.

1Récupérer les schémas d'outils (pas de JSON à la main)

Les deux endpoints publient une route de schéma d'outil qui renvoie une définition d'outil Claude dans la forme exacte attendue par 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.

Deux choses importantes que ça vous offre gratuitement : le schéma de paramètres (pour que Claude sache que vous acceptez query, country_code, start_date, etc.) et une description claire et adaptée au modèle de ce que fait l'outil.

2Écrire le handler d'outil

Quand Claude renvoie un bloc tool_use, votre travail consiste à appeler l'API réelle et à renvoyer un bloc tool_result. Une fonction par outil, un dispatcher pour router selon le nom choisi par 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,
    }

Trois points à noter. tool_use_id est ce qui permet à Claude de corréler le résultat avec son appel précédent — vous devez le renvoyer tel quel. is_error: True indique à Claude de se rétablir proprement (souvent en essayant une autre requête). Et on passe le texte brut de la réponse — Claude est à l'aise avec la forme JSON que renvoie API Pick, donc aucune transformation nécessaire.

3La boucle d'agent

La boucle est courte : envoyer la conversation, examiner chaque bloc de la réponse, exécuter les éventuels outils, ajouter à la conversation à la fois le tool_use de Claude et votre tool_result, répéter jusqu'à ce que stop_reason vaille 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}")

Remarquez que le modèle peut déclencher plusieurs appels d'outils en un seul tour — par exemple, il peut émettre un unique appel extract_urls avec cinq URL pour les traiter en lot. On gère ça en collectant tous les blocs tool_use du tour de l'assistant et en renvoyant tous les blocs tool_result au tour utilisateur suivant.

4Le lancer

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

Vous verrez quelque chose comme :

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

Voilà un agent de recherche. ~120 lignes, deux outils, une sortie sourcée.

Ce que ça coûte

Un appel de recherche typique :

  • 1 appel de recherche — 15 crédits ($0.015)
  • 1 appel d'extraction couvrant 3 à 5 URL — 6 à 10 crédits ($0.006–$0.010)
  • ~3 000 tokens en entrée + 800 en sortie côté Claude — ~$0.02

En chiffre rond : ~3 centimes par réponse recherchée. À 1 000 appels de recherche par jour, ça fait ~$30/jour — à peu près le budget café du matin d'un analyste. Budgétez le LLM, pas les API de recherche.

Trois raffinements pour la production

1. Mettre en cache par question

Si votre produit pose des questions similaires, hachez (question, today's date) et mettez la réponse finale en cache pendant une heure. La plupart des recherches pilotées par les utilisateurs ont un taux de réutilisation en longue traîne supérieur à 30 %.

2. Contraindre quand la fraîcheur compte

Pour les questions « cette semaine » / « aujourd'hui », fixez start_date dans l'appel de recherche à la date du jour ou à la veille. Sans cela, la recherche peut renvoyer des articles de l'an dernier. La version la plus simple : une règle de system prompt du type « toujours inclure start_date quand la question contient today, this week, recent ou latest ».

3. Garde-fous contre l'hallucination

La règle au plus fort effet de levier : « Si tu n'as pas assez de contenu extrait pour répondre, renvoie : Je n'ai pas trouvé de source fiable pour cela. Ne devine pas. » Ajouter ça au system prompt fait chuter d'un ordre de grandeur les réponses inventées dans nos tests.

Vers quoi l'emmener ensuite

Trois extensions naturelles :

  • Le verticaliser : remplacez Web Search par Academic Search pour un agent de revue de littérature, SEC Filings Search pour de la due diligence, ou Clinical Search pour la recherche médicale. Le handler ne change pas — seul le system prompt change.
  • Ajouter une sortie structurée : demandez au modèle de renvoyer du JSON avec des champs answer et citations[], afin de les afficher sous forme de carte UI.
  • Streamer les tokens : passez à client.messages.stream() pour une sortie incrémentale — utile quand la réponse est longue. La boucle d'appel d'outils reste la même.

Questions fréquentes

Pourquoi deux outils plutôt qu'un unique endpoint « answer » ?

Séparer search et extract vous donne le contrôle sur le nombre d'URL à lire, le moment où s'arrêter et lesquelles citer. Un endpoint « answer » hébergé masque tout ça — vous ne pouvez pas facilement changer de stratégie sans tout ré-architecturer. Avec deux outils, le même code devient un agent de briefing matinal, un relecteur de littérature ou un scraper de veille concurrentielle, juste en ajustant le prompt.

Est-ce que ça marche aussi avec OpenAI Assistants / Responses API ?

Oui. L'architecture est identique — la seule chose qui change, c'est la façon de parser les blocs d'appel d'outil et de soumettre les résultats. Le handler prend la forme submit_tool_outputs(...) au lieu d'un bloc de contenu tool_result, mais la boucle d'agent et les définitions d'outils sont le même JSON.

Comment l'agent décide-t-il de s'arrêter ?

Claude renvoie stop_reason: end_turn quand il a assez de contexte pour répondre, et tool_use quand il veut appeler un outil. La boucle continue simplement jusqu'à end_turn. En pratique, le modèle fait en général 1 à 3 cycles search/extract avant de répondre.

Comment garantir qu'il cite vraiment ses sources ?

Deux leviers. D'abord : dans le system prompt, exiger explicitement « [source: URL] » en ligne après chaque affirmation. Ensuite : la réponse d'URL Extract inclut l'URL, donc quand le modèle l'a en contexte il a tendance à citer naturellement. Si les citations passent à la trappe, ajoutez une passe de formatage finale qui rejette la réponse et demande au modèle d'ajouter les citations.

Et la latence ?

Chaque aller-retour de recherche fait ~1 s ; chaque lot d'extraction prend 1 à 4 s selon le nombre d'URL et le rendu JS. Un appel typique « rechercher une question » se boucle en 5 à 15 s de temps réel. Si vous avez besoin de plus de rapidité, parallélisez l'étape d'extraction (extract prend déjà une liste d'URL en un seul appel) et contraignez le modèle à un seul tour d'outil par question via les instructions du system prompt.

APIs utilisées dans cet article

Sarah Choy
Écrit par
Sarah Choy
CEO, API Pick

Sarah Choy est CEO d'API Pick. Elle écrit sur la création d'APIs prêtes pour la production destinées aux agents IA et aux workflows LLM.