<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Optimization on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/optimization/</link><description>Recent content in Optimization on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Fri, 05 Jun 2026 04:00:00 +0000</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/optimization/index.xml" rel="self" type="application/rss+xml"/><item><title>Batch sizing in vLLM: the two-hour grid search worth weeks of hardware</title><link>https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;code>max-num-seqs&lt;/code> and &lt;code>max-num-batched-tokens&lt;/code> are the two dials that control how much work vLLM processes on each scheduler iteration. Their default values are calibrated to be safe on any hardware, not to maximise throughput on yours. A systematic grid search over 25 configurations, runnable in two hours, identifies the combination that, for your specific workload and hardware, can double throughput without changing a single line of the model or adding a GPU. The OTel metrics that confirm you found the optimum are &lt;code>vllm:num_waiting_seqs&lt;/code>, &lt;code>vllm:num_preemptions_total&lt;/code> and &lt;code>vllm:time_per_output_token_seconds&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>An industrial kitchen with one chef and ten stoves. If the maître d&amp;rsquo; only sends one order at a time, the chef works at 10% of capacity. If he sends a hundred simultaneous orders but there are ingredients for only twenty, the chef spends half his time waiting for restocking. The optimum is the point where every stove is lit and the resupply never runs dry.&lt;/p>
&lt;p>&lt;code>max-num-seqs&lt;/code> is how many orders the chef can have in preparation at once. &lt;code>max-num-batched-tokens&lt;/code> is how many ingredients he can process in a single movement of the wok. Getting either of them wrong leaves stoves empty.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-the-defaults-are-not-for-your-hardware">The problem: the defaults are not for your hardware&lt;/h2>
&lt;p>In vLLM V1 (≥ 0.6), the defaults are:&lt;/p>
&lt;pre tabindex="0">&lt;code>max-num-seqs = 1024 (V1) / 256 (V0)
max-num-batched-tokens = 8192
&lt;/code>&lt;/pre>&lt;p>These values guarantee that vLLM starts on any GPU without OOM. They do not guarantee optimal throughput. The reason: the optimal point depends on three variables vLLM does not know at start-up:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The length distribution of your real workload&lt;/strong> — a RAG system with 2K-token prompts needs a different budget from a chat with 50-token messages.&lt;/li>
&lt;li>&lt;strong>VRAM available for KV cache&lt;/strong> — determined by the model, the quantisation and &lt;code>--gpu-memory-utilization&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Real expected concurrency&lt;/strong> — how many simultaneous users arrive at the 95th percentile.&lt;/li>
&lt;/ol>
&lt;p>The interaction between these three factors makes it impossible for a universal default to be optimal in specific cases.&lt;/p>
&lt;hr>
&lt;h2 id="the-arithmetic-that-matters">The arithmetic that matters&lt;/h2>
&lt;p>On each iteration, vLLM&amp;rsquo;s scheduler decides which tokens to process. The total budget available per step is &lt;code>max-num-batched-tokens&lt;/code>. That budget is split between:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Decode tokens&lt;/strong>: 1 for each active request in the generation phase. With 64 requests in decode, 64 tokens of budget are consumed.&lt;/li>
&lt;li>&lt;strong>Prefill tokens&lt;/strong> (in chunks): the rest of the budget goes to processing new prompts.&lt;/li>
&lt;/ul>
$$\text{prefill tokens per step} = \text{max num batched tokens} - \text{num requests decode}$$
&lt;p>If &lt;code>max-num-batched-tokens = 8192&lt;/code> and you have 512 requests in decode, each step can only process &lt;code>8192 - 512 = 7680&lt;/code> prefill tokens. With 2000-token prompts, that is ~3.8 new prompts per iteration.&lt;/p>
&lt;p>The problem appears when &lt;code>max-num-seqs&lt;/code> is very high relative to the available KV cache. Each active request in decode occupies KV cache blocks. If the blocks run out, vLLM performs &lt;strong>preemption&lt;/strong>: it pauses a request, frees its KV cache and re-queues it. Each preemption costs additional latency for the paused request and complexity for the scheduler.&lt;/p>
$$\text{KV budget} = \frac{\text{free VRAM} \times \text{gpu memory utilization}}{\text{bytes per token} \times \text{max model len}}$$
&lt;p>For a Qwen2.5-14B on an RTX 4090 with Q4_K_M (9 GB of model, 15 GB free):&lt;/p>
$$\text{KV budget} = \frac{15 \times 0.92 \times 10^9}{40\,000} \approx 345\,000 \text{ tokens}$$
&lt;p>With &lt;code>max-model-len = 8192&lt;/code>, the maximum number of simultaneous requests with a full context is:&lt;/p>
$$\text{real max seqs} = \frac{345\,000}{8192} \approx 42 \text{ requests}$$
&lt;p>Setting &lt;code>max-num-seqs = 1024&lt;/code> with those numbers guarantees constant preemptions. The optimum is at 40-50.&lt;/p>
&lt;hr>
&lt;h2 id="the-grid-search-methodology">The grid search: methodology&lt;/h2>
&lt;h3 id="step-1-measure-the-real-workload">Step 1: measure the real workload&lt;/h3>
&lt;p>Before hunting for the optimum, you need to know your traffic&amp;rsquo;s percentiles. From Langfuse or the vLLM logs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Extract the length distribution from Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">traces&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetch_traces&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">data&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompt_lens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input_tokens&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">traces&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input_tokens&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">output_lens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output_tokens&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">traces&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output_tokens&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">numpy&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">np&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Prompt p50=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p95=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">95&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p99=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">99&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Output p50=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">50&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p95=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">95&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> p99=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">percentile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_lens&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">99&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.0f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="step-2-work-out-the-kv-budget">Step 2: work out the KV budget&lt;/h3>
&lt;p>Run once with &lt;code>--dry-run&lt;/code>, or read vLLM&amp;rsquo;s start-up log:&lt;/p>
&lt;pre tabindex="0">&lt;code>INFO: # GPU blocks: 4521, # CPU blocks: 512
&lt;/code>&lt;/pre>&lt;p>Each block is 16 tokens. &lt;code>4521 × 16 = 72,336 tokens&lt;/code> of total KV budget.&lt;/p>
&lt;h3 id="step-3-the-grid">Step 3: the grid&lt;/h3>
&lt;p>With the KV budget known and the p95 of prompt/output length:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># grid_search_batch.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">subprocess&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">json&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">MODEL&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Qwen/Qwen2.5-14B-Instruct-AWQ&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">PROMPT_LEN&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">512&lt;/span> &lt;span class="c1"># p50 of your workload&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">OUTPUT_LEN&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">256&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">CONCURRENCY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">32&lt;/span> &lt;span class="c1"># simultaneous users expected at peak&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">seqs_values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">32&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">64&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">256&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">512&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tokens_values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">4096&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">8192&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">16384&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">32768&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">65536&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">seqs&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">seqs_values&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">tokens&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">tokens_values&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cmd&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;python&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;-m&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;vllm.entrypoints.benchmark_throughput&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--model&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">MODEL&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--max-num-seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">seqs&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--max-num-batched-tokens&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--num-prompts&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;200&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--input-len&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">PROMPT_LEN&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;--output-len&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">OUTPUT_LEN&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">cmd&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">capture_output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">text&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">timeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">300&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Parse throughput from the output&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">line&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">out&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">splitlines&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="s2">&amp;#34;Throughput&amp;#34;&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tps&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">float&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;:&amp;#34;&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strip&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">()[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">results&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s2">&amp;#34;seqs&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">seqs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tokens&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tokens&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;tps&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">tps&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;seqs=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">seqs&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> tokens=&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> → &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">tps&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> tok/s&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Save for analysis&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;grid_results.json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;w&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dump&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">results&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">indent&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>25 configurations × ~5 min = &lt;strong>~2 hours&lt;/strong>. Real execution time, not waiting time.&lt;/p>
&lt;h3 id="step-4-read-the-surface">Step 4: read the surface&lt;/h3>
&lt;p>The result is a 5×5 throughput matrix. The typical shape:&lt;/p>
&lt;pre tabindex="0">&lt;code>max-num-batched-tokens → 4K 8K 16K 32K 64K
max-num-seqs ↓
32 180 310 380 390 385 ← max-num-seqs too low
64 185 350 480 510 508 ← optimal point for this workload
128 182 340 450 480 475
256 178 320 400 410 402 ← KV cache runs out, preemptions
512 170 290 360 370 368 ← high preemptions
&lt;/code>&lt;/pre>&lt;p>The optimum in this example: &lt;code>max-num-seqs=64, max-num-batched-tokens=32768&lt;/code>. Above that, preemptions cancel out the concurrency gain.&lt;/p>
&lt;hr>
&lt;h2 id="confirmation-with-otel">Confirmation with OTel&lt;/h2>
&lt;p>Once the optimal configuration is deployed, three Prometheus metrics confirm it is well calibrated:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. Queued requests — should stay close to 0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># If it grows steadily: max-num-seqs too low or max-num-batched-tokens insufficient&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># 2. Preemptions — should be 0 or very occasional (&amp;lt;1/min)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># If it grows: max-num-seqs too high for the available KV cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># 3. ITL (inter-token latency) — should be stable, with no spikes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Bimodality = badly calibrated batch size (some requests outside the CUDA graph bucket)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.99&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_per_output_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The optimal configuration produces:&lt;/p>
&lt;ul>
&lt;li>&lt;code>num_waiting_seqs&lt;/code> ≈ 0 under normal operation&lt;/li>
&lt;li>&lt;code>num_preemptions_total&lt;/code> stable (not growing)&lt;/li>
&lt;li>&lt;code>time_per_output_token&lt;/code> unimodal&lt;/li>
&lt;/ul>
&lt;p>If &lt;code>num_waiting_seqs&lt;/code> is high with &lt;code>gpu_cache_usage_perc&lt;/code> low: raise &lt;code>max-num-batched-tokens&lt;/code> to process prefills faster. If &lt;code>num_preemptions_total&lt;/code> grows: lower &lt;code>max-num-seqs&lt;/code> or enable FP8 KV cache to free up blocks.&lt;/p>
&lt;hr>
&lt;h2 id="reference-configurations-by-profile">Reference configurations by profile&lt;/h2>
&lt;p>Based on the grid search for mid-range hardware (generic 4×H100, 14B-70B model):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Profile&lt;/th>
&lt;th style="text-align:right">Prompt p50&lt;/th>
&lt;th style="text-align:right">Output p50&lt;/th>
&lt;th style="text-align:right">max-num-seqs&lt;/th>
&lt;th style="text-align:right">max-num-batched-tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Conversational chat&lt;/td>
&lt;td style="text-align:right">150 tok&lt;/td>
&lt;td style="text-align:right">300 tok&lt;/td>
&lt;td style="text-align:right">256&lt;/td>
&lt;td style="text-align:right">16384&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Enterprise RAG&lt;/td>
&lt;td style="text-align:right">1500 tok&lt;/td>
&lt;td style="text-align:right">200 tok&lt;/td>
&lt;td style="text-align:right">64&lt;/td>
&lt;td style="text-align:right">32768&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Coding (completion)&lt;/td>
&lt;td style="text-align:right">800 tok&lt;/td>
&lt;td style="text-align:right">500 tok&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">32768&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Summarisation&lt;/td>
&lt;td style="text-align:right">2500 tok&lt;/td>
&lt;td style="text-align:right">400 tok&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">65536&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Batch processing&lt;/td>
&lt;td style="text-align:right">4000 tok&lt;/td>
&lt;td style="text-align:right">800 tok&lt;/td>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">65536&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>None of these is universal. They are starting points for the grid search on your real hardware and workload.&lt;/p>
&lt;hr>
&lt;h2 id="when-not-to-touch-the-defaults">When not to touch the defaults&lt;/h2>
&lt;p>If your system sits below 50% KV cache utilisation (&lt;code>vllm:gpu_cache_usage_perc &amp;lt; 0.50&lt;/code>) under real demand and with no &lt;code>num_waiting_seqs&lt;/code>, the defaults are enough for your current load. The grid search contributes more when you are close to maximum capacity or when you want to extract the full performance of fixed hardware.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">&lt;code>max-num-batched-tokens&lt;/code> is the budget chunked prefill uses to interleave decode; this article covers the tuning of that parameter&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">&lt;code>max-num-seqs&lt;/code> interacts directly with &lt;code>gpu-memory-utilization&lt;/code> and the KV cache capacity for decode&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">the &lt;code>num_waiting_seqs&lt;/code>, &lt;code>num_preemptions_total&lt;/code> and &lt;code>time_per_output_token&lt;/code> metrics configured in the full OTel pipeline&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the KV budget formula that determines the real maximum of &lt;code>max-num-seqs&lt;/code> for your hardware&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">hardware sizing starts from the optimal throughput this grid search determines&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="in-this-same-series">In this same series&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">the second free optimisation: taking the prefix cache hit rate from 15% to 75%&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 in weights and KV cache: +40-60% throughput measured before and after with an eval suite&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP=4×1 vs TP=2×2: when the crossover point changes the platform decision&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://medium.com/@mahernaija/tuning-vllm-for-maximum-throughput-a-research-engineers-field-guide-21f341d71248">Tuning vLLM for Maximum Throughput (2026)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://rocm.docs.amd.com/en/latest/how-to/rocm-for-ai/inference-optimization/vllm-optimization.html">vLLM V1 performance optimization — ROCm&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.anyscale.com/llm/serving/parameter-tuning">Anyscale: Tune parameters for LLMs&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Instrumenting vLLM with OTel: measuring what the optimisations really do</title><link>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM exposes two independent observability signals: Prometheus metrics (pull, aggregated) and OTel traces (push, per request). To measure whether chunked prefill, prefix caching, speculative decoding, FP8 KV cache and concurrency are really working, you need both. The metrics tell you what is happening in the system; the traces tell you why one specific request was slow. This article configures the complete pipeline and maps each optimisation to its diagnostic metric.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A Formula 1 driver and his telemetry engineers. The driver feels that the car &amp;ldquo;goes odd&amp;rdquo; in turn 3, but without the sensor data he does not know whether it is the rear tyre, the differential or the fuel. The engineers see exactly what happened in that turn, temperature per sensor, lateral load per millisecond.&lt;/p>
&lt;p>vLLM without OTel is the driver on his own: you notice that the TTFT &amp;ldquo;looks high&amp;rdquo; but you do not know whether it is a long prefill, a prefix cache miss, or a KV cache preemption. With OTel you have the full picture: the metrics are the race summary (aggregated), the traces are the lap-by-lap telemetry (per-request).&lt;/p>
&lt;hr>
&lt;h2 id="architecture-of-the-two-signals">Architecture of the two signals&lt;/h2>
&lt;p>vLLM deliberately separates its two observability channels:&lt;/p>
&lt;pre tabindex="0">&lt;code> ┌─────────────────────────────┐
│ vLLM │
│ │
requests ────────────►│ SchedulerStats │
│ │ │
│ ├─► Prometheus /metrics │◄── scrape (pull)
│ │ (aggregated, ~15s) │
│ │ │
│ └─► OTLP exporter │──► push (per request)
│ (spans, immediate) │
└─────────────────────────────┘
│ OTLP gRPC/HTTP
▼
┌─────────────────────┐
│ OTel Collector │
│ │
│ receivers: │
│ otlp (traces) │
│ prometheus │
│ exporters: │
│ langfuse │
│ prometheus remote│
│ loki (logs) │
└─────────────────────┘
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Prometheus pull&lt;/strong> exposes metrics with the &lt;code>vllm:&lt;/code> prefix at &lt;code>:8000/metrics&lt;/code>. They are histograms, gauges and counters updated on every scheduler iteration. Good for dashboards and alerts about the system as a whole.&lt;/p>
&lt;p>&lt;strong>OTLP push&lt;/strong> sends one span per request, immediately on completion. It contains attributes of the specific request: prompt tokens, generated tokens, TTFT, model. Good for debugging anomalous requests and for Langfuse.&lt;/p>
&lt;hr>
&lt;h2 id="installation-and-basic-configuration">Installation and basic configuration&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># vLLM with OTel support&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;vllm[otel]&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Installs: opentelemetry-sdk, opentelemetry-api,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># opentelemetry-exporter-otlp, opentelemetry-semantic-conventions-ai&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Start vLLM with OTel enabled&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_SERVICE_NAME&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;vllm-production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_ENDPOINT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://otel-collector:4317&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_PROTOCOL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;grpc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_INSECURE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span> &lt;span class="c1"># on an internal network without TLS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --otlp-traces-endpoint http://otel-collector:4317 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Prometheus metrics need no extra configuration: they are always at &lt;code>:8000/metrics&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="otel-collector-minimum-configuration">OTel Collector: minimum configuration&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># otel-collector-config.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocols&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grpc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4317&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">http&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4318&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scrape_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scrape_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">static_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;vllm:8000&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">batch&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">attributes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">deployment.environment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">upsert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlphttp/langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://cloud.langfuse.com/api/public/otel&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Authorization&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Basic &amp;lt;base64(pk:sk)&amp;gt;&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheusremotewrite&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://prometheus:9090/api/v1/write&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">service&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pipelines&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">traces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch, resource]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlphttp/langfuse]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheus]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheusremotewrite]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="the-five-metrics-that-matter">The five metrics that matter&lt;/h2>
&lt;p>Every optimisation has a primary diagnostic signal. If the metric does not move as expected after enabling the flag, there is a configuration or load problem.&lt;/p>
&lt;h3 id="1-chunked-prefill--vllmtime_to_first_token_seconds">1. Chunked prefill → &lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/h3>
&lt;p>Chunked prefill should reduce the variance of the TTFT, not necessarily the median. Its main goal is for the high percentiles (p99) to come down even if the p50 rises slightly.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># TTFT p50 and p99 — expect p99 to drop with chunked prefill enabled&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.99&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> the p99/p50 ratio approaches 1. Without chunked prefill, one request&amp;rsquo;s long prefill blocks all the others and the p99 rises disproportionately.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> p50 and p99 both rise. A &lt;code>--max-num-batched-tokens&lt;/code> that is too low makes the chunks so small that the prefill takes many steps to complete even though the other requests are not blocked. Raise the budget.&lt;/p>
&lt;p>It is also useful to look at the &lt;code>llm.usage.prompt_tokens&lt;/code> attribute per span in the OTel traces: requests with many prompt tokens should have a proportional TTFT, not a blocking one.&lt;/p>
&lt;hr>
&lt;h3 id="2-prefix-caching--vllmgpu_prefix_cache_hit_rate">2. Prefix caching → &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Prefix cache hit rate on the GPU (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Evolution over a 5-minute window&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained hit rate &amp;gt; 0.5 in workloads with a shared system prompt. With a hit rate of 0.8, 80% of requests skip the prefix prefill; the TTFT of those requests falls to the cost of the variable suffix alone.&lt;/p>
&lt;p>&lt;strong>Sign of a problem: a hit rate close to zero&lt;/strong> despite system prompts that &amp;ldquo;look&amp;rdquo; identical. The usual causes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ This breaks the prefix caching hash:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Today is &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ^^^ a different timestamp on every request&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ The system prompt has to be identical byte for byte:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant specialising in infrastructure...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any variation in the system prompt, timestamps, session IDs, interpolated prompt versions, produces a different hash and a cache miss. The OTel traces do not expose the hit/miss per request directly in the current implementation; use them to correlate a high &lt;code>llm.usage.prompt_tokens&lt;/code> with a high TTFT in the same request.&lt;/p>
&lt;hr>
&lt;h3 id="3-speculative-decoding--vllmspec_decode_draft_acceptance_rate">3. Speculative decoding → &lt;code>vllm:spec_decode_draft_acceptance_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Acceptance rate of the draft model (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Estimated effective speedup (with k=5 proposed tokens)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># speedup ≈ (1 + α·k) / (1 + overhead_draft)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Simplified: if α=0.75 and k=5 → speedup ≈ 1 + 0.75×5×(1 - cost_ratio) &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained acceptance rate &amp;gt; 0.70. Below 0.60, the draft model&amp;rsquo;s overhead outweighs the gain from the accepted tokens and speculative decoding is counterproductive.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> an acceptance rate &amp;lt; 0.50. The usual causes:&lt;/p>
&lt;ul>
&lt;li>A drafter from a different family than the verifier (for instance, Mistral 0.5B as the draft for Qwen 7B).&lt;/li>
&lt;li>A high generation temperature (&amp;gt;0.9): the higher the temperature, the more the draft&amp;rsquo;s distribution diverges from the verifier&amp;rsquo;s.&lt;/li>
&lt;li>A very large batch: at high concurrency, the draft can fall outside the domain of the current requests.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: inefficient speculative decoding&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">SpecDecodeInefficient&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">warning&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">Draft acceptance rate low: disable spec decode or change the drafter&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In the OTel traces, the complete span of the request includes the total decode time. Without an acceptance rate per span, the way to detect that spec decode is working is to compare the total decode time divided by the tokens generated: if it is significantly lower than the baseline without spec decode, it is helping.&lt;/p>
&lt;hr>
&lt;h3 id="4-fp8-kv-cache-and-concurrency--vllmgpu_cache_usage_perc--vllmnum_preemptions_total">4. FP8 KV cache and concurrency → &lt;code>vllm:gpu_cache_usage_perc&lt;/code> + &lt;code>vllm:num_preemptions_total&lt;/code>&lt;/h3>
&lt;p>These two metrics are the two sides of KV cache management:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># KV cache utilisation (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># With FP8 enabled, the same hardware supports more requests before saturating&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Cumulative preemptions (counter)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Rises when vLLM cannot fit more requests and pauses one&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that FP8 works:&lt;/strong> with &lt;code>--kv-cache-dtype fp8&lt;/code> enabled, &lt;code>gpu_cache_usage_perc&lt;/code> should saturate at concurrency levels around 2× higher than the BF16 baseline before &lt;code>num_preemptions_total&lt;/code> starts to grow.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> &lt;code>num_preemptions_total&lt;/code> growing at rates &amp;gt; 1/minute with &lt;code>gpu_cache_usage_perc&lt;/code> below 0.90. It indicates that &lt;code>max-num-seqs&lt;/code> is too high for the available KV cache: requests enter the system but there are no free blocks to assign to them. Lower &lt;code>max-num-seqs&lt;/code> or reduce &lt;code>max-model-len&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: KV cache saturated with preemptions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">KVCacheSaturated&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">2m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.85&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">3m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">critical&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">KV cache saturated: lower max-num-seqs or max-model-len&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The impact of FP8 on capacity can be quantified:&lt;/p>
$$\Delta\text{capacity} = \frac{\text{max tokens FP8}}{\text{max tokens BF16}} \approx 2\times$$
&lt;p>Measure before and after enabling &lt;code>--kv-cache-dtype fp8&lt;/code>: the level of &lt;code>gpu_cache_usage_perc&lt;/code> for a given concurrency should fall by half.&lt;/p>
&lt;hr>
&lt;h3 id="5-effective-concurrency--vllmnum_running_seqs--vllmnum_waiting_seqs">5. Effective concurrency → &lt;code>vllm:num_running_seqs&lt;/code> + &lt;code>vllm:num_waiting_seqs&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Requests active in the engine (decode + prefill in progress)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Requests queued waiting for a slot&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Waiting ratio: if &amp;gt; 0.2 sustained, there is a concurrency bottleneck&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Healthy sign:&lt;/strong> &lt;code>num_running_seqs&lt;/code> stable close to the configured &lt;code>--max-num-seqs&lt;/code> value, &lt;code>num_waiting_seqs&lt;/code> low (&amp;lt; 10% of running).&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> a high &lt;code>num_waiting_seqs&lt;/code> with a low &lt;code>gpu_cache_usage_perc&lt;/code>. It indicates that the scheduler is not filling the available slots because &lt;code>max-num-batched-tokens&lt;/code> is too low: the token budget per step does not allow the pending prefills to be processed fast enough. Raise &lt;code>max-num-batched-tokens&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="reference-dashboard-the-5-metrics-in-grafana">Reference dashboard: the 5 metrics in Grafana&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;panels&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;TTFT p50 / p99 (chunked prefill)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p50&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p99&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Prefix cache hit rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_prefix_cache_hit_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;GPU hit rate&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Spec decode acceptance rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:spec_decode_draft_acceptance_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acceptance rate&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KV cache usage + preemptions&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_cache_usage_perc&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cache usage&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rate(vllm:num_preemptions_total[2m]) * 60&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;preemptions/min&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Effective concurrency&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_running_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;running&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_waiting_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;waiting&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="connecting-traces-to-langfuse">Connecting traces to Langfuse&lt;/h2>
&lt;p>vLLM&amp;rsquo;s OTel traces are GenAI semconv compatible spans. Langfuse accepts them directly via OTLP:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># In the OTel Collector (already configured above)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The otlphttp/langfuse exporter sends traces to Langfuse Cloud or self-hosted&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># For self-hosted Langfuse (ENS/sovereign):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">exporters:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> otlphttp/langfuse:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> endpoint: &lt;span class="s2">&amp;#34;http://langfuse-internal:3000/api/public/otel&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> headers:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Authorization: &lt;span class="s2">&amp;#34;Basic &amp;lt;base64(pk_xxx:sk_xxx)&amp;gt;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In Langfuse, each vLLM request appears as a trace with:&lt;/p>
&lt;ul>
&lt;li>&lt;code>gen_ai.system&lt;/code>: the model served&lt;/li>
&lt;li>&lt;code>gen_ai.usage.input_tokens&lt;/code>: prompt tokens&lt;/li>
&lt;li>&lt;code>gen_ai.usage.output_tokens&lt;/code>: generated tokens&lt;/li>
&lt;li>Span duration: end-to-end latency&lt;/li>
&lt;/ul>
&lt;p>What does &lt;strong>not&lt;/strong> appear directly in the span: the speculative decoding acceptance rate, the prefix cache hit/miss, or the number of preemptions. That data is only in Prometheus. The correct workflow is:&lt;/p>
&lt;ol>
&lt;li>Langfuse identifies an anomalous request by latency.&lt;/li>
&lt;li>Prometheus/Grafana shows whether in that interval there were high preemptions, low spec decode, or a prefix cache miss.&lt;/li>
&lt;li>The two are correlated by timestamp.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="quick-diagnostic-matrix">Quick diagnostic matrix&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observable symptom&lt;/th>
&lt;th>Prometheus metric&lt;/th>
&lt;th>Probable cause&lt;/th>
&lt;th>Action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT p99 very high&lt;/td>
&lt;td>&lt;code>ttft p99/p50 &amp;gt;&amp;gt; 2&lt;/code>&lt;/td>
&lt;td>Long blocking prefills&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT p50 high, p99 the same&lt;/td>
&lt;td>&lt;code>ttft p50 &amp;gt; 500ms&lt;/code>&lt;/td>
&lt;td>Prefix cache not working&lt;/td>
&lt;td>Check the system prompt hash&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Slow decode with no improvement&lt;/td>
&lt;td>&lt;code>spec_decode_acceptance &amp;lt; 0.60&lt;/code>&lt;/td>
&lt;td>Incompatible drafter&lt;/td>
&lt;td>Change the drafter or disable it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sporadic OOM / crash&lt;/td>
&lt;td>&lt;code>gpu_cache_usage_perc = 1.0&lt;/code> + preemptions&lt;/td>
&lt;td>KV cache full&lt;/td>
&lt;td>Lower &lt;code>max-num-seqs&lt;/code> or enable FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>High queue with free cache&lt;/td>
&lt;td>&lt;code>waiting &amp;gt;&amp;gt; 0&lt;/code> + &lt;code>cache &amp;lt; 0.70&lt;/code>&lt;/td>
&lt;td>Low token budget&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="implications-for-sovereign-on-premise-inference">Implications for sovereign on-premise inference&lt;/h2>
&lt;p>In an ENS deployment where you cannot use Langfuse Cloud or DataDog, the complete self-hosted stack is:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># docker-compose.yml (or equivalent K8s manifests)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otel-collector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">otel/opentelemetry-collector-contrib:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">./otel-config.yaml:/etc/otel/config.yaml]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">langfuse/langfuse:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">environment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">DATABASE_URL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres://...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prom/prometheus:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grafana&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">grafana/grafana:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The whole pipeline runs on-premise. The traces never leave the perimeter. ENS compliance does not depend on which observability you choose: it depends on the inference data not going out to third parties. With a local stack, both conditions are met.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the fundamentals of OTel GenAI semconv: what spans are, the standard attributes and how they flow from the SDK to the collector&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">the prefill optimisations this article instruments: chunked prefill, prefix caching, FP8 KV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">the decode optimisations: speculative decoding, gpu-memory-utilization, max-num-seqs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM for the GPU metrics underneath vLLM: SM utilization, memory bandwidth, temperature; the hardware layer below the application metrics&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">how to correlate GPU metrics (DCGM) with application metrics (vLLM Prometheus) for a complete diagnosis&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the scheduler that produces the num_running_seqs and num_waiting_seqs metrics; without understanding the scheduler, the concurrency metrics have no context&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/examples/online_serving/opentelemetry/">vLLM OpenTelemetry setup — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM Metrics — diseño y lista completa&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/model-providers/vllm">Tracing vLLM with Langfuse via OpenTelemetry&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.dash0.com/blog/observing-vllm-with-opentelemetry-and-dash0">Observing vLLM with OpenTelemetry and Dash0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/blog/2024/llm-observability/">OpenTelemetry GenAI Semantic Conventions&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Knowledge Distillation: teaching a small model to think like a big one</title><link>https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Knowledge Distillation is the technique of training a small model (&lt;em>student&lt;/em>) using the output probabilities of a large model (&lt;em>teacher&lt;/em>) as supervision, instead of using only the hard labels from the training dataset. The result is a small model that reasons better than its size suggests, because it learns the teacher&amp;rsquo;s uncertainty distributions rather than memorising binary answers. It is the reason Phi-4 (14B) beats most 70B models at reasoning, and why the Gemma 3 family models are surprisingly capable for their size. It is not a compression technique for an existing model: it is a training process that produces a smaller model from scratch or from a different starting point.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A master surgeon with thirty years of experience and a first-year resident. If the resident only learns from the anatomy manual (binary correct answers: &amp;ldquo;cut here, not here&amp;rdquo;) it will take years to develop the master&amp;rsquo;s clinical judgement. But if they operate alongside them, watching their micro-decisions, their hesitations, the ambiguous cases where the master knows that two options are almost equally valid, they learn something the manual cannot teach: the structure of uncertainty.&lt;/p>
&lt;p>Knowledge distillation is exactly that. The &amp;ldquo;anatomy manual&amp;rdquo; is the hard labels (the correct answer). The &amp;ldquo;master surgeon&amp;rdquo; is the teacher LLM. The probability distributions over the vocabulary are the materialisation of that uncertainty the student absorbs.&lt;/p>
&lt;hr>
&lt;h2 id="what-it-really-is">What it really is&lt;/h2>
&lt;p>When an LLM generates text, it does not produce a single word: it produces a probability distribution over its whole vocabulary at each position. For the next token, the model might say:&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;Paris&amp;#34;: 42%
&amp;#34;Lyon&amp;#34;: 8%
&amp;#34;Marseille&amp;#34;: 6%
&amp;#34;the city&amp;#34;: 5%
...rest of the vocabulary: 39%
&lt;/code>&lt;/pre>&lt;p>This distribution is dense information. It tells you not only &lt;em>what&lt;/em> the correct answer is, but also &lt;em>which other answers were plausible and to what degree&lt;/em>. A student trained only with the label &amp;ldquo;Paris&amp;rdquo; (probability 1.0 on the correct token, 0.0 on the rest) never sees this richness.&lt;/p>
&lt;p>Distillation uses the teacher&amp;rsquo;s full distribution as the student&amp;rsquo;s training target. The loss function has two terms:&lt;/p>
$$\mathcal{L}_{total} = (1 - \alpha) \cdot \mathcal{L}_{CE}(y, \hat{y}_S) + \alpha \cdot \mathcal{L}_{KD}(p_T, p_S, T)$$
&lt;p>Where:&lt;/p>
&lt;ul>
&lt;li>$\mathcal{L}_{CE}$ is the standard cross-entropy with the hard labels (classic supervision).&lt;/li>
&lt;li>$\mathcal{L}_{KD}$ is the KL divergence between the teacher&amp;rsquo;s and the student&amp;rsquo;s distributions.&lt;/li>
&lt;li>$\alpha$ controls the relative weight of each term (typically 0.5–0.9 in favour of KD).&lt;/li>
&lt;li>$T$ is the &lt;em>temperature&lt;/em>, a parameter that softens the distributions to make the KD signal more informative.&lt;/li>
&lt;/ul>
&lt;h3 id="the-role-of-temperature">The role of temperature&lt;/h3>
&lt;p>If the teacher assigns 99% to &amp;ldquo;Paris&amp;rdquo; and 0.001% to every other word, the distribution is almost as informative as a hard label. A temperature $T > 1$ softens that distribution:&lt;/p>
$$p_T(k) = \frac{\exp(z_k / T)}{\sum_j \exp(z_j / T)}$$
&lt;p>With $T = 4$ and the original logits, the distribution that was previously [99%, 0.001%, 0.001%&amp;hellip;] becomes something like [42%, 8%, 6%&amp;hellip;]. The student sees the teacher&amp;rsquo;s real probability neighbourhood, not just its point answer.&lt;/p>
&lt;p>&lt;strong>Numerical example with temperature:&lt;/strong>&lt;/p>
&lt;p>Teacher logits for &amp;ldquo;The capital of France is _____&amp;rdquo;:&lt;/p>
&lt;pre tabindex="0">&lt;code>Paris: 8.5
Lyon: 3.2
Europe: 2.1
a: 1.8
&lt;/code>&lt;/pre>&lt;p>With T=1 (standard softmax):
&lt;/p>
$$p(\text{Paris}) = \frac{e^{8.5}}{e^{8.5} + e^{3.2} + e^{2.1} + e^{1.8}} \approx 99.3\%$$
&lt;p>With T=4:
&lt;/p>
$$p(\text{Paris}) = \frac{e^{8.5/4}}{e^{8.5/4} + e^{3.2/4} + e^{2.1/4} + e^{1.8/4}} = \frac{e^{2.125}}{e^{2.125} + e^{0.8} + e^{0.525} + e^{0.45}} \approx 54\%$$
&lt;p>The signal at T=4 is far more informative for the student: it learns that Lyon is more plausible than Europe, that Europe is more plausible than &amp;ldquo;a&amp;rdquo;, and so on.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-modes-of-distillation">The three modes of distillation&lt;/h2>
&lt;h3 id="offline-or-black-box">Offline (or &amp;ldquo;black-box&amp;rdquo;)&lt;/h3>
&lt;p>The teacher generates a synthetic dataset of answers before training. The student is trained on that dataset as if it were ordinary hard labels.&lt;/p>
&lt;pre tabindex="0">&lt;code>teacher → generates 100M (prompt, completion) pairs → dataset
student → is trained on that dataset
&lt;/code>&lt;/pre>&lt;p>It is the cheapest way to scale: the teacher runs only once, the student is trained on the generated data with conventional hardware. Most open source instruction models (Alpaca, Vicuna, WizardLM in their early versions) used this strategy: GPT-4 as teacher, saved data, Llama-7B as student.&lt;/p>
&lt;p>&lt;strong>Limitation:&lt;/strong> the student never sees the teacher&amp;rsquo;s probability distributions, only its answers. It is distillation of &amp;ldquo;behaviour&amp;rdquo;, not of &amp;ldquo;knowledge&amp;rdquo; in the strict sense. If the teacher makes a mistake (and GPT-4 does make mistakes), the error is crystallised into the dataset.&lt;/p>
&lt;h3 id="online-or-white-box">Online (or &amp;ldquo;white-box&amp;rdquo;)&lt;/h3>
&lt;p>Teacher and student run together during training. The student processes each batch, the teacher processes the same batch in parallel, and the KD loss is computed in real time with the full probability distributions.&lt;/p>
&lt;pre tabindex="0">&lt;code>for batch in dataset:
logits_teacher = teacher(batch) # teacher forward pass
logits_student = student(batch) # student forward pass
loss = KL(softmax(logits_teacher/T), softmax(logits_student/T))
loss.backward() # only updates student
&lt;/code>&lt;/pre>&lt;p>The teacher has gradients disabled (&lt;code>torch.no_grad()&lt;/code>). The learning signal is richer than in offline, but the cost is high: you need to keep the teacher in VRAM throughout training. To distil a 405B teacher into an 8B student, you would need several H100s for the teacher alone.&lt;/p>
&lt;h3 id="on-policy">On-policy&lt;/h3>
&lt;p>A recent variant (2024–2026) that combines the best of both: the teacher generates answers dynamically during training, but the student evaluates them with its own distribution. The cycle is:&lt;/p>
&lt;ol>
&lt;li>The student generates a proposed answer (&lt;em>rollout&lt;/em>).&lt;/li>
&lt;li>The teacher scores that proposal with its probability distribution.&lt;/li>
&lt;li>The student updates with the teacher&amp;rsquo;s signal.&lt;/li>
&lt;/ol>
&lt;p>This stops the student from learning from distributions outside its own domain (the &lt;em>distribution shift&lt;/em> problem in offline). It is the basis of algorithms such as &lt;a href="https://github.com/nick7nlp/Awesome-LLM-On-Policy-Distillation">SimCT (2026)&lt;/a> that use teachers from different families (Qwen, Phi, Gemma) to generate a cross-tokeniser signal.&lt;/p>
&lt;hr>
&lt;h2 id="why-the-best-small-models-use-distillation">Why the best small models use distillation&lt;/h2>
&lt;p>Phi-4 (Microsoft, 14B), Gemma 3 (Google, 9B/27B), and the compact Qwen3 family models are the clearest examples. Their benchmarks are anomalous with respect to their size: Phi-4-14B beats LLaMA-3-70B on MATH and GPQA-Diamond, two mathematical and scientific reasoning benchmarks where size is usually decisive.&lt;/p>
&lt;p>Why? The key is what supervises the training:&lt;/p>
&lt;ul>
&lt;li>A model trained on internet data learns the distribution of human text, which includes a lot of low-quality text, errors and ambiguities.&lt;/li>
&lt;li>A student that learns from a frontier teacher (GPT-4o, Claude 3 Opus, Gemini 1.5 Pro) absorbs a distribution filtered towards high-quality text and correct reasoning.&lt;/li>
&lt;/ul>
&lt;p>The student with 14B parameters does not &amp;ldquo;know more&amp;rdquo; than an undistilled one of the same size, but it has learned to use them better because its training gradients were never contaminated by low-quality text.&lt;/p>
&lt;p>&lt;strong>Empirical data point:&lt;/strong> Phi-4 (14B distilled) vs LLaMA-3-70B (not distilled) on the MATH benchmark (2025):&lt;/p>
&lt;ul>
&lt;li>Phi-4: 80.4%&lt;/li>
&lt;li>LLaMA-3-70B: 68.0%&lt;/li>
&lt;/ul>
&lt;p>A model 5× smaller beats the large one because the training signal is better, not because it has more parameters.&lt;/p>
&lt;hr>
&lt;h2 id="reasoning-distillation-the-case-of-thinking-models">Reasoning distillation: the case of thinking models&lt;/h2>
&lt;p>Reasoning models (DeepSeek-R1, Qwen3-thinking, QwQ) generate internal chains of thought before giving the final answer. Distilling reasoning is more complex because you do not only want to transfer the answer: you want to transfer the &lt;em>way of thinking&lt;/em>.&lt;/p>
&lt;p>The current strategy (2025–2026) is &lt;strong>reasoning trace distillation&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>The teacher (a large thinking model) generates answers with its full internal chain of thought.&lt;/li>
&lt;li>The dataset includes those chains of thought as part of the output.&lt;/li>
&lt;li>The student learns to imitate both the chain and the final answer.&lt;/li>
&lt;/ol>
&lt;p>This explains why Qwen3-7B-thinking can reason formally about mathematics while being 10× smaller than the models that preceded it without distillation: it learned the &lt;em>process&lt;/em>, not just the &lt;em>result&lt;/em>.&lt;/p>
&lt;hr>
&lt;h2 id="when-to-use-distillation-vs-the-alternatives">When to use distillation vs. the alternatives&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Technique&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Requires retraining&lt;/th>
&lt;th>Result&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Quantisation&lt;/td>
&lt;td>Reduces weight precision&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Same model, smaller&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pruning&lt;/td>
&lt;td>Removes irrelevant weights&lt;/td>
&lt;td>No (PTQ)&lt;/td>
&lt;td>Same model, sparser&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distillation&lt;/td>
&lt;td>Trains a new model&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Different model, smaller&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Distillation does not compress an existing model: it produces a new one. That is why it is complementary, not a substitute: you can distil a 405B into an 8B, and then quantise that 8B to INT4 to reduce its inference cost.&lt;/p>
&lt;p>&lt;strong>When it is the right option:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>You need a model 5–10× smaller than the best available one.&lt;/li>
&lt;li>You have access (API or local) to a quality teacher.&lt;/li>
&lt;li>You have training data or the ability to generate it.&lt;/li>
&lt;li>Latency or inference cost is a hard constraint.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>When it is not:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>You want to compress an existing model quickly: use quantisation + pruning.&lt;/li>
&lt;li>You have no training budget (online distillation takes weeks of GPU).&lt;/li>
&lt;li>The teacher is not significantly better than the base student: the KD signal will be weak.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>In a sovereign deployment, the teacher can be a large model running locally (no external API needed). The flow is:&lt;/p>
&lt;pre tabindex="0">&lt;code>generic 4×H100:
teacher: Llama-3.3-70B-Instruct (on the 4×H100, full load)
→ generates a dataset of 10M (prompt, completion with logits) pairs
→ 3-4 weeks of generation at batch 32
After the dataset:
student: Qwen2.5-7B (fine-tuned with KD loss over the dataset)
→ 2-3 days of training on the same H100s
→ result: a 7B that reasons like the 70B in the specific domain
Production:
RTX 4090: serves the 7B student quantised to INT4 (4 GB)
&lt;/code>&lt;/pre>&lt;p>The teacher is only needed to generate the data. The student is what goes into production. The investment in training compute pays for itself in months of cheaper inference.&lt;/p>
&lt;p>For ENS/NIS2: this flow is 100% on-premise, zero dependency on external APIs, and the resulting model is yours in every sense.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/ — a technical alternative: instead of training a new model, remove parts of the existing model; distillation and pruning are complementary&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/ — the next step after distilling: quantise the student for efficient inference&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/ — speculative decoding drafters are frequently students distilled from the base model that learn to predict its distribution&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/ — distillation as a form of continuous fine-tuning: the teacher is the model in production, the student is the next version&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/ — DPO and its variants can be seen as distilling human preferences into the model; the maths of the reference distribution is analogous to the teacher in KD&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/1503.02531">Distilling the Knowledge in a Neural Network&lt;/a> — Hinton, Vinyals &amp;amp; Dean, 2015 (paper fundacional)&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2412.08905">Phi-4 Technical Report&lt;/a> — Microsoft Research, 2024&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2501.12948">DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning&lt;/a> — DeepSeek, 2025 (destilación de razonamiento)&lt;/li>
&lt;li>&lt;a href="https://github.com/nick7nlp/Awesome-LLM-On-Policy-Distillation">Awesome LLM On-Policy Distillation&lt;/a> — colección de papers de destilación en-policy, 2025–2026&lt;/li>
&lt;li>&lt;a href="https://openreview.net/pdf/cf5bed8b71779ae42d0e681f1e2a7de3b3c8f6ad.pdf">Knowledge Distillation for LLMs: Survey&lt;/a> — ICLR 2025&lt;/li>
&lt;/ul></description></item><item><title>Optimising decode in vLLM: squeezing every token out of small hardware</title><link>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Decode is the phase in which vLLM generates output tokens one at a time. It is &lt;strong>memory-bound&lt;/strong>, not compute-bound: the GPU spends more time waiting for the weights to arrive from VRAM than doing arithmetic. On small hardware, an RTX 4090 (24 GB) or an L40 (48 GB), a badly configured decode wastes half the card&amp;rsquo;s capacity. Five vLLM parameters change the equation: &lt;code>gpu-memory-utilization&lt;/code>, &lt;code>max-num-seqs&lt;/code>, speculative decoding, FP8 KV cache and a &lt;code>swap-space&lt;/code> correctly set to zero. Well calibrated, the difference is real: from 15 tokens/s to 35–50 tokens/s on the same hardware.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>An assembly-line worker putting cars together. Every car needs exactly the same process: walk to the store for the part, come back, bolt it on, repeat. The travel time to the store, the VRAM latency, is fixed and cannot be eliminated. But there are ways to make it less painful:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Have several cars in parallel on the line&lt;/strong> (more concurrency, the same travel time amortised).&lt;/li>
&lt;li>&lt;strong>Have an assistant prefabricate common parts&lt;/strong> (speculative decoding: the draft model proposes, the verifier confirms).&lt;/li>
&lt;li>&lt;strong>Keep only the most-used parts in the workshop&lt;/strong> (quantised KV cache: more contexts fit in the same space).&lt;/li>
&lt;/ol>
&lt;p>Those three strategies are exactly the three axes of decode optimisation in vLLM.&lt;/p>
&lt;hr>
&lt;h2 id="why-decode-is-memory-bound">Why decode is memory-bound&lt;/h2>
&lt;p>During prefill, the GPU processes N tokens in parallel: the attention operation is a large matmul and the compute units are busy. During decode, it processes &lt;strong>1 token per step&lt;/strong>: the matmul becomes a vector-matrix product, an operation that underuses the tensor cores.&lt;/p>
&lt;p>The typical compute utilisation ratio during decode on an RTX 4090:&lt;/p>
$$\text{MFU}_{decode} \approx 5–15\% \quad \text{(vs 40–60\% in prefill)}$$
&lt;p>The bottleneck is not compute power, it is bandwidth. To generate each token, the model has to read its complete weights from VRAM:&lt;/p>
$$\text{time per token} \approx \frac{\text{weight size in bytes}}{\text{VRAM bandwidth}}$$
&lt;p>For Qwen2.5-7B in BF16 (14 GB of weights) on an RTX 4090 (1,008 GB/s):&lt;/p>
$$t \approx \frac{14 \times 10^9}{1.008 \times 10^{12}} \approx 13.9 \text{ ms/token} \approx 72 \text{ tokens/s theoretical maximum}$$
&lt;p>The real value is lower (~30–50 tok/s) because of scheduler overhead, attention over the growing KV cache and other latencies. But the theoretical limit sets the ceiling.&lt;/p>
&lt;p>With Q4_K_M (weights ~4 GB):&lt;/p>
$$t \approx \frac{4 \times 10^9}{1.008 \times 10^{12}} \approx 3.97 \text{ ms/token} \approx 252 \text{ tokens/s theoretical}$$
&lt;p>Quantising the model is the most direct way to improve decode throughput on memory-bound hardware. Everything else optimises on top of that ceiling.&lt;/p>
&lt;hr>
&lt;h2 id="the-five-levers">The five levers&lt;/h2>
&lt;h3 id="1-give-vllm-all-the-vram-you-can----gpu-memory-utilization">1. Give vLLM all the VRAM you can — &lt;code>--gpu-memory-utilization&lt;/code>&lt;/h3>
&lt;p>&lt;code>--gpu-memory-utilization&lt;/code> (short form &lt;code>--gpu-mem-util&lt;/code>) defines the fraction of available VRAM that vLLM may use for the &lt;strong>KV cache&lt;/strong>, once the model weights are loaded. The rest is reserved for activations during the forward pass and for the CUDA context.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The default value is &lt;code>0.90&lt;/code>. On bare metal where no other process uses the GPU, &lt;code>0.92–0.95&lt;/code> is safe. Do not go above &lt;code>0.95&lt;/code>: vLLM needs headroom for activations during batch peaks, and running out of VRAM in the middle of an inference results in a process crash, not a clean error.&lt;/p>
&lt;p>&lt;strong>Why it matters:&lt;/strong> more KV cache available = more simultaneous requests in flight = better GPU utilisation during decode. PagedAttention allocates the KV cache in fixed-size blocks (16 tokens per block by default), and vLLM manages them like virtual memory pages. The more blocks available, the more requests it can serve without any of them waiting for space.&lt;/p>
&lt;pre tabindex="0">&lt;code>RTX 4090, Qwen2.5-7B-BF16 (14 GB weights):
Total VRAM: 24 GB
Weights: 14 GB
Available for KV cache: 10 GB
gpu-memory-utilization 0.90 → 0.90 × 10 GB = 9 GB for KV cache
gpu-memory-utilization 0.94 → 0.94 × 10 GB = 9.4 GB → ~4% more tokens in flight
&lt;/code>&lt;/pre>&lt;p>The impact is modest with models that fit comfortably, but it is amplified with models that push VRAM to its limit.&lt;/p>
&lt;hr>
&lt;h3 id="2-real-concurrency----max-num-seqs">2. Real concurrency — &lt;code>--max-num-seqs&lt;/code>&lt;/h3>
&lt;p>&lt;code>--max-num-seqs&lt;/code> is the maximum number of requests vLLM can have in process simultaneously (counting prefill and decode together). It is the parameter that controls the system&amp;rsquo;s effective concurrency.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">128&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The effect is direct: more requests in simultaneous decode = better amortisation of the fixed cost of reading the weights. When the decode batch grows from 1 to 8, the time to generate 8 tokens is almost the same as generating 1 (the weights are read once for all of them). Aggregate throughput scales almost linearly until the KV cache or the activation VRAM becomes the bottleneck.&lt;/p>
$$\text{aggregate throughput}(B) \approx B \times \text{throughput}(1) \quad \text{for } B \ll B_{max}$$
&lt;p>&lt;strong>Common mistake:&lt;/strong> raising &lt;code>--max-num-seqs&lt;/code> without making sure there is enough KV cache in VRAM for all the requests. If vLLM cannot hold the KV caches of 128 simultaneous requests, it does preemption (pauses some request and frees its KV cache) at a latency cost. Monitor &lt;code>vllm:num_preemptions_total&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Interaction with &lt;code>--max-num-batched-tokens&lt;/code>:&lt;/strong> the vLLM scheduler processes up to &lt;code>max-num-batched-tokens&lt;/code> tokens per step. If you have 128 requests in decode generating 1 token each, that is 128 decode tokens. The decode budget consumes 128 tokens of the total budget; the rest goes to prefill in chunks. Tune both values together.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># For an RTX 4090 serving ~50 concurrent users with responses of up to 512 tokens&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 64 decode tokens per step + up to 8128 chunked prefill tokens&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="3-speculative-decoding----speculative-model----num-speculative-tokens">3. Speculative decoding — &lt;code>--speculative-model&lt;/code> + &lt;code>--num-speculative-tokens&lt;/code>&lt;/h3>
&lt;p>Speculative decoding is the highest-impact change for decode on small hardware. The idea is simple: a small draft model proposes several tokens at once, and the verifier model validates or rejects them in a single forward pass.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-draft-tensor-parallel-size &lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Why it works:&lt;/strong> the 7B verifier has to read 14 GB of weights per step. With 5 proposed tokens, if the acceptance rate is 80%, an average of 4–5 tokens are generated per verifier step instead of 1. Effective throughput goes up without the GPU working any harder.&lt;/p>
&lt;p>The acceptance rate (α) depends on how well the draft predicts the verifier&amp;rsquo;s distribution. Within the same domain, models from the same family usually have α &amp;gt; 0.75:&lt;/p>
$$\text{speedup} \approx \frac{1 + \alpha \cdot k}{1 + \alpha \cdot k / \text{cost ratio}}$$
&lt;p>Where $k$ is the number of proposed tokens and cost_ratio is the draft/verifier cost ratio. For a 0.5B draft and a 7B verifier (ratio ~14×):&lt;/p>
$$\text{speedup} \approx 1 + 0.8 \times 5 \approx 5 \text{ (theoretical maximum, not reachable)}$$
&lt;p>In practice, with α = 0.75 and k = 5 on hardware without NVLink: &lt;strong>1.8–2.5× more tokens/s&lt;/strong> compared with decode alone.&lt;/p>
&lt;p>&lt;strong>EAGLE-3 in 2026:&lt;/strong> the best drafters today are not small versions of the same model, but networks specialised in predicting the verifier&amp;rsquo;s distribution. EAGLE-3 reports a 3–6.5× speedup over vanilla decode in public benchmarks. In production with mixed batches the real speedup is more conservative (1.5–3×). vLLM supports EAGLE/EAGLE-2 via &lt;code>--speculative-model&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># With an EAGLE drafter (requires a drafter trained specifically for the base model)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model yuhuili/EAGLE3-LLaMA3.1-Instruct-8B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">6&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>When speculative decoding does NOT help:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Very large batches (&amp;gt;32 requests): the acceptance rate varies between requests and the batch spends more time re-drafting than accepting.&lt;/li>
&lt;li>High-entropy tasks (brainstorming, very creative code): the draft predicts worse, α falls below 0.5 and the draft overhead outweighs the gain.&lt;/li>
&lt;li>If the draft model does not fit in the available VRAM alongside the verifier.&lt;/li>
&lt;/ul>
&lt;p>On an RTX 4090 with a 7B verifier and a 0.5B draft (BF16): 14 + 1 GB = 15 GB. That leaves 9 GB for the KV cache. It works.&lt;/p>
&lt;hr>
&lt;h3 id="4-quantised-kv-cache----kv-cache-dtype-fp8">4. Quantised KV cache — &lt;code>--kv-cache-dtype fp8&lt;/code>&lt;/h3>
&lt;p>This was already covered in the prefill article for its effect on context capacity. From the decode point of view, the benefit is different: more tokens fit in the KV cache → more simultaneous requests without preemption → better aggregate throughput.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Precision warning in decode:&lt;/strong> the KV cache is read on every attention step of the decode. Quantisation introduces noise into the attention activations. For long texts (&amp;gt;4K tokens of context) it can accumulate. In quality benchmarks (MMLU, HellaSwag) the degradation with FP8 KV and &lt;code>--calculate-kv-scales&lt;/code> is &amp;lt;0.5% on modern models. Without &lt;code>--calculate-kv-scales&lt;/code>, the degradation can be larger because the scales are fixed statically.&lt;/p>
&lt;p>&lt;strong>Optimal combination for RTX 4090:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --quantization awq &lt;span class="se">\ &lt;/span> &lt;span class="c1"># weights in INT4: 4 GB model&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --kv-cache-dtype fp8 &lt;span class="se">\ &lt;/span> &lt;span class="c1"># KV cache at half the size&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.94
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Available VRAM: 24 - 4 = 20 GB for KV cache&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># With FP8: ~40 KB/token (vs 80 KB BF16) → 20 GB / 40 KB = 500,000 tokens of total context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># With max-num-seqs 64 and a 4K ctx: 64 × 4096 × 40KB = 10 GB → fits with room to spare&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="5-remove-the-swap----swap-space-0">5. Remove the swap — &lt;code>--swap-space 0&lt;/code>&lt;/h3>
&lt;p>&lt;code>--swap-space&lt;/code> defines how much system RAM (not VRAM) vLLM may use to preempt KV caches. When vLLM has more active requests than fit in VRAM, it can &amp;ldquo;pause&amp;rdquo; some by moving their KV cache to RAM and reactivating them later.&lt;/p>
&lt;p>The problem: moving a 4K-token KV cache from VRAM to RAM and back has a latency of tens of milliseconds over PCIe. For a system where you want predictable latency, the swap introduces unacceptable jitter.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>--swap-space 0&lt;/code>, when vLLM cannot hold any more requests in VRAM, it simply queues them instead of doing preemption. The queue adds waiting latency, but it is predictable and it does not interrupt the requests already in flight.&lt;/p>
&lt;p>&lt;strong>When should you have swap?&lt;/strong> If your workload has short demand peaks and you can tolerate occasional jitter in exchange for not rejecting requests, a swap of 4–8 GB can be useful. In ENS deployments where latency is a contractual SLA, &lt;code>--swap-space 0&lt;/code> is the right option.&lt;/p>
&lt;hr>
&lt;h2 id="the-reference-configuration-per-hardware">The reference configuration per hardware&lt;/h2>
&lt;h3 id="rtx-4090-24-gb--7b-model-internal-use">RTX 4090 (24 GB) — 7B model, internal use&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-draft-tensor-parallel-size &lt;span class="m">1&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dtype bfloat16
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected throughput: &lt;strong>35–55 tokens/s per user&lt;/strong>, up to 64 simultaneous, TTFT &amp;lt;500ms for prompts &amp;lt;1K tokens.&lt;/p>
&lt;h3 id="l40-48-gb--14b-model-multi-user">L40 (48 GB) — 14B model, multi-user&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-14B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.90 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">128&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-1.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dtype bfloat16
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected throughput: &lt;strong>25–40 tokens/s per user&lt;/strong>, up to 128 simultaneous with speculative decoding active, TTFT &amp;lt;800ms for prompts &amp;lt;2K tokens.&lt;/p>
&lt;hr>
&lt;h2 id="how-to-measure-that-decode-is-optimised">How to measure that decode is optimised&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Key metrics at vllm:8000/metrics&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:generation_tokens_total &lt;span class="c1"># total tokens generated → trend&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:e2e_request_latency_seconds_* &lt;span class="c1"># end-to-end latency per percentile&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:time_per_output_token_seconds_* &lt;span class="c1"># ITL (inter-token latency)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:num_preemptions_total &lt;span class="c1"># if it rises, the KV cache is filling up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:spec_decode_draft_acceptance_rate &lt;span class="c1"># speculative decoding hit rate&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>spec_decode_draft_acceptance_rate&lt;/code> &amp;lt; 0.6, the drafter is not helping: turn speculative decoding off or find a drafter better trained for your model or domain.&lt;/p>
&lt;p>If &lt;code>num_preemptions_total&lt;/code> grows, you have too many simultaneous requests for the available KV cache. Options: lower &lt;code>max-num-seqs&lt;/code>, enable FP8 KV cache, lower &lt;code>max-model-len&lt;/code>, or quantise the model further.&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>In a sovereign deployment with fixed hardware, you cannot buy more GPUs at will. Every well-calibrated tenth of &lt;code>gpu-memory-utilization&lt;/code>, every point of speculative decoding acceptance rate and every MB of KV cache freed by FP8 is real capacity you do not have to provision with another node.&lt;/p>
&lt;p>The combination of quantised weights (AWQ/GPTQ), FP8 KV cache and speculative decoding lets a 14B serve on one L40 what without optimisations would need two L40s in tensor parallel. That is the economic argument for investing time in these parameters.&lt;/p>
&lt;p>Decode cannot be accelerated indefinitely on memory-bound hardware: the theoretical limit is set by VRAM bandwidth. But the difference between the minimum and the maximum achievable on that hardware can be 3–4× with the right levers.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">the prefill-phase optimisations: chunked prefill, prefix caching and FP8 KV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">how speculative decoding works inside&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the structure decode consults on every token&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">why quantising weights changes the speed ceiling of decode&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the foundation of concurrency management in vLLM&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to measure with Prometheus whether speculative decoding, gpu-memory-utilization and max-num-seqs are working: &lt;code>spec_decode_draft_acceptance_rate&lt;/code>, &lt;code>num_preemptions_total&lt;/code> and the diagnostic matrix&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.06180">Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a> — Kwon et al., 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2406.16858">EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees&lt;/a> — Li et al., 2024&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2211.17192">Fast Inference from Transformers via Speculative Decoding&lt;/a> — Leviathan et al., 2022&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/features/spec_decode.html">vLLM Speculative Decoding&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/">FP8 KV Cache en vLLM&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Optimising prefill in vLLM: the knobs your TTFT will not forgive</title><link>https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Prefill is the phase in which vLLM processes your input prompt and produces the initial KV cache. It is compute-bound (unlike decode, which is memory-bound), it takes longer the longer the prompt is, and it blocks the decode of every other queued request. There are four levers in vLLM that radically change its behaviour: chunked prefill, prefix caching, FP8 KV cache and the per-batch token budget. With modest hardware, a 24 GB RTX 4090 or a 48 GB L40, the difference between ignoring them and using them well can be a 3× lower TTFT and 40% more aggregate throughput.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Picture a printing shop from the early twentieth century. Setting the lead type (preparing the forme) is slow and blocks the press. Printing the pages already set is fast, but it needs the forme ready before it can start.&lt;/p>
&lt;p>Prefill is setting the type. Decode is printing. A press that can only do one thing at a time, either set or print, leaves the machinery idle half the time. The historical solution was to have one worker setting the next page while the previous one was already on the press. That is, exactly, chunked prefill.&lt;/p>
&lt;hr>
&lt;h2 id="what-prefill-really-is">What prefill really is&lt;/h2>
&lt;p>When a request arrives at vLLM, the engine has to process every token of the prompt at once before it can emit the first token of the response. During that processing it computes, for each prompt token, its attention &lt;strong>Key&lt;/strong> and &lt;strong>Value&lt;/strong> vectors. The result, the initial KV cache, is stored in VRAM and used throughout the subsequent decode.&lt;/p>
&lt;p>Unlike decode, where the model processes &lt;strong>one new token&lt;/strong> per step, in prefill it processes &lt;strong>N tokens in one go&lt;/strong>. That makes it far more efficient in FLOPs/token (GPUs are good at large matmuls), but it carries a quadratic cost in attention:&lt;/p>
$$\text{prefill attention FLOPs} \approx 4 \cdot N^2 \cdot d_{model}$$
&lt;p>With a prompt of 1,000 tokens and $d_{model} = 4096$ (Qwen2.5-7B): $4 \cdot 10^6 \cdot 4096 \approx 16 \times 10^9$ FLOPs in attention alone. With 4,000 tokens, &lt;strong>256× more&lt;/strong>, because of the quadratic nature.&lt;/p>
&lt;pre tabindex="0">&lt;code>Prefill (compute-bound):
prompt tokens → [attention O(N²)] → [FFN] → initial KV cache
Decode (memory-bound):
1 new token → [cross-attention over KV cache] → next token
&lt;/code>&lt;/pre>&lt;hr>
&lt;h2 id="why-prefill-is-a-problem-on-small-hardware">Why prefill is a problem on small hardware&lt;/h2>
&lt;p>On an H100 with 3.35 TB/s of bandwidth, a long prefill amortises quickly. On an RTX 4090 (1.008 TB/s) or an L40 (864 GB/s), the bottleneck shows up sooner and has concrete consequences:&lt;/p>
&lt;p>&lt;strong>The head-of-line blocking problem.&lt;/strong> By default, vLLM processes a complete prefill before doing any decode. If you have 10 requests queued, 9 in decode and 1 with an 8,000-token prompt, those 9 requests stall while the GPU chews through the long prefill. Their users see the streaming freeze. This is called &lt;strong>head-of-line blocking&lt;/strong> and it is the number one enemy of TTFT in production.&lt;/p>
&lt;hr>
&lt;h2 id="the-four-levers">The four levers&lt;/h2>
&lt;h3 id="1-chunked-prefill----enable-chunked-prefill----max-num-batched-tokens">1. Chunked prefill — &lt;code>--enable-chunked-prefill&lt;/code> + &lt;code>--max-num-batched-tokens&lt;/code>&lt;/h3>
&lt;p>Chunked prefill splits a long prefill into pieces (&lt;em>chunks&lt;/em>) and interleaves them with decode steps in the same batch. In vLLM V1 (≥ 0.6) it is on by default.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--max-num-batched-tokens&lt;/code> is the total token budget vLLM can process in a single engine step, counting prefill and decode together. It is the most important parameter for controlling the trade-off:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">&lt;code>max-num-batched-tokens&lt;/code>&lt;/th>
&lt;th style="text-align:left">Effect&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Low (512–2048)&lt;/td>
&lt;td style="text-align:left">More decode steps per cycle → better ITL, worse TTFT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">High (8192–32768)&lt;/td>
&lt;td style="text-align:left">Large prefill chunks → better TTFT and throughput, worse ITL&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For an RTX 4090 serving 7B–13B models with mixed contexts (256–4096 tokens):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">--max-num-batched-tokens &lt;span class="m">8192&lt;/span> &lt;span class="c1"># a reasonable balance point&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For an L40 (48 GB) with larger models and longer prompts:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">--max-num-batched-tokens &lt;span class="m">16384&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>How it works internally:&lt;/strong> with a budget of 4,096 tokens and a prefill of 10,000, vLLM splits it into 3 chunks (4,096 + 4,096 + 1,808). Between chunks, it processes the pending decode steps. The requests in decode keep advancing; the long prefill takes longer to finish, but it freezes nothing.&lt;/p>
&lt;pre tabindex="0">&lt;code>Without chunked prefill:
t=0 [prefill 10k tokens]─────────────────────────────┐
t=1 └─[decode r1,r2...r9]
With chunked prefill (budget 4096):
t=0 [prefill chunk 4096][decode r1..r9]
t=1 [prefill chunk 4096][decode r1..r9]
t=2 [prefill chunk 1808][decode r1..r9]
t=3 [decode all, including the new one]
&lt;/code>&lt;/pre>&lt;p>The TTFT of the long request rises slightly (3 steps instead of 1), but the ITL of the other 9 requests is not interrupted.&lt;/p>
&lt;hr>
&lt;h3 id="2-prefix-caching----enable-prefix-caching">2. Prefix caching — &lt;code>--enable-prefix-caching&lt;/code>&lt;/h3>
&lt;p>If several requests share the same prefix, a system prompt, a few-shot block, a context document, vLLM can compute the KV cache of that prefix once and reuse it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is called &lt;strong>Automatic Prefix Caching (APC)&lt;/strong>. Internally, vLLM splits the KV cache into fixed-size blocks (16 tokens per block by default) and assigns them a content-based SHA hash. When a new request arrives, it checks whether any initial block is already in the cache. On a hit, that prefill is skipped.&lt;/p>
&lt;p>&lt;strong>The numerical impact:&lt;/strong> suppose a 512-token system prompt that appears in 80% of your requests, and a rate of 100 req/min:&lt;/p>
&lt;ul>
&lt;li>Without APC: 80 req/min × 512 tokens × prefill_cost = 41,000 tokens/min of redundant prefill&lt;/li>
&lt;li>With APC (80% hit rate): 20 req/min × 512 = 10,240 tokens/min of prefill → &lt;strong>a 75% reduction&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>The TTFT of those 80 requests falls to whatever it costs to process only the new suffix.&lt;/p>
&lt;p>&lt;strong>Limitation with chunked prefill:&lt;/strong> when chunked prefill is active, only the first chunk of the prefill benefits from APC in the current vLLM implementation. For workloads where the cache hit rate is very high and the suffixes are short, consider lowering &lt;code>--max-num-batched-tokens&lt;/code> so that the first chunk covers more of the shared prefix.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Configuration optimised for a high prefix cache hit rate&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span> &lt;span class="c1"># smaller chunks = prefix fits in chunk 1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="3-fp8-kv-cache----kv-cache-dtype-fp8">3. FP8 KV cache — &lt;code>--kv-cache-dtype fp8&lt;/code>&lt;/h3>
&lt;p>The KV cache takes up VRAM. The more VRAM it consumes, the fewer concurrent requests you can keep in flight. On a 24 GB RTX 4090, the Qwen2.5-14B model in BF16 already takes ~28 GB, so it does not fit. In Q4 it takes ~9 GB, leaving ~14 GB for the KV cache.&lt;/p>
&lt;p>How many context tokens fit in 14 GB of BF16 KV cache for a 14B with GQA?&lt;/p>
$$\text{KV size per token} = 2 \cdot n_{kv\_heads} \cdot d_{head} \cdot n_{layers} \cdot 2 \text{ bytes}$$
&lt;p>For Qwen2.5-14B: $n_{kv\_heads}=8$, $d_{head}=128$, $n_{layers}=40$, BF16 → $2 \cdot 8 \cdot 128 \cdot 40 \cdot 2 = 163{,}840$ bytes ≈ 160 KB/token.&lt;/p>
&lt;p>14 GB / 160 KB ≈ &lt;strong>87,500 tokens&lt;/strong> of total context. With 8 users in parallel and 4,096 tokens of context each: 32,768 tokens used out of 87,500. There is headroom, but it is finite.&lt;/p>
&lt;p>Moving to FP8 (1 byte instead of 2):&lt;/p>
$$\text{KV FP8} = 80 \text{ KB/token} \implies 14 \text{ GB} / 80 \text{ KB} = 175{,}000 \text{ tokens}$$
&lt;p>Twice the context capacity with the same VRAM. That buys either more concurrency or longer contexts.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="c1"># calibrates the scales dynamically; without this there is degradation&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Warning for RTX 4090 and L40:&lt;/strong> Ada Lovelace has FP8 instructions at the CUDA level but without Hopper&amp;rsquo;s (H100&amp;rsquo;s) dedicated scaling hardware. The memory reduction is real; the compute speedup is smaller than on an H100. Do not expect the same speedup as in a Hopper datacenter. On the L40S (the variant with optimised FP8 tensor cores) the benefit is larger than on an RTX 4090.&lt;/p>
&lt;hr>
&lt;h3 id="4-context-budget----max-model-len">4. Context budget — &lt;code>--max-model-len&lt;/code>&lt;/h3>
&lt;p>&lt;code>--max-model-len&lt;/code> defines the maximum number of tokens vLLM can handle in a single request (prompt plus generation). It is the hard limit that determines how much VRAM is reserved for the KV cache in the worst case.&lt;/p>
&lt;p>On small hardware, lowering it frees VRAM for more concurrency:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 7B model on RTX 4090, typical context of 4K but the model supports 128K&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">8192&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># instead of 131072&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --gpu-memory-utilization 0.92
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the context cut to 8,192 tokens, vLLM does not reserve KV cache for 131,072 potential tokens and can fit more simultaneous requests. The risk is obvious: requests above 8,192 tokens fail with an error. Tune it to the P99 of your real length distribution.&lt;/p>
&lt;hr>
&lt;h2 id="interaction-between-parameters">Interaction between parameters&lt;/h2>
&lt;p>The four parameters are not independent. A common mistake is enabling prefix caching without tuning the block size, or raising &lt;code>max-num-batched-tokens&lt;/code> without checking that &lt;code>max-num-seqs&lt;/code> allows it to be filled:&lt;/p>
&lt;pre tabindex="0">&lt;code>max-num-batched-tokens = 8192
max-num-seqs = 4
average prompt = 512 tokens → 4 × 512 = 2048 prefill tokens &amp;lt; 8192
The 8192 budget is never filled because max-num-seqs caps it first.
Fix: raise max-num-seqs or lower max-num-batched-tokens.
&lt;/code>&lt;/pre>&lt;p>A balanced configuration for RTX 4090 + a 7B model:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Configuration for an L40 (48 GB) + a 14B model:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-14B-Instruct-AWQ &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.90 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">128&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="how-to-measure-that-it-is-working">How to measure that it is working&lt;/h2>
&lt;p>The metrics that confirm prefill is optimised:&lt;/p>
&lt;pre tabindex="0">&lt;code># In vLLM&amp;#39;s Prometheus metrics (port 8000/metrics):
vllm:time_to_first_token_seconds_bucket → TTFT distribution
vllm:gpu_cache_usage_perc → KV cache utilisation
vllm:prefix_cache_hit_rate → APC hit rate (if enabled)
vllm:num_running_seqs → simultaneous in-flight requests
&lt;/code>&lt;/pre>&lt;p>A &lt;code>prefix_cache_hit_rate&lt;/code> below 30% on workloads with a fixed system prompt means something in the hash is not working (a system prompt that varies by timestamp, a date format in the prompt, and so on).&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>Chunked prefill and prefix caching are &lt;strong>zero cost&lt;/strong>: they are enabled with flags and require no extra hardware. FP8 KV cache requires the model to be compatible (almost every modern transformer is) and that you are on Ada Lovelace or newer.&lt;/p>
&lt;p>For sovereign ENS deployments where the hardware is fixed and you cannot scale horizontally on demand, a well-configured prefill is the difference between needing 4 nodes and needing 2 for the same load.&lt;/p>
&lt;p>The second article in this series covers the &lt;strong>decode&lt;/strong> optimisations: speculative decoding, KV cache tuning to maximise concurrency and how to configure &lt;code>gpu-memory-utilization&lt;/code> without vLLM running out of VRAM at midnight.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">how the KV cache works inside&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">why FlashAttention changes memory consumption in prefill&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the foundation chunked prefill builds on&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">when scaling a single node is no longer enough&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to measure with Prometheus and OTel whether chunked prefill and prefix caching are working: &lt;code>ttft p99&lt;/code>, &lt;code>gpu_prefix_cache_hit_rate&lt;/code> and the concrete alerts&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.06180">Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a> — Kwon et al., 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2403.02310">Sarathi-Serve: Chunked Prefill and Stall-Free Scheduling&lt;/a> — Agrawal et al., 2024&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/">Quantized KV Cache en vLLM&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Prefix cache: hit rate engineering to go from 15% to 75%</title><link>https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM&amp;rsquo;s prefix cache stores the KV cache blocks of shared prefixes and reuses them in later requests. A hit avoids recomputing that prefix: TTFT drops to the cost of the variable suffix only. In enterprise workloads with fixed system prompts (RAG, domain chatbots, assistants with long instructions) the hit rate should be &amp;gt;70%. In practice it is 10-20% for entirely avoidable reasons. This article identifies them, fixes them, and gives the OTel queries to confirm the result.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A simultaneous conference interpreter who has to translate the speeches of twenty speakers. All of them open with the same two-page protocol preamble: the conference declaration, the rules of conduct, the day&amp;rsquo;s programme. An interpreter with no memory rereads the two pages for each speaker before starting on their specific speech. An interpreter with good notes reads them once, files them, and when the second speaker starts goes straight to the speech.&lt;/p>
&lt;p>The prefix cache is that file. The prefix hash is the reference that lets you skip to the new part. But if the preamble changes by even one word, because someone drops in the day&amp;rsquo;s date, the interpreter has to reread everything from the beginning.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-prefix-cache-hash-works">How the prefix cache hash works&lt;/h2>
&lt;p>vLLM splits the KV cache into blocks of 16 tokens. Each block has a hash computed over its exact content. When a new request arrives, vLLM checks whether any initial block of the prompt is already in cache by comparing hashes.&lt;/p>
&lt;p>The hash is computed over &lt;strong>the byte-by-byte content of the tokens&lt;/strong>. Any difference, a space, a different character, one extra token, produces a completely different hash. There is no partial matching inside a block.&lt;/p>
&lt;p>Direct consequence: if your system prompt has 512 tokens and token number 3 changes between requests (because you interpolate a date, an ID, a version number), &lt;strong>no block hits&lt;/strong> even though 99% of the text is identical.&lt;/p>
&lt;pre tabindex="0">&lt;code>Block 0 (tokens 0-15): hash = a3f7... ← in cache?
Block 1 (tokens 16-31): hash = 9d2c... ← in cache?
...
Block 31 (tokens 496-511): hash = 7e1a... ← in cache?
&lt;/code>&lt;/pre>&lt;p>If block 0 does not hit (because its content changed), blocks 1-31 are not even checked although they are identical. The prefix cache is sequential.&lt;/p>
&lt;hr>
&lt;h2 id="audit-why-your-real-hit-rate-is-low">Audit: why your real hit rate is low&lt;/h2>
&lt;p>Before changing anything, you need to know &lt;em>what&lt;/em> is breaking the hash. The most direct method: pull the last 1000 production prompts and work out what fraction of the prefix varies.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># audit_prefix_cache.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">langfuse&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">hashlib&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">collections&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">transformers&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">tokenizer&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AutoTokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">from_pretrained&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Qwen/Qwen2.5-14B-Instruct&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">traces&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fetch_traces&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1000&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">data&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prompts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">t&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">traces&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">t&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Tokenise and extract the first 512 tokens (the typical system prompt)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">prefixes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">prompt&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">prompts&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">add_special_tokens&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="p">[:&lt;/span>&lt;span class="mi">512&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefixes&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefix_tokens&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># How many unique prefixes are there?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">unique&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">total&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Unique prefixes: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">unique&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2"> (&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">unique&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">%)&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Theoretical hit rate if they were all equal: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="n">unique&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">total&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="si">:&lt;/span>&lt;span class="s2">.1f&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">%&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find which token differs between the most common prefix and the rest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">collections&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Counter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">most_common_prefix&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Counter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">prefixes&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">most_common&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">divergence_positions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">prefix&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">prefixes&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">prefix&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">a&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">enumerate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">zip&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">prefix&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">a&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="n">b&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">divergence_positions&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">break&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">divergence_positions&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">pos&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Counter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">divergence_positions&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">most_common&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token_text&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tokenizer&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="n">most_common_prefix&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">pos&lt;/span>&lt;span class="p">]])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">Most frequent divergence at position &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">pos&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">: &amp;#39;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">token_text&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#39;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;→ The token at that position varies between requests&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The most common culprits, in order of frequency:&lt;/p>
&lt;p>&lt;strong>1. Timestamps and dates:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ Breaks the hash on every request&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Current date: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">strftime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;%Y-%m-&lt;/span>&lt;span class="si">%d&lt;/span>&lt;span class="s1"> %H:%M&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Take the date out of the system prompt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant specialising in cloud infrastructure.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Pass the date as part of the user message if it is needed&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>2. Session and user IDs:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;User ID: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. Preferences: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">user_prefs&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Separate the static from the contextual&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are a specialised assistant.&amp;#34;&lt;/span> &lt;span class="c1"># always the same&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Add the user context as the first message of the history&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>3. Interpolated prompt versions:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;[v&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">PROMPT_VERSION&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">] You are an assistant...&amp;#34;&lt;/span> &lt;span class="c1"># changes with every deploy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Do not version in the text, version in the prompt name in Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>4. Dynamic few-shots:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ Examples retrieved at random from a pool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">examples&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">random&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sample&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">example_pool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">k&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Examples:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">format_examples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">examples&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ Fixed few-shots always in the same order&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">FIXED_EXAMPLES&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">example_pool&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Examples:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">format_examples&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FIXED_EXAMPLES&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="se">\n\n&lt;/span>&lt;span class="s2">You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="template-engineering-the-structure-that-maximises-hits">Template engineering: the structure that maximises hits&lt;/h2>
&lt;p>The principle is simple: &lt;strong>everything static goes first, everything dynamic goes after&lt;/strong>. The prefix cache is sequential. Once a block fails to hit, the rest are not looked up either.&lt;/p>
&lt;pre tabindex="0">&lt;code>OPTIMAL STRUCTURE for maximising prefix cache:
┌──────────────────────────────────────────────┐
│ STATIC BLOCK (tokens 0-511) │ ← hit rate ~100%
│ Invariant system prompt │
│ Fixed instructions │
│ Few-shots always in the same order │
├──────────────────────────────────────────────┤
│ SEMI-STATIC BLOCK (tokens 512-1023) │ ← hit rate ~60-80%
│ RAG documents for this session │
│ Conversation history so far │
├──────────────────────────────────────────────┤
│ DYNAMIC BLOCK (tokens 1024+) │ ← hit rate ~0% (expected)
│ Current user message │
│ Context specific to this request │
└──────────────────────────────────────────────┘
&lt;/code>&lt;/pre>&lt;p>For RAG specifically: if the retrieved documents are the same across a set of similar queries (very frequent in RAG over fixed corporate documents), ordering them &lt;strong>always in the same order&lt;/strong> (by ID, by a fixed score, not by a variable score) multiplies the hit rate of the semi-static block.&lt;/p>
&lt;hr>
&lt;h2 id="prefix-aware-routing-the-next-level">Prefix-aware routing: the next level&lt;/h2>
&lt;p>With a single vLLM instance, the prefix cache works automatically. The problem shows up with multiple replicas: the load balancer distributes requests round-robin, and the prefix cached on replica A is no use at all when the request lands on replica B.&lt;/p>
&lt;p>The solution is &lt;strong>prefix-aware routing&lt;/strong>: send requests with the same prefix to the same node.&lt;/p>
&lt;p>&lt;strong>With Ray Serve (native integration):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ray_serve_prefix_router.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ray&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">serve&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ray.serve.llm&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">LLMConfig&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">build_llm_deployment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@serve.deployment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">PrefixAwareRouter&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="fm">__init__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">replicas&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">replicas&lt;/span> &lt;span class="c1"># list of vLLM handles&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="fm">__call__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">body&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">messages&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">body&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Compute the hash of the system prompt (static prefix)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">msg&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">messages&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">msg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;system&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">msg&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">break&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">hash&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">system_content&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Deterministic routing: same hash → same node&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">replica_idx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">%&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">replicas&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">replica_idx&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">remote&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>With an L7 gateway (Nginx/Traefik):&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-nginx" data-lang="nginx">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># nginx.conf — routing by X-Prefix-Hash header
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">upstream&lt;/span> &lt;span class="s">vllm_backends&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">hash&lt;/span> &lt;span class="nv">$http_x_prefix_hash&lt;/span> &lt;span class="s">consistent&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-1&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-2&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kn">server&lt;/span> &lt;span class="n">vllm-3&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="mi">8000&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client computes the hash of the static prefix and includes it as a header:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">hashlib&lt;/span>&lt;span class="o">,&lt;/span> &lt;span class="nn">requests&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">llm_request&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">base_url&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">system_msg&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">next&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">m&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">m&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">messages&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">m&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;system&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">prefix_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">hashlib&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sha256&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">system_msg&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">hexdigest&lt;/span>&lt;span class="p">()[:&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">requests&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">post&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/v1/chat/completions&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">json&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">messages&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;my-model&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">headers&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;X-Prefix-Hash&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">prefix_hash&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="measuring-the-impact-with-otel">Measuring the impact with OTel&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Current hit rate (0.0 to 1.0) — target &amp;gt; 0.70 with enterprise workloads&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># TTFT by percentile — should fall when the hit rate rises&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.95&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The inverse correlation between hit rate and TTFT is the proof that the cache is working. If the hit rate goes from 15% to 70% and the p50 TTFT does not change, there is a configuration problem: the cache may be disabled, or the routing may not be sending requests to the right node.&lt;/p>
&lt;p>&lt;strong>Correlation query in Grafana&lt;/strong> (two-axis panel):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Left Y axis: hit rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Right Y axis: TTFT p50 (inverted)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The inverse slope should be visible: when the hit rate drops (a spike of requests with new prompts), TTFT rises. When the hit rate stabilises (users repeating the same flow), TTFT falls.&lt;/p>
&lt;hr>
&lt;h2 id="the-impact-in-numbers">The impact in numbers&lt;/h2>
&lt;p>For a system with 100 req/min, a 512-token system prompt and a before/after hit rate:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Metric&lt;/th>
&lt;th style="text-align:right">Hit rate 15%&lt;/th>
&lt;th style="text-align:right">Hit rate 75%&lt;/th>
&lt;th style="text-align:right">Difference&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Prefill tokens per minute&lt;/td>
&lt;td style="text-align:right">5,100&lt;/td>
&lt;td style="text-align:right">12,800 — 50% cached → 6,400 effective&lt;/td>
&lt;td style="text-align:right">−37% load&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">TTFT p50 (512 prompt + 100 suffix)&lt;/td>
&lt;td style="text-align:right">~820 ms&lt;/td>
&lt;td style="text-align:right">~180 ms (suffix only)&lt;/td>
&lt;td style="text-align:right">−78%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Prefill capacity freed&lt;/td>
&lt;td style="text-align:right">—&lt;/td>
&lt;td style="text-align:right">+1,200 tok/min&lt;/td>
&lt;td style="text-align:right">available for more requests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 75% hit rate in this example is equivalent to being able to serve 37% more requests on the same hardware, because the prefill work of 3 out of every 4 requests is already done.&lt;/p>
&lt;hr>
&lt;h2 id="when-the-prefix-cache-does-not-help">When the prefix cache does not help&lt;/h2>
&lt;p>The prefix cache is ineffective in workloads where every request has a completely unique prompt: translating a different document every time, code analysis with always-different context, creative generation with no system prompt. In these cases the hit rate structurally cannot exceed 5-10% and the template engineering effort does not pay off.&lt;/p>
&lt;p>The signal: if your p99 input length is greater than the p50, you have high prompt variance and the prefix cache contributes little. If the p50 and the p99 are similar (consistent prompts), the prefix cache is the cheapest lever available.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">FlashInfer&amp;rsquo;s cascade attention: the &lt;em>compute&lt;/em> side of the shared prefix that the prefix cache solves in &lt;em>memory&lt;/em> — attending the common prefix once instead of R times&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">&lt;code>--enable-prefix-caching&lt;/code> and the interaction with chunked prefill: only the first chunk benefits from the cache, which affects the optimal budget for &lt;code>max-num-batched-tokens&lt;/code>&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the block structure the prefix cache operates on: why the 16-token granularity matters for template design&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the grid search that determines the optimal &lt;code>max-num-seqs&lt;/code>, which interacts with the number of blocks available for the cache&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 gateway where prefix-aware routing via header is implemented&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to configure &lt;code>gpu_prefix_cache_hit_rate&lt;/code> on the Grafana dashboard and the alert for when it falls below the target threshold&lt;/a>&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing: what LiteLLM does not do&lt;/a> — the real state of KV-aware routers in September 2026, and how much they genuinely improve.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="in-this-same-series">In this same series&lt;/h3>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">the first optimisation in the series: the grid search of max-num-seqs × max-num-batched-tokens&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 in weights and KV cache: doubling the VRAM available for cache and measuring the quality degradation before going to production&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP=4×1 vs TP=2×2: the per-session routing that complements the prefix-aware routing in this article&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/prefix_caching/">vLLM Automatic Prefix Caching — official documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html">Prefix-aware routing — Ray Serve&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://llm-d.ai/blog/kvcache-wins-you-can-see">KV-Cache Wins You Can See — llm-d blog&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.digitalocean.com/blog/reduce-llm-inference-costs-prefix-caching">The Inference Tax: Prefix-Aware Routing — DigitalOcean&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/vllm-project/vllm/issues/24394">vLLM issue #24394: Improve Prefix Cache Hit Rate&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Pruning LLM models: cutting without amputating</title><link>https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A 7B-parameter model has tens of billions of neural connections. Many of them contribute so little that you could remove them without any reasonable benchmark noticing. That is pruning: identifying the irrelevant weights and deleting them to obtain a model that is smaller, faster or lighter on memory. Modern techniques (SparseGPT, Wanda, 2:4 structured sparsity) do this without retraining, in a few GPU hours, and with less than 1 point of perplexity penalty. It does not replace quantisation; it combines with it.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>An oak tree with a hundred branches. When winter comes, the tree prunes its weak branches: it redirects resources towards the main trunks. An expert pruner does not cut at random, they look at which branches have little foliage, which are dry, which grow in the wrong direction, and cut only those.&lt;/p>
&lt;p>A language model is that tree. Its &amp;ldquo;branches&amp;rdquo; are the weights connecting neurons. After training, many of those connections are vestiges of the optimisation process: they existed so the gradient could descend smoothly, but in production they barely change the output. The pruner that removes them precisely is SparseGPT or Wanda. The one that cuts at random is &lt;em>magnitude pruning&lt;/em> without calibration. Both give you a smaller tree; only the expert gives you one that still bears the same fruit.&lt;/p>
&lt;hr>
&lt;h2 id="what-pruning-really-is">What pruning really is&lt;/h2>
&lt;p>A transformer language model stores its knowledge in weight matrices. An attention layer has four matrices: $W_Q, W_K, W_V, W_O$. An FFN layer has at least two ($W_{up}, W_{down}$, plus $W_{gate}$ in SwiGLU). For a 7B model with 32 layers, the number of individual parameters exceeds 7 billion.&lt;/p>
&lt;p>Pruning is the process of setting a subset of those parameters to zero so that:&lt;/p>
&lt;ol>
&lt;li>The resulting model takes up less memory (if stored in a sparse format) or computes fewer operations.&lt;/li>
&lt;li>Answer quality does not drop appreciably.&lt;/li>
&lt;/ol>
&lt;p>There are two classification dimensions that matter:&lt;/p>
&lt;p>&lt;strong>Granularity:&lt;/strong> which unit gets removed.&lt;/p>
&lt;ul>
&lt;li>&lt;em>Unstructured pruning&lt;/em>: individual weights, scattered across the whole matrix. High compression, hard to accelerate on conventional hardware.&lt;/li>
&lt;li>&lt;em>Structured pruning&lt;/em>: whole attention heads, entire FFN neurons, or complete layers. Less compression, but the resulting model is dense and compatible with any hardware.&lt;/li>
&lt;li>&lt;em>Semi-structured N:M&lt;/em>: for each group of M consecutive weights, exactly N are zero. The 2:4 case (2 zeros out of every 4) is the one supported by the Tensor Cores in NVIDIA Ampere and later.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Timing:&lt;/strong> when the removal happens.&lt;/p>
&lt;ul>
&lt;li>&lt;em>Post-training&lt;/em> (weight PTQ): requires no gradient, the standard in large LLMs.&lt;/li>
&lt;li>&lt;em>During training&lt;/em> (gradual/iterative): more precise, incompatible with 70B+ models on cost grounds.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="why-so-many-redundant-weights-exist">Why so many redundant weights exist&lt;/h2>
&lt;p>The answer lies in how the models are trained. Stochastic gradient descent with millions of steps and a decaying learning rate produces networks that are &lt;em>over-parameterised by design&lt;/em>: the extra parameters do not represent additional knowledge, but room for manoeuvre so that optimisation converges more easily.&lt;/p>
&lt;p>The &lt;strong>Lottery Ticket Hypothesis&lt;/strong> (Frankle &amp;amp; Carlin, ICLR 2019) formalised this intuition: inside any trained dense network there is a subnetwork that, trained from scratch in isolation, reaches the same quality. The original network is that subnetwork wrapped in parametric noise generated by the training process.&lt;/p>
&lt;p>For LLMs, the empirical evidence is consistent: 7B–70B models tolerate up to 50% unstructured sparsity with no observable degradation on conversational tasks. In larger models, the tolerance threshold rises.&lt;/p>
&lt;hr>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;h3 id="which-weights-are-safe-to-remove">Which weights are safe to remove?&lt;/h3>
&lt;h4 id="magnitude-pruning-the-naive-criterion">Magnitude pruning: the naive criterion&lt;/h4>
$$\text{importance}(w_{ij}) = |w_{ij}|$$
&lt;p>The weights with the smallest absolute value are removed. Intuitive, but incomplete: a small weight connected to a very large activation still contributes significantly to the output.&lt;/p>
&lt;h4 id="wanda-magnitude--activation">Wanda: magnitude × activation&lt;/h4>
$$\text{importance}(w_{ij}) = |w_{ij}| \cdot \|x_j\|_2$$
&lt;p>Where $x_j$ is the input activation vector corresponding to weight $j$, computed over a calibration dataset of ~128 samples. The product captures both dimensions: a weight is safe to remove only if &lt;em>it&lt;/em> is small &lt;em>and&lt;/em> its input neuron is barely active.&lt;/p>
&lt;p>&lt;strong>Numerical example:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Weight A: $|w| = 0.001$, $\|x\|_2 = 500$ → importance = 0.5&lt;/li>
&lt;li>Weight B: $|w| = 0.01$, $\|x\|_2 = 10$ → importance = 0.1&lt;/li>
&lt;/ul>
&lt;p>Magnitude pruning would remove A (smaller absolute value). Wanda removes B (lower importance). B is the safer one to delete.&lt;/p>
&lt;p>Wanda requires neither gradients nor Hessian matrix inverses. It runs in minutes on a 70B model on a single GPU. On WikiText-2 perplexity benchmarks at 50% unstructured sparsity, Wanda reaches results comparable to SparseGPT at 10–100× less computational cost.&lt;/p>
&lt;h4 id="sparsegpt-hessian-compensation">SparseGPT: Hessian compensation&lt;/h4>
&lt;p>SparseGPT applies the same mathematical framework as GPTQ (layer-by-layer quantisation), but for pruning. When it removes a weight $w_p$, it computes a correction $\delta w$ over the remaining weights in the same row to minimise the change in the layer&amp;rsquo;s output:&lt;/p>
$$\min_{\delta w} \|W x - (W + \delta W) x\|_2^2 \quad \text{s.t.} \quad w_p + \delta w_p = 0$$
&lt;p>The solution uses the inverse of the second-order Hessian matrix $H = X X^T$. The extra cost is justified by the higher precision when target sparsity is high (&amp;gt;70%) or the model is small (&amp;lt;7B, where redundancy is lower).&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Criterion&lt;/th>
&lt;th>Cost&lt;/th>
&lt;th>50% sparsity (7B, WikiText-2 ppl)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Magnitude&lt;/td>
&lt;td>|w|&lt;/td>
&lt;td>Instant&lt;/td>
&lt;td>+2–5 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Wanda&lt;/td>
&lt;td>|w| · |x|&lt;/td>
&lt;td>Minutes&lt;/td>
&lt;td>~+0.5 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SparseGPT&lt;/td>
&lt;td>Hessian&lt;/td>
&lt;td>1–4h GPU&lt;/td>
&lt;td>~+0.4 points&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="24-structured-sparsity-nvidias-special-case">2:4 Structured Sparsity: NVIDIA&amp;rsquo;s special case&lt;/h3>
&lt;p>NVIDIA Ampere (A100) and later (H100, Ada Lovelace) include dedicated hardware for the 2:4 pattern: exactly 2 out of every 4 consecutive weights are zero. This lets the hardware skip the multiplications by zero efficiently, obtaining up to &lt;strong>2× speedup in matmul&lt;/strong> on models with 2:4 weights.&lt;/p>
&lt;p>The constraint is that the sparsity has to be exactly 2:4, not an arbitrary pattern. NVIDIA&amp;rsquo;s tools (APEX Sparse, cuSPARSELt) and frameworks such as PyTorch 2.x support this natively:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">torch.sparse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">to_sparse_semi_structured&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">SparseSemiStructuredTensor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Convert dense weights to 2:4 sparse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sparse_weight&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">to_sparse_semi_structured&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dense_weight&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Forward pass automatically uses sparse tensor cores&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">F&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">linear&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sparse_weight&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>What to expect in practice with 2:4:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>RTX 4090 (Ada Lovelace): supports 2:4 sparse tensor cores for FP16/BF16. Theoretical speedup 2×, real 1.3–1.6× depending on batch and sequence size.&lt;/li>
&lt;li>H100 (Hopper): the same, with additional improvements in FP8 + 2:4 combined.&lt;/li>
&lt;li>A100: supported, without FP8.&lt;/li>
&lt;li>Consumer GPUs earlier than Ada (3090, etc.): &lt;strong>no hardware support&lt;/strong>. 2:4 sparsity gives a smaller model on disk but does not accelerate inference.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="structured-pruning-removing-whole-heads-and-layers">Structured pruning: removing whole heads and layers&lt;/h2>
&lt;h3 id="attention-head-pruning">Attention head pruning&lt;/h3>
&lt;p>A 32-layer transformer with 32 heads per layer has 1,024 attention heads. Systematic studies on Llama-2 and Qwen models show that between 20–40% of the heads have marginal influence on the final output: their output can be set to zero without the benchmark changing within the margin of error.&lt;/p>
&lt;p>The most used metric is &lt;em>Taylor importance&lt;/em>: the product of the gradient of the loss with respect to the head&amp;rsquo;s output and the value of that output, summed over a calibration dataset:&lt;/p>
$$\text{I}_{head} = \left| \sum_t \frac{\partial \mathcal{L}}{\partial o_t} \cdot o_t \right|$$
&lt;p>The heads with the lowest $I_{head}$ are removed first. After removing 25% of the heads in Llama-3-8B, MMLU degradation is &amp;lt;1% and attention inference time falls ~20% because the attention matmuls are smaller.&lt;/p>
&lt;h3 id="layer-dropping-the-most-aggressive-shortcut">Layer dropping: the most aggressive shortcut&lt;/h3>
&lt;p>Removing a whole transformer layer deletes its attention block and its FFN. The most robust criterion is &lt;strong>Block Influence (BI)&lt;/strong>, introduced in ShortGPT (2024):&lt;/p>
$$\text{BI}(l) = 1 - \cos(\text{input}_l, \text{output}_l)$$
&lt;p>A layer whose output is almost identical to its input (cosine close to 1, BI close to 0) acts as an identity function: removing it does not change the flow of information. The layers in the middle of the transformer tend to have lower BI than the initial and final layers.&lt;/p>
&lt;p>&lt;strong>Numerical example on LLaMA-2-70B:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Layers 0–5 (early): BI &amp;gt; 0.3 → do not remove&lt;/li>
&lt;li>Layers 20–45 (mid): BI &amp;lt; 0.05 → candidates for removal&lt;/li>
&lt;li>Layers 76–80 (final): BI &amp;gt; 0.2 → do not remove&lt;/li>
&lt;/ul>
&lt;p>Removing 8 layers out of 80 (10%): the model goes from ~140 GB to ~126 GB in BF16. Inference speedup: ~10% (proportional to the number of layers removed). Degradation on reasoning benchmarks: 1–3%.&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>Unstructured pruning (50% sparsity) produces models with the same number of parameters but half of them at zero. Without specialised sparse kernels, that gives no speedup: the GPU still runs the multiplications, it just multiplies by zero very efficiently. The real benefit is storage and transfer (the model takes up less on disk and in system RAM).&lt;/p>
&lt;p>With 2:4 structured sparsity on Ada/Hopper hardware, the speedup is real but moderate (1.3–1.7×) and requires additional tooling (cuSPARSELt or PyTorch sparse).&lt;/p>
&lt;p>Structured pruning (heads, layers) does accelerate on any hardware because it reduces the model&amp;rsquo;s real size. It is the right option if the goal is throughput on hardware without sparse tensor cores.&lt;/p>
&lt;p>&lt;strong>Combining with quantisation:&lt;/strong> pruning and quantisation are orthogonal. A 50% sparse model at INT4 takes up roughly an eighth of the original in FP32. It is the end point of many aggressive compression pipelines for edge inference.&lt;/p>
&lt;hr>
&lt;h2 id="applied-to-generic-on-premise-hardware">Applied to generic on-premise hardware&lt;/h2>
&lt;h3 id="rtx-4090-24-gb-ada-lovelace">RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>Supports 2:4 sparse tensor cores for FP16/BF16. With Wanda + 2:4 sparsity on a Qwen2.5-14B:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Pruning pipeline: Wanda 2:4 + INT4 quantisation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. Run Wanda with calibration over 128 samples&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python wanda/main.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --model Qwen/Qwen2.5-14B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sparsity_ratio 0.5 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sparsity_type 2:4 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --save pruned_model/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2. Quantise the pruned model (optional but complementary)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python -m awq.entry --model_path pruned_model/ &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --w_bit &lt;span class="m">4&lt;/span> --output_path pruned_awq_model/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected result: ~13 GB BF16 → ~6.5 GB after 2:4 pruning in sparse format → ~3.2 GB with AWQ INT4. The 14B model will fit in the RTX 4090 with room for KV cache.&lt;/p>
&lt;h3 id="4-h100-sxm-320-gb-total-hopper">4× H100 SXM (320 GB total, Hopper)&lt;/h3>
&lt;p>On this hardware structured pruning (layer dropping) makes more sense than 2:4 for high-concurrency inference: you reduce the number of FLOPs per token proportionally, which benefits throughput at large batch where the bottleneck is compute, not memory.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Apply layer dropping with the ShortGPT BI metric&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">shortgpt&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">compute_block_influence&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">drop_layers&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">bi_scores&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">compute_block_influence&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">calibration_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Remove the 15% of layers with the lowest BI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">drop_layers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bi_scores&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">drop_ratio&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.15&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A Llama-3-70B pruned by 15% of its layers fits in 3 H100s instead of 4, freeing a GPU for another task.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/ — the complementary lever: quantising reduces the precision of the weights pruning has decided to keep; combined they give maximum compression&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/ — pruning reduces the model&amp;rsquo;s size, but the KV cache keeps growing with context; they are separate costs in VRAM&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/ — speculative decoding drafters are often pruned versions of the base model, not models trained from scratch&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/ — how the pruned model is served in vLLM: throughput parameters change with a structurally smaller model&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/ — a conceptual alternative to pruning: instead of removing parts of the large model, train a small one to imitate its behaviour&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2301.00774">SparseGPT: Massive Language Models Can be Accurately Pruned in One Shot&lt;/a> — Frantar &amp;amp; Alistarh, 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2306.11695">A Simple and Effective Pruning Approach for Large Language Models (Wanda)&lt;/a> — Sun et al., ICLR 2024&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/1803.03635">The Lottery Ticket Hypothesis&lt;/a> — Frankle &amp;amp; Carlin, ICLR 2019&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2403.03853">ShortGPT: Layers in Large Language Models are More Redundant Than You Expect&lt;/a> — Men et al., 2024&lt;/li>
&lt;li>&lt;a href="https://pytorch.org/blog/when-quantization-isnt-enough-why-24-sparsity-matters/">NVIDIA 2:4 Sparsity in PyTorch&lt;/a> — PyTorch Blog&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2605.06402">SparseForge: Efficient Semi-Structured LLM Sparsification&lt;/a> — 2025&lt;/li>
&lt;/ul></description></item><item><title>Semantic cache in RAG: the receptionist with a photographic memory</title><link>https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/</link><pubDate>Thu, 04 Jun 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>In a RAG system with real traffic, 30–70% of queries are semantically equivalent to some earlier one even when the text differs. The semantic cache intercepts those queries ahead of the retriever and the LLM, returning the answer already computed if the cosine similarity with a previous query exceeds a threshold θ (typically 0.92–0.95). At 10,000 requests/day and a 45% hit rate, that amounts to not running 4,500 LLM generations: roughly 0.62 GPU hours saved every day on a cluster running Llama-3.1-70B. The fundamental trade-off is that a high θ gives more accurate answers but less saving; a low θ maximises saving but can return incorrect answers for subtly different queries.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy-the-receptionist-with-a-notebook">The analogy: the receptionist with a notebook&lt;/h2>
&lt;p>Picture the front desk of a 400-room hotel. Over the course of the day, the receptionist takes hundreds of questions. But if you look through the log book, you will see that 60% of those questions are variants of the same ten:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;Where is the gym?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;What time is breakfast?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;Do you have parking?&amp;rdquo;&lt;/li>
&lt;li>&amp;ldquo;How do I connect to the WiFi?&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>By the third day, the receptionist has built a mental notebook of answers. When someone asks &amp;ldquo;where can I go to exercise?&amp;rdquo;, they do not call the concierge (retrieval) or open the hotel&amp;rsquo;s 300-page internal manual (LLM): they look at the notebook, work out that the question is the same as &amp;ldquo;where is the gym?&amp;rdquo;, and answer in two seconds.&lt;/p>
&lt;p>But when someone asks &amp;ldquo;what time does the gym close &lt;strong>today&lt;/strong>?&amp;rdquo;, the receptionist knows the notebook cannot be trusted: the schedule may have changed because of a private event. They have to call the concierge.&lt;/p>
&lt;p>That is exactly the mechanism of a semantic cache:&lt;/p>
&lt;ul>
&lt;li>The &lt;strong>notebook&lt;/strong> is the cache store (Redis with a vector index, or a Qdrant collection).&lt;/li>
&lt;li>&lt;strong>Working out that &amp;ldquo;exercise&amp;rdquo; ≈ &amp;ldquo;gym&amp;rdquo;&lt;/strong> is the cosine similarity search with threshold θ.&lt;/li>
&lt;li>&lt;strong>Calling the concierge&lt;/strong> is retrieval over the corpus.&lt;/li>
&lt;li>&lt;strong>Consulting the manual&lt;/strong> is LLM generation.&lt;/li>
&lt;li>&lt;strong>&amp;ldquo;Today&amp;rdquo;&lt;/strong> is the temporal query signal that invalidates the cache.&lt;/li>
&lt;/ul>
&lt;p>The threshold θ is exactly what separates &amp;ldquo;where is it&amp;rdquo; (semantically the same) from &amp;ldquo;what time is it open today&amp;rdquo; (semantically different). It is not magic: it is vector arithmetic over learned representations.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-in-production">The problem in production&lt;/h2>
&lt;p>A typical RAG pipeline has three layers of latency and compute: embedding the query, vector search over the corpus, and generation with the LLM. In development, that cost is irrelevant. In production with 50 concurrent users, each of those layers scales linearly with the number of requests.&lt;/p>
&lt;p>The problem is that users ask the same questions over and over, with slightly different wording:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Original query&lt;/th>
&lt;th>Equivalent query&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;How do I configure the agent?&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;What is the process for configuring the agent?&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;error installing the dependency&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;the dependency installation fails&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;what is an embedding?&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;explain to me what embeddings are&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Running the full pipeline for each of these variants is pure waste. Empirical studies in technical support and corporate Q&amp;amp;A systems report that between 30% and 70% of a day&amp;rsquo;s queries are semantically redundant with respect to earlier queries from the same week.&lt;/p>
&lt;p>The query distribution in real systems follows a power law similar to the Zipf distribution: the 100 most frequent topics account for roughly 60% of total traffic. A well-calibrated cache captures exactly that concentration.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-semantic-cache-works">How the semantic cache works&lt;/h2>
&lt;p>The full flow is shown in the diagram below. Let us describe it in prose first.&lt;/p>
&lt;p>When a new query $q$ arrives:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Embedding the query&lt;/strong>: $q$ is embedded with the same model used to index the corpus. This is critical: if the corpus was indexed with &lt;code>text-embedding-3-large&lt;/code> and the cache uses a different embedder, the vector spaces are not comparable.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Search in the cache store&lt;/strong>: an ANN (Approximate Nearest Neighbor) search is run over the vectors of previously cached queries. The most similar query $q^*$ and its cosine similarity $s$ are retrieved.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Threshold decision&lt;/strong>:
&lt;/p>
$$
\text{answer} = \begin{cases} r^* &amp; \text{if } s(q, q^*) \geq \theta \\ \text{pipeline}(q) &amp; \text{if } s(q, q^*) &lt; \theta \end{cases}
$$
&lt;p>
where $r^*$ is the cached answer associated with $q^*$.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>On a miss&lt;/strong>: the full pipeline runs (retrieval + LLM). The generated answer is stored in the cache with a configurable TTL for future similar queries.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>The cache store is not an ordinary key-value database. It is a vector index over the query embeddings, with the values being the generated answers. Each entry has the structure:&lt;/p>
&lt;pre tabindex="0">&lt;code>{vector: embed(q), response: r, ttl: T, metadata: {...}}
&lt;/code>&lt;/pre>&lt;h3 id="flow-diagram">Flow diagram&lt;/h3>
&lt;figure>
&lt;svg viewBox="0 0 760 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="sc-title sc-desc" style="width:100%;max-width:760px;font-family:system-ui,sans-serif;">
&lt;title id="sc-title">Semantic cache flow in a RAG pipeline&lt;/title>
&lt;desc id="sc-desc">Flow diagram showing how a query first passes through the semantic cache and, depending on whether there is a hit or a miss, either a cached answer is returned or the full retrieval and LLM pipeline runs.&lt;/desc>
&lt;!-- Background -->
&lt;rect width="760" height="420" fill="#f8f9fa" rx="8"/>
&lt;!-- Query input -->
&lt;rect x="20" y="180" width="110" height="52" rx="8" fill="#e3f2fd" stroke="#1565c0" stroke-width="1.5"/>
&lt;text x="75" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#0d47a1">Query&lt;/text>
&lt;text x="75" y="220" text-anchor="middle" font-size="11" fill="#1565c0">from the user&lt;/text>
&lt;!-- Arrow query → embedder -->
&lt;line x1="130" y1="206" x2="168" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Embedder -->
&lt;rect x="168" y="180" width="100" height="52" rx="8" fill="#f3e5f5" stroke="#6a1b9a" stroke-width="1.5"/>
&lt;text x="218" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#4a148c">Embedder&lt;/text>
&lt;text x="218" y="220" text-anchor="middle" font-size="10" fill="#6a1b9a">same model&lt;/text>
&lt;!-- Arrow embedder → cache check -->
&lt;line x1="268" y1="206" x2="306" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- Cache check -->
&lt;rect x="306" y="172" width="120" height="68" rx="8" fill="#fff3e0" stroke="#e65100" stroke-width="1.5"/>
&lt;text x="366" y="196" text-anchor="middle" font-size="12" font-weight="600" fill="#bf360c">Cache check&lt;/text>
&lt;text x="366" y="212" text-anchor="middle" font-size="10" fill="#e65100">ANN search&lt;/text>
&lt;text x="366" y="226" text-anchor="middle" font-size="10" fill="#e65100">sim ≥ θ ?&lt;/text>
&lt;!-- HIT branch (top) -->
&lt;line x1="366" y1="172" x2="366" y2="120" stroke="#2e7d32" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr-green)"/>
&lt;text x="380" y="148" font-size="10" fill="#2e7d32" font-weight="600">HIT&lt;/text>
&lt;rect x="296" y="68" width="140" height="48" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="1.5"/>
&lt;text x="366" y="90" text-anchor="middle" font-size="12" font-weight="600" fill="#1b5e20">Answer&lt;/text>
&lt;text x="366" y="106" text-anchor="middle" font-size="11" fill="#2e7d32">cached → user&lt;/text>
&lt;!-- MISS branch (right) -->
&lt;line x1="426" y1="206" x2="466" y2="206" stroke="#c62828" stroke-width="1.5" stroke-dasharray="5,3" marker-end="url(#arr-red)"/>
&lt;text x="435" y="196" font-size="10" fill="#c62828" font-weight="600">MISS&lt;/text>
&lt;!-- Retriever -->
&lt;rect x="466" y="180" width="100" height="52" rx="8" fill="#e8eaf6" stroke="#283593" stroke-width="1.5"/>
&lt;text x="516" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#1a237e">Retriever&lt;/text>
&lt;text x="516" y="220" text-anchor="middle" font-size="10" fill="#283593">vector search&lt;/text>
&lt;!-- Arrow retriever → LLM -->
&lt;line x1="566" y1="206" x2="606" y2="206" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- LLM -->
&lt;rect x="606" y="180" width="100" height="52" rx="8" fill="#fce4ec" stroke="#880e4f" stroke-width="1.5"/>
&lt;text x="656" y="202" text-anchor="middle" font-size="12" font-weight="600" fill="#880e4f">LLM&lt;/text>
&lt;text x="656" y="220" text-anchor="middle" font-size="10" fill="#ad1457">generation&lt;/text>
&lt;!-- Arrow LLM → new answer -->
&lt;line x1="656" y1="232" x2="656" y2="290" stroke="#555" stroke-width="1.5" marker-end="url(#arr)"/>
&lt;!-- New answer -->
&lt;rect x="596" y="290" width="120" height="48" rx="8" fill="#e8f5e9" stroke="#2e7d32" stroke-width="1.5"/>
&lt;text x="656" y="312" text-anchor="middle" font-size="12" font-weight="600" fill="#1b5e20">Answer&lt;/text>
&lt;text x="656" y="328" text-anchor="middle" font-size="11" fill="#2e7d32">new → user&lt;/text>
&lt;!-- Arrow new answer → store in cache -->
&lt;line x1="596" y1="314" x2="430" y2="314" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;text x="510" y="308" text-anchor="middle" font-size="10" fill="#e65100">store + TTL&lt;/text>
&lt;!-- Arrow from store to cache check (feedback) -->
&lt;line x1="366" y1="314" x2="366" y2="240" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;!-- Cache store label -->
&lt;rect x="296" y="340" width="140" height="48" rx="8" fill="#fff8e1" stroke="#f57f17" stroke-width="1.5"/>
&lt;text x="366" y="361" text-anchor="middle" font-size="11" font-weight="600" fill="#e65100">Cache store&lt;/text>
&lt;text x="366" y="377" text-anchor="middle" font-size="10" fill="#f57f17">Redis / Qdrant&lt;/text>
&lt;line x1="366" y1="340" x2="366" y2="314" stroke="#e65100" stroke-width="1.5" stroke-dasharray="4,3" marker-end="url(#arr-orange)"/>
&lt;!-- Legend -->
&lt;text x="20" y="390" font-size="10" fill="#555">— — — HIT path&lt;/text>
&lt;rect x="20" y="397" width="30" height="2" fill="#2e7d32"/>
&lt;text x="60" y="410" font-size="10" fill="#2e7d32">cache hit&lt;/text>
&lt;rect x="110" y="397" width="30" height="2" fill="#c62828"/>
&lt;text x="150" y="410" font-size="10" fill="#c62828">cache miss&lt;/text>
&lt;rect x="200" y="397" width="30" height="2" fill="#e65100"/>
&lt;text x="240" y="410" font-size="10" fill="#e65100">store / feedback&lt;/text>
&lt;!-- Arrow markers -->
&lt;defs>
&lt;marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#555"/>
&lt;/marker>
&lt;marker id="arr-green" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#2e7d32"/>
&lt;/marker>
&lt;marker id="arr-red" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#c62828"/>
&lt;/marker>
&lt;marker id="arr-orange" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#e65100"/>
&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;figcaption>The semantic cache flow as middleware between the gateway and the retriever. Cache hits skip the vector search over the corpus and the LLM generation entirely.&lt;/figcaption>
&lt;/figure>
&lt;hr>
&lt;h2 id="the-threshold-θ-and-its-trade-off">The threshold θ and its trade-off&lt;/h2>
&lt;p>The threshold θ is the most sensitive parameter in the system. It works exactly like the receptionist&amp;rsquo;s recognition threshold: if it is too demanding, it will only identify textually identical questions and the notebook will not be much use. If it is too lax, it will return the answer to &amp;ldquo;where is the gym?&amp;rdquo; to someone who asked &amp;ldquo;what time does the gym close?&amp;rdquo;.&lt;/p>
&lt;p>The cosine similarity between two vectors $\mathbf{a}$ and $\mathbf{b}$ is:&lt;/p>
$$
s(\mathbf{a}, \mathbf{b}) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \cdot \|\mathbf{b}\|}
$$
&lt;p>For prose text (Spanish or English), modern embedders such as &lt;code>text-embedding-3-large&lt;/code> or &lt;code>nomic-embed-text&lt;/code> assign cosine similarities of around 0.90–0.96 to semantically equivalent paraphrases and similarities of 0.75–0.88 to related but non-equivalent queries.&lt;/p>
&lt;p>The cache quality metric is not just hit rate: it is &lt;strong>precision@cache&lt;/strong>, defined as the fraction of cached answers that remain correct for the new query. A cached answer is &amp;ldquo;correct&amp;rdquo; if an evaluator (another LLM, or metrics such as BERTScore) judges it equivalent to the one the full pipeline would have generated for that specific query.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>θ&lt;/th>
&lt;th>Estimated hit rate&lt;/th>
&lt;th>Estimated precision@cache&lt;/th>
&lt;th>Effective saving&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0.85&lt;/td>
&lt;td>~65%&lt;/td>
&lt;td>~72%&lt;/td>
&lt;td>~47%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.90&lt;/td>
&lt;td>~55%&lt;/td>
&lt;td>~85%&lt;/td>
&lt;td>~47%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.92&lt;/td>
&lt;td>~48%&lt;/td>
&lt;td>~91%&lt;/td>
&lt;td>~44%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>0.93&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~45%&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~94%&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~42%&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.95&lt;/td>
&lt;td>~35%&lt;/td>
&lt;td>~98%&lt;/td>
&lt;td>~34%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.97&lt;/td>
&lt;td>~18%&lt;/td>
&lt;td>~99.5%&lt;/td>
&lt;td>~18%&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The effective saving is defined as $\text{hit rate} \times \text{precision@cache}$, since a hit with an incorrect answer is not a saving: it is an error that can cost more in lost trust than what was saved in GPU.&lt;/p>
&lt;p>The &lt;strong>empirical sweet spot&lt;/strong> for most corporate Q&amp;amp;A applications in Spanish or English lies between θ = 0.92 and θ = 0.95. In highly specialised domains where small nuances change the answer (medicine, law, finance), θ ≥ 0.95 is advisable.&lt;/p>
&lt;hr>
&lt;h2 id="the-arithmetic-of-the-saving">The arithmetic of the saving&lt;/h2>
&lt;p>Let us put concrete numbers on a real system.&lt;/p>
&lt;p>&lt;strong>Base configuration:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>10,000 requests/day&lt;/li>
&lt;li>Technical corpus of 1 million chunks in a Qdrant index&lt;/li>
&lt;li>LLM: Llama-3.1-70B on 4×H100 SXM (320 GB, NVLink)&lt;/li>
&lt;li>Average answer: 200 output tokens&lt;/li>
&lt;li>LLM throughput on this hardware: ~400 tokens/s/GPU with batching (continuous batching enabled, see &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous-batching-fundamentos&lt;/a>)&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Cost of a request without cache:&lt;/strong>&lt;/p>
&lt;p>Embedding the query takes ~2 ms on a GPU. The ANN vector search over 1 M chunks in Qdrant takes ~5 ms (measured empirically with HNSW, ef=128). Generating 200 tokens at 400 tok/s in total (4 GPUs) comes to:&lt;/p>
$$
t_{\text{LLM}} = \frac{200 \text{ tokens}}{400 \text{ tok/s}} = 0.5 \text{ s per request}
$$
&lt;p>If 10,000 requests reach the LLM in a day, the total GPU time spent on generation is:&lt;/p>
$$
T_{\text{GPU}} = 10{,}000 \times 0.5 \text{ s} = 5{,}000 \text{ s} \approx 1.38 \text{ GPU hours per day}
$$
&lt;p>&lt;strong>With a semantic cache at θ = 0.93 and a hit rate of ~45%:&lt;/strong>&lt;/p>
&lt;p>Only 5,500 requests (55%) reach the LLM:&lt;/p>
$$
T_{\text{GPU,cache}} = 5{,}500 \times 0.5 \text{ s} = 2{,}750 \text{ s} \approx 0.76 \text{ GPU hours per day}
$$
&lt;p>&lt;strong>Saving:&lt;/strong>&lt;/p>
$$
\Delta T_{\text{GPU}} = 1.38 - 0.76 = 0.62 \text{ GPU hours/day}
$$
&lt;p>In inference compute, this amounts roughly to being able to serve 45% more users without adding hardware, or cutting inference costs by 45% if you work with external APIs billed per token.&lt;/p>
&lt;p>The cost of the semantic cache itself (embedding the query + ANN search over the cache store) is ~7 ms per request, negligible against the 500 ms of generation avoided on hits.&lt;/p>
&lt;p>&lt;strong>Zipf distribution of topics:&lt;/strong>&lt;/p>
&lt;p>The reason it works is the Zipf distribution of traffic. If we number topics by frequency (topic 1 = most frequent), the frequency of topic $k$ is proportional to $1/k$. With 1,000 distinct topics:&lt;/p>
$$
\text{fraction of traffic covered by top-}N = \frac{\sum_{k=1}^{N} 1/k}{\sum_{k=1}^{1000} 1/k} \approx \frac{\ln N}{\ln 1000} = \frac{\ln N}{6.9}
$$
&lt;p>For the top-100 topics: $\ln(100)/6.9 \approx 4.6/6.9 \approx 67\%$ of traffic. The cache does not need to cover every topic: it captures 67% of traffic while covering only 10% of the topics.&lt;/p>
&lt;hr>
&lt;h2 id="oss-stack-2026">OSS stack 2026&lt;/h2>
&lt;h3 id="gptcache">GPTCache&lt;/h3>
&lt;p>&lt;a href="https://github.com/zilliztech/GPTCache">GPTCache&lt;/a> is the reference library for a standalone semantic cache. Its architecture is modular:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Embedder&lt;/strong>: ONNX Runtime with converted models (&lt;code>onnx/all-MiniLM-L6-v2&lt;/code> by default), with no GPU dependency for the cache layer.&lt;/li>
&lt;li>&lt;strong>Vector store&lt;/strong>: Faiss (local), Milvus, or Qdrant.&lt;/li>
&lt;li>&lt;strong>Scalar store&lt;/strong>: SQLite (development) or Redis (production) for metadata, TTL, and answers.&lt;/li>
&lt;li>&lt;strong>Similarity evaluation&lt;/strong>: cosine by default, configurable.&lt;/li>
&lt;/ul>
&lt;p>Minimal configuration in Python:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">cache&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.adapter&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">openai&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.embedding&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Onnx&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.manager&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">CacheBase&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">VectorBase&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_data_manager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">gptcache.similarity_evaluation.distance&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">SearchDistanceEvaluation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">onnx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Onnx&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">data_manager&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_data_manager&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">CacheBase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;redis&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;redis://localhost:6379&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">VectorBase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;qdrant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">host&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;localhost&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;query_cache&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">init&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embedding_func&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">onnx&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to_embeddings&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">data_manager&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">data_manager&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">similarity_evaluation&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">SearchDistanceEvaluation&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_openai_key&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>GPTCache intercepts calls to the OpenAI API (or to compatible proxies) transparently. The TTL is configured at the &lt;code>data_manager&lt;/code> level.&lt;/p>
&lt;h3 id="meancache">MeanCache&lt;/h3>
&lt;p>&lt;a href="https://arxiv.org/abs/2403.02694">MeanCache&lt;/a> (2024) extends GPTCache to multi-turn conversations. The problem with standard GPTCache is that in dialogue the &amp;ldquo;relevant&amp;rdquo; query is not just the last message but the whole context window. MeanCache computes the query embedding as the weighted mean of the embeddings of the last $k$ turns:&lt;/p>
$$
\mathbf{e}_{\text{query}} = \frac{\sum_{i=1}^{k} w_i \cdot \mathbf{e}_{q_i}}{\sum_{i=1}^{k} w_i}
$$
&lt;p>where $w_i$ decreases with the age of the turn. This reduces false positives in dialogues where the topic keeps shifting.&lt;/p>
&lt;h3 id="qdrant-as-a-dual-cache-store">Qdrant as a dual cache store&lt;/h3>
&lt;p>If the RAG corpus is already in Qdrant, you can use the same instance with a &lt;strong>separate collection&lt;/strong> for the cache. The advantages are operational: a single service to manage, the same backup and monitoring infrastructure.&lt;/p>
&lt;p>The cache collection uses &lt;code>payload filters&lt;/code> to implement TTL:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">QdrantClient&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">qdrant_client.models&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Filter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Range&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">QdrantClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;localhost&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">port&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">6333&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Search the cache with a TTL filter&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">hits&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">search&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">collection_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;semantic_cache&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_vector&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_filter&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">must&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">FieldCondition&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;expires_at&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">range&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">Range&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">gt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">limit&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">score_threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.93&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You have to run a periodic cleanup of expired entries, since Qdrant has no native TTL (unlike Redis).&lt;/p>
&lt;h3 id="langfuse-for-traceability">Langfuse for traceability&lt;/h3>
&lt;p>&lt;a href="https://langfuse.com">Langfuse&lt;/a> is the OSS standard for LLM pipeline observability (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">tracing-llm-otel-genai&lt;/a>). Every request should be tagged with whether it was a cache hit or a miss:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse.decorators&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">observe&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">langfuse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@observe&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">process_query&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cache_result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">semantic_cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">lookup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_score&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">cache_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">response&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># full pipeline...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">langfuse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update_current_observation&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metadata&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;cache_hit&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With this metadata, Langfuse lets you compute the real hit rate, the distribution of similarity scores, and detect whether the threshold θ needs adjusting.&lt;/p>
&lt;hr>
&lt;h2 id="on-premise-hardware-a-reference-configuration">On-premise hardware: a reference configuration&lt;/h2>
&lt;p>For an on-premise deployment with this stack, a suitable configuration for RAG with a semantic cache is:&lt;/p>
&lt;p>&lt;strong>Inference node:&lt;/strong> 4×H100 SXM (320 GB NVLink in total) for Llama-3.1-70B in FP8. Throughput ~400 tok/s in generation with continuous batching (vLLM or TGI).&lt;/p>
&lt;p>&lt;strong>Vector services node:&lt;/strong> CPU with 256 GB RAM. Qdrant for the corpus (1–10 M chunks) and for the cache store (up to 500K entries in memory). Redis 7.x for metadata and an exact-match cache as a first layer.&lt;/p>
&lt;p>&lt;strong>Embedding node:&lt;/strong> CPU or a mid-range GPU (A10G). The cache embedder can run on ONNX Runtime on CPU with no perceptible impact on latency (~2 ms per embedding).&lt;/p>
&lt;p>Separating the cache store from the corpus matters: the corpus holds millions of chunks with large HNSW indexes; the cache store holds at most tens of thousands of queries with a much smaller index and search times of 1–2 ms.&lt;/p>
&lt;hr>
&lt;h2 id="cases-where-the-cache-fails">Cases where the cache fails&lt;/h2>
&lt;p>The receptionist with a notebook fails in three well-defined scenarios:&lt;/p>
&lt;h3 id="1-queries-with-a-temporal-context">1. Queries with a temporal context&lt;/h3>
&lt;p>&amp;ldquo;What is the current status of the incident?&amp;rdquo; or &amp;ldquo;What changed in the latest release?&amp;rdquo; are questions whose correct answer changes over time. A cache with a 24-hour TTL could return stale information.&lt;/p>
&lt;p>The solution is to detect temporal markers in the query (regular expressions over &amp;ldquo;today&amp;rdquo;, &amp;ldquo;now&amp;rdquo;, &amp;ldquo;current&amp;rdquo;, &amp;ldquo;latest&amp;rdquo;, &amp;ldquo;yesterday&amp;rdquo;, and their Spanish equivalents) and force a cache miss for these queries, whatever the similarity score.&lt;/p>
&lt;h3 id="2-personalised-queries-over-private-data">2. Personalised queries over private data&lt;/h3>
&lt;p>If the RAG has access to user data (account history, private documents), two different users asking the same question must get different answers. A shared cache that ignores user context is a privacy risk.&lt;/p>
&lt;p>The solution is a cache partitioned by &lt;code>user_id&lt;/code> or &lt;code>tenant_id&lt;/code>. This lowers the hit rate (each user&amp;rsquo;s cache is smaller) but it is the only safe option in multi-tenant architectures.&lt;/p>
&lt;h3 id="3-ttl-and-a-stale-corpus">3. TTL and a stale corpus&lt;/h3>
&lt;p>When the corpus is updated (new documents are ingested, errors are corrected), cached answers can go out of date. A fixed TTL (24–48 hours) mitigates the problem but does not remove it.&lt;/p>
&lt;p>For corpora with frequent updates, the solution is an active invalidation mechanism: when the corpus is updated in Qdrant, a job runs that identifies which cache entries might be affected (by semantic overlap with the updated chunks) and deletes them. This is the &amp;ldquo;selective cache invalidation&amp;rdquo; mentioned in the section on topics not covered.&lt;/p>
&lt;hr>
&lt;h2 id="integration-into-the-pipeline-as-middleware">Integration into the pipeline as middleware&lt;/h2>
&lt;p>The semantic cache is implemented as middleware between the API gateway and the retriever. It does not change the API contract: the client still sends queries and receives answers in the same format.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">SemanticCacheMiddleware&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="fm">__init__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cache_store&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">retriever&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">llm&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.93&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cache_store&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retriever&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">retriever&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">llm&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">llm&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">threshold&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">query&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># First layer: exact-match cache (Redis GET, O(1))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">exact&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">exact_lookup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">exact&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">exact&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;exact&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Second layer: semantic cache (ANN search)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_embedding&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embed&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">semantic&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">semantic_lookup&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">threshold&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">semantic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">semantic&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;semantic&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Miss: full pipeline&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">chunks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retriever&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">retrieve&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">llm&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">generate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">chunks&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Store for future queries&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cache&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">store&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embedding&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query_embedding&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">query&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">query&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">response&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ttl&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;ttl&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">86400&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">response&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;cache_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;miss&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The first exact-match layer (Redis GET) is an extra optimisation: for textually identical queries, the embedding is not even computed. The cost is a Redis operation taking microseconds. Only if there is no exact match does it fall through to the semantic lookup.&lt;/p>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Selective cache invalidation&lt;/strong>: when a subset of the corpus is updated (for example, the documents for a specific product are reindexed), you would need to identify which cache entries overlap semantically with the updated chunks and mark them stale. The mechanism involves computing similarity between the embeddings of the updated chunks and the embeddings of the cached queries, which is expensive at scale.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Multi-tenant cache: isolation vs sharing&lt;/strong>: in a SaaS product with multiple customers, a shared cache maximises the hit rate but can expose one tenant&amp;rsquo;s answers to another if filtering is not done correctly. A cache partitioned by tenant is safe but has much lower hit rates. The middle ground is a shared cache with ACL filtering applied over Qdrant&amp;rsquo;s payload filters.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Semantic cache for streaming responses&lt;/strong>: when the LLM emits tokens in streaming mode (SSE), the cache cannot easily intercept the complete answer. The options are: cache on the first miss and return the whole answer at once on hits (breaking the streaming experience), or implement a &amp;ldquo;fake streaming&amp;rdquo; that emits the tokens of the cached answer at a controlled rate.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Exact-match cache as a first layer&lt;/strong>: ahead of the semantic cache, an O(1) lookup in Redis with the query as the key can catch textually identical queries at negligible cost. The code in the previous section already shows this two-layer architecture.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a> — the retrieval that the semantic cache avoids running on hits; understanding how the vector search you are saving works&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant: document ingestion in microservices&lt;/a> — the vector store that can double as a cache store, with the same Qdrant instance for corpus and cache&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache in transformers&lt;/a> — caching at the transformer&amp;rsquo;s attention level; different from the semantic cache at the RAG system&amp;rsquo;s query level, but complementary&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching in LLM inference&lt;/a> — the batching that processes cache misses; the semantic cache reduces the pressure of requests reaching the inference engine&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">Tracing LLMs with OTel and GenAI&lt;/a> — how to instrument cache hits vs misses with OpenTelemetry to measure the real saving in production&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Bang, J. et al. (2024). &lt;em>MeanCache: User-Centric Semantic Cache for Large Language Model Based Web Applications&lt;/em>. arXiv:2403.02694.&lt;/li>
&lt;li>Zilliz. (2023). &lt;em>GPTCache: A Library for Creating Semantic Cache for LLM Queries&lt;/em>. GitHub: &lt;a href="https://github.com/zilliztech/GPTCache">zilliztech/GPTCache&lt;/a>.&lt;/li>
&lt;li>Qdrant Team. (2024). &lt;em>Qdrant Documentation: Filtering with payload&lt;/em>. &lt;a href="https://qdrant.tech/documentation/concepts/filtering/">qdrant.tech/documentation&lt;/a>.&lt;/li>
&lt;li>Manning, C. D., Raghavan, P., Schütze, H. (2008). &lt;em>Introduction to Information Retrieval&lt;/em>. Cambridge University Press. Cap. 19: Web search (distribución Zipf).&lt;/li>
&lt;li>Langfuse. (2024). &lt;em>Observability for LLM Applications&lt;/em>. &lt;a href="https://langfuse.com/docs">langfuse.com/docs&lt;/a>.&lt;/li>
&lt;li>Meta AI. (2024). &lt;em>Llama 3.1 Model Card&lt;/em>. &lt;a href="https://ai.meta.com/blog/meta-llama-3-1/">ai.meta.com&lt;/a>.&lt;/li>
&lt;li>Guo, Y. et al. (2023). &lt;em>Evaluating the Factual Consistency of Large Language Models Through Summarization&lt;/em>. Referencia para BERTScore como métrica de evaluación de respuestas cacheadas.&lt;/li>
&lt;/ol></description></item></channel></rss>