Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124


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.
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.
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 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
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).
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.
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 :
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
}
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))
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.
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.
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
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.