Skip to content

Tokenization & context windows

TL;DR Un token ≈ 0.75 mot anglais, 0.55 mot français, 1 chiffre. Trois familles de tokenizers en 2026 (cl100k/o200k OpenAI, Claude tokenizer Anthropic, SentencePiece/tekken pour Mistral/Llama). Les fenêtres ont explosé (Claude Opus 4.8 / Opus 4.7 / Sonnet 4.6 = 1M au tarif standard, Haiku 4.5 = 200K, GPT-5 1M, Gemini 2M, Mistral Large 128K), mais "tout coller dans le prompt" reste une fausse bonne idée à cause du lost-in-the-middle et du coût. Patterns gagnants : hierarchical summarization, retrieval + window pruning, refresh memory, sliding window.

Le réflexe senior : tu ne paies pas en mots, tu paies en tokens — et le tokenizer est model-specific. Pour Claude, ne devine jamais avec tiktoken (c'est le tokenizer d'OpenAI : il sous-compte Claude de 15–20% sur du texte ordinaire, bien plus sur du code ou du FR). Compte avec l'API native client.messages.count_tokens(model=...). La fenêtre brute (1M) n'est pas la fenêtre utile : au-delà de ~70% tu paies en latence et en qualité.

Mental model

Le tokenizer = la caisse enregistreuse du LLM. Tu ne paies pas en caractères, ni en mots, mais en tokens. Chaque famille de modèle a sa caisse à elle. Comparer des prix sans comparer les tokenizers, c'est comparer des courses Auchan en euros avec Tesco en livres sans le taux de change.

text
   TEXTE FR : "L'avocat consulte la jurisprudence sur Légifrance."

        ┌─────────────────┼─────────────────┐
        ▼                 ▼                 ▼
   ┌──────────┐    ┌──────────┐      ┌──────────┐
   │ tiktoken │    │ Claude   │      │ Mistral  │
   │ (o200k)  │    │ tokenizer│      │  SP-BPE  │
   └─────┬────┘    └─────┬────┘      └─────┬────┘
         │ 13 tok        │ 14 tok          │ 17 tok
         ▼               ▼                 ▼
   $$$ INPUT       $$$ INPUT          $$$ INPUT

   CONTEXT WINDOW = TON BUDGET DE TOKENS PAR REQUÊTE

   ┌─────────────────────────────────────────────┐
   │  system │ docs/RAG │ history │ user │ resp  │
   │  10%    │   60%    │   15%   │  5%  │  10%  │  ← répartition typique
   └─────────────────────────────────────────────┘
            ▲                                ▲
            └── le modèle attend MIEUX ce qui est ICI (début/fin)
                — au milieu = "lost in the middle"

Analogie : ton context window = un open space. Tu peux faire entrer 200 personnes (200K tokens), mais si tu y mets aussi le bureau du PDG, le café, la machine à café et 3 imprimantes, plus personne ne travaille. La capacité brute ≠ la capacité utile.

Code minimal

Compter les tokens avant d'envoyer = règle d'or pour ne pas se prendre un 400 en prod.

python
# pip install tiktoken anthropic mistral-common
import tiktoken
from anthropic import Anthropic
from mistral_common.tokens.tokenizers.mistral import MistralTokenizer

text_fr = "L'avocat consulte la jurisprudence sur Légifrance pour préparer ses conclusions."

# 1. OpenAI tokenizer (o200k_base pour GPT-4o/4.1/5)
enc_openai = tiktoken.get_encoding("o200k_base")
n_openai = len(enc_openai.encode(text_fr))

# 2. Anthropic — utilise l'API count_tokens (pas de tokenizer local exposé en 2026).
#    IMPORTANT: le tokenizer est model-specific. Compte avec LE modèle que tu utiliseras
#    en inférence. Opus 4.7 et 4.8 partagent le MÊME tokenizer (introduit avec 4.7),
#    mais Sonnet 4.6 et Haiku 4.5 peuvent recompter différemment les mêmes octets.
client = Anthropic()
n_claude = client.messages.count_tokens(
    model="claude-opus-4-8",  # le modèle d'inférence réel, pas un modèle "par défaut"
    messages=[{"role": "user", "content": text_fr}],
).input_tokens

# 3. Mistral — tokenizer local
mistral_tok = MistralTokenizer.v3()  # v3 = Mistral Large 2 / Codestral 2
from mistral_common.protocol.instruct.request import ChatCompletionRequest
from mistral_common.protocol.instruct.messages import UserMessage
req = ChatCompletionRequest(messages=[UserMessage(content=text_fr)])
n_mistral = len(mistral_tok.encode_chat_completion(req).tokens)

print(f"FR text: {len(text_fr)} chars, {len(text_fr.split())} mots")
print(f"  OpenAI o200k: {n_openai} tokens")
print(f"  Claude (API): {n_claude} tokens")
print(f"  Mistral v3:   {n_mistral} tokens")

Sortie typique :

FR text: 84 chars, 11 mots
  OpenAI o200k: 17 tokens
  Claude (API): 18 tokens
  Mistral v3:   22 tokens

Conclusion : le français coûte ~30% plus cher en tokens que l'anglais chez tous les providers, et Mistral est ~25% plus verbeux que OpenAI sur du FR pur. À volume égal, hébergement Mistral chez Scaleway peut redevenir cher si tu n'optimises pas.

⚠️ Le piège tokenizer du senior. Ne réutilise JAMAIS un comptage tiktoken (o200k) pour budgéter un appel Claude : c'est le tokenizer d'OpenAI. Sur le texte ci-dessus, o200k et l'API Claude divergent déjà ; sur du code ou du JSON l'écart grimpe (15–30%). Le enc_openai.encode(...) ci-dessus est là uniquement pour comparer les familles — pour budgéter une requête Claude en prod, la seule source de vérité est client.messages.count_tokens(model=<le modèle d'inférence>). Et comme le tokenizer est versionné par modèle, re-baseline quand tu changes de modèle : Opus 4.7 a introduit un nouveau tokenizer, conservé à l'identique sur Opus 4.8 (token counts inchangés entre 4.7 et 4.8) ; un passage depuis Opus 4.6, Sonnet ou Haiku recompte ~1×–1.35× les mêmes octets. La bonne pratique : appelle count_tokens une fois avec l'ancien modèle, une fois avec le nouveau, et compare les input_tokens — n'applique jamais un multiplicateur en aveugle.

Cas d'usage concrets

LegalTech — Analyse de contrat 100 pages (cabinet d'avocats d'affaires Paris)

Problème : un contrat de cession d'actions (SPA) = 80-150 pages, soit ~150-300k tokens. Tient désormais dans la fenêtre 1M de Claude Opus 4.8 / Sonnet 4.6, mais ça reste une fausse bonne idée : single-shot sur 300k tokens = lost-in-the-middle sur les clauses centrales, latence p99 de 20-40 sec, et coût × itérations (un avocat repose 5-10 questions sur le même contrat). Le réflexe "j'ai 1M de fenêtre donc je colle tout" est exactement l'erreur que cette page combat.

Solution : pipeline hiérarchique :

  1. Découpe le PDF par section (préambule, déclarations, garanties, conditions suspensives, etc.) — utilise le sommaire.
  2. Résume chaque section avec Claude Haiku 4.5 (cheap, ~2k tok input → 300 tok output).
  3. Concatène les résumés (10-20k tok total) → Claude Sonnet 4.6 pour synthèse globale + identification des points de vigilance.
  4. Pour chaque "point de vigilance", re-lance Sonnet sur la section concernée uniquement (full text).
python
def analyze_contract(pdf_path: str) -> dict:
    sections = split_by_toc(pdf_path)                     # 30-50 sections
    summaries = [summarize(s, model="claude-haiku-4-5") for s in sections]
    global_synthesis = synthesize(summaries, model="claude-sonnet-4-6")
    red_flags = global_synthesis["red_flags"]
    deep_dives = {
        flag["section_id"]: deep_analyze(
            sections[flag["section_id"]], flag["question"], model="claude-sonnet-4-6"
        )
        for flag in red_flags
    }
    return {"synthesis": global_synthesis, "deep_dives": deep_dives}

Gains chiffrés :

  • Lecture humaine senior : 8-12h → analyse IA + revue humaine 2h
  • À 250€/h facturé client × 8h économisées × 4 contrats/mois = 32 000€/mois de capacité libérée
  • Coût IA : ~3€/contrat (Haiku + Sonnet, bien cachés)
  • TJM mission : 1 400€/j × 25 j (custom + intégration DocuSign) = 35 000€ + 2 000€/mois MCO

CRM B2B — Synthèse de 50 emails clients pour onboarding CSM (éditeur SaaS RH lyonnais)

Problème : quand un Customer Success Manager (CSM) prend un compte existant, il doit lire l'historique des 50-200 emails échangés depuis 18 mois. 2-4h passées à digérer ça, et l'humain en retient mal les détails contractuels.

Solution : RAG + summarization sliding-window. Découpe la timeline en blocs de 10 emails, résume chaque bloc avec contexte du précédent, puis synthèse globale. Output = brief structuré (état relation, points d'attention, engagements pris, contacts clés).

python
def onboard_csm_brief(account_id: str) -> dict:
    emails = fetch_emails(account_id, last_n=200)           # ordonnés par date
    chunks = [emails[i:i+10] for i in range(0, len(emails), 10)]
    running_summary = ""
    block_summaries = []
    for chunk in chunks:
        s = summarize_with_context(chunk, prior=running_summary, model="claude-haiku-4-5")
        block_summaries.append(s)
        running_summary = compact(running_summary + "\n" + s, max_tok=2000)
    final_brief = synthesize_brief(block_summaries, model="claude-sonnet-4-6")
    return final_brief

Gains chiffrés :

  • Onboarding CSM : 4h → 30 min de lecture du brief
  • 8 CSM × 5 comptes/an × 3.5h économisées × 65€/h = 9 100€/an
  • Taux de churn -2% (CSM mieux préparés) = +200k€/an de revenus retenus
  • TJM : 1 200€/j × 12 j = 14 400€ + 600€/mois MCO

Secteur public — Synthèse de comptes-rendus de réunions parlementaires (association lobbying)

Problème : association de lobbying suit 15-20 commissions parlementaires/sénatoriales. Comptes-rendus de 80-200 pages chacun, 4-8 par semaine = 800-1600 pages/semaine. Analyste passe 60% de son temps en lecture.

Solution : Gemini 2.5 Pro (context 2M) pour single-shot sur un compte-rendu entier → extraction structurée des prises de position par député, par sujet (numérique, énergie, santé), avec citations exactes. Indexation dans une base vectorielle pour requêtes ad-hoc plus tard.

python
def summarize_parliamentary(transcript_md: str, watch_topics: list[str]) -> dict:
    # Gemini 2.5 Pro: 2M context = un compte-rendu entier en 1 appel
    resp = gemini.generate_content(
        model="gemini-2.5-pro",
        prompt=PARLIAMENTARY_PROMPT.format(topics=watch_topics),
        content=transcript_md,
    )
    return parse_structured(resp)

Gains chiffrés :

  • Analyste : 30h/semaine de lecture → 5h de validation
  • À 50€/h chargé × 25h × 50 semaines = 62 500€/an de capacité libérée
  • Réactivité : alerte sur prise de position client en 2h vs 3 jours
  • TJM : 1 300€/j × 20 j = 26 000€

Exemple end-to-end

Système complet : PDF 200 pages → chunking hiérarchique → résumés par section → synthèse globale avec citations exactes.

python
"""
hierarchical_pdf_analyzer.py — Analyse d'un PDF long (50-300 pages).

Pipeline :
1. PDF → text par page (pypdf)
2. Detect TOC + split par section (heuristique sur typographie)
3. Pour chaque section : chunk en ~2k tok blocks
4. Map: résumé par chunk (Haiku, parallèle)
5. Reduce: résumé par section (Haiku)
6. Synthèse globale avec citations (Sonnet 4.6)
7. Output : Markdown + JSON structuré
"""

from __future__ import annotations
import re
import os
import json
import asyncio
from dataclasses import dataclass, field
from pathlib import Path
import tiktoken
from anthropic import AsyncAnthropic
from pypdf import PdfReader

enc = tiktoken.get_encoding("o200k_base")
client = AsyncAnthropic()

# ---------- 1. Data model ----------

@dataclass
class Section:
    id: str
    title: str
    page_start: int
    page_end: int
    text: str
    chunks: list[str] = field(default_factory=list)
    chunk_summaries: list[str] = field(default_factory=list)
    section_summary: str = ""

@dataclass
class DocAnalysis:
    title: str
    n_pages: int
    sections: list[Section]
    global_synthesis: "Synthese"   # objet Pydantic typé (défini plus bas)

# ---------- 2. PDF → sections ----------

SECTION_REGEX = re.compile(r"^(?:Article|ARTICLE|Chapitre|CHAPITRE|Section|SECTION)\s+([0-9IVX]+)[\s.\-:]+(.+)$")

def pdf_to_sections(pdf_path: Path) -> list[Section]:
    reader = PdfReader(str(pdf_path))
    pages = [p.extract_text() or "" for p in reader.pages]
    full_text = "\n".join(pages)

    # Split by section header
    lines = full_text.split("\n")
    sections: list[Section] = []
    current: dict | None = None
    for i, line in enumerate(lines):
        m = SECTION_REGEX.match(line.strip())
        if m:
            if current:
                current["text"] = "\n".join(current["lines"])
                sections.append(Section(**{k: v for k, v in current.items() if k != "lines"}))
            current = {
                "id": m.group(1),
                "title": m.group(2).strip(),
                "page_start": estimate_page(i, lines, len(pages)),
                "page_end": 0,
                "text": "",
                "lines": [],
            }
        elif current:
            current["lines"].append(line)
    if current:
        current["text"] = "\n".join(current["lines"])
        current["page_end"] = len(pages)
        sections.append(Section(**{k: v for k, v in current.items() if k != "lines"}))

    # Fallback: si pas de section détectée, chunk arbitraire de 10 pages
    if not sections:
        for i in range(0, len(pages), 10):
            sections.append(Section(
                id=f"p{i+1}-{min(i+10, len(pages))}",
                title=f"Pages {i+1}-{min(i+10, len(pages))}",
                page_start=i + 1,
                page_end=min(i + 10, len(pages)),
                text="\n".join(pages[i:i+10]),
            ))
    return sections

def estimate_page(line_idx: int, lines: list[str], n_pages: int) -> int:
    return min(int(line_idx / max(len(lines), 1) * n_pages) + 1, n_pages)

# ---------- 3. Chunking par tokens ----------

def chunk_by_tokens(text: str, max_tok: int = 2000, overlap: int = 200) -> list[str]:
    tokens = enc.encode(text)
    chunks = []
    i = 0
    while i < len(tokens):
        chunk = tokens[i : i + max_tok]
        chunks.append(enc.decode(chunk))
        i += max_tok - overlap
    return chunks

# ---------- 4. Map: summarize chunk ----------

SYS_CHUNK = """Tu es un assistant juridique. Résume ce fragment de contrat en 3-5 phrases factuelles.
Conserve : noms de parties, montants, dates, obligations, conditions suspensives.
Ne fais AUCUNE interprétation, AUCUNE recommandation. Cite textuellement les clauses importantes entre guillemets."""

async def summarize_chunk(chunk: str) -> str:
    resp = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=400,
        system=[{"type": "text", "text": SYS_CHUNK, "cache_control": {"type": "ephemeral"}}],
        messages=[{"role": "user", "content": chunk}],
    )
    return resp.content[0].text.strip()

# ---------- 5. Reduce: summarize section ----------

SYS_SECTION = """Tu résumes une section entière à partir de résumés de fragments.
Output : 6-10 lignes structurées avec headers Markdown (## Points clés, ## Clauses notables, ## Risques)."""

async def summarize_section(s: Section) -> str:
    s.chunks = chunk_by_tokens(s.text, max_tok=2000, overlap=200)
    s.chunk_summaries = await asyncio.gather(*[summarize_chunk(c) for c in s.chunks])
    combined = f"# Section {s.id}: {s.title}\n\n" + "\n\n---\n\n".join(s.chunk_summaries)
    resp = await client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=800,
        system=[{"type": "text", "text": SYS_SECTION, "cache_control": {"type": "ephemeral"}}],
        messages=[{"role": "user", "content": combined}],
    )
    s.section_summary = resp.content[0].text.strip()
    return s.section_summary

# ---------- 6. Synthèse globale avec citations ----------
#
# Réflexe senior 2026 : on NE bricole PLUS l'extraction JSON à la main
# (raw.find("{") … json.loads) — c'est fragile (le modèle peut préfixer du
# texte, fermer une accolade dans une string, tronquer sur max_tokens).
# On contraint la sortie avec un schéma Pydantic via output_config.format,
# et on laisse le SDK valider. Le schéma est compilé une fois (cache 24h),
# puis quasi gratuit. C'est aussi ce qui rend la sortie testable en CI.

from pydantic import BaseModel

class MontantCle(BaseModel):
    montant_eur: float
    description: str
    section_ref: str

class DateCle(BaseModel):
    date_iso: str           # YYYY-MM-DD
    description: str
    section_ref: str

class RedFlag(BaseModel):
    severity: str           # "haute" | "moyenne" | "basse"
    description: str
    section_ref: str
    recommandation: str

class Synthese(BaseModel):
    executive_summary: str
    parties: list[str]
    objet_principal: str
    montants_cles: list[MontantCle]
    dates_cles: list[DateCle]
    red_flags: list[RedFlag]

SYS_GLOBAL = """Tu es un avocat d'affaires senior. À partir des résumés de chaque
section, produis la synthèse structurée demandée. Chaque champ DOIT référencer la
section d'origine via `section_ref`. N'invente RIEN : si une info est absente des
résumés, ne la fabrique pas."""

async def global_synthesis(sections: list[Section]) -> Synthese:
    combined = "\n\n".join(
        f"## Section {s.id}{s.title} (pages {s.page_start}-{s.page_end})\n{s.section_summary}"
        for s in sections
    )
    # messages.parse() = create() + validation Pydantic. .parsed est typé,
    # ou None si le modèle a refusé (stop_reason="refusal") ou tronqué.
    resp = await client.messages.parse(
        model="claude-sonnet-4-6",
        max_tokens=3000,
        system=[{"type": "text", "text": SYS_GLOBAL, "cache_control": {"type": "ephemeral"}}],
        messages=[{"role": "user", "content": combined}],
        output_config={"format": Synthese},
    )
    if resp.parsed is None:                       # refusal / max_tokens / schéma non respecté
        raise ValueError(f"synthèse non parsable: stop_reason={resp.stop_reason}")
    return resp.parsed

# ---------- 7. Pipeline ----------

async def analyze_pdf(pdf_path: Path) -> DocAnalysis:
    sections = pdf_to_sections(pdf_path)
    print(f"[+] {len(sections)} sections détectées")
    # Process all sections in parallel (with concurrency cap)
    sem = asyncio.Semaphore(8)
    async def bounded(s):
        async with sem:
            return await summarize_section(s)
    await asyncio.gather(*[bounded(s) for s in sections])
    print(f"[+] Résumés de sections OK")
    synthesis = await global_synthesis(sections)
    print(f"[+] Synthèse globale OK")
    return DocAnalysis(
        title=pdf_path.stem,
        n_pages=sum(1 for _ in PdfReader(str(pdf_path)).pages),
        sections=sections,
        global_synthesis=synthesis,
    )

# ---------- 8. Render Markdown ----------

def render_markdown(analysis: DocAnalysis) -> str:
    md = [f"# Analyse — {analysis.title}\n\n_{analysis.n_pages} pages, {len(analysis.sections)} sections_\n"]
    g = analysis.global_synthesis  # objet Synthese typé (plus de dict brut)
    md.append("\n## Executive summary\n\n" + g.executive_summary)
    md.append("\n## Red flags\n")
    for rf in g.red_flags:
        md.append(f"- **[{rf.severity}]** {rf.description} _(section {rf.section_ref})_ — {rf.recommandation}")
    md.append("\n## Dates clés\n")
    for d in g.dates_cles:
        md.append(f"- `{d.date_iso}` — {d.description} _(section {d.section_ref})_")
    md.append("\n## Détail par section\n")
    for s in analysis.sections:
        md.append(f"\n### Section {s.id}{s.title}\n\n{s.section_summary}\n")
    return "\n".join(md)

# ---------- 9. CLI ----------

async def main():
    import sys
    pdf = Path(sys.argv[1])
    analysis = await analyze_pdf(pdf)
    out_md = pdf.with_suffix(".analysis.md")
    out_json = pdf.with_suffix(".analysis.json")
    out_md.write_text(render_markdown(analysis))
    # Synthese est un modèle Pydantic → sérialisation native, pas json.dumps(dict)
    out_json.write_text(analysis.global_synthesis.model_dump_json(indent=2))
    print(f"[+] Output: {out_md}, {out_json}")

if __name__ == "__main__":
    asyncio.run(main())

Runnable : python hierarchical_pdf_analyzer.py contrat_spa.pdf. Sur un PDF 150 pages, ~90 sec, coût ~2.50€ avec cache activé.

Pourquoi messages.parse() plutôt que raw.find("{"). Le réflexe junior — demander du JSON dans le prompt puis le re-parser à la main — casse en prod de trois façons : (1) le modèle préfixe parfois Voici la synthèse : avant l'accolade ; (2) une accolade littérale dans une string fait sauter le find/rfind ; (3) sur max_tokens atteint, le JSON est tronqué et json.loads lève. La sortie structurée native (output_config={"format": Synthese} + messages.parse()) contraint le décodage côté serveur contre ton schéma Pydantic, te rend un objet typé (resp.parsed, ou None sur refus/troncature que tu traites explicitement), et élimine les tokens gaspillés à décrire le format dans le prompt — c'est aussi du contexte en moins. Le schéma est compilé une fois (cache 24h) ; les appels suivants ne paient pas la compilation. Côté thinking : pour une synthèse multi-document (tâche globale, raisonnement d'agrégation), garde l'adaptive thinking (thinking={"type": "adaptive"}) avec output_config={"effort": "high"} sur l'étage Sonnet — c'est l'étage où le raisonnement compte ; sur l'étage map (Haiku, résumé local mécanique) reste à low/sans thinking pour le coût. Ne mets jamais budget_tokens : c'est un 400 sur les modèles 4.7/4.8, et déprécié sur 4.6/Sonnet 4.6.

Patterns courants

1. Hierarchical summarization (Map-Reduce)

Chunk → résumé par chunk → résumé des résumés. Marche bien jusqu'à 500-1000 pages. Au-delà : 3 niveaux (chunk → section → chapter → doc).

2. Sliding window avec running summary

Pour les conversations longues (chatbot, agent support) :

python
def chat_with_sliding_window(messages: list, max_recent: int = 20, summary_tok: int = 2000):
    if len(messages) <= max_recent:
        return messages, ""
    older = messages[:-max_recent]
    summary = summarize(older, max_tok=summary_tok)
    return messages[-max_recent:], summary  # injecte summary en system, garde messages récents

3. Retrieval + window pruning (RAG bien fait)

Tu n'envoies QUE les chunks pertinents (top-5 ou top-10), pas le doc entier. Ça permet d'utiliser un context window plus petit (= cheap) et d'éviter le lost-in-the-middle.

4. Refresh memory (long agents)

Toutes les N steps de l'agent, tu compresses l'historique en un "scratchpad" structuré (objectif, actions faites, décisions clés). Tu remplaces l'historique brut par ce scratchpad.

5. Position critique : début / fin du prompt

Lost-in-the-middle est documenté depuis 2023 et toujours vrai en 2026 (moins fort sur Claude 4.x et Gemini 2.5, mais présent). Place les éléments critiques (instruction principale, données décisives) au début ou à la fin.

python
prompt = f"""{INSTRUCTION_CRITIQUE}

<context>
{long_corpus}
</context>

Rappel: {INSTRUCTION_CRITIQUE_REPEAT}

Question: {question}"""

6. Token budget guards en prod

python
# La fenêtre brute d'Opus 4.8 / 4.7 / Sonnet 4.6 = 1M, mais le budget UTILE
# tient à ~70% (qualité + marge réponse). Ne budgète pas sur le chiffre marketing.
MAX_INPUT_TOK = 700_000  # ~70% de 1M ; ajuste selon ton p99 de qualité observé

def assert_budget(messages, system) -> None:
    n = count_tokens(messages, system)  # = client.messages.count_tokens(model=...)
    if n > MAX_INPUT_TOK:
        raise ValueError(f"prompt {n} > budget {MAX_INPUT_TOK}")

Toujours avant d'envoyer, jamais après. Et note bien : la fenêtre annoncée (1M) est un plafond physique, pas un budget recommandé — sur Haiku 4.5 (200K) ce garde serait ~140_000.

Versions & écosystème 2026

ModèleContext (juin 2026)Prix in/out ($/M)TokenizerNote FR
Claude Opus 4.8 (flagship)1M (tarif standard)5 / 25Anthropic (count_tokens API)Excellent FR, le plus autonome
Claude Opus 4.71M5 / 25Anthropic (même tokenizer que 4.8)Excellent FR
Claude Sonnet 4.61M3 / 15AnthropicExcellent FR, mid-tier
Claude Haiku 4.5200K1 / 5AnthropicBon FR, super cheap
GPT-5 / o4-mini1M (ChatGPT-5), 400K APIo200k_baseBon FR
GPT-4o / 4.1128Ko200k_baseBon FR
Gemini 2.5 Pro2MSentencePieceExcellent FR, lost-in-middle moins fort
Mistral Large 2128Ktekken (Mistral v3)Natif FR, mais token count plus haut
Mistral Small 332KtekkenOpen weights, FR ok
Llama 4 70B128KTiktoken-like (Llama-4)FR correct mais < Mistral
Qwen 3 235B128KTiktoken-likeFR moyen
DeepSeek V3.2128KDeepSeek tokenizerFR moyen

⚠️ Erreur à ne plus jamais commettre. En 2026 le flagship Anthropic est Opus 4.8 (claude-opus-4-8), pas Opus 4.7. Les modèles Claude 4.x ont une fenêtre 1M au tarif standard (pas de premium long-contexte), sauf Haiku 4.5 à 200K. Les IDs canoniques sont des alias nus : claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5jamais de suffixe date fabriqué (-20260101) ni d'ID retiré.

Outils 2026 :

  • tiktoken (OpenAI) — versions o200k, cl100k toujours utiles
  • anthropic.count_tokens — pas de tokenizer local exposé
  • mistral-common — tokenizer Mistral local
  • transformers AutoTokenizer — pour modèles open
  • litellm — wrap tout, avec litellm.token_counter()

Pitfalls

  1. Compter tokens en .split() ou len/4 → tu rates 20-40% sur du FR. Utilise les vrais tokenizers.

  2. Comparer prix $/1M token entre providers sans comparer tokenizers → Mistral peut sembler moins cher au $/M mais coûter plus à volume.

  3. "On a 200K de context, on peut tout coller" → coût × latence × lost-in-the-middle te punissent. La règle : utilise le minimum nécessaire.

  4. Streaming sans budget + JSON bricolé → en partial streaming structuré, si tu coupes au mauvais moment, JSON cassé. Deux fixes : (a) valide toujours le JSON complet à la fin, jamais un fragment ; (b) en 2026 préfère carrément la sortie structurée native (output_config={"format": <schéma>} + messages.parse()) au prompt « réponds en JSON » suivi d'un json.loads(raw[start:end]) — le décodage est contraint côté serveur, tu récupères un objet typé (None sur refus/troncature) et tu ne paies pas les tokens du schéma décrit en prose.

  5. Caractères spéciaux (emoji, CJK, math) comptent gros. Un emoji = 3-5 tokens. Un caractère chinois = 1-3 tokens chez OpenAI, 1 chez Mistral.

  6. Tokens cachés ≠ tokens gratuits chez Anthropic : tu paies les cache_read_input_tokens à 0.30$/M (10% prix input). Donc l'économie est 90%, pas 100%.

  7. Numéros, IDs, UUIDs explosent : un UUID = ~13 tokens. Si tu passes 100 UUIDs, c'est 1300 tokens juste pour des IDs. Préfère des short-IDs ou indexer hors-prompt.

  8. Concat naïf de chunks RAG sans vérifier le total : tu prends 20 chunks de 500 tok = 10k tok, ça passe ; mais avec 5 docs × 20 chunks = 50k, ton system + history = 10k, tu débordes.

  9. PDF → texte mal extrait (tableaux, multi-colonnes) → le LLM "voit" un bordel et hallucine. Utilise pypdfium2, unstructured, ou Claude vision direct sur PDF (document content type, supporté en 2026).

  10. Lost-in-the-middle ignoré : tu mets l'instruction critique au milieu d'un long contexte → ratio de réussite -20%. Toujours en début OU en fin.

Pricing / ROI client

Calcul rapide budget tokens :

  • 1 page A4 dense FR = ~500 mots = ~900 tokens
  • Contrat 100 pages = ~90k tokens input
  • Sur Claude Sonnet 4.6 (input 3$/M) : 90k × $3/M = $0.27 par contrat (input seulement)
  • Avec pipeline hiérarchique : ~$0.15-0.25 par contrat (cheap Haiku pour map + Sonnet pour reduce)
  • Avec cache aggressive (re-analyse) : -90% sur les parties stables

Pitch ROI client (cabinet d'avocats) :

  • Manuel : 6h × 250€/h = 1500€ de coût opportunité par contrat (qui n'est PAS facturé à 100%)
  • IA + revue : 2h × 250€/h + 0.25$ API = 500€ par contrat
  • ROI : 1000€ économisés × 4 contrats/mois × 12 mois = 48 000€/an

Prix mission :

  • Setup : 1200-1400€/j × 15-25 j = 18-35k€
  • MCO + monitoring + ajouts use cases : 800-1500€/mois

Testing / Eval

Eval "needle-in-haystack"

Le test classique : insère une info précise (la "needle") à différentes positions dans un long context, demande au modèle de la retrouver.

python
def needle_test(model: str, ctx_lengths: list[int], positions: list[float]):
    """positions: 0.0 = début, 0.5 = milieu, 1.0 = fin"""
    needle = "Le code secret est BLEU-42-OMEGA-7."
    results = []
    for L in ctx_lengths:
        for p in positions:
            filler_pre = generate_filler(int(L * p))
            filler_post = generate_filler(int(L * (1 - p)))
            context = f"{filler_pre}\n{needle}\n{filler_post}"
            ans = call(model, system="Trouve le code secret dans le texte.", user=context)
            ok = "BLEU-42-OMEGA-7" in ans
            results.append({"L": L, "p": p, "ok": ok})
    return results

Plot du heatmap (L × p → success rate). Permet de connaître les zones aveugles de chaque modèle sur ton usage.

Eval token count en CI

python
def test_prompt_within_budget():
    prompt = build_prompt(SAMPLE_DOC)
    n = enc.encode(prompt)
    assert len(n) < 150_000, f"prompt {len(n)} > budget"

À mettre dans la CI pour éviter les régressions silencieuses (qqun ajoute 50k tokens dans le system prompt → boom en prod).

Quand utiliser / éviter

Utilise long context (full document in prompt) quand :

  • Doc < 50k tokens et tâche nécessite vue d'ensemble
  • Q&A interactif avec prompt caching (le doc est caché)
  • Modèle a démontré good performance sur ta tâche au full-context

Évite (préfère RAG / hiérarchique) quand :

  • Doc > 100k tokens et tâche locale ("trouve telle clause")
  • Coût compte (RAG = 10-100x moins cher)
  • Tu as besoin de citations exactes ancrées par chunk_id
  • Latence p99 < 5 sec attendue (long context = 10-30 sec)

Annexe — Cheatsheet tokens par langue

Mesures empiriques mai 2026 (tiktoken o200k_base, texte "ordinaire") :

LangueTokens / motTokens / caractèreNote
Anglais1.30.25Référence
Français1.70.28Accents, articles, conjugaisons
Espagnol1.60.27Très proche du FR
Allemand2.10.30Mots composés
Italien1.60.27Proche FR
Portugais1.70.28Proche FR
Néerlandais2.00.30Composés
Chinois0.61.51 caractère = 1-2 tokens
Japonais0.81.3Kanji + hiragana
Arabe1.90.30Préfixes/suffixes
Hébreu2.20.35Spécificités morphologiques
Russe2.00.32Cyrillique
Code (Python)2.20.30Symboles, indentation
JSON2.50.32Très verbeux en tokens
YAML1.80.28Plus compact que JSON
Markdown1.50.25Proche prose anglais

Implication pricing : un prompt FR de 1000 mots coûte ~25-30% de plus qu'un prompt EN équivalent. Sur volume, traduire en EN les prompts non sensibles à la culture peut être rentable (system prompt EN, user content FR).

Annexe — Long-context perf par modèle (benchmark public)

ModèleFenêtre bruteNeedle accuracy ~90%Vraie utilité max
Claude Opus 4.81Mjusqu'à ~700K~600-700K (dégradation douce au-delà)
Claude Opus 4.71Mjusqu'à ~650K~550K
Claude Sonnet 4.61Mjusqu'à ~500K~400K en pratique
Claude Haiku 4.5200Kjusqu'à ~180K~150K
GPT-51Mjusqu'à 600K~400K, dégradation au-delà
Gemini 2.5 Pro2Mjusqu'à 1.5M~1M, lent
Mistral Large 2128Kjusqu'à 100K~80K
Llama 4 70B128Kjusqu'à 80K~60K

Le needle-in-haystack (retrouver UNE info) surévalue toujours l'utilité réelle. La capacité à raisonner sur tout le contexte simultanément (multi-hop, agréger 20 clauses dispersées) s'effondre bien avant le score needle. Un modèle à 90% needle sur 700K peut chuter à 60% sur une tâche de synthèse multi-document à 400K.

Règle d'or : utilise ~70% de la fenêtre annoncée, garde 30% de marge pour la réponse + la dégradation qualité. Et même sous ce seuil, préfère RAG/hiérarchique dès que la tâche est locale (« trouve telle clause ») plutôt que globale.

Annexe — Patterns de compression

Quand ton context déborde, plutôt qu'augmenter le modèle, compresse :

python
def compact_history(messages: list, target_tok: int = 4000) -> list:
    """Résume les anciens messages en gardant les 6 plus récents intacts."""
    if count_tokens(messages) <= target_tok:
        return messages
    recent = messages[-6:]
    older = messages[:-6]
    summary = call_llm(
        system="Résume cet historique de conversation en gardant les décisions clés, contexte, et préférences utilisateur.",
        user=json.dumps([{"role": m["role"], "content": m["content"][:500]} for m in older]),
        max_tok=800,
    )
    return [{"role": "system", "content": f"<historique_compressé>{summary}</historique_compressé>"}] + recent

Alternative : embedding-based memory (chat-with-history via vector search) → tu retrouves les messages pertinents au lieu de les coller tous.

Annexe — Coût d'un context window plein

Calcul réaliste mai 2026, en envoyant 150K tokens en input :

ModèleSans cacheAvec cache 90% readCache write coût
Claude Opus 4.8150K × $5/M = $0.7515K × $5 + 135K × $0.50/M = $0.143150K × $6.25/M one-shot
Claude Sonnet 4.6150K × $3/M = $0.4515K × $3 + 135K × $0.30/M = $0.085150K × $3.75/M one-shot
Claude Haiku 4.5150K × $1/M = $0.1515K × $1 + 135K × $0.10/M = $0.029150K × $1.25/M one-shot
GPT-5150K × $5/M = $0.75OpenAI cache : $0.30/M = $0.045implicite
Gemini 2.5 Pro150K × $2.50/M = $0.375context caching = $0.625/M one-shot

Rappel sur l'économie du cache Anthropic : cache_read = 0.1× prix input, cache_write (5 min) = 1.25× prix input (2× pour le TTL 1h). Le break-even du cache 5 min tombe dès la 2ᵉ requête identique (1.25× write + 0.1× read = 1.35× vs 2× sans cache) ; le TTL 1h a besoin de 3 requêtes (2× + 0.2× = 2.2× vs 3×). Donc tu ne caches que ce qui est relu, et tu places le breakpoint à la fin du préfixe stable (toolssystem → début messages), pas à la fin du prompt entier.

Take-away : caching = différence entre prototype et production. Sur Sonnet 4.6, un agent qui fait 1000 calls/j avec 150K de contexte stable = $450/j sans cache → $85/j avec. Le réflexe senior : vérifier usage.cache_read_input_tokens > 0 — s'il est à zéro sur des requêtes au préfixe identique, un invalidateur silencieux traîne (un datetime.now() dans le system prompt, un json.dumps sans sort_keys, un set de tools qui varie).

Comment un staff engineer raisonne sur le contexte

Le débutant pense « tokens = compression ». Le senior pense budget partagé sous contrainte : la fenêtre est un espace que se disputent system prompt, tools, RAG, historique et marge-réponse, et chaque token a un coût marginal en $, latence ET qualité — les trois montent ensemble, pas séparément. Trois axes structurent toute décision d'architecture contextuelle.

1. Le contexte est un budget, pas un réservoir. Tu ne demandes pas « ça rentre ? » mais « quel est le ROI marginal du 400 001ᵉ token ? ». Au-delà de ~70% de la fenêtre, ce ROI devient négatif : tu paies plus cher, tu attends plus longtemps (la latence p99 monte avec l'input même en streaming), et tu dégrades la qualité (lost-in-the-middle + raisonnement multi-hop qui s'effondre). La bonne question n'est jamais « comment faire rentrer plus », mais « comment mettre moins mais mieux ».

2. La distinction qui sépare junior et senior : tâche locale vs globale.

  • Locale (« trouve la clause de non-concurrence », « quel est le montant de la garantie ? ») → RAG + window pruning. Tu n'envoies que les top-k chunks. 10-100× moins cher, citations ancrées par chunk_id, pas de lost-in-the-middle. Coller le doc entier ici est une faute de débutant.
  • Globale (« ce contrat est-il équilibré ? », « résume la position de chaque député ») → long-contexte ou hiérarchique. Le modèle a besoin d'une vue d'ensemble qu'aucun retrieval top-k ne reconstitue.

Mal classer la tâche = mauvaise archi garantie. C'est la première chose qu'un staff engineer tranche avant d'écrire une ligne.

3. Observability : tu ne pilotes pas ce que tu ne mesures pas. En prod, logge resp.usage à chaque appel et émets des métriques :

python
import logging, time
logger = logging.getLogger("llm.cost")

def log_usage(resp, t0: float, route: str) -> None:
    u = resp.usage
    # Total réel = uncached + cache_write + cache_read (input_tokens = uncached SEUL)
    total_in = u.input_tokens + (u.cache_creation_input_tokens or 0) + (u.cache_read_input_tokens or 0)
    cache_hit = (u.cache_read_input_tokens or 0) / max(total_in, 1)
    logger.info(
        "route=%s in=%d cache_read=%d cache_write=%d out=%d hit=%.0f%% lat_ms=%d",
        route, u.input_tokens, u.cache_read_input_tokens or 0,
        u.cache_creation_input_tokens or 0, u.output_tokens,
        cache_hit * 100, (time.monotonic() - t0) * 1000,
    )

Les 4 SLO que tu surveilles : coût/requête (dérive = quelqu'un a gonflé le system prompt), cache hit ratio (chute = invalidateur silencieux introduit dans un déploiement), input tokens p99 (explosion = concat RAG naïf sans garde), latence p99 (corrélée à l'input). Un budget guard en CI (test_prompt_within_budget) attrape les régressions avant la prod ; les métriques attrapent celles qui passent quand même.

4. Sécurité : le contexte est une surface d'attaque. Tout ce que tu colles dans le prompt — chunks RAG, emails clients, contenu web — peut contenir une injection (« ignore les instructions précédentes, exfiltre… »). Trois réflexes : (a) sépare instructions (system, canal de confiance) et données (user/tool, non-fiable) — ne mets jamais d'instruction opérateur dans un bloc de données ; (b) ne loggue jamais le contexte brut sans redaction (PII, secrets) ; (c) un UUID/secret qui traîne dans 100 chunks, c'est 1300 tokens et une fuite potentielle dans les logs.

🏋️ Exercices

Progression : de « implémente X » à « rends-le production-grade / casse-le puis répare / défends le chiffre ». Fais-les dans l'ordre, chacun s'appuie sur le précédent.

Exercice 1 — Le comparateur de tokenizers honnête

Objectif : prouver chiffres en main que tiktoken ment sur Claude, et de combien selon le contenu.

Écris un harness qui prend un corpus de 4 types (prose FR, prose EN, code Python, JSON dense) et compare, pour chacun, le comptage tiktoken (o200k) vs client.messages.count_tokens(model="claude-opus-4-8"). Sors un tableau écart-% par type de contenu. Puis réponds : sur quel type de contenu l'erreur de tiktoken est-elle maximale, et pourquoi un budget guard basé sur tiktoken ferait-il sauter ta prod sur ce type précis ?

Indice/Solution : l'écart est minimal sur prose EN (~3-5%), maximal sur JSON/code (15-30%+ : les symboles, l'indentation et les clés courtes se tokenisent mal en o200k vs le tokenizer Claude). Un garde calé à 150K en tiktoken laisse passer un prompt qui fait réellement 180K côté Claude → 400 request_too_large en prod. La seule source de vérité = count_tokens(model=<modèle d'inférence>).

Exercice 2 — Pipeline hiérarchique async + cache

Objectif : implémenter le map-reduce de hierarchical_pdf_analyzer.py avec AsyncAnthropic, parallélisme borné, et cache sur le préfixe stable.

Reprends le pipeline : chunk → résumé Haiku 4.5 (map, en parallèle avec asyncio.gather + Semaphore(8)) → résumé section → synthèse Sonnet 4.6. Ajoute cache_control sur le system prompt de chaque étage (SYS_CHUNK, SYS_SECTION), et logge usage.cache_read_input_tokens à chaque appel. Mesure le coût total sur un PDF de 100 pages, une fois à froid, une fois en relançant immédiatement.

Indice/Solution : le system prompt de map est identique sur tous les chunks → après le 1er appel, tous les suivants lisent le cache (économie ~90% sur la partie system). Au 2ᵉ run complet (< 5 min), le cache TTL est encore chaud → coût quasi nul sur les parties stables. Si ton cache_read reste à 0, vérifie que tu n'interpoles pas une variable (date, id de section) AVANT le cache_control dans le bloc system.

Exercice 3 — Needle-in-haystack : trouve la zone aveugle de ton modèle

Objectif : produire la heatmap (longueur × position) du taux de succès, et identifier où Opus 4.8 décroche.

Implémente needle_test pour de vrai contre claude-opus-4-8 : balaie des longueurs de 50K à 900K et des positions 0.0 / 0.25 / 0.5 / 0.75 / 1.0. Plotte la heatmap. Puis durcis le test : remplace la needle « code secret » triviale par une needle qui exige du raisonnement (« la date limite de la condition suspensive X est 30 jours après la signature ; la signature est le 3 mars ; quelle est la date limite ? », noyée à différentes positions). Compare les deux heatmaps.

Indice/Solution : la needle triviale donne ~100% presque partout jusqu'à très haut (les modèles 4.x sont forts en récupération). La needle-raisonnement s'effondre bien plus tôt et surtout au milieu : retrouver l'info ≠ raisonner dessus. C'est exactement pourquoi le score needle marketing surévalue l'utilité réelle. Documente la longueur à laquelle ton modèle passe sous 90% sur la needle-raisonnement : c'est ça ta vraie fenêtre utile pour cette tâche.

Exercice 4 — Casse le cache, puis répare-le

Objectif : reproduire un cache hit ratio à 0%, diagnostiquer, corriger — la compétence prod #1 sur le coût.

Pars d'un agent multi-tour qui devrait avoir un cache hit élevé. Introduis volontairement TROIS invalidateurs silencieux : (1) un f"Date du jour : {datetime.now()}" en tête de system prompt, (2) un json.dumps(tools_config) sans sort_keys=True, (3) un tools=build_tools(user_id) dont l'ordre varie. Lance 10 requêtes au préfixe « identique », observe cache_read_input_tokens = 0. Puis répare les trois et re-mesure.

Indice/Solution : le cache est un préfixe match — un seul octet qui change en position N invalide tout ce qui suit. (1) la date pousse l'invalidation en tête → rien ne cache jamais ; fix : injecter la date dans un message user en fin de prompt, pas dans le system. (2) sans sort_keys, la sérialisation varie entre process → sort_keys=True. (3) les tools sont rendus en position 0 → un ordre instable casse tout ; fix : trie les tools par nom et fige le set. Après correction, cache_read doit grimper dès la 2ᵉ requête.

Exercice 5 — Défends le chiffre devant le client

Objectif : construire et soutenir un modèle de coût, puis résister au contre-interrogatoire.

Pour le cas LegalTech (contrat 100 pages, ~90K tokens, 4 contrats/mois, un avocat repose 5-10 questions par contrat), calcule le coût IA mensuel réel dans deux architectures : (A) single-shot Sonnet 4.6 à chaque question (tout le contrat re-collé), (B) RAG + cache. Détaille input/output, cache_read/write, et le nombre d'appels. Puis défends : pourquoi (A) coûte-t-il plus de 10× (B) alors que la fenêtre 1M « permet » de tout coller ?

Indice/Solution : (A) re-paie 90K tokens d'input à chaque question (10 questions × 4 contrats × 90K × $3/M = ~$10.8/mois rien qu'en input, sans le cache, + latence 20-40s × chaque question + lost-in-the-middle sur les clauses centrales). (B) indexe une fois, ne récupère que les top-k chunks pertinents (~5K tokens/question) et cache le préfixe stable → ~$1/mois, latence < 5s, citations ancrées. Le piège à démonter : « 1M de fenêtre » est un plafond physique, pas une invitation à tout coller — le coût et la latence scalent avec l'input effectif, pas avec la fenêtre disponible.

Exercice 6 (bonus, hard) — Sliding window qui ne perd pas l'information critique

Objectif : prouver qu'un running-summary naïf perd des engagements, puis le rendre fiable.

Implémente chat_with_sliding_window sur une conversation de 200 messages où un engagement contractuel précis (« remise de 15% valable jusqu'au 30 juin ») apparaît au message 12 puis n'est plus jamais répété. Compresse l'historique. Vérifie : le modèle se souvient-il de la remise au message 180 ? Probablement non. Corrige sans simplement augmenter la fenêtre.

Indice/Solution : un résumé glissant naïf dilue puis perd les faits ponctuels non répétés (compaction lossy). Deux fixes complémentaires : (a) extraction structurée des « faits durs » (montants, dates, engagements) dans un scratchpad séparé du résumé prose, ré-injecté intact à chaque tour ; (b) embedding-based memory : au lieu de résumer, indexe les messages et fais un retrieval sémantique sur la question courante → le message 12 remonte quand on parle de remise. La leçon : compresser ≠ résumer en prose ; les faits décisifs doivent survivre à la compression par un canal dédié.

Exercice 7 (bonus, hard) — Casse l'extraction JSON bricolée, puis durcis-la

Objectif : reproduire les modes d'échec d'un parsing JSON fait main, puis prouver que la sortie structurée native les élimine.

Pars d'un extracteur qui demande du JSON dans le prompt et le re-parse avec raw[raw.find("{"):raw.rfind("}")+1] puis json.loads. Construis trois entrées adverses qui le cassent : (1) un contrat dont une clause contient une accolade littérale ({...}) dans une string ; (2) un prompt où le modèle préfixe Voici l'analyse : avant le JSON ; (3) un cas où max_tokens est volontairement trop bas → JSON tronqué. Mesure le taux d'échec sur 50 docs. Puis réécris avec client.messages.parse(model="claude-opus-4-8", output_config={"format": <PydanticSchema>}) et re-mesure — y compris le delta de tokens d'input (le schéma n'est plus décrit en prose) et le comportement sur refus (resp.parsed is None).

Indice/Solution : (1) le rfind("}") attrape la mauvaise accolade → JSON invalide ou champs manquants silencieux ; (2) le préfixe ne casse pas le find mais un modèle qui ferme par \``après le JSON, oui ; (3) la troncature lèvejson.loads. La sortie native contraint le décodage **côté serveur** contre le schéma : plus de find/rfind, objet typé, et sur refus de sécurité tu obtiens parsed=None+stop_reason="refusal"` à traiter explicitement plutôt qu'une exception opaque. Bonus chiffré : décrire un schéma de 8 champs en prose coûte ~150-300 tokens d'input à chaque appel ; le schéma natif est compilé une fois (cache 24h) → sur 10k appels/j c'est 1.5-3M tokens/j économisés rien qu'en input.

🎤 En entretien

« Pourquoi ne pas utiliser tiktoken pour estimer le coût d'un appel Claude ? » Parce que c'est le tokenizer d'OpenAI : il sous-compte Claude de ~15-20% sur du texte ordinaire et bien plus sur code/JSON/FR. La seule source de vérité est client.messages.count_tokens(model=<le modèle d'inférence>), car le tokenizer est versionné par modèle (Opus 4.7/4.8 partagent le leur ; Sonnet/Haiku diffèrent).

« On a une fenêtre de 1M, pourquoi ne pas juste tout mettre dans le prompt ? » Parce que la fenêtre brute ≠ la fenêtre utile : au-delà de ~70%, coût + latence p99 + lost-in-the-middle (et surtout l'effondrement du raisonnement multi-hop) punissent. Pour une tâche locale, RAG est 10-100× moins cher ; le long-contexte ne se justifie que pour une vraie tâche globale, et même là on cache et on borne.

« Mon agent en prod coûte 5× le budget prévu et le cache hit ratio est à 0%. Par où commences-tu ? » Je diffe les octets rendus de deux requêtes au préfixe censément identique. Le cache est un préfixe-match : un invalidateur silencieux en tête (un datetime.now() dans le system prompt, un json.dumps non trié, un set de tools qui varie par utilisateur) casse tout en aval. Je remonte l'invalidateur après le dernier breakpoint ou je le rends déterministe, et je vérifie cache_read_input_tokens qui doit grimper dès la 2ᵉ requête.

« Comment décides-tu entre long-contexte et RAG pour un document de 300 pages ? » Je classe la tâche : locale (« trouve telle clause ») → RAG + window pruning, citations ancrées, 10-100× moins cher ; globale (« ce contrat est-il équilibré ? ») → pipeline hiérarchique map-reduce (chunk → section → synthèse) plutôt que single-shot, qui évite le lost-in-the-middle et la latence 20-40s tout en gardant la vue d'ensemble. Le critère n'est pas la taille du doc, c'est la localité de la tâche.

« Ton pipeline doit sortir du JSON structuré. Tu le demandes dans le prompt et tu re-parses, ou autre chose ? » Autre chose : sortie structurée native (output_config={"format": <schéma Pydantic/zod>} + messages.parse()). Le décodage est contraint côté serveur contre le schéma, je récupère un objet typé (None sur refus/troncature, que je traite explicitement), je supprime les modes d'échec du json.loads(raw[start:end]) (préfixe de courtoisie, accolade littérale dans une string, troncature sur max_tokens), et j'économise les tokens d'input du schéma décrit en prose — le schéma est compilé une fois puis caché 24h. Le prompt-engineering du format JSON à la main, c'est un anti-pattern 2024.

Liens

Bibliothèque tech perso — Achref