Un agent IA, sur le fond, c'est une boucle : le modèle reçoit une question, décide s'il a besoin d'un outil, l'exécute, regarde le résultat, et recommence jusqu'à pouvoir répondre. Pas besoin de LangChain ni de framework pour ça — le SDK officiel et une trentaine de lignes de Python suffisent à comprendre (et à faire tourner) le mécanisme. C'est la même philosophie que le serveur MCP minimal en PHP : déballer le protocole avant d'empiler des abstractions dessus.

Un point de vocabulaire avant de commencer : est-ce qu'on a vraiment besoin d'un agent ici ? Si la séquence d'étapes est connue à l'avance, un workflow scripté fait mieux. La boucle ci-dessous n'a de sens que quand le nombre et l'ordre des appels d'outils varient selon la question posée.

La boucle en trois temps

Le pattern s'appelle ReAct (reason, act, observe) : le modèle raisonne sur ce qu'il sait, choisit une action (appeler un outil ou répondre), observe le résultat, et reboucle. Côté code, ça se traduit par une boucle qui alterne un appel API et l'exécution locale des tools que le modèle a demandés.

Question messages[0] LLM messages.create() end_turn Réponse finale stop_reason tool_use Exécuter le tool tool.handler(**input) tool_result
Tant que le modèle répond avec stop_reason: tool_use, la boucle réinjecte le résultat de l'outil et rappelle l'API. Elle s'arrête sur end_turn — ou sur un garde-fou max_turns.

Définir les tools

Un tool, c'est un nom, une description, un schéma JSON pour les arguments, et une fonction Python qui fait le travail. Une dataclass évite le boilerplate d'une classe classique (pas de __init__ à écrire) tout en gardant le typage :

from dataclasses import dataclass
from typing import Callable, Any


@dataclass
class Tool:
    name: str
    description: str
    input_schema: dict[str, Any]
    handler: Callable[..., str]

    def to_api(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "input_schema": self.input_schema,
        }

Deux tools pour l'exemple : une météo simulée, et un calcul. Le calcul est l'occasion d'un vrai piège :

import ast
import operator
import json

def get_weather(city: str) -> str:
    # ici un vrai appel API météo ; simulé pour l'exemple
    return json.dumps({"city": city, "temp_c": 21, "condition": "nuageux"})


def calculate(expression: str) -> str:
    # eval(expression) marcherait, mais exécuterait n'importe quel code
    # que le modèle (ou un prompt injecté) déciderait de mettre dans expression
    ops = {ast.Add: operator.add, ast.Sub: operator.sub,
           ast.Mult: operator.mul, ast.Div: operator.truediv}

    def _eval(node):
        if isinstance(node, ast.Constant):
            return node.value
        if isinstance(node, ast.BinOp):
            return ops[type(node.op)](_eval(node.left), _eval(node.right))
        raise ValueError("expression non supportée")

    return str(_eval(ast.parse(expression, mode="eval").body))


TOOLS = [
    Tool(
        name="get_weather",
        description="Donne la météo actuelle d'une ville",
        input_schema={
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
        handler=get_weather,
    ),
    Tool(
        name="calculate",
        description="Évalue une expression arithmétique simple (+ - * /)",
        input_schema={
            "type": "object",
            "properties": {"expression": {"type": "string"}},
            "required": ["expression"],
        },
        handler=calculate,
    ),
]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}

calculate parse l'expression en arbre syntaxique (ast) et n'évalue que les nœuds qu'il reconnaît explicitement — addition, soustraction, multiplication, division. Tout le reste lève une exception. C'est plus verbeux qu'eval(), c'est le prix d'un tool qui ne peut pas devenir un accès shell.

La boucle complète

Le cœur du fichier. messages accumule tout l'historique — questions, réponses du modèle, résultats d'outils — puisque l'API est sans état : chaque appel renvoie l'intégralité de la conversation.

import anthropic

client = anthropic.Anthropic()  # lit ANTHROPIC_API_KEY dans l'environnement


def run_agent(question: str, max_turns: int = 6) -> str:
    messages: list[dict[str, Any]] = [{"role": "user", "content": question}]

    for _ in range(max_turns):
        response = client.messages.create(
            model="claude-sonnet-5",
            max_tokens=1024,
            tools=[t.to_api() for t in TOOLS],
            messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return "".join(b.text for b in response.content if b.type == "text")

        tool_results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            tool = TOOLS_BY_NAME[block.name]
            try:
                output = tool.handler(**block.input)
            except Exception as exc:
                output = f"erreur: {exc}"
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
            })
        messages.append({"role": "user", "content": tool_results})

    return "budget de tours dépassé sans réponse finale"


if __name__ == "__main__":
    print(run_agent("Quelle température fait-il à Lyon, et combien font 12 * (3 + 5) ?"))

Le modèle peut demander les deux tools dans la même réponse (response.content contient alors plusieurs blocs tool_use) : la boucle for block in response.content les exécute tous avant de renvoyer les résultats groupés, en un seul aller-retour.

Idiome à noter : dispatch par match

La condition if block.type != "tool_use" suffit avec deux types de blocs. Dès qu'il y en a plus (texte, tool_use, et par exemple des blocs de réflexion étendue), match (Python 3.10+) rend le dispatch plus lisible qu'une chaîne de if/elif — le pendant du match déjà utilisé côté PHP 8 dans le serveur MCP minimal :

match block.type:
    case "text":
        texte += block.text
    case "tool_use":
        resultats.append(execute(block))
    case "thinking":
        pass  # bloc de raisonnement, rien à exécuter
    case _:
        raise ValueError(f"type de bloc inattendu: {block.type}")

Garde-fous à mettre en place

Le squelette ci-dessus fonctionne, mais rien n'empêche encore un tool mal conçu ou un modèle qui boucle de faire n'importe quoi. Cinq points à couvrir avant de laisser tourner ça sans supervision :

RisqueGarde-fou
Boucle qui ne s'arrête jamaisLimite de tours explicitemax_turns dans run_agent
Tool qui exécute du code arbitraireParser restreint plutôt qu'eval()calculate(), module ast
Résultat de tool non fiable réinjecté tel quelTraiter le contenu externe comme non fiablevoir injection de prompt : défense
Aucune visibilité sur le dérouléLogger chaque tour (input, tool appelé, sortie)voir observabilité d'un agent
Trop de tools, contexte qui exploseDécouper en agents spécialisésvoir agents et sous-agents
Le code de la boucle tient en une page ; ces garde-fous sont ce qui sépare une démo d'un agent qu'on peut laisser tourner sans surveillance constante.

Sur le premier point en particulier : avant même d'ajouter des garde-fous, la bonne question est est-ce que cette tâche justifie une boucle agentique plutôt qu'un simple appel avec tools, sans réinjection. Beaucoup de cas s'arrêtent après un seul tour.

Aller plus loin

Ce squelette suffit à comprendre le mécanisme et à prototyper. Pour une vraie mise en prod : streaming de la réponse pendant l'exécution, retry avec backoff sur les erreurs réseau, persistance de messages entre les requêtes d'une conversation, et exécution des tools dans un bac à sable si leurs effets de bord sont sensibles (accès fichier, réseau, DB).

FAQ

Faut-il un framework comme LangChain pour faire un agent en Python ?

Non — la boucle tool_use/tool_result tient en une trentaine de lignes avec le SDK officiel. Un framework devient utile à partir d'une certaine complexité (orchestration multi-agents, mémoire persistante), pas pour comprendre ou prototyper le mécanisme de base.

Pourquoi éviter eval() dans un tool de calcul ?

Parce qu'un modèle (ou un prompt injecté via un résultat d'outil) peut faire passer n'importe quel code dans l'expression évaluée. Un parser restreint via le module ast limite le tool aux opérations explicitement reconnues.

Combien de tours faut-il autoriser dans la boucle ?

Ça dépend de la tâche, mais un max_turns explicite est indispensable dans tous les cas : sans lui, un agent qui n'atteint jamais de réponse finale boucle indéfiniment et fait exploser le coût en tokens.