Pourquoi votre facture LLM explose – et comment la mise en cache sémantique peut la réduire de 73 %



Notre facture d’API LLM augmentait de 30 % d’un mois à l’autre. Le trafic augmentait, mais pas si vite. Lorsque j’ai analysé nos journaux de requêtes, j’ai découvert le vrai problème : les utilisateurs posent les mêmes questions de différentes manières.

"Quelle est votre politique de retour ?," "Comment puis-je retourner quelque chose ?"et "Puis-je obtenir un remboursement ?" ont tous suivi notre LLM séparément, générant des réponses presque identiques, chacune entraînant des coûts API complets.

La mise en cache exacte, la première solution évidente, n’a capturé que 18 % de ces appels redondants. La même question sémantique, formulée différemment, contournait entièrement le cache.

J’ai donc implémenté une mise en cache sémantique basée sur la signification des requêtes, et non sur la manière dont elles sont formulées. Après sa mise en œuvre, notre taux de réussite du cache est passé à 67 %, réduisant ainsi les coûts de l’API LLM de 73 %. Mais pour y parvenir, il faut résoudre des problèmes qui échappent aux implémentations naïves.

Pourquoi la mise en cache des correspondances exactes échoue

La mise en cache traditionnelle utilise le texte de requête comme clé de cache. Cela fonctionne lorsque les requêtes sont identiques :

# Mise en cache de correspondance exacte

cache_key = hachage (query_text)

si cache_key dans le cache :

retourner le cache[cache_key]

Mais les utilisateurs ne formulent pas les questions de la même manière. Mon analyse de 100 000 requêtes de production a révélé :

  • Seulement 18% étaient des doublons exacts des requêtes précédentes

  • 47% étaient sémantiquement similaires aux requêtes précédentes (même intention, formulation différente)

  • 35% étaient des requêtes véritablement nouvelles

Ces 47 % représentaient des économies massives qui nous manquaient. Chaque requête sémantiquement similaire déclenchait un appel LLM complet, générant une réponse presque identique à celle que nous avions déjà calculée.

Architecture de mise en cache sémantique

La mise en cache sémantique remplace les clés basées sur du texte par une recherche de similarité basée sur l’intégration :

classe SemanticCache :

def __init__(self, embedding_model, similarity_threshold=0.92) :

self.embedding_model = embedding_model

self.threshold = similarité_seuil

self.vector_store = VectorStore() # FAISS, Pinecone, etc.

self.response_store = RéponseStore() # Redis, DynamoDB, etc.

def get (self, requête : str) -> Facultatif[str]:

"""Renvoie la réponse mise en cache si une requête sémantiquement similaire existe."""

query_embedding = self.embedding_model.encode(query)

# Rechercher la requête en cache la plus similaire

matches = self.vector_store.search(query_embedding, top_k=1)

si correspond et correspond[0].similarité >= self.threshold :

cache_id = correspondances[0].identifiant

retourner self.response_store.get(cache_id)

retourner Aucun

def set (self, requête : str, réponse : str) :

"""Cachez la paire requête-réponse."""

query_embedding = self.embedding_model.encode(query)

cache_id = générer_id()

self.vector_store.add(cache_id, query_embedding)

self.response_store.set(cache_id, {

‘requête’ : requête,

‘réponse’ : réponse,

‘horodatage’ : datetime.utcnow()

})

L’idée clé : au lieu de hacher le texte de la requête, j’intègre les requêtes dans l’espace vectoriel et je trouve les requêtes mises en cache dans un seuil de similarité.

Le problème du seuil

Le seuil de similarité est le paramètre critique. Réglez-le trop haut et vous manquerez des accès valides au cache. Réglez-le trop bas et vous renvoyez de mauvaises réponses.

Notre seuil initial de 0,85 semblait raisonnable ; 85 % similaire devrait être "la même question," droite?

Faux. À 0,85, nous avons obtenu des accès au cache tels que :

  • Requête: "Comment résilier mon abonnement ?"

  • En cache : "Comment annuler ma commande ?"

  • Similarité : 0,87

Ce sont des questions différentes avec des réponses différentes. Renvoyer la réponse mise en cache serait incorrect.

J’ai découvert que les seuils optimaux varient selon le type de requête :

Type de requête

Seuil optimal

Raisonnement

Questions de type FAQ

0,94

Haute précision requise ; les mauvaises réponses nuisent à la confiance

Recherches de produits

0,88

Plus de tolérance pour les quasi-matchs

Requêtes d’assistance

0,92

Équilibre entre couverture et précision

Requêtes transactionnelles

0,97

Très faible tolérance aux erreurs

J’ai implémenté des seuils spécifiques au type de requête :

classe AdaptiveSemanticCache :

def __init__(soi) :

self.thresholds = {

‘FAQ’ : 0,94,

‘recherche’ : 0,88,

‘soutien’ : 0,92,

« transactionnel » : 0,97,

‘par défaut’ : 0,92

}

self.query_classifier = QueryClassifier()

def get_threshold(self, query: str) -> float :

query_type = self.query_classifier.classify(query)

retourner self.thresholds.get(query_type, self.thresholds['default'])

def get (self, requête : str) -> Facultatif[str]:

seuil = self.get_threshold (requête)

query_embedding = self.embedding_model.encode(query)

matches = self.vector_store.search(query_embedding, top_k=1)

si correspond et correspond[0].similarité >= seuil :

return self.response_store.get (correspond à[0].identifiant)

retourner Aucun

Méthodologie de réglage des seuils

Je ne pouvais pas régler les seuils aveuglément. J’avais besoin d’une vérité terrain sur quelles paires de requêtes étaient réellement "le même."

Notre méthodologie :

Étape 1 : Exemples de paires de requêtes. J’ai échantillonné 5 000 paires de requêtes à différents niveaux de similarité (0,80-0,99).

Étape 2 : Étiquetage humain. Les annotateurs ont étiqueté chaque paire comme "même intention" ou "intention différente." J’ai utilisé trois annotateurs par paire et j’ai obtenu un vote majoritaire.

Étape 3 : Calculez les courbes de précision/rappel. Pour chaque seuil, nous avons calculé :

  • Précision : parmi les accès au cache, quelle fraction avait la même intention ?

  • Rappel : parmi les paires de même intention, quelle fraction avons-nous atteinte en cache ?

def calculate_precision_recall (paires, étiquettes, seuil) :

"""Calculez la précision et le rappel à un seuil de similarité donné."""

prédictions = [1 if pair.similarity >= threshold else 0 for pair in pairs]

true_positives = sum(1 pour p, l dans zip(prédictions, étiquettes) si p == 1 et l == 1)

false_positives = sum(1 pour p, l dans zip(prédictions, étiquettes) si p == 1 et l == 0)

false_negatives = sum(1 pour p, l dans zip(prédictions, étiquettes) si p == 0 et l == 1)

précision = true_positives / (true_positives + false_positives) si (true_positives + false_positives) > 0 sinon 0

rappel = true_positives / (true_positives + false_negatives) si (true_positives + false_negatives) > 0 sinon 0

retour précision, rappel

Étape 4 : Sélectionnez le seuil en fonction du coût des erreurs. Pour les requêtes FAQ où les mauvaises réponses nuisent à la confiance, j’ai optimisé la précision (le seuil de 0,94 donnait une précision de 98 %). Pour les requêtes de recherche pour lesquelles manquer un accès au cache ne coûte que de l’argent, j’ai optimisé le rappel (seuil de 0,88).

Surcharge de latence

La mise en cache sémantique ajoute de la latence : vous devez intégrer la requête et rechercher le magasin de vecteurs avant de savoir si vous devez appeler le LLM.

Nos mesures :

Opération

Latence (p50)

Latence (p99)

Incorporation de requêtes

12 ms

28 ms

Recherche de vecteurs

8 ms

19 ms

Recherche totale du cache

20 ms

47 ms

Appel API LLM

850 ms

2400 ms

La surcharge de 20 ms est négligeable par rapport à l’appel LLM de 850 ms que nous évitons lors des accès au cache. Même à p99, le temps système de 47 ms est acceptable.

Cependant, les échecs de cache prennent désormais 20 ms de plus qu’auparavant (intégration + recherche + appel LLM). Avec notre taux de réussite de 67 %, le calcul s’avère favorable :

  • Avant : 100 % des requêtes × 850 ms = 850 ms en moyenne

  • Après : (33 % × 870 ms) + (67 % × 20 ms) = 287 ms + 13 ms = 300 ms en moyenne

Amélioration nette de la latence de 65 % parallèlement à la réduction des coûts.

Invalidation du cache

Les réponses mises en cache deviennent obsolètes. Les informations sur les produits changent, les politiques sont mises à jour et la bonne réponse d’hier devient la mauvaise réponse d’aujourd’hui.

J’ai mis en œuvre trois stratégies d’invalidation :

  1. TTL basé sur le temps

Expiration simple basée sur le type de contenu :

TTL_BY_CONTENT_TYPE = {

‘tarification’ : timedelta (heures = 4), # Change fréquemment

‘politique’ : timedelta(jours=7), # Change rarement

‘product_info’ : timedelta(jours=1), # Actualisation quotidienne

‘general_faq’ : timedelta (jours = 14), # Très stable

}

  1. Invalidation basée sur un événement

Lorsque les données sous-jacentes changent, invalidez les entrées de cache associées :

classe CacheInvalidator :

def on_content_update(self, content_id : str, content_type : str) :

"""Invalidez les entrées de cache liées au contenu mis à jour."""

# Rechercher les requêtes en cache faisant référence à ce contenu

affecté_queries = self.find_queries_referencing(content_id)

pour query_id dans affectées_queries :

self.cache.invalidate(query_id)

self.log_invalidation(content_id, len(affected_queries))

  1. Détection d’obsolescence

Pour les réponses qui pourraient devenir obsolètes sans événements explicites, j’ai mis en place des contrôles de fraîcheur périodiques :

def check_freshness(self, cached_response : dict) -> bool :

"""Vérifiez que la réponse mise en cache est toujours valide."""

# Réexécutez la requête sur les données actuelles

fresh_response = self.generate_response (cached_response['query'])

# Comparez la similarité sémantique des réponses

cached_embedding = self.embed(cached_response['response'])

fresh_embedding = self.embed (fresh_response)

similarité = cosine_similarity (cached_embedding, fresh_embedding)

# Si les réponses divergent de manière significative, invalidez

si similarité < 0,90 :

self.cache.invalidate(cached_response['id'])

retourner Faux

retourner vrai

Nous effectuons quotidiennement des contrôles de fraîcheur sur un échantillon d’entrées mises en cache, détectant l’obsolescence qui manque au TTL et à l’invalidation basée sur les événements.

Résultats de production

Après trois mois de production :

Métrique

Avant

Après

Changement

Taux de réussite du cache

18%

67%

+272%

Coûts de l’API LLM

47 000 $/mois

12,7 000 $/mois

-73%

Latence moyenne

850 ms

300 ms

-65%

Taux de faux positifs

N / A

0,8%

Plaintes des clients (mauvaises réponses)

Référence

+0,3%

Augmentation minime

Le taux de faux positifs de 0,8 % (requêtes pour lesquelles nous avons renvoyé une réponse en cache sémantiquement incorrecte) se situait dans des limites acceptables. Ces cas se sont produits principalement aux limites de notre seuil, où la similarité était juste au-dessus du seuil mais où l’intention différait légèrement.

Les pièges à éviter

N’utilisez pas de seuil global unique. Différents types de requêtes ont une tolérance différente aux erreurs. Ajustez les seuils par catégorie.

Ne sautez pas l’étape d’intégration lors des accès au cache. Vous pourriez être tenté d’ignorer la surcharge d’intégration lors du renvoi des réponses mises en cache, mais vous avez besoin de l’intégration pour la génération de clé de cache. Les frais généraux sont inévitables.

N’oubliez pas l’invalidation. La mise en cache sémantique sans stratégie d’invalidation conduit à des réponses obsolètes qui érodent la confiance des utilisateurs. Invalidation de build dès le premier jour.

Ne mettez pas tout en cache. Certaines requêtes ne doivent pas être mises en cache : réponses personnalisées, informations urgentes, confirmations transactionnelles. Créez des règles d’exclusion.

def Should_cache(self, requête : str, réponse : str) -> bool :

"""Déterminez si la réponse doit être mise en cache.""

# Ne pas mettre en cache les réponses personnalisées

si self.contains_personal_info(réponse) :

retourner Faux

# Ne pas mettre en cache les informations urgentes

si self.is_time_sensitive(query):

retourner Faux

# Ne pas mettre en cache les confirmations transactionnelles

si self.is_transactional(query):

retourner Faux

retourner vrai

Points clés à retenir

La mise en cache sémantique est un modèle pratique pour le contrôle des coûts LLM qui capture les échecs de mise en cache de correspondance exacte de redondance. Les principaux défis sont le réglage des seuils (utiliser des seuils spécifiques au type de requête basés sur une analyse de précision/rappel) et l’invalidation du cache (combiner la détection TTL, basée sur les événements et l’obsolescence).

Avec une réduction des coûts de 73 %, il s’agit de notre optimisation du retour sur investissement le plus élevé pour les systèmes LLM de production. La complexité de mise en œuvre est modérée, mais le réglage du seuil nécessite une attention particulière pour éviter une dégradation de la qualité.

Sreenivasa Reddy Hulebeedu Reddy est un ingénieur logiciel principal.



Source link

اترك ردّاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *