Optimisation des coûts pour les systèmes LLM : où va réellement l'argent

Utilisez les jetons là où ils comptent vraiment.

Sommaire

Les coûts des LLMs évoluent de manière linéaire avec l’utilisation. Un système traitant 10 000 requêtes par jour à 0,01 $ par requête coûte 100 $ par jour, soit 365 $ par an. À l’échelle de l’entreprise, cela représente plus de 10 000 $.

L’optimisation des coûts ne consiste pas à faire des compromis sur la qualité. Il s’agit de dépenser les jetons là où ils comptent vraiment.

Chaque jeton que vous gaspillez est un jeton que vous auriez pu dépenser pour obtenir une meilleure réponse.

Stratégies d’optimisation des coûts des LLMs

Budget des jetons

La manière la plus simple de contrôler les coûts est de définir des limites. Par session, par tâche ou par jour.

Stratégie 1 : Budgets par session

Les budgets par session sont simples :

class SessionBudget:
    def __init__(self, budget_tokens: int = 10000):
        self.budget = budget_tokens
        self.used = 0

    def allocate(self, tokens: int) -> bool:
        if self.used + tokens <= self.budget:
            self.used += tokens
            return True
        return False

    def remaining(self) -> int:
        return self.budget - self.used

Stratégie 2 : Budgets par tâche

Les budgets par tâche sont plus utiles. Différentes tâches nécessitent des quantités de contexte différentes :

task_budgets:
  classify:
    max_tokens: 100
    model: qwen2.5-1.5b
  summarize:
    max_tokens: 500
    model: qwen2.5-7b
  code_review:
    max_tokens: 2000
    model: qwen2.5-coder-7b
  reason:
    max_tokens: 4000
    model: qwen2.5-32b

Stratégie 3 : Budgets adaptatifs

Les budgets adaptatifs s’ajustent en fonction de ce qui se produit réellement. Si les tâches de classification utilisent systématiquement 80 jetons, cessez d’allouer 100 :

class AdaptiveBudget:
    def __init__(self):
        self.task_history = {}

    def allocate(self, task_type: str) -> int:
        if task_type in self.task_history:
            return int(self.task_history[task_type] * 1.5)
        return 1000

    def record(self, task_type: str, tokens_used: int):
        if task_type not in self.task_history:
            self.task_history[task_type] = tokens_used
        else:
            self.task_history[task_type] = (
                0.9 * self.task_history[task_type] + 0.1 * tokens_used
            )

La moyenne mobile exponentielle (poids de 0,9) signifie que l’utilisation récente compte plus que l’historique. Ajustez le poids en fonction de la volatilité de vos charges de travail.

Inférence API vs locale

L’inférence locale est moins chère à grande échelle. Le point d’équilibre dépend de votre matériel et des tarifs de l’API.

Modèle API ($/M jetons) Coût local/heure Point d’équilibre
GPT-4o 2,50 $ / 10,00 $ N/A
Claude Sonnet 4 3,00 $ / 15,00 $ N/A
Qwen2.5-72B 0,50 $ / 2,00 $ ~0,50 $ ~4 heures/jour
Qwen2.5-32B 0,30 $ / 1,20 $ ~0,20 $ ~2 heures/jour
Qwen2.5-7B 0,10 $ / 0,40 $ ~0,05 $ ~1 heure/jour

Le calcul matériel :

Matériel Investissement initial Électricité mensuelle Point d’équilibre vs API
RTX 3090 (occasion) 600 $ 15 $ ~4 mois
RTX 4090 1 500 $ 20 $ ~6 mois
RTX 5080 1 000 $ 18 $ ~5 mois
DGX Spark 2 000 $ 30 $ ~8 mois

À une utilisation modérée — une heure ou plus par jour — l’inférence locale se rentabilise. À une utilisation élevée, les économies sont considérables. Le piège est l’investissement initial. Une RTX 5080 coûte 1 000 $. Une facture API, vous pouvez la mettre en pause. Du matériel, non.

Stratégies de repli

Lorsque votre modèle préféré est trop cher ou trop lent, basculez sur quelque chose de moins coûteux. La clé est de savoir quand la qualité est « suffisante ».

Stratégie 1 : Repli basé sur la qualité

Le repli basé sur la qualité essaie différents modèles jusqu’à ce que la sortie atteigne un seuil :

class QualityFallback:
    def __init__(self, quality_threshold: float = 0.8):
        self.threshold = quality_threshold
        self.models = [
            {"model": "claude-sonnet-4", "cost": 0.015},
            {"model": "qwen2.5-72b", "cost": 0.002},
            {"model": "qwen2.5-32b", "cost": 0.001},
            {"model": "qwen2.5-7b", "cost": 0.0004},
        ]

    def route(self, prompt: str) -> str:
        for model_config in self.models:
            result = self.call_model(model_config["model"], prompt)
            if self.evaluate_quality(result) >= self.threshold:
                return result
        return self.call_model(self.models[0]["model"], prompt)

Le problème réside dans l’évaluation elle-même. Comment mesurer la qualité sans faire appel à un autre modèle ? Certains systèmes utilisent un petit classificateur. D’autres utilisent des vérifications heuristiques — longueur, structure, présence de mots-clés. Aucun de ces moyens n’est parfait.

Stratégie 2 : Repli basé sur la latence

Le repli basé sur la latence est plus simple. Orientez vers le modèle le plus rapide qui respecte votre budget temps :

class LatencyFallback:
    def __init__(self, max_latency: float = 5.0):
        self.max_latency = max_latency
        self.models = [
            {"model": "qwen2.5-1.5b", "latency": 0.5},
            {"model": "qwen2.5-7b", "latency": 2.0},
            {"model": "qwen2.5-32b", "latency": 10.0},
            {"model": "claude-sonnet-4", "latency": 5.0},
        ]

    def route(self, prompt: str) -> str:
        for model_config in sorted(self.models, key=lambda x: x["latency"]):
            if model_config["latency"] <= self.max_latency:
                return self.call_model(model_config["model"], prompt)
        return self.call_model(self.models[0]["model"], prompt)

Mise en cache

La mise en cache est l’optimisation des coûts la plus sous-estimée. Les invites identiques se produisent plus souvent que vous ne le pensez — requêtes de classification, requêtes de type FAQ, appels d’outils répétés.

Stratégie 1 : Mise en cache des invites

La mise en cache exacte des invites est simple :

import hashlib

class PromptCache:
    def __init__(self, max_size: int = 1000):
        self.cache = {}
        self.max_size = max_size

    def get(self, prompt: str) -> str | None:
        key = hashlib.sha256(prompt.encode()).hexdigest()
        return self.cache.get(key)

    def set(self, prompt: str, response: str):
        key = hashlib.sha256(prompt.encode()).hexdigest()
        if len(self.cache) >= self.max_size:
            self.cache.pop(next(iter(self.cache)))
        self.cache[key] = response

Stratégie 2 : Mise en cache sémantique

La mise en cache sémantique est plus utile. Elle capture les invites qui sont différentes mais qui signifient la même chose :

from sentence_transformers import SentenceTransformer

class SemanticCache:
    def __init__(self, similarity_threshold: float = 0.95):
        self.model = SentenceTransformer('all-MiniLM-L6-v2')
        self.cache = {}
        self.threshold = similarity_threshold

    def get(self, prompt: str) -> str | None:
        prompt_embedding = self.model.encode([prompt])[0]
        for cached_prompt, cached_response in self.cache.items():
            cached_embedding = self.model.encode([cached_prompt])[0]
            similarity = self.cosine_similarity(
                prompt_embedding, cached_embedding
            )
            if similarity >= self.threshold:
                return cached_response
        return None

    def set(self, prompt: str, response: str):
        self.cache[prompt] = response

Le seuil est important. 0,95 est agressif — seules les invites très similaires correspondent. 0,85 est plus indulgent mais risque de retourner de mauvaises réponses. Mesurez votre taux d’échec et ajustez.

La mise en cache des réponses pour les requêtes courantes vaut également le coup. Si les utilisateurs demandent « quel temps fait-il » ou « quelle heure est-il » à plusieurs reprises, mettez en cache le motif, pas seulement l’invite exacte :

class ResponseCache:
    def __init__(self):
        self.common_queries = {
            "what is the weather": "Check weather API",
            "what is the time": "Check system time",
            "who is the president": "Check current president",
        }

    def get(self, query: str) -> str | None:
        query_lower = query.lower()
        for common_query, response in self.common_queries.items():
            if common_query in query_lower:
                return response
        return None

Ce n’est pas sophistiqué, mais ça marche. Les requêtes courantes sont courantes pour une raison.

Quand l’optimisation aide

L’optimisation est importante lorsque vous traitez de grands volumes, exécutez des charges de travail mixtes ou payez des coûts d’API qui s’accumulent.

Elle n’a pas d’importance lorsque vous faites des prototypes, utilisez un seul modèle ou traitez de faibles volumes. La complexité du budgétisation, du repli et de la mise en cache n’en vaut pas la peine pour un système qui effectue 100 requêtes par jour.

Faites fonctionner le flux de base en premier. Ajoutez l’optimisation lorsque la facture arrive.

Compromis

Stratégie Coût Qualité Complexité
Aucune optimisation Le plus élevé Constante La plus basse
Budget des jetons Modéré Variable Moyenne
Modèles de repli Faible-Moyen Variable Moyenne
Mise en cache Le plus bas Élevée (pour les hits de cache) Moyenne
Hybride Optimisé Optimisé La plus élevée

Les systèmes de production fonctionnent généralement de manière hybride. Budgétisez par session, faites du repli basé sur la qualité ou la latence, mettez en cache ce que vous pouvez. La complexité est réelle, mais les économies aussi.

Liens associés

S'abonner

Recevez de nouveaux articles sur les systèmes, l'infrastructure et l'ingénierie IA.