Aller au contenu principal

Optimiser les coûts à grande échelle

Mis à jour le 28 juillet 2026

Le coût comme contrainte d’ingénierie

Un prompt mal optimisé qui coûte 0,002 $ par appel ne coûte rien tant que vous êtes en développement. Passé le million d’appels, il coûte 2 000 $, et il continuera de les coûter chaque mois tant que personne ne l’aura relu. C’est ce changement d’échelle qui transforme le coût en contrainte d’ingénierie : il ne se règle pas en négociant un contrat, il se règle en modifiant du code.

Le préalable à toute optimisation est la visibilité. Une facture mensuelle vous dit combien vous avez dépensé, pas où. Cette leçon commence donc par l’instrumentation, avant d’aborder les leviers de réduction.

Savoir où part l’argent

Quatre éléments déterminent le coût d’un appel, et ils ne se comportent pas de la même façon. Les tokens en entrée, qui agrègent le prompt système, l’historique de conversation et le message utilisateur, croissent avec la durée d’une session. Les tokens en sortie, c’est-à-dire la réponse générée, sont facturés nettement plus cher que l’entrée. Les tokens mis en cache bénéficient d’une réduction d’environ cinquante pour cent sur le tarif d’entrée, ce qui rend leur suivi indispensable pour comprendre une facture. Enfin le modèle lui-même : GPT-5.6 Luna est bien moins cher que GPT-5.6 Sol, et ce facteur domine tous les autres.

Le suivi consiste à convertir chaque objet usage retourné par l’API en un montant, appliqué au bon tarif et en isolant la part mise en cache.

import openai
from dataclasses import dataclass, field

client = openai.OpenAI()

# Tarifs par million de tokens (juillet 2026) — cached_input indicatif (~50 % de l'input),
# à ajuster selon la grille officielle
TARIFS = {
    "gpt-5.6-luna": {"input": 0.20, "output": 1.20, "cached_input": 0.02},
    "gpt-5.6-terra": {"input": 2.00, "output": 12.00, "cached_input": 0.20},
    "gpt-5.6-sol": {"input": 5.00, "output": 30.00, "cached_input": 0.50},
}

@dataclass
class SuiviCouts:
    """Suit les coûts en temps réel."""
    appels: list[dict] = field(default_factory=list)

    def enregistrer(self, modele: str, usage: dict) -> float:
        tokens_entree = usage.input_tokens
        tokens_cache = getattr(
            usage.input_tokens_details, "cached_tokens", 0
        )
        tokens_sortie = usage.output_tokens
        tokens_entree_non_cache = tokens_entree - tokens_cache

        tarif = TARIFS.get(modele, TARIFS["gpt-5.6-terra"])
        cout = (
            (tokens_entree_non_cache / 1_000_000) * tarif["input"]
            + (tokens_cache / 1_000_000) * tarif["cached_input"]
            + (tokens_sortie / 1_000_000) * tarif["output"]
        )

        self.appels.append({
            "modele": modele,
            "tokens_entree": tokens_entree,
            "tokens_cache": tokens_cache,
            "tokens_sortie": tokens_sortie,
            "cout": cout,
        })
        return cout

    @property
    def cout_total(self) -> float:
        return sum(a["cout"] for a in self.appels)

    def rapport(self) -> str:
        if not self.appels:
            return "Aucun appel enregistré."
        return (
            f"Appels: {len(self.appels)} | "
            f"Coût total: ${self.cout_total:.4f} | "
            f"Coût moyen: ${self.cout_total / len(self.appels):.6f}"
        )

suivi = SuiviCouts()

Le tableau TARIFS est un point d’attention permanent : il vieillit dès que la grille officielle change. Traitez-le comme une donnée de configuration à vérifier, pas comme une constante du programme, et faites du coût moyen par appel une métrique que vous regardez chaque semaine.

Quatre leviers de réduction

Le premier levier, et de loin le plus rentable, consiste à ne pas payer un modèle haut de gamme pour une tâche qui ne l’exige pas. Router explicitement selon la complexité évite la dérive par défaut où tout le trafic finit sur le modèle le plus cher parce qu’il figurait dans le premier exemple copié.

async def routeur_economique(prompt: str, complexite: str) -> str:
    """Route vers le modèle le moins cher capable."""
    if complexite == "simple":
        modele = "gpt-5.6-luna"
    elif complexite == "moyen":
        modele = "gpt-5.6-terra"
    else:
        modele = "gpt-5.6-sol"

    response = client.responses.create(model=modele, input=prompt)
    suivi.enregistrer(modele, response.usage)
    return response.output_text

Le deuxième levier va plus loin lorsque la complexité n’est pas connue d’avance. Plutôt que de classer la demande, la cascade tente d’abord le modèle le moins cher et n’escalade que si la réponse ne passe pas un contrôle de qualité. Le contrôle utilisé ici est délibérément rudimentaire — une longueur minimale et l’absence d’un aveu d’ignorance — et c’est le point que vous devrez travailler : une cascade escalade trop souvent devient plus chère qu’un appel direct au bon modèle.

async def cascade_modeles(prompt: str) -> str:
    """Essaie le modèle le moins cher d'abord."""
    modeles = ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]

    for modele in modeles:
        response = client.responses.create(
            model=modele,
            input=prompt,
            max_output_tokens=500,
        )
        suivi.enregistrer(modele, response.usage)

        # Vérifier la qualité (heuristique simple)
        texte = response.output_text
        if len(texte) > 50 and "je ne sais pas" not in texte.lower():
            return texte

    return texte  # Dernier résultat même si imparfait

Le troisième levier s’attaque aux tokens d’entrée, qui grossissent silencieusement dans toute application conversationnelle : au vingtième tour, vous repayez les dix-neuf précédents à chaque message. Comprimer l’historique en gardant le premier message, qui porte le contexte, et le dernier, qui porte la question, puis en remplissant le budget restant avec les échanges les plus récents, préserve l’essentiel de la cohérence pour une fraction du coût.

def compresser_historique(
    messages: list[dict],
    max_tokens: int = 4000,
) -> list[dict]:
    """Garde les messages les plus récents dans le budget."""
    # Toujours garder le premier (contexte) et le dernier (question)
    if len(messages) <= 2:
        return messages

    premier = messages[0]
    dernier = messages[-1]
    milieu = messages[1:-1]

    # Estimer les tokens (approximation : 1 token ≈ 4 caractères)
    tokens_fixes = (len(premier["content"]) + len(dernier["content"])) // 4
    budget_milieu = max_tokens - tokens_fixes

    # Garder les messages les plus récents
    resultat = []
    tokens_utilises = 0
    for msg in reversed(milieu):
        tokens_msg = len(msg["content"]) // 4
        if tokens_utilises + tokens_msg > budget_milieu:
            break
        resultat.insert(0, msg)
        tokens_utilises += tokens_msg

    return [premier] + resultat + [dernier]

Le quatrième levier ne demande aucune finesse algorithmique, seulement de la discipline : la Batch API offre cinquante pour cent de réduction, et il suffit d’identifier ce qui peut attendre. Faites porter l’urgence par la tâche elle-même, dès sa création, plutôt que de la déduire après coup — c’est la seule façon d’éviter que tout finisse par défaut dans la file temps réel.

def classifier_urgence(taches: list[dict]) -> dict:
    """Sépare les tâches en temps réel vs batch."""
    temps_reel = []
    batch = []

    for tache in taches:
        if tache.get("urgence") == "immediat":
            temps_reel.append(tache)
        else:
            batch.append(tache)

    return {
        "temps_reel": temps_reel,
        "batch": batch,
        "economie_estimee": len(batch) * 0.001,  # Exemple
    }

Être prévenu avant la facture

Toutes ces optimisations peuvent être défaites par un seul déploiement malheureux : une boucle de retry mal bornée, un prompt système triplé, un modèle changé par erreur. L’alerte de coût est le filet de sécurité qui vous prévient en quelques heures plutôt qu’en fin de mois. Notez le seuil intermédiaire à quatre-vingts pour cent : il transforme une alarme brutale en signal anticipé, celui qui vous laisse le temps d’agir avant le dépassement.

class AlerteCout:
    """Déclenche des alertes quand les coûts dépassent un seuil."""

    def __init__(self, seuil_journalier: float = 50.0):
        self.seuil = seuil_journalier
        self.suivi = SuiviCouts()

    def verifier(self) -> str | None:
        if self.suivi.cout_total > self.seuil:
            return (
                f"ALERTE : coût journalier ${self.suivi.cout_total:.2f} "
                f"dépasse le seuil de ${self.seuil:.2f}"
            )
        if self.suivi.cout_total > self.seuil * 0.8:
            return (
                f"AVERTISSEMENT : coût à {self.suivi.cout_total / self.seuil:.0%} "
                f"du seuil journalier"
            )
        return None

Points clés à retenir

  • Suivez vos coûts en temps réel avec response.usage
  • Routez vers le modèle le moins cher capable de la tâche
  • Utilisez la Batch API (-50 %) pour tout ce qui n’est pas temps réel
  • Réduisez les tokens d’entrée en compressant l’historique
  • Mettez en place des alertes de coût pour éviter les surprises