Ik heb inmiddels aardig wat LLM-features in productie gebracht. Ik wist dat tokens er één voor één uitkomen en dat lange context duur is. Toch bleven latency en GPU-kosten me verrassen: een feature die in de demo vlot voelde, werd traag zodra de context groeide. Wat ik miste, was een echt mentaal model van waar die kosten precies zitten. En je kunt niet repareren wat je niet kunt zien.
Elke week duik ik diep in een AI-onderwerp en test ik het in de praktijk. Deze week dook ik een laag onder de API en herbouwde ik de inferentieloop met de hand: het stuk code dat een getraind model token voor token tekst laat genereren. Ik deed dat bovenop Andrej Karpathy’s microgpt · 200 regels pure Python die de volledige algoritmische essentie van een GPT bevatten. Geen Triton, geen flash-attention, geen kernel-autotuning. Gewoon matrixvermenigvuldigingen op numpy-niveau, met cache = {} letterlijk uitgeschreven als een Python dict. Je kunt op elke stap een print() zetten.
Het concept dat me het meeste inzicht gaf: de KV-cache.
Wat de KV-cache eigenlijk is
Een transformer die tekst genereert doet bij elke stap hetzelfde: neem de hele prompt tot nu toe, projecteer elk token naar een Query-, een Key- en een Value-vector op elke laag, run attention, voorspel het volgende token. Lees die drie vectoren als: wat zoek ik (Query), wat bied ik aan (Key), wat draag ik bij (Value). De K en de V geven de KV-cache zijn naam.
Wat in studieboeken niet goed uit de verf komt: de K- en V-vectoren van een token veranderen niet meer zodra dat token in de sequentie staat. K en V van token #1 zijn op stap 5 hetzelfde als op stap 2. Alleen de Q voor de huidige positie is nieuw.
Dus heb je twee keuzes:
- Herbereken K en V voor elk token bij elke stap. Simpel, correct, verspillend.
- Bereken K en V één keer, sla ze op, voeg bij elke stap de K en V van het nieuwe token toe. Dit is de KV-cache.
Ik heb beide versies herbouwd bovenop microgpt. Zelfde modelgewichten, zelfde tokenizer, zelfde prompts. Daarna genereerde ik 256 tokens vanaf een prompt van 512 tokens en keek waar de tijd naartoe ging.
Wat de cijfers zeiden
Zonder cache duurde het genereren van 256 tokens vanaf een prompt van 512 tokens op mijn CPU-run 142 seconden. Het patroon was bijna perfect lineair in het aantal stappen: elke stap herberekende alle eerdere K,V-projecties. Naarmate de sequentie groeide, duurde elke stap langer dan de vorige. Stap 256 deed ongeveer 13× zoveel K,V-werk als stap 1.
Met cache duurde dezelfde generatie 11 seconden. Elke stap deed dezelfde vaste hoeveelheid K,V-projectiewerk · één nieuw token, ongeacht positie. De staart van de curve was compleet vlak.
Dat is geen kleine optimalisatie. Het bepaalt of een feature in productie blijft of stilletjes wordt teruggedraaid omdat hij te traag is. Vrijwel elk LLM dat je in productie tegenkomt, draait met de cache actief. Zonder de cache zouden GPT-achtige modellen in de praktijk onbruikbaar traag zijn.
Waar het geheugen naartoe gaat
De cache is snel omdat hij vooraf berekend is. Hij is duur omdat hij ergens moet zitten: in het VRAM, het werkgeheugen van de GPU.
Voor elk token in je context, op elke laag van het model, sla je één K-vector en één V-vector op. De totale cachegrootte is ongeveer:
2 × n_layers × n_heads × head_dim × seq_len × bytes_per_paramIk heb dit ingevuld met cijfers voor een paar echte modellen. Voor Llama-3-8B met 8K context is dat ongeveer 1 GB GPU-geheugen per gelijktijdig verzoek. Voor 70B met 32K context heb je het over 40+ GB alleen al voor de cache. De modelgewichten zelf veranderen niet met de contextlengte. De cache wel.
Daarom is prijsstelling voor lange context niet-lineair. Daarom zakken batchgroottes bij lange prompts. Daarom lopen GPU's die nominaal "genoeg" VRAM hebben, vast bij tientallen gelijktijdige gebruikers. De cache is de bottleneck.
Waar het misging
Om de kosten concreet te maken heb ik vier scenario's gedraaid op de gecachte versie van microgpt:
Korte prompts, korte generatie. 256 tokens in, 256 uit. De cache blijft klein. Latency wordt gedomineerd door de feed-forward-lagen van het model (FFN). De cache is in wezen gratis. Dit zijn de meeste chatworkloads.
Lange prompts, korte generatie. 8000 tokens in, 100 uit. De cache raakt vol voordat het eerste token van de generatie er is: er is een eenmalige kost voor het verwerken van de volledige prompt, in het vak heet dat prefill (~3,4 s in mijn run), waarna de generatie snel gaat. Dit is de RAG-achtige stijl van "stop opgehaalde chunks in de context". De prefill is wat gebruikers als latency ervaren.
Korte prompts, zeer lange generatie. 100 tokens in, 8000 uit. De cache groeit token voor token. De kost per stap is constant qua berekening, maar het geheugen loopt lineair op. Bij token 8000 had ik ~1 GB cache voor één enkel verzoek.
Gelijktijdige verzoeken. Ik heb 32 streams van het vorige scenario gebatcht. Cachegeheugen is per stream · het wordt niet gedeeld. Het totale VRAM-gebruik ging over de 32 GB heen nog voordat het model zelf was geladen. Hier beginnen productiesystemen hun eigen GPU-budget op te eten.
Elk scenario ontkrachtte een andere intuïtie die ik had. "Lange context is duur" blijkt twee compleet verschillende dingen te betekenen: lange prompt is één kost (prefill), lange generatie is een andere (geheugengroei). Veel engineers gooien die twee op één hoop; ik deed het zelf ook. Naast elkaar gezet:
| Lange prompt | Lange generatie | |
|---|---|---|
| Waar de kost zit | Eenmalige prefill voordat het eerste token verschijnt | Cachegeheugen dat token voor token groeit |
| Wat de gebruiker merkt | Lange wachttijd tot het eerste token | Weinig, tot de GPU bij drukte volloopt |
| Typische workload | RAG: opgehaalde chunks in de context | Lange antwoorden, agents, codegeneratie |
| Waar je aan draait | Kortere prompts, hergebruik van een vaste prefix | GQA, sliding window, cache-quantisatie |
Wat productiesystemen hieraan doen
Zodra je de cache kunt doorzien, wordt veel recente inferentie-infrastructuur ineens een stuk minder mysterieus:
- Multi-Query Attention (MQA) en Grouped-Query Attention (GQA): deel K en V over meerdere Q-heads. Cache krimpt 4–8×. Llama 3, Mistral, Gemma gebruiken dit allemaal.
- Sliding window attention: houd alleen de meest recente N tokens in de cache. De cache krijgt een vaste grootte. Je verliest wat langeafstands-recall, iets wat modellen compenseren met globale tokens. Mistral en Gemma 2 gebruiken dit.
- PagedAttention (vLLM): wijs de cache niet meer toe als één groot aaneengesloten blok per verzoek. Behandel het als virtueel geheugen: verdeel het in pagina's en deel blokken tussen verzoeken met dezelfde prefix. Hierdoor bedient vLLM 5–20× meer gelijktijdige gebruikers dan naïeve serving.
- Cache-quantisatie: sla K en V op als int8 of int4. Minimaal nauwkeurigheidsverlies, half of een kwart van het geheugengebruik.
Geen van deze technieken is een hack. Het zijn bewuste keuzes, gemaakt met exacte kennis van de kosten. Maar ze zijn pas zinvol als je het mentale model hanteert dat de cache de bottleneck is.
En als je zelf geen GPU's beheert?
De meeste Nederlandse teams draaien geen eigen inferentie; ze bouwen op de API's van OpenAI, Anthropic of Google. Ook dan betaal je hier elke maand voor. De prijsstelling van die API's volgt direct uit de mechanica hierboven: input-tokens zijn goedkoper dan output-tokens, eerder verwerkte (gecachte) input-tokens zijn nog veel goedkoper, en lange context kost onevenredig veel. Dat is geen willekeur, dat is de KV-cache die door de prijslijst heen schemert.
Concreet: wie zijn prompts zo opbouwt dat het stabiele deel vooraan staat (systeemprompt, instructies, vaste voorbeelden) en het wisselende deel achteraan, laat de provider de cache van dat vaste stuk hergebruiken en betaalt daar fors minder voor. Dat is een aanpassing van een middag, geen infrastructuurproject. Zelf hosten wordt naar mijn inschatting pas interessant bij serieus volume of strenge eisen aan waar je data draait; en dan heb je de rekensom uit dit artikel nodig om je GPU's te dimensioneren.
Wat ik nu tegen engineers zeg
Drie vuistregels die ik na dit experiment overhield:
- Als je inferentie traag is en je weet niet waarom, profileer prefill apart van generatie. Systemen met lange prompts zijn prefill-bound. Systemen met lange generatie zijn geheugen-bound. De oplossingen zien er compleet anders uit.
- VRAM-rekensom is
weights + cache × concurrency, niet alleen weights. De meeste teams die ik zie, dimensioneren GPU's op basis van het model en raken dan in paniek wanneer gelijktijdig verkeer alles laat crashen. - Als je modellen kiest voor productie, kijk of ze GQA of MQA gebruiken. Twee modellen met identieke benchmarkscores kunnen qua servingkosten 4–8× uiteenlopen.
Waarom microgpt nog steeds de moeite waard is
De reden dat ik steeds teruggrijp op microgpt is dat je dit experiment er zelf echt mee kunt doen. Je kunt drie regels uitcommentariëren, kijken hoe de latency explodeert, ze weer aanzetten en kijken hoe de latency weer instort. Je hoeft geen blogpost of paper te vertrouwen. Je kunt je eigen print-statements tussen de regels zetten en precies zien wat er wordt herberekend.
De volledige algoritmische essentie van een GPT past echt in 200 regels. Het meeste wat productie-inferentie snel maakt · paging, quantisatie, kernel fusion · is engineering bovenop die 200 regels. Maar het kerninzicht dat voor zo'n 90% verklaart waarom moderne LLM's economisch überhaupt levensvatbaar zijn, is de patch van vier regels die herberekening verandert in een dictionary lookup.
Als je wilt weten wat er echt gebeurt binnen de modellen die jij in productie brengt, is dit waar ik zou beginnen.
