Skip to content

Fichiers & JSON

TL;DR — Lire et écrire des fichiers en Python passe par le context manager open(...) (toujours with, jamais f = open() à la main) et, en 2025, par pathlib.Path plutôt que par les vieilles fonctions os.path à base de chaînes. Pour le JSON, la règle senior est : json (la stdlib) sérialise des dict non typés ; Pydantic v2 sérialise et valide des modèles typés — et c'est cette frontière qui compte. Tout ce qui entre dans ton système (réponse d'un LLM, payload HTTP, fichier de config) doit être validé au bord ; tout ce qui circule en interne reste typé. Les pièges qui te coûteront une nuit de prod : l'encodage implicite (passe toujours encoding="utf-8"), json.dumps qui plante sur un datetime ou un Decimal, le JSON non déterministe qui casse ton cache de prompt Anthropic, et le parsing d'une réponse LLM tronquée. Côté agents : tu valideras les structured outputs d'Anthropic avec messages.parse() + un modèle Pydantic, tu streameras du NDJSON, et tu n'écriras jamais un secret dans un fichier de mémoire d'agent.

🧠 Mental model

Pense au JSON comme à une douane. À l'intérieur de ton pays (ton process Python), les gens portent des passeports typés : des objets Pydantic, des dataclasses, des int, des datetime. Dès qu'une marchandise franchit la frontière — vers le disque, vers le réseau, vers un LLM — elle doit être convertie en un format universel et plat que tout le monde comprend : des octets UTF-8 représentant du JSON. À l'aller (sérialisation, dumps), tu remplis un formulaire douanier. Au retour (désérialisation, loads), tu inspectes la marchandise : c'est là que Pydantic fait son travail de douanier, pas json.

L'erreur de junior, c'est de faire confiance à ce qui revient de la douane parce que « ça ressemble à ce que j'attendais ». L'erreur de senior évitée, c'est de comprendre que json.loads te rend un dict[str, Any] — un sac d'octets ré-hydraté, sans aucune garantie de forme. Le Any est radioactif : il contamine tout ce qu'il touche et désactive ton type-checker.

   PROCESS PYTHON (typé)                FRONTIÈRE                MONDE EXTÉRIEUR (octets)
   ┌──────────────────────┐                                     ┌──────────────────────┐
   │ AgentReply(           │   model_dump_json()  ───────────▶  │ {"intent":"refund",  │
   │   intent="refund",    │   (sérialisation)                  │  "amount":"12.50",   │
   │   amount=Decimal(...) │                                    │  "at":"2026-..."}    │
   │   at=datetime(...))   │                                    │                      │
   │                       │   ◀───────────  model_validate_json│  disque / HTTP / LLM │
   │  ✅ garanti valide    │   (désérialisation + VALIDATION)   │  ⚠️ non fiable        │
   └──────────────────────┘                                     └──────────────────────┘
            ▲                                                              │
            │                    le douanier (Pydantic) refuse             │
            └──────── ce qui ne correspond pas au schéma ◀────────────────┘

Si tu viens de NestJS/Angular, tu connais déjà cette frontière : c'est exactement le rôle de tes DTO avec class-validator (@IsString(), ValidationPipe). Pydantic v2 est l'équivalent Python, en plus rapide (le cœur est en Rust) et mieux intégré au typage statique. json tout seul, c'est JSON.parse() en TypeScript : pratique, mais il te rend du any, et tu sais déjà à quel point any est un piège.

Les fondamentaux : lire et écrire un fichier

open() est un context manager — utilise with

Un fichier est une ressource du système d'exploitation (un file descriptor). Si tu ne le fermes pas, tu fuis. Sous charge, tu finis par épuiser le quota de descripteurs du process et tout s'effondre avec un OSError: Too many open files. Le with garantit la fermeture, même si une exception explose au milieu.

python
from pathlib import Path

# ✅ La bonne façon : pathlib + with + encoding explicite
config_path = Path("config/settings.json")

with config_path.open("r", encoding="utf-8") as f:
    raw = f.read()

# ✅ Encore mieux pour les petits fichiers : pathlib fait le with pour toi
raw = config_path.read_text(encoding="utf-8")
config_path.write_text(raw, encoding="utf-8")
python
# ❌ La mauvaise façon : on fuit le descripteur si une exception survient
f = open("config/settings.json")   # pas de with
data = f.read()
f.close()                          # jamais atteint si .read() lève

# ❌ Encodage implicite : marche sur ta machine (macOS = utf-8), casse en prod
#    (un conteneur Linux peut être en POSIX/ASCII → UnicodeDecodeError sur un é)
with open("config/settings.json") as f:   # quel encodage ? mystère
    data = f.read()

Pourquoi encoding="utf-8" est non négociable. Sans cet argument, Python utilise l'encodage préféré de la locale (locale.getpreferredencoding()), qui dépend de la machine. Ton code passe les tests sur ton Mac et lève un UnicodeDecodeError en prod sur le premier caractère accentué. Depuis Python 3.10 tu peux activer un avertissement avec python -X warn_default_encoding, et Python 3.15 prévoit de basculer le défaut sur UTF-8 (PEP 686). En attendant : tape-le partout.

Texte vs binaire

open() en mode texte ("r"/"w") te rend des str et applique l'encodage + la traduction des fins de ligne. En mode binaire ("rb"/"wb"), tu manipules des bytes bruts — c'est ce qu'il te faut pour une image, un PDF, ou un upload vers l'API Files d'Anthropic.

python
# Texte → str
report = Path("report.md").read_text(encoding="utf-8")

# Binaire → bytes (ne JAMAIS passer encoding= en mode binaire : TypeError)
pdf_bytes = Path("invoice.pdf").read_bytes()

JSON avec la stdlib : json.loads / json.dumps

La stdlib json couvre le cas où tu veux juste transformer un dict/list en chaîne et inversement. Les quatre fonctions à connaître :

FonctionSensTravaille sur
json.loads(s)désérialiseune string (ou bytes) → objet Python
json.dumps(obj)sérialiseobjet Python → string
json.load(f)désérialiseun fichier ouvert → objet Python
json.dump(obj, f)sérialiseobjet Python → écrit dans un fichier
python
import json
from pathlib import Path

# Lire un fichier JSON
data: dict = json.loads(Path("settings.json").read_text(encoding="utf-8"))

# Écrire un fichier JSON — note les options "production-grade"
Path("settings.json").write_text(
    json.dumps(
        data,
        ensure_ascii=False,   # garde les accents/emoji lisibles au lieu de é
        indent=2,             # lisible par un humain (omets-le pour minimiser la taille)
        sort_keys=True,       # déterministe → diff git propre ET cache de prompt stable
    ),
    encoding="utf-8",
)

Le piège n°1 : dumps ne sait pas sérialiser tes types riches

json.dumps ne connaît que les types JSON natifs : dict, list, str, int/float, bool, None. Donne-lui un datetime, un Decimal, un UUID, un Path ou un set, et il lève TypeError: Object of type datetime is not JSON serializable.

python
from datetime import datetime, UTC

# ❌ Plante
json.dumps({"at": datetime.now(UTC)})  # TypeError

# ⚠️ Solution stdlib : un default= custom (verbeux, à maintenir à la main)
def to_jsonable(o):
    if isinstance(o, datetime):
        return o.isoformat()
    raise TypeError(f"{type(o).__name__} not serializable")

json.dumps({"at": datetime.now(UTC)}, default=to_jsonable)

C'est exactement pourquoi on passe à Pydantic dès qu'il y a des types riches : Pydantic sait sérialiser datetime, Decimal, UUID, Enum, etc. nativement, et le fait de manière cohérente sur tout le modèle.

Le piège n°2 : float détruit l'argent

json parse les nombres décimaux en float IEEE 754. 0.1 + 0.2 != 0.3. Pour de l'argent, des montants, des taux, tu veux du Decimal. Avec la stdlib c'est json.loads(s, parse_float=Decimal) ; avec Pydantic tu déclares simplement le champ Decimal.

JSON avec Pydantic v2 : sérialiser ET valider

Pydantic est le standard de fait pour tout code Python moderne qui touche à de la donnée externe (c'est le socle de FastAPI). En v2, le moteur (pydantic-core) est écrit en Rust : la validation est ~5–50× plus rapide que la v1.

python
from datetime import datetime
from decimal import Decimal
from enum import StrEnum

from pydantic import BaseModel, Field


class Intent(StrEnum):
    REFUND = "refund"
    UPGRADE = "upgrade"
    CANCEL = "cancel"


class AgentReply(BaseModel):
    intent: Intent
    amount: Decimal = Field(ge=0)            # >= 0, validé à la frontière
    reason: str = Field(min_length=1)
    at: datetime


# --- Désérialisation + VALIDATION en une étape ---
raw = '{"intent": "refund", "amount": "12.50", "reason": "doublon", "at": "2026-06-16T10:00:00Z"}'
reply = AgentReply.model_validate_json(raw)   # parse les bytes/str directement
#                  ^^^^^^^^^^^^^^^^^^^ ← préférable à model_validate(json.loads(raw)) :
#                  un seul passage, messages d'erreur plus riches

reply.amount          # Decimal('12.50')  ← pas un float
reply.at.tzinfo       # datetime aware, parsé tout seul

# --- Sérialisation ---
reply.model_dump_json(indent=2)      # → str JSON
reply.model_dump()                   # → dict Python (types riches conservés)
reply.model_dump(mode="json")        # → dict prêt pour json.dumps (datetime → str)
python
# ❌ L'anti-pattern : faire confiance à json.loads et accéder aux clés à l'aveugle
import json
data = json.loads(raw)          # data: Any (radioactif)
amount = data["amout"]          # KeyError silencieuse à l'exécution — typo non détectée
                                # le type-checker ne dit RIEN car data est Any
total = data["amount"] * 1.2    # float * float → erreur d'arrondi sur de l'argent

La règle senior tient en une phrase : json.loads te rend Any, model_validate_json te rend un type. Le premier désactive ton type-checker ; le second le rallume.

Field, alias, et extra="forbid"

Le monde extérieur utilise souvent camelCase (un front Angular, une API tierce) alors que Python veut snake_case. Les alias règlent ça sans polluer ton code. Et extra="forbid" te protège contre les champs inattendus — précieux quand tu valides une réponse LLM qui aurait halluciné une clé.

python
from pydantic import BaseModel, ConfigDict, Field


class CreateTicket(BaseModel):
    model_config = ConfigDict(
        extra="forbid",            # rejette toute clé non déclarée
        populate_by_name=True,     # accepte le nom Python OU l'alias en entrée
    )
    customer_id: str = Field(alias="customerId")
    priority: int = Field(default=3, ge=1, le=5)


CreateTicket.model_validate({"customerId": "c_42"})   # ✅ alias accepté
CreateTicket.model_validate({"customerId": "c_42", "rogue": 1})  # ❌ ValidationError: extra fields

Le tie-in agents : sérialiser/désérialiser autour d'un LLM

C'est ici que tout converge. Un agent qui parle à Claude est une machine à franchir la frontière JSON : tu envoies des messages, tu reçois des octets, tu dois les valider. Trois cas concrets, tous avec le SDK anthropic officiel et AsyncAnthropic (modèle par défaut : claude-opus-4-8).

1. Structured outputs validés : messages.parse() + Pydantic

La pire pratique consiste à demander du JSON dans le prompt puis à faire json.loads(response.text) et prier. Les modèles modernes d'Anthropic supportent les structured outputs natifs : tu fournis un schéma, l'API contraint la sortie à le respecter, et le SDK te rend directement une instance Pydantic validée.

python
import asyncio
from datetime import datetime
from decimal import Decimal
from enum import StrEnum

from anthropic import AsyncAnthropic
from pydantic import BaseModel, Field

client = AsyncAnthropic()   # lit ANTHROPIC_API_KEY dans l'environnement


class Sentiment(StrEnum):
    POSITIVE = "positive"
    NEUTRAL = "neutral"
    NEGATIVE = "negative"


class TicketAnalysis(BaseModel):
    sentiment: Sentiment
    urgency: int = Field(ge=1, le=5)
    refund_amount: Decimal | None = Field(default=None, ge=0)
    summary: str = Field(min_length=1, max_length=280)


async def analyze(ticket_text: str) -> TicketAnalysis:
    response = await client.messages.parse(
        model="claude-opus-4-8",
        max_tokens=1024,
        thinking={"type": "adaptive"},                 # adaptatif (pas de budget_tokens)
        messages=[{"role": "user", "content": ticket_text}],
        output_format=TicketAnalysis,                  # le modèle Pydantic contraint la sortie
    )
    # parsed_output peut être None si stop_reason == "refusal" → on garde la main
    if response.parsed_output is None:
        raise ValueError(f"pas de sortie structurée (stop_reason={response.stop_reason})")
    return response.parsed_output   # déjà une instance TicketAnalysis validée


print(asyncio.run(analyze("Je veux être remboursé de 12,50€, c'est inadmissible !")))
python
# ❌ L'anti-pattern historique : JSON dans le prompt + parsing optimiste
response = await client.messages.create(
    model="claude-opus-4-8",
    max_tokens=1024,
    messages=[{"role": "user", "content": f"Réponds en JSON: {ticket_text}"}],
)
data = json.loads(response.content[0].text)   # ⚠️ casse si le modèle préfixe "Voici le JSON:"
                                              # ⚠️ casse si la sortie est tronquée (max_tokens)
                                              # ⚠️ data est Any → aucune garantie de forme
urgency = data["urgency"]                     # KeyError potentielle en prod

Note au passage le thinking={"type": "adaptive"} : sur Opus 4.8 le thinking est piloté en mode adaptatif (et la profondeur via le paramètre top-level effort="high") — jamais budget_tokens, qui renvoie un 400 sur cette génération de modèles.

2. Streaming de tokens : reconstituer une réponse au fil de l'eau

Pour toute requête longue (gros max_tokens, sortie volumineuse), il faut streamer, sinon tu risques un timeout HTTP côté SDK. Le streaming te donne aussi une UX "tokens qui apparaissent" comme dans un chat. On consomme les événements et on laisse le helper reconstruire le message final.

python
async def stream_answer(question: str) -> str:
    chunks: list[str] = []
    async with client.messages.stream(
        model="claude-opus-4-8",
        max_tokens=4096,
        messages=[{"role": "user", "content": question}],
    ) as stream:
        async for text in stream.text_stream:    # deltas de texte au fil de l'eau
            chunks.append(text)
            print(text, end="", flush=True)
        final = await stream.get_final_message()  # message complet typé, usage, stop_reason
    print()
    return "".join(chunks) or final.content[0].text

3. Boucle d'outils (tool-use) : la frontière JSON à chaque tour

Un agent qui utilise des outils franchit la frontière JSON deux fois par tour : l'API te renvoie un tool_use avec un input (du JSON, déjà parsé en dict par le SDK — ne le re-parse jamais à la main), tu exécutes, et tu renvoies un tool_result (que tu sérialises). Le réflexe senior : valider l'input de l'outil avec Pydantic avant de l'exécuter, parce que c'est de la donnée venue d'un LLM.

python
import json

from anthropic import AsyncAnthropic
from pydantic import BaseModel, Field, ValidationError

client = AsyncAnthropic()


class GetWeatherArgs(BaseModel):
    city: str = Field(min_length=1)
    unit: str = Field(default="celsius", pattern="^(celsius|fahrenheit)$")


TOOLS = [
    {
        "name": "get_weather",
        "description": "Donne la météo actuelle d'une ville.",
        "input_schema": GetWeatherArgs.model_json_schema(),   # Pydantic GÉNÈRE le schéma
    }
]


def get_weather(args: GetWeatherArgs) -> dict:
    return {"city": args.city, "temp": 21, "unit": args.unit}


async def run_agent(user_msg: str) -> str:
    messages: list[dict] = [{"role": "user", "content": user_msg}]
    while True:
        resp = await client.messages.create(
            model="claude-opus-4-8",
            max_tokens=2048,
            thinking={"type": "adaptive"},
            tools=TOOLS,
            messages=messages,
        )
        if resp.stop_reason != "tool_use":
            return next(b.text for b in resp.content if b.type == "text")

        messages.append({"role": "assistant", "content": resp.content})
        tool_results = []
        for block in resp.content:
            if block.type != "tool_use":
                continue
            try:
                # block.input est DÉJÀ un dict (parsé par le SDK) — on le VALIDE
                args = GetWeatherArgs.model_validate(block.input)
                result = get_weather(args)
                payload, is_error = json.dumps(result), False
            except ValidationError as e:
                payload, is_error = e.json(), True   # on renvoie l'erreur au modèle
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": payload,
                "is_error": is_error,
            })
        messages.append({"role": "user", "content": tool_results})

Deux idées senior ici : (1) model_json_schema() génère le schéma de l'outil depuis ton modèle Pydantic — une seule source de vérité, pas de schéma JSON écrit à la main qui dérive ; (2) on renvoie l'erreur de validation au modèle (is_error: true) plutôt que de crasher, ce qui lui permet de se corriger au tour suivant.

⚙️ En production

Modes de défaillance

  • UnicodeDecodeError au démarrage en prod. Cause quasi systématique : un open() sans encoding="utf-8" sur une machine dont la locale n'est pas UTF-8 (conteneur minimal). Correctif : encoding="utf-8" partout, et LANG=C.UTF-8 dans le Dockerfile en ceinture de sécurité.
  • json.decoder.JSONDecodeError: Unterminated string sur une réponse LLM. Quasi toujours une sortie tronquée par max_tokens. Vérifie response.stop_reason == "max_tokens" avant de parser ; augmente max_tokens ou streame. Avec les structured outputs (messages.parse), tu n'as plus ce parsing fragile — c'est la raison principale de les préférer.
  • stop_reason == "refusal". Le modèle a refusé : content peut être vide et parsed_output vaut None. Du code qui fait response.content[0].text aveuglément lève un IndexError. Branche sur stop_reason d'abord.
  • Object of type X is not JSON serializable. Un type riche (datetime, Decimal, set, un objet métier) a atteint json.dumps. Passe par model_dump(mode="json") de Pydantic, ou un default=.

Performance

  • Gros fichiers : ne charge pas tout en mémoire. json.loads(Path(...).read_text()) lit 2 Go d'un coup → OOM. Pour des données line-delimited (NDJSON / JSON Lines), itère ligne par ligne : tu traites un objet à la fois, mémoire constante. C'est le format idéal pour logger des traces d'agent ou un dataset d'éval.

    python
    from pathlib import Path
    from pydantic import TypeAdapter
    
    adapter = TypeAdapter(TicketAnalysis)   # valideur réutilisable, compilé une fois
    
    def stream_ndjson(path: Path):
        with path.open("r", encoding="utf-8") as f:
            for line in f:                  # une ligne = un objet JSON, mémoire O(1)
                if line.strip():
                    yield adapter.validate_json(line)
  • Parser plus vite. Pour du JSON volumineux côté chaud, orjson (Rust) sérialise/parse ~5–10× plus vite que la stdlib et gère datetime/UUID nativement. Pydantic v2 l'exploite déjà en interne pour model_validate_json. Ne l'ajoute que si le profiling le justifie.

  • TypeAdapter pour valider en boucle. Construire le valideur une seule fois (hors de la boucle) et le réutiliser évite de recompiler le schéma à chaque ligne.

Sécurité

  • JSON non fiable = entrée hostile. json.loads lui-même est sûr (pas d'exécution de code, contrairement à pickle ou eval — ne désérialise jamais du pickle venu de l'extérieur). Mais un payload profondément imbriqué peut faire exploser la pile/mémoire : borne la taille du body au niveau du serveur (FastAPI/uvicorn), pas après coup.
  • Path traversal. Quand un nom de fichier vient d'un LLM ou d'un utilisateur (outil "écris ce fichier", upload), Path(base) / user_name peut s'échapper avec ../../etc/passwd. Vérifie : (base / name).resolve().is_relative_to(base.resolve()). Indispensable dès que tu donnes à un agent un outil d'écriture fichier.
  • Jamais de secret dans un fichier de mémoire d'agent. Les fichiers de /memories, les transcripts de session et les payloads de prompt sont persistés et relus. Une clé API écrite là est durablement exfiltrable. Garde les secrets côté hôte (variable d'env, secrets manager), jamais dans le contexte du modèle.
  • ensure_ascii et l'injection. En écrivant du JSON destiné à être ré-affiché dans une UI web, assure-toi que la couche d'affichage échappe le HTML — le JSON ne protège pas du XSS.

Observabilité

  • Logue en JSON structuré, pas en print. Un log d'agent en JSON Lines ({"ts":..., "event":"tool_use", "tool":"get_weather", "ms":42}) est requêtable. Sérialise avec sort_keys=True pour des diffs stables.
  • Trace l'usage. Chaque réponse Anthropic porte response.usage (input/output/cache tokens). Logue-le : c'est ta facture et ton signal de dérive de coût. Surveille cache_read_input_tokens : s'il est à zéro alors que tu attends des hits de cache, un invalidateur silencieux est à l'œuvre — souvent un JSON non déterministe dans le prompt (clés non triées, datetime.now()). D'où le sort_keys=True : un JSON déterministe est une condition du cache de prompt.

Les tradeoffs senior

  • json vs Pydantic. json pour un blob trivial, sans types riches, à durée de vie courte (un cache local de chaîne, un toggle). Pydantic dès qu'il y a un schéma, une frontière (HTTP, LLM, disque persistant), ou des types riches. En cas de doute, Pydantic : le coût marginal est nul et tu gagnes la validation.
  • Valider au bord, faire confiance à l'intérieur. Valide une fois, à l'entrée (le contrôleur FastAPI, le retour du LLM). Ensuite, tout circule typé : pas de re-validation paranoïaque à chaque couche, qui ne ferait que ralentir sans rien prouver de plus.
  • Structured outputs vs prompt-and-parse. Les structured outputs natifs éliminent une classe entière de bugs (préfixes parasites, JSON malformé) mais ajoutent une latence de compilation de schéma au premier appel (mise en cache 24 h ensuite). Pour un schéma stable réutilisé, c'est gagnant ; pour un one-shot exploratoire, messages.create + parsing tolérant peut suffire.

🏋️ Exercices

Exercice 1 — Le round-trip qui survit aux types riches

Objectif. Écris une fonction save(model: BaseModel, path: Path) et load(cls: type[T], path: Path) -> T qui sérialise un modèle Pydantic contenant datetime, Decimal et un Enum vers un fichier, puis le relit en garantissant l'égalité load(...) == model. Le fichier doit être lisible par un humain (indenté, accents préservés) et déterministe.

Indice/Solution. path.write_text(model.model_dump_json(indent=2), encoding="utf-8") à l'écriture ; cls.model_validate_json(path.read_text(encoding="utf-8")) à la lecture. Le piège : ne passe pas par json.dumps(model.model_dump()) (qui te ramène le TypeError sur datetime) — laisse Pydantic gérer la sérialisation de bout en bout. Pour le déterminisme avec json natif il faudrait sort_keys=True ; avec Pydantic l'ordre des champs du modèle est déjà stable.

Exercice 2 — Le validateur de structured output Anthropic

Objectif. Implémente analyze(text: str) -> TicketAnalysis (cf. section agents) avec messages.parse(), puis rends-la production-grade : gère le cas parsed_output is None, le stop_reason == "refusal", et ajoute un retry sur RateLimitError et APIStatusError (5xx/529) avec backoff exponentiel.

Indice/Solution. Le SDK retente déjà 429/5xx automatiquement (max_retries=2 par défaut) — augmente-le via AsyncAnthropic(max_retries=4) plutôt que de réinventer la boucle. Pour le reste, branche explicitement : if response.parsed_output is None: raise ... après avoir inspecté response.stop_reason. Capture anthropic.APIStatusError et lis e.type ("rate_limit_error", "overloaded_error") pour décider. N'attrape jamais l'erreur par string matching sur le message.

Exercice 3 — Le pipeline NDJSON à mémoire constante

Objectif. Tu as un fichier evals.ndjson de 5 Go, une ligne = un objet EvalCase. Écris un générateur qui le valide ligne par ligne (mémoire O(1)), saute les lignes malformées en les loguant, et produit un compteur {"ok": n, "skipped": m}. Puis fais-le écrire les cas valides dans clean.ndjson au fil de l'eau.

Indice/Solution. Itère sur le fichier ligne par ligne (for line in f), pas f.read(). Utilise un TypeAdapter(EvalCase) construit une fois hors boucle. Entoure chaque adapter.validate_json(line) d'un try/except ValidationError, incrémente les compteurs, et écris dans le fichier de sortie ouvert en parallèle dans le même with. Vérifie ta mémoire avec /usr/bin/time -l (macOS) : elle doit rester plate quelle que soit la taille du fichier.

Exercice 4 — Casse-puis-répare : le cache de prompt qui ne hit jamais

Objectif. On te donne un agent dont le cache_read_input_tokens reste à zéro sur des requêtes pourtant identiques. Le system prompt est construit ainsi : system = json.dumps({"instructions": INSTR, "ts": datetime.now().isoformat(), "tools": list(tool_set)}). Identifie les trois invalidateurs silencieux et corrige-les.

Indice/Solution. (1) datetime.now() change à chaque requête → le préfixe diffère → cache jamais lu : sors le timestamp du prompt (il n'a rien à y faire). (2) list(tool_set) itère un set → ordre non déterministe : trie (sorted(tool_set)). (3) json.dumps sans sort_keys=True → ordre des clés non garanti : ajoute-le. Le cache de prompt Anthropic est un prefix match exact sur les octets : un seul octet qui bouge invalide tout ce qui suit. Vérifie le correctif en lisant usage.cache_read_input_tokens > 0 au deuxième appel.

Exercice 5 — L'outil d'écriture fichier sandboxé

Objectif. Donne à un agent un outil write_file(path: str, content: str). Implémente le handler de façon à ce qu'aucun chemin fourni par le modèle ne puisse s'échapper d'un répertoire WORKSPACE. Teste avec ../../etc/passwd, un chemin absolu /etc/passwd, et un lien symbolique.

Indice/Solution. target = (WORKSPACE / path).resolve() puis if not target.is_relative_to(WORKSPACE.resolve()): raise PermissionError. resolve() est crucial : il aplatit les .. et suit les symlinks avant la vérification. Crée les parents avec target.parent.mkdir(parents=True, exist_ok=True). Renvoie l'erreur au modèle via is_error: true plutôt que de crasher la boucle. Bonus : valide les arguments de l'outil avec un modèle Pydantic dont le champ path a un pattern interdisant les caractères dangereux.

🎤 En entretien

  • « Quelle différence entre json.loads et pydantic.model_validate_json ? »json.loads désérialise vers un dict[str, Any] sans aucune garantie de forme (il rend de l'Any, qui désactive le type-checker) ; model_validate_json désérialise et valide contre un schéma typé en un seul passage, et te rend une instance fortement typée. On valide tout ce qui franchit la frontière du système avec le second.

  • « Pourquoi encoding="utf-8" explicite alors que ça marche sans ? » — Sans, Python prend l'encodage de la locale, qui dépend de la machine : le code passe en dev (macOS/UTF-8) et lève UnicodeDecodeError en prod (conteneur ASCII) au premier accent. C'est un bug d'environnement classique, invisible aux tests locaux.

  • « Comment garantir qu'une réponse LLM respecte un schéma ? » — Structured outputs natifs : on passe un modèle Pydantic dans output_config={"format": ...} et l'API contraint la génération ; messages.parse() rend une instance déjà validée. C'est supérieur au "demande du JSON dans le prompt puis json.loads", qui casse sur les préfixes parasites et les sorties tronquées. On vérifie quand même stop_reason/parsed_output is None pour les refus.

  • « Un agent a un cache de prompt qui ne hit jamais — par où tu cherches ? » — Le cache Anthropic est un prefix match exact sur les octets. Je cherche un invalidateur silencieux dans le préfixe : un datetime.now() interpolé, un set/dict sérialisé sans ordre stable, un set d'outils qui varie. Je rends le JSON déterministe (sort_keys=True, sorted(...)) et je sors tout ce qui est volatile du système prompt vers la fin des messages. Je confirme avec usage.cache_read_input_tokens.

Bibliothèque tech perso — Achref