Naš račun za LLM API rastao je 30% iz mjeseca u mjesec. Promet se povećavao, ali ne tako brzo. Kada sam analizirao naše zapisnike upita, otkrio sam pravi problem: korisnici postavljaju ista pitanja na različite načine.
"Kakva je vaša politika povrata?," "Kako da nešto vratim?"i "Mogu li dobiti povrat?" svi su zasebno pogađali naš LLM, generirajući gotovo identične odgovore, a svaki je snosio pune troškove API-ja.
Predmemoriranje točnog podudaranja, očito prvo rješenje, uhvatilo je samo 18% ovih suvišnih poziva. Isto semantičko pitanje, drugačije formulirano, u potpunosti je zaobišlo predmemoriju.
Dakle, implementirao sam semantičko predmemoriranje na temelju onoga što upiti znače, a ne kako su formulirani. Nakon njegove implementacije, stopa pogodaka predmemorije porasla je na 67%, smanjujući troškove LLM API-ja za 73%. Ali doći do toga zahtijeva rješavanje problema koje naivne implementacije propuštaju.
Sadržaj objave
Zašto predmemoriranje točnog podudaranja ne uspijeva
Tradicionalno predmemoriranje koristi tekst upita kao ključ predmemorije. Ovo radi kada su upiti identični:
# Predmemoriranje točnog podudaranja
cache_key = hash(query_text)
ako je cache_key u predmemoriji:
povratna predmemorija[cache_key]
Ali korisnici ne formuliraju pitanja identično. Moja analiza 100.000 proizvodnih upita pronašla je:
-
Samo 18% bili su točni duplikati prethodnih upita
-
47% bili su semantički slični prethodnim upitima (ista namjera, drugačije riječi)
-
35% bili su istinski novi upiti
Tih 47% predstavljalo je ogromne uštede troškova koje smo propustili. Svaki semantički sličan upit pokrenuo je puni LLM poziv, generirajući odgovor gotovo identičan onome koji smo već izračunali.
Arhitektura semantičkog predmemoriranja
Semantičko predmemoriranje zamjenjuje ključeve temeljene na tekstu traženjem sličnosti temeljenim na ugrađivanju:
klasa SemanticCache:
def __init__(self, embedding_model, similarity_threshold=0.92):
self.embedding_model = model_ugradnje
self.threshold = sličnost_threshold
self.vector_store = VectorStore() # FAISS, Šišarka itd.
self.response_store = ResponseStore() # Redis, DynamoDB, itd.
def get(self, query: str) -> Neobavezno[str]:
"""Vrati predmemorirani odgovor ako postoji semantički sličan upit."""
query_embedding = self.embedding_model.encode(query)
# Pronađite najsličniji upit u predmemoriji
odgovara = self.vector_store.search(query_embedding, top_k=1)
ako šibice i šibice[0].sličnost >= self.threshold:
cache_id = odgovara[0].id
vrati self.response_store.get(cache_id)
vratiti Ništa
def set(self, query: str, response: str):
"""Predmemorirajte par upit-odgovor."""
query_embedding = self.embedding_model.encode(query)
cache_id = generiraj_id()
self.vector_store.add(cache_id, query_embedding)
self.response_store.set(cache_id,
‘upit’: upit,
‘odgovor’: odgovor,
‘vremenska oznaka’: datetime.utcnow()
)
Ključni uvid: umjesto hashiranja teksta upita, ugrađujem upite u vektorski prostor i pronalazim predmemorirane upite unutar praga sličnosti.
Problem s pragom
Prag sličnosti je kritični parametar. Postavite ga previsoko i propustit ćete važeće rezultate predmemorije. Postavite ga prenisko i vratit ćete pogrešne odgovore.
Naš početni prag od 0,85 činio se razumnim; 85% slično bi trebalo biti "isto pitanje," pravo?
krivo Na 0,85 dobili smo rezultate predmemorije poput:
-
Upit: "Kako mogu otkazati svoju pretplatu?"
-
Predmemorirano: "Kako mogu otkazati svoju narudžbu?"
-
Sličnost: 0,87
To su različita pitanja s različitim odgovorima. Vraćanje odgovora iz predmemorije bilo bi netočno.
Otkrio sam da se optimalni pragovi razlikuju ovisno o vrsti upita:
|
Vrsta upita |
Optimalni prag |
Obrazloženje |
|
Pitanja u stilu FAQ |
0,94 |
Potrebna visoka preciznost; krivi odgovori kvare povjerenje |
|
Pretrage proizvoda |
0,88 |
Više tolerancije za skoro podudaranje |
|
Podrška upita |
0,92 |
Ravnoteža između pokrivenosti i točnosti |
|
Transakcijski upiti |
0,97 |
Vrlo niska tolerancija na pogreške |
Implementirao sam pragove specifične za vrstu upita:
klasa AdaptiveSemanticCache:
def __init__(self):
sam.pragovi =
‘faq’: 0,94,
‘traži’: 0,88,
‘podrška’: 0,92,
‘transakcijski’: 0,97,
‘zadano’: 0,92
self.query_classifier = QueryClassifier()
def get_threshold(self, query: str) -> float:
query_type = self.query_classifier.classify(query)
return self.thresholds.get(query_type, self.thresholds['default'])
def get(self, query: str) -> Neobavezno[str]:
prag = self.get_threshold(upit)
query_embedding = self.embedding_model.encode(query)
odgovara = self.vector_store.search(query_embedding, top_k=1)
ako šibice i šibice[0].sličnost >= prag:
return self.response_store.get(odgovara[0].id)
vratiti Ništa
Metodologija podešavanja praga
Nisam mogao slijepo podešavati pragove. Trebala mi je temeljna istina na kojoj su zapravo parovi upita "isti."
Naša metodologija:
Korak 1: Primjeri parova upita. Uzorkovao sam 5000 parova upita na različitim razinama sličnosti (0,80-0,99).
Korak 2: Ljudsko označavanje. Anotatori su označili svaki par kao "ista namjera" ili "različite namjere." Koristio sam tri anotatora po paru i dobio većinu glasova.
Korak 3: Izračunajte krivulje preciznosti/prisjećanja. Za svaki prag izračunali smo:
-
Preciznost: od pogodaka predmemorije, koji udio je imao istu namjeru?
-
Podsjetimo: od parova iste namjere, koji dio smo predmemorirali?
def compute_precision_recall(parovi, oznake, prag):
"""Izračunajte preciznost i prisjećanje na zadanom pragu sličnosti."""
predviđanja = [1 if pair.similarity >= threshold else 0 for pair in pairs]
true_positives = zbroj (1 za p, l u zip-u (predviđanja, oznake) ako je p == 1 i l == 1)
false_positives = zbroj (1 za p, l u zip-u (predviđanja, oznake) ako je p == 1 i l == 0)
false_negatives = zbroj (1 za p, l u zip-u (predviđanja, oznake) ako je p == 0 i l == 1)
preciznost = istinito_pozitivno / (istinsko_pozitivno + lažno_pozitivno) if (točno_pozitivno + lažno_pozitivno) > 0 else 0
opoziv = istiniti_pozitivi / (pravi_pozitivi + lažno_negativni) ako (pravi_pozitivni + lažni_negativni) > 0 else 0
povratna preciznost, opoziv
Korak 4: Odaberite prag na temelju cijene pogrešaka. Za FAQ upite gdje pogrešni odgovori štete povjerenju, optimizirao sam za preciznost (prag od 0,94 dao je 98% preciznosti). Za upite pretraživanja gdje izostanak pogotka iz predmemorije samo košta, optimizirao sam za prisjećanje (prag 0,88).
Dodatni troškovi kašnjenja
Semantičko predmemoriranje dodaje kašnjenje: morate ugraditi upit i pretražiti vektorsku pohranu prije nego što znate hoćete li pozvati LLM.
Naše mjere:
|
Operacija |
Latencija (p50) |
Latencija (p99) |
|
Ugradnja upita |
12ms |
28 ms |
|
Pretraga vektora |
8ms |
19 ms |
|
Ukupno pretraživanje predmemorije |
20 ms |
47 ms |
|
LLM API poziv |
850 ms |
2400 ms |
Dodatni troškovi od 20 ms zanemarivi su u usporedbi s LLM pozivom od 850 ms koji izbjegavamo pri učitavanju predmemorije. Čak i na p99, opterećenje od 47 ms je prihvatljivo.
Međutim, promašaji predmemorije sada traju 20 ms dulje nego prije (ugrađivanje + pretraživanje + LLM poziv). S našom stopom pogodaka od 67%, matematika ide povoljno:
-
Prije: 100% upita × 850 ms = prosjek 850 ms
-
Nakon: (33% × 870 ms) + (67% × 20 ms) = 287 ms + 13 ms = 300 ms prosjek
Neto poboljšanje latencije od 65% uz smanjenje troškova.
Poništavanje predmemorije
Predmemorirani odgovori postaju ustajali. Mijenjaju se informacije o proizvodu, ažuriraju se pravila i jučerašnji točan odgovor postaje današnji pogrešan odgovor.
Implementirao sam tri strategije poništavanja:
Jednostavan istek na temelju vrste sadržaja:
TTL_BY_CONTENT_TYPE =
‘pricing’: timedelta(sati=4), # Često se mijenja
‘policy’: timedelta(days=7), # Rijetko se mijenja
‘product_info’: timedelta(days=1), # Dnevno osvježavanje
‘general_faq’: timedelta(days=14), # Vrlo stabilan
-
Poništavanje na temelju događaja
Prilikom promjene temeljnih podataka, poništite povezane unose u predmemoriju:
klasa CacheInvalidator:
def on_content_update(self, content_id: str, content_type: str):
"""Poništite unose predmemorije koji se odnose na ažurirani sadržaj."""
# Pronađite predmemorirane upite koji su upućivali na ovaj sadržaj
zahvaćeni_upiti = self.find_queries_referencing(content_id)
za query_id u affected_queries:
self.cache.invalidate(query_id)
self.log_invalidation(content_id, len(affected_queries))
-
Otkrivanje ustajalosti
Za odgovore koji bi mogli postati ustajali bez eksplicitnih događaja, implementirao sam periodične provjere svježine:
def check_freshness(self, cached_response: dict) -> bool:
"""Provjerite je li odgovor u predmemoriji još uvijek važeći."""
# Ponovno pokrenite upit prema trenutnim podacima
svježi_odgovor = sam.generiraj_odgovor(spremljen_odgovor['query'])
# Usporedite semantičku sličnost odgovora
cached_embedding = self.embed(cached_response['response'])
svježe_ugrađivanje = self.embed(svježi_odgovor)
sličnost = kosinusna_sličnost(cached_embedding, fresh_embedding)
# Ako se odgovori značajno razlikuju, poništiti
ako je sličnost < 0,90:
self.cache.invalidate(cached_response['id'])
vrati False
vratiti True
Svakodnevno provodimo provjere svježine na uzorku predmemoriranih unosa, hvatajući zastarjelost koju TTL i poništavanje temeljeno na događajima propuštaju.
Rezultati proizvodnje
Nakon tri mjeseca proizvodnje:
|
Metrički |
Prije |
Nakon |
Promijeniti |
|
Stopa pogodaka predmemorije |
18% |
67% |
+272% |
|
LLM API troškovi |
47 tisuća dolara mjesečno |
12,7 tisuća USD mjesečno |
-73% |
|
Prosječna latencija |
850 ms |
300 ms |
-65% |
|
Lažno pozitivna stopa |
N/A |
0,8% |
— |
|
Žalbe kupaca (pogrešni odgovori) |
Osnovna linija |
+0,3% |
Minimalno povećanje |
Stopa lažno pozitivnih odgovora od 0,8% (upiti za koje smo vratili predmemorirani odgovor koji je bio semantički netočan) bila je unutar prihvatljivih granica. Ti su se slučajevi primarno dogodili na granicama našeg praga, gdje je sličnost bila malo iznad granice, ali se namjera malo razlikovala.
Zamke koje treba izbjegavati
Nemojte koristiti jedan globalni prag. Različite vrste upita imaju različitu toleranciju za pogreške. Podešavanje pragova po kategoriji.
Ne preskačite korak ugrađivanja pri učitavanju predmemorije. Možda ćete doći u iskušenje da preskočite dodatne troškove ugrađivanja pri vraćanju odgovora iz predmemorije, ali potrebna vam je ugradnja za generiranje ključa u predmemoriju. Režija je neizbježna.
Ne zaboravite poništenje. Semantičko predmemoriranje bez strategije poništavanja dovodi do ustajalih odgovora koji narušavaju povjerenje korisnika. Poništavanje izgradnje od prvog dana.
Ne spremajte sve u predmemoriju. Neki se upiti ne bi trebali spremati u predmemoriju: personalizirani odgovori, vremenski osjetljive informacije, potvrde transakcija. Izgradite pravila isključenja.
def should_cache(self, query: str, response: str) -> bool:
"""Odredite treba li odgovor spremiti u predmemoriju.""
# Ne spremajte personalizirane odgovore u predmemoriju
if self.contains_personal_info(response):
vrati False
# Nemojte keširati vremenski osjetljive informacije
if self.is_time_sensitive(query):
vrati False
# Nemojte predmemorirati potvrde transakcija
if self.is_transactional(query):
vrati False
vratiti True
Ključni podaci za van
Semantičko predmemoriranje praktičan je obrazac za kontrolu troškova LLM-a koji bilježi promašaje predmemoriranja točnog podudaranja zalihosti. Ključni izazovi su podešavanje praga (koristite pragove specifične za vrstu upita na temelju analize preciznosti/poziva) i poništavanje predmemorije (kombinirajte TTL, otkrivanje na temelju događaja i otkrivanje zastarjelosti).
Uz smanjenje troškova od 73%, ovo je bila naša najbolja optimizacija povrata ulaganja za proizvodne LLM sustave. Složenost implementacije je umjerena, ali podešavanje praga zahtijeva posebnu pozornost kako bi se izbjegla degradacija kvalitete.
Sreenivasa Reddy Hulebeedu Reddy je vodeći softverski inženjer.




