Skip to content

Classes & dataclasses

TL;DR — En Python moderne (3.12), une class "normale" sert à porter du comportement et un cycle de vie (état mutable, méthodes, héritage, async), tandis qu'une @dataclass est le bon défaut dès que l'objet est surtout un sac de données typées : elle génère __init__, __repr__, __eq__ et bien plus à partir des annotations de champs, sans boilerplate. La règle senior : dataclass pour la donnée interne, Pydantic v2 pour la donnée qui franchit une frontière (requête HTTP, payload LLM, config externe) parce que Pydantic valide et coerce, là où une dataclass ne fait que structurer. Tout au long, on tisse ça avec le service d'un agent LLM via le SDK Anthropic (AsyncAnthropic) : modéliser un message, un appel d'outil (tool_use), une sortie structurée, et l'état d'une boucle agentique.


🧠 Mental model

Pense à tes objets comme à trois types de récipients dans une cuisine de restaurant.

  • La class classique, c'est le robot de cuisine : il a un état (le bol est plein ou vide), des boutons (méthodes), il peut être branché en mode async (il tourne pendant que tu fais autre chose), et tu peux en dériver un modèle "pro" (héritage). Tu l'utilises quand l'objet fait des choses.
  • La @dataclass, c'est la barquette étiquetée : tu déclares ce qu'il y a dedans (name: str, price: float) et Python imprime automatiquement l'étiquette (__repr__), la balance pour comparer deux barquettes (__eq__), et le remplissage (__init__). Zéro cérémonie. Mais personne ne vérifie que tu n'as pas mis du poisson dans la barquette "dessert" : structure, pas validation.
  • Le modèle Pydantic v2, c'est la barquette avec contrôle qualité à l'entrée : non seulement elle est étiquetée, mais un inspecteur refuse la livraison si le contenu ne correspond pas ("3" devient 3 ou explose, un email mal formé est rejeté). C'est ce que tu veux à la porte du restaurant — l'API HTTP, la réponse du LLM.
                 fait des choses ?            franchit une frontière ?
                 (état, async, méthodes)      (HTTP, LLM, fichier, env)
                        │                              │
            ┌───────────┴───────────┐      ┌───────────┴───────────┐
            ▼                        ▼      ▼                        ▼
        class                   @dataclass                   pydantic.BaseModel
   (AgentRunner,           (Message, ToolCall,            (ChatRequest, AgentReply,
    LLMService...)          état interne typé)             Settings, structured output)
   comportement            données internes              données validées/coercées

Pour un ex-dev PHP/TS, le raccourci mental : une @dataclass ≈ un interface TS qui se matérialise à l'exécution, et un modèle Pydantic ≈ une classe TS décorée avec class-validator / un schéma Zod côté serveur. NestJS te l'a déjà appris : les DTO se valident à la frontière, les entités internes non. Même découpage ici.


Le socle : la class classique

Avant les dataclasses, on écrit beaucoup de code répétitif. Voici la manière naïve — celle qu'on retrouve dans tout code hérité :

python
# ❌ Verbeux, source de bugs : à maintenir à la main
class Message:
    def __init__(self, role: str, content: str, tokens: int = 0) -> None:
        self.role = role
        self.content = content
        self.tokens = tokens

    def __repr__(self) -> str:
        return f"Message(role={self.role!r}, content={self.content!r}, tokens={self.tokens})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Message):
            return NotImplemented
        return (self.role, self.content, self.tokens) == (other.role, other.content, other.tokens)

Chaque champ apparaît quatre fois (__init__ signature, __init__ corps, __repr__, __eq__). Ajoute un champ et tu peux en oublier un — c'est exactement comme ça que __eq__ finit par mentir.

La class reste indispensable, mais pour le comportement, pas pour la donnée. Exemple légitime : un service qui fait des choses (état, méthodes, async).

python
import os
from anthropic import AsyncAnthropic


class LLMService:
    """Porte le client, la config, et la logique d'appel. C'est du comportement → class."""

    def __init__(self, *, model: str = "claude-opus-4-8", api_key: str | None = None) -> None:
        self._client = AsyncAnthropic(api_key=api_key or os.environ["ANTHROPIC_API_KEY"])
        self._model = model

    async def ask(self, prompt: str) -> str:
        # streaming par défaut dès qu'une réponse peut être longue : évite les timeouts HTTP
        async with self._client.messages.stream(
            model=self._model,
            max_tokens=16_000,
            thinking={"type": "adaptive"},  # adaptatif = le bon défaut sur Opus 4.8
            messages=[{"role": "user", "content": prompt}],
        ) as stream:
            message = await stream.get_final_message()
        return "".join(block.text for block in message.content if block.type == "text")

Un objet avec un client réseau, de l'état (_model), des méthodes async : ça, c'est une vraie classe. On n'en ferait jamais une dataclass.


La @dataclass : la donnée interne, sans boilerplate

Réécrivons Message proprement :

python
from dataclasses import dataclass


@dataclass
class Message:
    role: str
    content: str
    tokens: int = 0

C'est tout. Python génère __init__, __repr__, __eq__. L'ordre des annotations fait la signature du constructeur, et un champ avec défaut ne peut pas précéder un champ sans défaut (même règle que les arguments de fonction).

python
m = Message(role="user", content="Bonjour")
print(m)                       # Message(role='user', content='Bonjour', tokens=0)
m == Message("user", "Bonjour")  # True — __eq__ compare champ par champ

Les options qui comptent en production

python
from dataclasses import dataclass, field


@dataclass(frozen=True, slots=True, kw_only=True)
class ToolCall:
    """Un appel d'outil émis par le LLM. Immuable : on ne le modifie jamais après réception."""
    id: str
    name: str
    arguments: dict[str, object] = field(default_factory=dict)

Trois drapeaux que tout senior connaît :

  • frozen=True → instances immuables (hashables, sûres à partager entre coroutines). Réassigner un champ lève FrozenInstanceError. Pour de la donnée qui décrit un fait passé (un appel d'outil reçu, un message déjà envoyé), c'est le défaut correct.
  • slots=True (3.10+) → l'objet n'a pas de __dict__, ce qui réduit l'empreinte mémoire et accélère l'accès aux attributs. Crucial quand tu en crées des dizaines de milliers (historique de conversation, traces). Le piège : pas d'attribut dynamique non déclaré.
  • kw_only=True → force l'appel par mots-clés (ToolCall(id=..., name=...)). Rend le code appelant lisible et te permet d'ajouter/réordonner des champs sans casser les appels positionnels existants.

Le piège n°1 : le défaut mutable

python
# ❌ BUG classique : la liste est partagée entre TOUTES les instances
@dataclass
class Conversation:
    messages: list[Message] = []   # TypeError à la définition en dataclass, mais...

En dataclass, ça lève en fait une ValueError à la définition (Python te protège), mais le réflexe à comprendre est universel : un défaut mutable est évalué une seule fois. La solution est field(default_factory=...) :

python
# ✅ Une nouvelle liste par instance
@dataclass
class Conversation:
    model: str = "claude-opus-4-8"
    messages: list[Message] = field(default_factory=list)
    metadata: dict[str, str] = field(default_factory=dict)

__post_init__ : la validation légère

Une dataclass ne valide rien par défaut. Si tu veux un invariant interne (pas une frontière externe), __post_init__ s'exécute après l'__init__ généré :

python
@dataclass
class TokenBudget:
    max_tokens: int

    def __post_init__(self) -> None:
        if self.max_tokens <= 0:
            raise ValueError(f"max_tokens doit être > 0, reçu {self.max_tokens}")

⚠️ Attention : avec frozen=True, tu ne peux pas réassigner dans __post_init__ (il faut passer par object.__setattr__). Et __post_init__ ne fait que ce que tu codes — il ne coerce pas les types. TokenBudget(max_tokens="100") accepte la string sans broncher. C'est précisément la limite qui justifie Pydantic.


Pydantic v2 : la donnée qui franchit une frontière

Dès qu'une donnée vient de dehors — corps de requête HTTP, réponse JSON d'un LLM, variable d'environnement — tu veux qu'elle soit validée et coercée, pas seulement structurée. C'est le domaine de Pydantic v2.

python
from pydantic import BaseModel, Field, field_validator


class ChatRequest(BaseModel):
    """Frontière HTTP : ce que le client envoie. Validé à la coercition."""
    prompt: str = Field(min_length=1, max_length=8_000)
    model: str = "claude-opus-4-8"
    max_tokens: int = Field(default=16_000, gt=0, le=128_000)

    @field_validator("prompt")
    @classmethod
    def strip_prompt(cls, v: str) -> str:
        return v.strip()
python
ChatRequest(prompt="  salut  ", max_tokens="2048")
# → ChatRequest(prompt='salut', model='claude-opus-4-8', max_tokens=2048)
#   La string "2048" a été COERCÉE en int. Un prompt vide → ValidationError.

Compare avec la dataclass équivalente : ChatRequest(max_tokens="2048") en dataclass te laisserait un str traîner dans max_tokens jusqu'à ce qu'il explose 30 lignes plus loin, au pire endroit. Pydantic échoue tôt, à la frontière, avec un message exploitable.

La frontière idéale : FastAPI fait le câblage

Dans FastAPI, un modèle Pydantic en paramètre de fonction est le contrat d'entrée. La validation, la doc OpenAPI et la coercition sont automatiques :

python
from fastapi import FastAPI, Depends, HTTPException
from functools import lru_cache

app = FastAPI()


@lru_cache
def get_llm_service() -> LLMService:
    """Injection de dépendance : un seul client réutilisé (le client porte un pool de connexions)."""
    return LLMService()


class ChatReply(BaseModel):
    """Frontière de sortie : ce qu'on renvoie au client. Sérialisé automatiquement."""
    answer: str
    model: str


@app.post("/chat")
async def chat(
    req: ChatRequest,                                  # validé en entrée
    svc: LLMService = Depends(get_llm_service),         # injecté
) -> ChatReply:                                         # validé/sérialisé en sortie
    answer = await svc.ask(req.prompt)
    return ChatReply(answer=answer, model=req.model)

Tu n'écris aucun parsing JSON, aucune vérif manuelle. C'est exactement le rôle qu'avaient tes DTO + pipes de validation en NestJS — sauf qu'ici le modèle Pydantic est à la fois le DTO, le validateur et le schéma OpenAPI.

Sorties structurées du LLM : Pydantic comme contrat de réponse

C'est ici que ça devient concret pour un builder d'agents. Le SDK Anthropic sait contraindre la réponse du modèle à un schéma via messages.parse() (sorties structurées natives), et il te rend directement une instance Pydantic validée :

python
from pydantic import BaseModel


class Sentiment(BaseModel):
    label: str        # "positive" | "negative" | "neutral"
    confidence: float
    rationale: str


async def classify(svc_client: AsyncAnthropic, text: str) -> Sentiment:
    message = await svc_client.messages.parse(
        model="claude-opus-4-8",
        max_tokens=1024,
        messages=[{"role": "user", "content": f"Classe le sentiment : {text}"}],
        output_format=Sentiment,  # le modèle est contraint à ce schéma Pydantic
    )
    # message.parsed_output est une instance Sentiment validée (ou None si refus/troncature)
    result = message.parsed_output
    if result is None:
        raise RuntimeError(f"Pas de sortie structurée (stop_reason={message.stop_reason})")
    return result

La dataclass n'aurait jamais pu jouer ce rôle : le LLM renvoie du JSON depuis l'extérieur, donc c'est une frontière → Pydantic. Le SDK valide la réponse contre ton schéma et te garantit une instance bien typée — ou un échec explicite (stop_reason == "refusal", troncature max_tokens).


La synthèse : dataclasses dans l'agent, Pydantic autour

Voici comment les trois cohabitent dans une boucle agentique (le pattern tool-use). L'état interne et les messages sont des dataclasses (rapides, immuables, pas besoin de valider ce qu'on a construit nous-mêmes) ; ce qui entre/sort traverse Pydantic.

python
from dataclasses import dataclass, field
from anthropic import AsyncAnthropic


@dataclass(slots=True)
class AgentState:
    """État INTERNE de la boucle : on le construit nous-mêmes → dataclass."""
    messages: list[dict[str, object]] = field(default_factory=list)
    iterations: int = 0
    max_iterations: int = 10

    def push(self, role: str, content: object) -> None:
        self.messages.append({"role": role, "content": content})


# Côté outil : un outil que NOTRE harness exécute.
def get_weather(city: str) -> str:
    return f"Il fait 18°C et nuageux à {city}."


TOOLS = [{
    "name": "get_weather",
    "description": "Donne la météo actuelle d'une ville. À appeler dès que l'utilisateur demande le temps qu'il fait.",
    "input_schema": {
        "type": "object",
        "properties": {"city": {"type": "string", "description": "Nom de la ville"}},
        "required": ["city"],
    },
}]


class AgentRunner:
    """Comportement : pilote la boucle, exécute les outils → class."""

    def __init__(self, client: AsyncAnthropic, model: str = "claude-opus-4-8") -> None:
        self._client = client
        self._model = model

    async def run(self, user_prompt: str) -> str:
        state = AgentState()
        state.push("user", user_prompt)

        while state.iterations < state.max_iterations:
            state.iterations += 1
            resp = await self._client.messages.create(
                model=self._model,
                max_tokens=16_000,
                thinking={"type": "adaptive"},
                tools=TOOLS,
                messages=state.messages,
            )
            state.push("assistant", resp.content)

            if resp.stop_reason == "refusal":
                raise RuntimeError("Le modèle a refusé la requête.")
            if resp.stop_reason != "tool_use":
                # end_turn : on extrait le texte final
                return "".join(b.text for b in resp.content if b.type == "text")

            # Le modèle veut appeler des outils : on les exécute tous, puis on reboucle
            tool_results: list[dict[str, object]] = []
            for block in resp.content:
                if block.type == "tool_use":
                    output = self._dispatch(block.name, block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,   # toujours rattacher au bon id
                        "content": output,
                    })
            state.push("user", tool_results)

        raise RuntimeError(f"Budget d'itérations épuisé ({state.max_iterations})")

    @staticmethod
    def _dispatch(name: str, args: dict[str, object]) -> str:
        if name == "get_weather":
            return get_weather(**args)
        return f"Outil inconnu : {name}"

Note le découpage : AgentState est une dataclass (slots=True parce qu'on en crée potentiellement beaucoup en parallèle), AgentRunner est une vraie classe (état + async + méthodes), et chaque tool_use/tool_result est rattaché par tool_use_id — l'erreur classique étant de renvoyer les résultats dans le désordre ou sans id.


⚙️ En production

Modes de défaillance.

  • Défaut mutable partagé : le bug le plus fréquent, et le plus sournois en async — deux requêtes concurrentes mutent la même liste de classe. field(default_factory=...) systématiquement, et frozen=True pour tout ce qui décrit un fait immuable.
  • Confondre frontière et interne : valider une frontière avec une dataclass = bombe à retardement (un str au lieu d'un int se propage). Structurer un objet interne avec Pydantic = surcoût de validation inutile sur le chemin chaud. Choisis selon "ça vient de dehors ?".
  • stop_reason non géré : sur Opus 4.8, une réponse peut s'arrêter en refusal (HTTP 200 quand même) ou max_tokens (sortie tronquée). Lire resp.content[0] sans vérifier stop_reason plante sur les refus (contenu vide). Toujours brancher sur stop_reason avant de lire le contenu.
  • parsed_output is None : messages.parse() renvoie None si le modèle refuse ou si la sortie est tronquée. Ne suppose jamais que c'est rempli.

Performance.

  • slots=True sur les dataclasses créées en masse (historiques, traces, événements de stream) : moins de mémoire, accès attribut plus rapide, pas de __dict__.
  • Pydantic v2 a son cœur de validation en Rust (pydantic-core) — rapide, mais une validation reste plus coûteuse qu'une affectation de dataclass. Ne valide pas en boucle ce que tu as déjà validé à l'entrée.
  • Streaming par défaut dès que max_tokens est élevé (> ~16K) ou la sortie potentiellement longue : messages.stream() + get_final_message() évite les timeouts HTTP du SDK. Le prompt caching (cache_control) sur un long system prompt stable réduit drastiquement le coût des appels répétés d'un agent.

Sécurité.

  • Les modèles Pydantic à la frontière sont ta première ligne : max_length sur les prompts borne la surface d'injection et le coût (un prompt de 8000 caractères max, pas illimité).
  • N'expose jamais une dataclass interne directement en réponse HTTP "parce que c'est pratique" — tu fuis des champs (clés, ids internes). Définis un modèle Pydantic de sortie explicite.
  • Les arguments d'un tool_use viennent du LLM : traite-les comme une entrée non fiable. Valide-les (idéalement via un modèle Pydantic par outil) avant de les passer à du code à effet de bord.

Observabilité.

  • __repr__ auto-généré des dataclasses = logs lisibles gratuits. Pour les secrets, override le champ ou utilise field(repr=False).
  • usage sur la réponse (input_tokens, output_tokens, cache_read_input_tokens) doit être loggé par appel pour le suivi de coût. Modélise-le en dataclass/Pydantic et émets-le dans tes traces.

Le tradeoff senior, en une phrase. Dataclass = vitesse et simplicité au prix de zéro garantie d'intégrité ; Pydantic = garanties d'intégrité au prix d'un coût de validation et d'une dépendance. La maturité, c'est de mettre la frontière au bon endroit : valider une fois, tôt, puis faire confiance à des dataclasses rapides en interne.


🏋️ Exercices

Exercice 1 — Du boilerplate à la dataclass (implémenter)

Objectif. Prends une classe Usage écrite à la main (champs input_tokens, output_tokens, cache_read_input_tokens, défaut 0) avec __init__/__repr__/__eq__ manuels, et réécris-la en @dataclass. Ajoute une @property total qui somme les trois, et une méthode cost_usd(self, in_rate: float, out_rate: float) -> float (rappel pricing Opus 4.8 : 5 $/Mtok en entrée, 25 $/Mtok en sortie ; les cache reads coûtent ~0.1× l'entrée). Indice/Solution. @dataclass + champs avec défaut 0 ; total est une @property (pas un champ) ; cost_usd calcule (input_tokens * in_rate + output_tokens * out_rate) / 1_000_000, avec les cache reads facturés à in_rate * 0.1. Vérifie que Usage() == Usage() est True gratuitement.

Exercice 2 — Geler et optimiser (production-grade)

Objectif. Reprends Message et rends-le frozen=True, slots=True, kw_only=True. Écris un test qui prouve (a) qu'on ne peut pas réassigner m.role (attend FrozenInstanceError), (b) qu'un Message est hashable et utilisable comme clé de dict, (c) qu'accéder à un attribut non déclaré lève AttributeError (effet de slots). Indice/Solution. frozen rend __hash__ automatique (basé sur les champs). Pour le test (c), tente m.foo = 1AttributeError car pas de __dict__. Piège : si tu veux un champ dérivé mis en cache, slots + frozen interdisent l'affectation classique → il faut object.__setattr__ dans __post_init__.

Exercice 3 — La frontière mal placée (casser puis réparer)

Objectif. On te donne un endpoint FastAPI qui prend une @dataclass ChatRequest au lieu d'un BaseModel. Envoie {"prompt": "", "max_tokens": "beaucoup"} et observe : soit ça passe (et plante plus loin), soit FastAPI ne valide rien. Diagnostique pourquoi, puis migre vers Pydantic avec Field(min_length=1) et max_tokens: int = Field(gt=0, le=128_000). Prouve que la requête invalide renvoie maintenant un 422 propre. Indice/Solution. FastAPI ne sait valider/coercer que les BaseModel (et types simples) ; une dataclass nue n'a pas de schéma de validation, donc max_tokens="beaucoup" reste une string. Après migration, Pydantic lève une ValidationError que FastAPI transforme en 422 avec le détail du champ fautif. Morale : la donnée HTTP est toujours une frontière.

Exercice 4 — Outil typé par Pydantic dans la boucle agentique (production-grade)

Objectif. Dans AgentRunner._dispatch, les block.input arrivent du LLM en dict non validé. Crée un modèle Pydantic WeatherArgs(city: str = Field(min_length=1)) et valide block.input avant d'appeler get_weather. Gère le cas où le LLM envoie un argument manquant ou mal typé : renvoie un tool_result avec is_error: true et un message exploitable, plutôt que de planter la boucle. Indice/Solution. WeatherArgs.model_validate(block.input) lève ValidationError si invalide ; capture-la, et pousse {"type": "tool_result", "tool_use_id": block.id, "content": str(e), "is_error": True}. Le modèle voit l'erreur et corrige son appel au tour suivant — c'est le pattern de récupération d'un agent robuste.

Exercice 5 — Sortie structurée stricte (casser puis réparer)

Objectif. Avec messages.parse() et un modèle Sentiment(label: Literal["positive","negative","neutral"], confidence: float = Field(ge=0, le=1)), force le modèle à classer un texte ambigu. Vérifie le chemin d'échec : que se passe-t-il si message.parsed_output is None ? Et si stop_reason == "max_tokens" (augmente max_tokens) ou "refusal" ? Indice/Solution. Literal[...] contraint l'enum côté schéma ; confidence borné [0,1]. Le branchement obligatoire : if message.parsed_output is None: handle(message.stop_reason). Ne lis jamais parsed_output sans ce garde — un refus ou une troncature te donne None.

Exercice 6 — Migration dataclass → Pydantic sans tout casser (senior)

Objectif. Tu as une dataclass Settings (config app : model, max_tokens, timeout_s) lue depuis l'environnement à la main. Migre-la vers pydantic_settings.BaseSettings pour qu'elle charge et valide les variables d'env automatiquement, avec des défauts et des bornes. Garde une rétrocompatibilité : le reste du code accède toujours settings.model. Indice/Solution. BaseSettings lit MODEL, MAX_TOKENS, TIMEOUT_S depuis l'env, coerce les types, applique les Field(gt=0). Comme l'accès attribut (settings.model) est identique à celui d'une dataclass, le code consommateur ne change pas — seul le chargement gagne validation et coercition. C'est la frontière "config externe" qu'on déplace correctement.


🎤 En entretien

Q : Dataclass ou Pydantic — comment tu choisis ? R : Pydantic dès que la donnée franchit une frontière (HTTP, LLM, env, fichier) parce qu'il valide et coerce ; dataclass pour la donnée interne que je construis moi-même, parce qu'elle est plus légère et que la validation y serait du coût inutile.

Q : Pourquoi field(default_factory=list) plutôt que = [] ? R : Un défaut mutable est évalué une seule fois et partagé entre toutes les instances — bug d'état partagé, particulièrement dangereux en async ; default_factory produit un objet neuf par instance.

Q : À quoi servent frozen=True et slots=True, concrètement ? R : frozen rend l'objet immuable et hashable (sûr à partager entre coroutines, utilisable comme clé) ; slots supprime le __dict__ pour réduire la mémoire et accélérer l'accès attribut — décisif quand on instancie des dizaines de milliers d'objets comme un historique de messages ou des traces.

Q : Une dataclass valide-t-elle ses types à l'exécution ? R : Non — les annotations sont purement déclaratives pour la dataclass ; TokenBudget(max_tokens="100") accepte la string sans broncher. Seul __post_init__ que j'écris peut imposer des invariants, et il ne coerce rien. C'est exactement la raison pour laquelle on passe à Pydantic à la frontière.

Bibliothèque tech perso — Achref