<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>On-Premise on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/on-premise/</link><description>Recent content in On-Premise on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 15 Sep 2026 09:30:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/on-premise/index.xml" rel="self" type="application/rss+xml"/><item><title>Real ClickHouse capacity in Langfuse v4: 2,782 bytes per observation, and why almost half of that is not data</title><link>https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/</link><pubDate>Tue, 15 Sep 2026 09:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/</guid><description>&lt;blockquote>
&lt;p>Sixth article in the series on operating self-hosted Langfuse v4. The previous five cover &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">putting LangGraph in front&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">two well-known agents instrumented&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">migrating with no window&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">the worker queues&lt;/a>. This one measures what all of that takes up. Benchmark run on 15 September 2026 against the event schema of version 4.36.0, identical to that of 4.35.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Langfuse publishes no capacity figure at all.&lt;/strong> Not bytes per row, not events per second, not expected size. I searched their code with every reasonable combination and there is nothing. The only quantified thing is the commercial quotas of their managed service, which say nothing about disk.&lt;/p>
&lt;p>&lt;strong>One observation takes 2,782 bytes of disk.&lt;/strong> Split across the two event tables: 2,347 in the full table and 435 in the listings one. Measured over two hundred thousand observations shaped the way a real agent produces them.&lt;/p>
&lt;p>&lt;strong>41.8 % of the full table&amp;rsquo;s disk is not data, it is indexes.&lt;/strong> Four full-text indexes add up to 187 MiB against 260 MiB of data. The one covering the input costs 523 bytes per observation, almost three quarters of what the column it indexes costs.&lt;/p>
&lt;p>&lt;strong>The materialized view trims the text eight times over and saves eighteen per cent of disk.&lt;/strong> Truncating input and output to two hundred characters leaves the text at 64 MiB of the original 540, and even so the second table adds 18.5 % on top of the first. What survives the trimming is the seventy-eight-column skeleton and a metadata field that is truncated element by element, not as a whole.&lt;/p>
&lt;p>&lt;strong>In the listings table, metadata costs more than input and output together.&lt;/strong> 132.6 bytes per observation against 101.8. And its full-text index adds another 111.9, almost as much as the column.&lt;/p>
&lt;p>&lt;strong>A compression ratio quoted without saying what was compressed is worth nothing.&lt;/strong> My first pass, with repeated text, gave 38 times. With text of an entropy close to that of natural language it gives 3.68. Same schema, same indexes, same version.&lt;/p>
&lt;p>&lt;strong>Generations are 11 % of the observations and 65 % of the bytes.&lt;/strong> Their mean input is 15,655 bytes and the 95th percentile is 55,205, because each one reserialises the full history. It is the quadratic law from the second article in the series, landed on disk.&lt;/p>
&lt;p>&lt;strong>On deletion, the disk does not go down.&lt;/strong> A delete stops showing the rows and leaves the space occupied. A second step is needed, applying the deleted mask, and the cron that does it &lt;strong>ships disabled&lt;/strong>, just like the retention one. On top of that the cleaner works table by table, so the second table keeps the rows the first one no longer shows.&lt;/p>
&lt;p>&lt;strong>And automatic retention is a paid feature when self-hosted.&lt;/strong> The &lt;code>data-retention&lt;/code> entitlement is not in the open edition. Without it, the event tables grow without limit: they carry no expiry clause.&lt;/p>
&lt;h2 id="you-are-here-the-sizing-you-cannot-do-by-reading">You are here: the sizing you cannot do by reading&lt;/h2>
&lt;p>The previous five articles describe how version 4 works and how to put it to work. The moment arrives to ask for disk, and there the series runs out of sources.&lt;/p>
&lt;p>The official documentation gives a container sizing: two CPUs and eight gibibytes for ClickHouse, in Kubernetes eight of request and sixteen of limit, a hundred gibibytes of disk and three minimum replicas. It is a reasonable starting point and it does not answer the only question that matters, which is how long that disk lasts.&lt;/p>
&lt;p>I went looking for it in the code, which is what this series does. I searched for events per second, rows per observation, bytes per row, capacity, benchmark, sizing, across the whole repository and its documentation. &lt;strong>There is no figure at all.&lt;/strong> The closest thing is the ingestion quotas of their managed service, which are commercial API limits, and some working notes on a synthetic load generator that refer to the tables of the previous version and bring no results, only parameters.&lt;/p>
&lt;p>So the only honest way out was to measure it. This article is that measurement, with the whole method laid out so that anyone can repeat it and contrast it with their own load.&lt;/p>
&lt;h2 id="the-analogy-the-archive-and-the-index-card">The analogy: the archive and the index card&lt;/h2>
&lt;p>A municipal archive keeps case files. Each file has its folder with everything inside, and on top of that the archivist fills in an index card with the header data and the first lines of the subject, so that the file can be searched without pulling out the folder.&lt;/p>
&lt;p>Intuition says the cards take up nothing compared with the folders. And that is true if you look only at the paper. It stops being true when the archivist, in order to be able to search by any word, also builds a term file: a box with one card per distinct word appearing in any case file, pointing to where it occurs. That box grows with the vocabulary, not with the number of case files, and ends up taking almost as much room as the case files themselves.&lt;/p>
&lt;p>In this setup the folders are the full table, the cards are the listings table, and the term file is the full-text indexes. The article measures the three things separately, because whoever sizes by looking only at the folders falls short by almost half.&lt;/p>
&lt;h2 id="the-benchmark">The benchmark&lt;/h2>
&lt;p>I want this to be reproducible, so the method goes in whole.&lt;/p>
&lt;p>&lt;strong>The schema is the real one.&lt;/strong> I extracted the ClickHouse migration DDL from the repository, in its single-instance variant. There are not three migrations but six: the three that create the two tables and the materialized view, plus one that adds three ingestion attribution columns, another that adds an n-gram index over the metadata, and another that adds three evaluation columns and two indexes. The result is &lt;strong>78 columns per table, eleven skip indexes in the full table and fourteen in the listings one&lt;/strong>.&lt;/p>
&lt;p>I verified that this DDL is identical between the version I have pinned across the series and the current head of the repository: the diff of the migrations directory is empty.&lt;/p>
&lt;p>&lt;strong>The engine is ClickHouse 26.9.1.&lt;/strong> The minimum version 4 demands is 25.12, because of the text indexes the schema needs. I am using a newer version, and that is a difference from an installation that sticks to the minimum.&lt;/p>
&lt;p>&lt;strong>The data is synthetic and correctly shaped.&lt;/strong> The benchmark does not invent the rows: the columns and their typical values come from the worker function that builds the record. I write only into the full table, which is what the worker does, and let the materialized view populate the second one. That reproduces the real write amplification.&lt;/p>
&lt;p>The topology of each trace reproduces the law we measured in the second article of the series: four fixed observations plus five for each tool call. The number of calls per turn comes from a distribution with a low median and a long tail, capped at fifteen. Generations carry the full message history, which grows with the turn, because that is exactly what the instrumentation does and it is the origin of the quadratic growth in bytes.&lt;/p>
&lt;p>Result: 200,012 observations across 22,563 traces, that is &lt;strong>8.86 observations per trace&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>And the entropy of the text is calibrated, which is the part that almost ruined the benchmark.&lt;/strong> My first pass used a repeated paragraph as filler. With that text, the full table compressed 38.1 times and the figures came out beautiful and false. A repeated lorem ipsum is the best possible input for a compressor. I redid the generator with a wide vocabulary sampled from a Zipf distribution and fifteen per cent high-entropy identifiers, calibrated to give the ratio that natural language mixed with identifiers gives.&lt;/p>
&lt;p>With that text, the same table compresses 3.68 times. &lt;strong>Ten times less.&lt;/strong> Any ClickHouse capacity figure that does not say what was compressed is noise, and mine would be too without this paragraph.&lt;/p>
&lt;h2 id="what-one-observation-takes-up">What one observation takes up&lt;/h2>
&lt;p>The two tables, after forcing the final merge:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>events_full&lt;/th>
&lt;th>events_core&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Rows&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>200,012&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Uncompressed&lt;/td>
&lt;td>957.87 MiB&lt;/td>
&lt;td>328.73 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compressed&lt;/td>
&lt;td>260.29 MiB&lt;/td>
&lt;td>60.34 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ratio&lt;/td>
&lt;td>3.68&lt;/td>
&lt;td>5.45&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>On disk&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;td>82.92 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Raw per observation&lt;/td>
&lt;td>5,022 B&lt;/td>
&lt;td>1,723 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compressed per observation&lt;/td>
&lt;td>1,364.6 B&lt;/td>
&lt;td>316.3 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>On disk per observation&lt;/strong>&lt;/td>
&lt;td>&lt;strong>2,347 B&lt;/strong>&lt;/td>
&lt;td>&lt;strong>435 B&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The figure to take away is the last row added up: &lt;strong>2,782 bytes of disk per observation&lt;/strong>, counting both tables, with one replica and no retention.&lt;/p>
&lt;p>And it is worth looking at the distance between the last two rows. The compressed data of the full table is 1,364.6 bytes per observation, but on disk it takes 2,347. The difference, 982 bytes per observation, is the next section.&lt;/p>
&lt;h2 id="where-the-bytes-go">Where the bytes go&lt;/h2>
&lt;p>Broken down by column, with what each one costs per observation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Column&lt;/th>
&lt;th>events_full&lt;/th>
&lt;th>events_core&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>input&lt;/code>&lt;/td>
&lt;td>717.5 B&lt;/td>
&lt;td>61.5 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>metadata_values&lt;/code>&lt;/td>
&lt;td>389.0 B&lt;/td>
&lt;td>132.6 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>output&lt;/code>&lt;/td>
&lt;td>174.7 B&lt;/td>
&lt;td>40.3 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>tool_definitions&lt;/code>&lt;/td>
&lt;td>28.0 B&lt;/td>
&lt;td>28.1 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>blob_storage_file_path&lt;/code>&lt;/td>
&lt;td>12.6 B&lt;/td>
&lt;td>12.5 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>span_id&lt;/code>&lt;/td>
&lt;td>8.1 B&lt;/td>
&lt;td>8.0 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The other 67 columns together&lt;/td>
&lt;td>17.3 B&lt;/td>
&lt;td>13.3 B&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three things jump out.&lt;/p>
&lt;p>The first is that &lt;strong>the skeleton is cheap&lt;/strong>. Sixty-seven columns, most of them empty in a normal observation, cost seventeen bytes between them all. ClickHouse compresses a column of empty strings down to almost nothing. Anyone who feared that the thirteen experiment columns or the telemetry ones would weigh can stop fearing it.&lt;/p>
&lt;p>The second is that in the full table &lt;strong>the input is half of everything&lt;/strong>. 717.5 of 1,364.6 compressed bytes. It is the direct consequence of each generation reserialising the history.&lt;/p>
&lt;p>The third is the one I was not expecting. In the listings table, &lt;strong>metadata costs more than input and output together&lt;/strong>: 132.6 against 101.8. The reason lies in how the materialized view truncates. It trims input and output to two hundred characters each, but it trims the metadata &lt;strong>element by element&lt;/strong>: an array with forty values can survive with eight thousand characters. The table that exists in order to be light fills up with the one thing its truncation does not control.&lt;/p>
&lt;h3 id="the-split-by-observation-type">The split by observation type&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Type&lt;/th>
&lt;th>Observations&lt;/th>
&lt;th>Mean input&lt;/th>
&lt;th>Input p95&lt;/th>
&lt;th>Mean output&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GENERATION&lt;/td>
&lt;td>21,952&lt;/td>
&lt;td>15,655 B&lt;/td>
&lt;td>55,205 B&lt;/td>
&lt;td>1,077 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AGENT&lt;/td>
&lt;td>22,563&lt;/td>
&lt;td>2,585 B&lt;/td>
&lt;td>6,013 B&lt;/td>
&lt;td>1,300 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TOOL&lt;/td>
&lt;td>21,952&lt;/td>
&lt;td>319 B&lt;/td>
&lt;td>319 B&lt;/td>
&lt;td>2,008 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CHAIN&lt;/td>
&lt;td>67,689&lt;/td>
&lt;td>413 B&lt;/td>
&lt;td>413 B&lt;/td>
&lt;td>20 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SPAN&lt;/td>
&lt;td>65,856&lt;/td>
&lt;td>272 B&lt;/td>
&lt;td>272 B&lt;/td>
&lt;td>206 B&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Generations are 11 % of the observations and contribute 65 % of the input and output bytes. The 95th percentile of their input is 55 kilobytes, in a benchmark where no conversation goes beyond fifteen tool calls.&lt;/p>
&lt;p>This has a sizing consequence worth saying out loud: &lt;strong>the size of the database is not determined by the number of requests, it is determined by the depth of the turns&lt;/strong>. Doubling the users doubles the disk. Doubling the tool calls per turn multiplies it by rather more than two.&lt;/p>
&lt;h2 id="the-indexes-which-are-almost-half">The indexes, which are almost half&lt;/h2>
&lt;p>Here is the finding that changes a sizing exercise the most.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Data&lt;/th>
&lt;th>Indexes&lt;/th>
&lt;th>% of disk in indexes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>events_full&lt;/td>
&lt;td>260.29 MiB&lt;/td>
&lt;td>187.31 MiB&lt;/td>
&lt;td>&lt;strong>41.8 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>events_core&lt;/td>
&lt;td>60.34 MiB&lt;/td>
&lt;td>22.49 MiB&lt;/td>
&lt;td>&lt;strong>27.1 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The marks and the primary key are irrelevant: thirty-two kilobytes and three hundred bytes respectively, with the granule size of 64 mebibytes the full table has declared.&lt;/p>
&lt;p>Broken down, and ordered by what they cost:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Index&lt;/th>
&lt;th>Table&lt;/th>
&lt;th>Bytes per observation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>idx_fts_input_low&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>523.1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_values&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>319.4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_output_low&lt;/code>&lt;/td>
&lt;td>events_full&lt;/td>
&lt;td>133.5&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_values&lt;/code>&lt;/td>
&lt;td>events_core&lt;/td>
&lt;td>111.9&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_fts_metadata_names&lt;/code>&lt;/td>
&lt;td>both&lt;/td>
&lt;td>4.3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>idx_span_id&lt;/code>&lt;/td>
&lt;td>both&lt;/td>
&lt;td>1.2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The remaining Bloom filters&lt;/td>
&lt;td>both&lt;/td>
&lt;td>0.1 each&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The full-text index over the input costs 523 bytes per observation when the column it indexes costs 717. That is, &lt;strong>indexing the input costs almost three quarters of what storing it costs&lt;/strong>. And the metadata one in the listings table costs 111.9 when its column costs 132.6: eighty-five per cent.&lt;/p>
&lt;p>The Bloom filters, on the other hand, are free as far as sizing goes. The four identifier ones and those for model name, experiment and evaluator add up to less than two bytes per observation between them all.&lt;/p>
&lt;p>This is not a criticism of the design. Those indexes are what make text search in the interface respond, and without them the experience would be another thing. It is sizing information that is published nowhere: &lt;strong>when asking for disk for ClickHouse you have to multiply what the columns say by 1.7.&lt;/strong>&lt;/p>
&lt;h2 id="the-materialized-view-as-an-amplifier">The materialized view as an amplifier&lt;/h2>
&lt;p>The second table exists so that listings do not have to touch the large fields. It truncates input and output to two hundred characters and does without the text indexes over them.&lt;/p>
&lt;p>The trimming works: the benchmark&amp;rsquo;s 540.55 MiB of input and output come down to 64.68 MiB, &lt;strong>eight and a half times less&lt;/strong>.&lt;/p>
&lt;p>And even so the second table adds 18.5 % of disk on top of the first. For the reasons already seen: the skeleton survives, the metadata truncated element by element survives, and its own text index over that metadata costs another 111.9 bytes.&lt;/p>
&lt;p>It is worth keeping in mind because it is counter-intuitive. You would think a table storing two hundred characters of text is an appendix. It is four hundred and thirty-five bytes per observation, almost a fifth of the total, and it is paid always, because the materialized view cannot be switched off without leaving the interface with no listings.&lt;/p>
&lt;h2 id="what-happens-on-deletion">What happens on deletion&lt;/h2>
&lt;p>The retention cron runs a lightweight delete. I reproduced it: I deleted the observations older than a date, which removed 67,395 rows of the 200,012.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Moment&lt;/th>
&lt;th>Visible rows&lt;/th>
&lt;th>Rows in the parts&lt;/th>
&lt;th>Disk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Before&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>After the delete&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>200,012&lt;/td>
&lt;td>447.68 MiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>After applying the mask&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>132,617&lt;/td>
&lt;td>299.11 MiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The delete frees &lt;strong>not one byte&lt;/strong>. The rows stop being visible and the space stays occupied. The disk only goes down at the second step, applying the deleted mask, which frees 33.2 %.&lt;/p>
&lt;p>And that second step is run by a different cron that &lt;strong>ships disabled&lt;/strong>, just like the retention one. With the default values, whoever switches retention on in the interface will see the traces disappear from the view and will never see the disk go down, until an ordinary merge drags in the affected parts, which can take however long it takes.&lt;/p>
&lt;p>There is a third detail. The cleaner works &lt;strong>table by table&lt;/strong>, with an independent deleter for each one. In my test I deleted in the full table and the listings one kept its 200,012 rows, because the materialized view does not propagate deletes. In a real installation both deleters run, but they are two separate operations that can diverge if one fails.&lt;/p>
&lt;p>On top of this you have to add what the fourth article already covered: &lt;strong>neither of the two tables carries an expiry clause&lt;/strong>. There is no automatic expiry in the engine. The whole lifecycle depends on those crons. And per-project retention requires an enterprise edition entitlement, so in the open edition the only retention possible is the one you write yourself.&lt;/p>
&lt;h2 id="projection">Projection&lt;/h2>
&lt;p>With the measured 2,782 bytes per observation, one replica and no retention:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observations per day&lt;/th>
&lt;th>Agent turns per day&lt;/th>
&lt;th>Per day&lt;/th>
&lt;th>Per month&lt;/th>
&lt;th>Per year&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>100,000&lt;/td>
&lt;td>~11,300&lt;/td>
&lt;td>0.26 GiB&lt;/td>
&lt;td>7.8 GiB&lt;/td>
&lt;td>94.6 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1,000,000&lt;/td>
&lt;td>~112,800&lt;/td>
&lt;td>2.59 GiB&lt;/td>
&lt;td>77.7 GiB&lt;/td>
&lt;td>945.6 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10,000,000&lt;/td>
&lt;td>~1,128,000&lt;/td>
&lt;td>25.91 GiB&lt;/td>
&lt;td>777.2 GiB&lt;/td>
&lt;td>9,456 GiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conversion to turns uses the benchmark&amp;rsquo;s 8.86 observations per trace, which depend on the depth of the agents. With deeper agents, the same turn figure produces more observations.&lt;/p>
&lt;p>And there is still a multiplication left. Langfuse recommends &lt;strong>three minimum replicas&lt;/strong> of ClickHouse in Kubernetes. With one million observations a day and three replicas, a year is &lt;strong>2.8 tebibytes of raw disk&lt;/strong>. The official sizing of a hundred gibibytes per replica covers about thirty-eight days at that rate.&lt;/p>
&lt;p>That is the number that is published nowhere and that has to go into the spreadsheet before committing to a service.&lt;/p>
&lt;h2 id="what-this-benchmark-does-not-measure">What this benchmark does not measure&lt;/h2>
&lt;p>For the sake of honesty, and because anyone repeating this is going to get different numbers:&lt;/p>
&lt;p>The data is synthetic. The shape of the row and the topology of the traces are taken from the code and from the measurements in the previous articles, but the text is generated by a sampler, not by a model. I have calibrated its entropy so that compression resembles that of natural language, and that calibration is the largest source of error in the whole article. A load with a lot of structured JSON will compress better; one with a lot of code or a lot of base64, worse.&lt;/p>
&lt;p>It is a single instance, with no replication. In a replicated installation there is a different engine and there are copies.&lt;/p>
&lt;p>It is ClickHouse 26.9.1, above the 25.12 minimum. The text index implementations have changed between versions and I have not compared.&lt;/p>
&lt;p>It does not measure performance: not ingestion per second, not query latency, not the CPU cost of merges. Disk only. The merge cost of full-text indexes is a topic with a life of its own and is left for the saturation runbook.&lt;/p>
&lt;p>And it does not measure object storage, which in version 4 is mandatory and keeps a copy of the original event. That is additional disk this benchmark does not touch.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Do not size using the official container sizing: it covers CPU and memory, not disk growth.&lt;/li>
&lt;li>Count &lt;strong>2,782 bytes per observation&lt;/strong> as a starting point, and adjust by measuring your own load.&lt;/li>
&lt;li>Multiply by the number of replicas before asking for the volume.&lt;/li>
&lt;li>Multiply what the columns suggest by 1.7, because the indexes are 41.8 % of the full table&amp;rsquo;s disk.&lt;/li>
&lt;li>Measure your own installation with the system tables instead of trusting this article: active parts for the total, columns for the breakdown, skip indexes for what is not data.&lt;/li>
&lt;li>Watch the metadata, which is the only thing the materialized view&amp;rsquo;s truncation does not control, because it trims each element and not the whole.&lt;/li>
&lt;li>Size by turn depth, not by number of requests.&lt;/li>
&lt;li>Switch on both crons, the retention one and the deleted mask one, and check that the disk really goes down after the second.&lt;/li>
&lt;li>Reckon on per-project retention requiring a licence, and budget the work of writing it if you do not have one.&lt;/li>
&lt;li>Remember that the tables have no expiry in the engine: with no cron, they grow for ever.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Langfuse publishes no capacity figure at all&lt;/strong>, neither in its code nor in its documentation.&lt;/li>
&lt;li>&lt;strong>A compression ratio without saying what was compressed means nothing&lt;/strong>: the same schema gives 38 times with repeated text and 3.68 with realistic text.&lt;/li>
&lt;li>&lt;strong>41.8 % of the full table&amp;rsquo;s disk is indexes&lt;/strong>, not data.&lt;/li>
&lt;li>&lt;strong>Indexing the input costs almost as much as storing it&lt;/strong>: 523 bytes per observation against 717.&lt;/li>
&lt;li>&lt;strong>The 78-column skeleton is irrelevant&lt;/strong>: seventeen bytes per observation across the sixty-seven small columns.&lt;/li>
&lt;li>&lt;strong>In the listings table metadata costs more than input and output together.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The materialized view&amp;rsquo;s truncation trims each element of the metadata, not the whole array.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The listings table is not an appendix&lt;/strong>: it adds 18.5 % of the first table&amp;rsquo;s disk despite storing two hundred characters.&lt;/li>
&lt;li>&lt;strong>A delete frees no disk.&lt;/strong> The mask has to be applied, and that cron ships disabled.&lt;/li>
&lt;li>&lt;strong>The retention one also ships disabled&lt;/strong> out of the box.&lt;/li>
&lt;li>&lt;strong>The cleaner works table by table&lt;/strong>; the materialized view does not propagate deletes.&lt;/li>
&lt;li>&lt;strong>The event tables carry no expiry clause.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Per-project retention is a paid feature&lt;/strong> when self-hosted.&lt;/li>
&lt;li>&lt;strong>Generations are 11 % of the observations and 65 % of the bytes.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Bloom filters are free&lt;/strong> as far as disk goes; the full-text ones are not.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>What I take from this benchmark is not the figure, although the figure was needed. It is that the split is not where you look for it.&lt;/p>
&lt;p>When somebody sizes a trace database, they look at the size of the prompts. It is the natural thing to do: they are the large fields and they are the ones you see in the interface. And it turns out that in this architecture the prompts are half the compressed data, and the compressed data is a little over half the disk. The rest is search structures nobody counts because they do not show up when you look at a row.&lt;/p>
&lt;p>There is a pattern there that goes beyond this tool. An observability database is, by definition, a database queried in unforeseen ways: by free text, by user, by session, by whatever is needed when something goes wrong at three in the morning. That ability to search by anything is paid for in disk, always, and it is paid at write time and not at search time. Langfuse&amp;rsquo;s design pays it fairly explicitly, with four full-text indexes declared in the migration, in plain sight for anyone who wants to read them.&lt;/p>
&lt;p>What was missing is for somebody to put the number alongside. Now it is there, with its margin of error and its method on the table, so that the next person to measure it can tell me where I got it wrong.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">What goes into a trace&lt;/a>: version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>Real ClickHouse capacity and cost (this article): how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Self-hosted Langfuse: architecture and tuning&lt;/a> — the architecture of the previous version, where this setup comes from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — who produces the traces measured here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other side of sizing, that of the gateway and the inference engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/">deepagents on a cluster of your own&lt;/a> — the harness that generates these observations and where it keeps its own state.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where retention and deletion fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Langfuse, migraciones de ClickHouse (&lt;code>packages/shared/clickhouse/migrations/canonical/&lt;/code>, ficheros 0039 a 0047): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, escritura del worker en ClickHouse (&lt;code>worker/src/services/IngestionService/&lt;/code>, &lt;code>worker/src/services/ClickhouseWriter/&lt;/code>): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, limpiador de retención por lotes y limpiador de máscaras de borrado (&lt;code>worker/src/features/batch-data-retention-cleaner/&lt;/code>, &lt;code>worker/src/features/deleted-mask-cleaner/&lt;/code>): &lt;a href="https://github.com/langfuse/langfuse">https://github.com/langfuse/langfuse&lt;/a>.&lt;/li>
&lt;li>Langfuse, guía de autoalojamiento y requisitos de infraestructura: &lt;a href="https://langfuse.com/self-hosting">https://langfuse.com/self-hosting&lt;/a>.&lt;/li>
&lt;li>ClickHouse, tablas del sistema &lt;code>parts&lt;/code>, &lt;code>columns&lt;/code> y &lt;code>data_skipping_indices&lt;/code>: &lt;a href="https://clickhouse.com/docs/en/operations/system-tables">https://clickhouse.com/docs/en/operations/system-tables&lt;/a>.&lt;/li>
&lt;li>ClickHouse, índices de salto y de texto completo: &lt;a href="https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree">https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree&lt;/a>.&lt;/li>
&lt;li>ClickHouse, borrados ligeros y máscara de borrado: &lt;a href="https://clickhouse.com/docs/en/guides/developer/lightweight-delete">https://clickhouse.com/docs/en/guides/developer/lightweight-delete&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>deepagents on a cluster of your own: the SDK is free, the server is not, and what you have to build in between</title><link>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</link><pubDate>Tue, 15 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/deepagents-free-sdk-licensed-server/</guid><description>&lt;blockquote>
&lt;p>Opening of the agentic platform vertical. The previous articles dealt with the pieces separately: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the inference gateway&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">its second door&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">what a fleet of agents costs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">isolation&lt;/a>. This one deals with what goes on top. Verified against &lt;code>deepagents&lt;/code> 0.7.14 and the LangGraph repository, commits of 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>deepagents is not a runtime.&lt;/strong> It is a factory that assembles middleware and delegates to LangChain&amp;rsquo;s agent factory. It returns a compiled LangGraph graph and nothing else. The repository&amp;rsquo;s own documentation says so without decoration: it introduces no new runtime. That is good news for whoever deploys it, because it runs wherever Python runs.&lt;/p>
&lt;p>&lt;strong>The planning tool no longer exists.&lt;/strong> It disappeared in 0.7.0 as a breaking change, along with the state channel that backed it. Almost everything written about this harness still describes it as one of its three legs. Today you have to ask for it explicitly, and you have to ask for it in each subagent too.&lt;/p>
&lt;p>&lt;strong>With the default configuration, the agent&amp;rsquo;s files live inside the checkpoint.&lt;/strong> The state backend stores the full content, binaries in base64, in the same place the conversation is persisted. Changing that is one line and it is the first architecture decision of the deployment.&lt;/p>
&lt;p>&lt;strong>The database grows per loop step, not per turn.&lt;/strong> Four tables, one row per superstep, one per channel and version, another per task write. And of the four cleanup operations the base class declares, the Postgres implementation has only one: delete an entire thread. The other three raise not-implemented.&lt;/p>
&lt;p>&lt;strong>Pruning by hand can empty the history silently.&lt;/strong> The code itself warns that a partial delete breaks the delta chain and leaves the channels rebuilding empty without raising any error. The only safe pruning is by whole thread.&lt;/p>
&lt;p>&lt;strong>Tenant isolation is the thread identifier, and there is no owner check.&lt;/strong> Whoever can send a thread identifier reads that thread. The on-disk filesystem backend fixes its root when the agent is built, so it separates nobody. And the documentation&amp;rsquo;s example for computing the per-user namespace does not work outside the managed platform.&lt;/p>
&lt;p>&lt;strong>There is a configuration trap that leaves a short-window model uncompacted.&lt;/strong> If the model does not expose its window, the compaction threshold falls back to 170,000 tokens. Served on a vLLM with a 32,768 window, the agent blows up on context length long before it compacts. The failure is silent and is fixed by passing the profile by hand.&lt;/p>
&lt;p>&lt;strong>Observability without the platform does exist, and it goes through a package that is not obvious.&lt;/strong> LangChain&amp;rsquo;s core does not emit OpenTelemetry: zero references. The exporter lives inside the LangSmith SDK, and there is a mode that emits over OTLP only, without talking to the service. It is the road to a Langfuse of your own, and it is worth knowing where it passes.&lt;/p>
&lt;p>&lt;strong>And here is where the free part ends: the server.&lt;/strong> The SDK, the graph engine and the checkpointers are MIT. The server that publishes them over HTTP carries an Elastic 2.0 licence, asks for a commercial licence key for production, adds Redis to the stack and reports metadata to an external endpoint unless there is an isolation agreement. The development tool is explicitly for development.&lt;/p>
&lt;p>&lt;strong>What is left is a list of things you have to write.&lt;/strong> Per-thread authorisation, retention, a run queue, cancellation, clean draining and migrations. None of them is hard. All of them are work, and none of them appears in the twenty-line front-page example.&lt;/p>
&lt;h2 id="you-are-here-the-harness-arrives-after-the-model">You are here: the harness arrives after the model&lt;/h2>
&lt;p>The sequence repeats on every project. First you set up inference, which is the visible part. Then the gateway, because you need keys and budgets. Then observability, because nobody knows what is going on. And when all of that works, somebody asks why the assistant cannot do multi-step tasks, and the word agent enters the scene.&lt;/p>
&lt;p>At that point the temptation is to write the loop by hand, and for two weeks it looks like a good idea. The loop is easy. What is not easy is everything you discover afterwards: what happens when the context fills up, where the intermediate files are stored, how you resume a task that was left half done, how you ask a human for confirmation without losing state.&lt;/p>
&lt;p>An agent harness is exactly that: the packaged answer to those questions. And choosing one looks like a library decision, about the size of choosing an HTTP client. It is not. It decides where your users&amp;rsquo; state lives, which database grows, what can be isolated and what cannot, and in the case at hand, whether there is a commercial licence waiting at the end of the road.&lt;/p>
&lt;p>This article looks at &lt;code>deepagents&lt;/code> with those eyes. It is not a usage guide, and I do not assess whether its agents are any good. I look at what it forces you to build.&lt;/p>
&lt;h2 id="the-analogy-the-scaffolding-and-the-crane">The analogy: the scaffolding and the crane&lt;/h2>
&lt;p>A building site needs scaffolding and it needs a crane, and they are not the same thing even though both are metal structures surrounding the building.&lt;/p>
&lt;p>You put up the scaffolding yourself with parts you bought. It adapts to the façade, it can be extended, and the day the work finishes you take it down and put it away. Nobody charges you for using it once it is bought.&lt;/p>
&lt;p>The crane is another matter. You hire it, it comes with an operator, it has a serial number and you have to notify somebody when it goes up. Without a crane the work still moves forward, more slowly and with more people, but it moves forward.&lt;/p>
&lt;p>Here the scaffolding is the SDK and the graph engine, and it belongs to whoever buys it. The crane is the server that exposes the agent over HTTP, and that one comes with a contract. This article is about where exactly the line between the two sits, because you cannot see it in the brochure, and about how much work can be done without a crane.&lt;/p>
&lt;h2 id="what-deepagents-actually-is">What deepagents actually is&lt;/h2>
&lt;p>The first thing to get out of your head is that it is a system. It is a function.&lt;/p>
&lt;p>The main factory assembles a list of middleware and calls LangChain&amp;rsquo;s agent factory, which is what builds the graph. What it returns is a compiled graph, with an added configuration on top that raises the recursion limit to a very high number and adds an integration tag in the metadata. The repository&amp;rsquo;s own architecture document states it: no new runtime is introduced.&lt;/p>
&lt;p>Compiled with the default options, the graph has the nodes you would expect from a reasoning-and-action loop: start, a prior node that repairs incomplete tool calls, the model node, the tools node and the end. The conditional edges run from the model to tools, to the model itself or to the end. There is nothing more. Everything that looks like a system is middleware that adds nodes only when it declares hooks of its own.&lt;/p>
&lt;p>The tools that ship by default are the filesystem ones, plus the subagent delegation one: list, read, write, edit, delete, search by pattern, search by content, execute, and the task tool.&lt;/p>
&lt;p>The good consequence of this is portability. If something is a LangGraph graph and nothing else, it runs in any Python container, with the checkpointer you choose, against the model you choose. There is no hidden service behind it.&lt;/p>
&lt;h3 id="what-is-no-longer-there">What is no longer there&lt;/h3>
&lt;p>It deserves its own section because it contradicts almost everything published. The planning tool, the one that wrote a task list and kept it in a state channel, &lt;strong>was removed in 0.7.0&lt;/strong> as a breaking change declared in the changelog. The tool is gone, the channel is gone, and the prompt fragment that went with it is gone.&lt;/p>
&lt;p>It survives as LangChain middleware, and you have to ask for it by hand. With one detail that bites: you have to ask for it in each subagent too, because each subagent assembles its own stack. All that remains from the previous stage are archaeological leftovers, such as the state key that is still on the list of keys excluded when passing context to a subagent.&lt;/p>
&lt;p>The plan, today, rests on files. Which is consistent with the rest of the design, and is another reason to look closely at where those files live.&lt;/p>
&lt;h2 id="where-state-lives">Where state lives&lt;/h2>
&lt;p>This is the first architecture decision and the one that is most expensive to get wrong.&lt;/p>
&lt;p>The agent&amp;rsquo;s state has two large channels. Messages, with a reducer of its own that deduplicates by identifier and treats deletions as tombstones. And files, with its own reducer where a null value means deleted. Both use a channel type that writes deltas and stores a full snapshot every fifty updates, and the code comment explains why: to take checkpoint growth from quadratic to linear with respect to the number of messages.&lt;/p>
&lt;p>That channel type is marked beta in its own code, with a warning that the on-disk representation may change. Worth knowing before you rest a platform on top of it.&lt;/p>
&lt;h3 id="the-backends">The backends&lt;/h3>
&lt;p>There is a common interface and several implementations. The ones that matter for deciding:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Backend&lt;/th>
&lt;th>Where the files live&lt;/th>
&lt;th>Persistence&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>State&lt;/td>
&lt;td>Inside the graph state, that is, inside the checkpoint&lt;/td>
&lt;td>Per thread, in the checkpointer&amp;rsquo;s database&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Filesystem&lt;/td>
&lt;td>In a real directory anchored to a root&lt;/td>
&lt;td>Wherever you mount the volume&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Store&lt;/td>
&lt;td>In LangGraph&amp;rsquo;s store, with a namespace&lt;/td>
&lt;td>Crosses threads, with a time to live&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Composite&lt;/td>
&lt;td>Routed by path prefix among the above&lt;/td>
&lt;td>Mixed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default backend is the state one. That is: &lt;strong>by default, everything the agent writes ends up inside the checkpoint&lt;/strong>, and binaries go base64-encoded inside the same JSON. For a demo it is convenient, because you have nothing to set up. For a platform with real users it is a sizing decision taken by omission.&lt;/p>
&lt;p>There is a detail that rounds the matter off. The filesystem middleware automatically evicts large tool results to a file when they exceed a token threshold, so that they do not occupy the context window. It is a good mechanism. But with the state backend, evicting to a file means moving the content from one place in the checkpoint to another place in the same checkpoint. It leaves the model&amp;rsquo;s window and it does not leave the database.&lt;/p>
&lt;h3 id="what-grows-in-the-database">What grows in the database&lt;/h3>
&lt;p>The Postgres checkpointer creates four tables: one for migrations, one for checkpoints, one for blobs by channel and version, and one for writes by task. The primary keys all start with the thread identifier, and there is an index per table on that column.&lt;/p>
&lt;p>What you have to internalise is the rhythm. One checkpoint row is written &lt;strong>per engine superstep&lt;/strong>, not per conversation turn. A turn with four tool calls is several supersteps. None of that is recycled on its own.&lt;/p>
&lt;p>And here comes the uncomfortable part. The checkpointer&amp;rsquo;s base class declares four maintenance operations: delete a thread, delete by run, copy a thread and prune with a strategy. In the Postgres implementation &lt;strong>only the first is implemented&lt;/strong>. The other three inherit the not-implemented exception from the base class. Deleting a thread is three delete statements by identifier, and that is it.&lt;/p>
&lt;p>In other words: retention is yours to build. And with a care that the code itself documents and that is worth quoting in full, because it is the kind of warning you read after the incident. A naive pruning that removes intermediate checkpoints and their writes can cut the delta chain; the surviving checkpoint is rarely a snapshot point, so its channels would rebuild empty &lt;strong>without any error being raised&lt;/strong>.&lt;/p>
&lt;p>Translated into operations: the periodic sweep deletes whole threads and only whole threads. Never loose rows. And since the checkpoints table has no date column, to know which thread is old you have to keep count outside or rely on the checkpoint identifier being a time-sortable UUID.&lt;/p>
&lt;p>There is a variant of the checkpointer that stores only the last state and retains no history, which caps growth at the root. With one important reservation to verify before using it: it is not clear that it is compatible with the delta channels this harness uses by default for messages and files. Combining the two without checking is asking for a silent problem.&lt;/p>
&lt;h3 id="the-store-which-does-have-cleanup">The store, which does have cleanup&lt;/h3>
&lt;p>Curiously, the sibling piece is better resolved. The store has a configurable time to live, with refresh on read, skipping of expired entries and a sweep interval, and the sweep is a delete by expiry date.&lt;/p>
&lt;p>Two warnings. The sweeper is a thread inside the process, so with several replicas there are several sweepers competing for the same delete; it is idempotent, but it is free contention and you avoid it by switching it off and putting the delete in a scheduled cluster job. And the store&amp;rsquo;s semantic search requires the vector extension in the database, which constrains the managed Postgres image, which normally does not carry it.&lt;/p>
&lt;h2 id="the-isolation-that-does-not-exist">The isolation that does not exist&lt;/h2>
&lt;p>If the platform is going to have more than one tenant, this is the section that decides the deployment topology.&lt;/p>
&lt;p>The only thing separating one thread from another is the thread identifier. It is the first column of the primary key in all three tables. &lt;strong>There is no owner check anywhere in the checkpointer.&lt;/strong> Whoever manages to send a thread identifier in the call configuration reads that thread. Authorisation is the responsibility of the layer you write on top, and since that layer also has to be written, it is worth noting down now.&lt;/p>
&lt;p>By backend, the situation differs:&lt;/p>
&lt;ul>
&lt;li>The state one inherits thread isolation, which is enough if the HTTP layer validates that the user owns the thread.&lt;/li>
&lt;li>The store one computes the namespace per call, through a function that receives the runtime context. It is the correct option for multi-tenant.&lt;/li>
&lt;li>The filesystem one fixes its root &lt;strong>in the constructor&lt;/strong>, when the agent is built. There is no path in the code that recomputes it per request, per thread or per tenant. Sharing one deployment between tenants with this backend means everybody sees the same directory.&lt;/li>
&lt;/ul>
&lt;p>On that last point, do not confuse it with the protection that does exist. Virtual mode, which now comes enabled by default, blocks paths that escape the root. That protects against directory traversal. It does not protect against the neighbour. The project&amp;rsquo;s own threat document admits it: that mode exists to support the composite backend&amp;rsquo;s prefix routing, not as a security boundary.&lt;/p>
&lt;p>There is also a documentation trap worth flagging because it costs an afternoon. The example the documentation proposes for computing the per-user namespace reads the identity from a server-information field of the runtime context. That field is annotated in the LangGraph code as metadata injected by the managed server, and &lt;strong>null when running open-source LangGraph without managed deployments&lt;/strong>. On a cluster of your own with an HTTP layer of your own, that example fails. What you have to use is the context you fill in yourself, with the identifier that comes validated from the identity provider.&lt;/p>
&lt;p>And one more boundary that gets crossed without warning: the memory and skills middleware interpolate file content into the system prompt as is, without sanitising. If two tenants share a directory that serves as the source for that, the first writes instructions that the second&amp;rsquo;s agent executes. It is prompt injection through shared storage, and it is avoided with the same measure as everything above: do not share the filesystem backend between tenants.&lt;/p>
&lt;p>The topology conclusion is short. Real multi-tenant with files means &lt;strong>one deployment per tenant&lt;/strong>, with its namespace, its volume and its quota, or else the store backend with a namespace computed per request. There is no third way.&lt;/p>
&lt;h2 id="the-model-and-the-trap-you-need-to-know-about">The model, and the trap you need to know about&lt;/h2>
&lt;p>The default model is a specific commercial provider, and it is marked deprecated: passing an empty model warns and will stop working. You can inject any LangChain chat model, which is what anyone serving their own weights will do.&lt;/p>
&lt;p>With two integration details that are not obvious.&lt;/p>
&lt;p>The first. To point at an OpenAI-compatible endpoint, which is how a vLLM behind a gateway looks, you have to pass &lt;strong>the already-built instance&lt;/strong>, not the string with the provider prefix. With the string, a provider profile is applied that forces the use of the responses API, which a vLLM normally does not implement. Passing the built object applies no profile and the agent stays clean.&lt;/p>
&lt;p>The second is the trap, and in my view it is the most likely configuration failure in the whole deployment.&lt;/p>
&lt;p>The compaction middleware computes its thresholds from the model profile. If the model declares its input window, it compacts on reaching 85 % of the window and keeps 10 %. If it does not declare it, it falls back to a fixed value: &lt;strong>170,000 tokens&lt;/strong>.&lt;/p>
&lt;p>The chain that leads to not declaring it is the usual one in a deployment of your own. The OpenAI client resolves the profile by looking the model name up in a static table of the provider&amp;rsquo;s models. A model served as &lt;code>qwen3-30b-a3b&lt;/code> is not in that table, so the profile comes out empty, and empty becomes null. The method that assigns it swallows any exception, so there is no warning.&lt;/p>
&lt;p>Result: a vLLM with a 32,768-token window will not compact until 170,000. The server will return a context-length error long before that, and it will do so intermittently, only when conversations get long. There is a safety net that catches the error and trims, but that is reactive recovery after a failed call, not planning.&lt;/p>
&lt;p>The fix is one line, because the profile is a public field and only fills itself in when it is empty: you pass it when building the model, with a value below the real window to leave room for generation. And it deserves an &lt;code>assert&lt;/code> at start-up, because it is the kind of thing nobody looks at until it fails.&lt;/p>
&lt;p>A related sizing note. With the default thresholds and a 32,768 window, compaction would trigger at 27,852 tokens, while eviction of large tool results is fixed at 20,000. That is, a single tool result can occupy almost the whole budget before anything evicts it. With 128,000 windows the defaults are proportionate; below 64,000 you have to lower those two thresholds by hand.&lt;/p>
&lt;h2 id="the-tools-and-the-mcp-that-is-not-where-it-seems">The tools, and the MCP that is not where it seems&lt;/h2>
&lt;p>This harness has its filesystem tools and its delegation tool, and the rest are passed as an ordinary list of LangChain tools.&lt;/p>
&lt;p>The fact that matters to anyone with an MCP gateway in place: &lt;strong>the SDK does not integrate MCP&lt;/strong>. There is no reference to the protocol in the package, nor any dependency on adapters. The integration lives in the command-line tool that accompanies the project, which does depend on the MCP adapters for LangChain, resolves configurations in the style of the desktop clients, and turns each remote tool into a LangChain tool that then goes through the ordinary list.&lt;/p>
&lt;p>So the path exists and is proven, but you have to walk it yourself: load the gateway&amp;rsquo;s tools with the adapters and pass them in the list. Which, incidentally, fits well with &lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">what we already know about the catalogue&lt;/a>: the credential-based filtering the gateway does is applied in the listing, so each agent receives the subset that belongs to it without the harness having to know anything.&lt;/p>
&lt;h3 id="the-subagents">The subagents&lt;/h3>
&lt;p>They are declared in three ways and invoked with a task tool that takes a description and a type. What you need to know to design with them:&lt;/p>
&lt;p>The subagent receives the parent&amp;rsquo;s state &lt;strong>except&lt;/strong> the messages and a few private keys, and it starts with a single human message which is the task description. Files &lt;strong>are&lt;/strong> inherited, and it shares the same backend object, so parent and child work on the same logical filesystem. The result comes back as a tool message with the structured response or the child&amp;rsquo;s last text.&lt;/p>
&lt;p>Depth is effectively one level. Verified by compiling the graph: the stack assembled for a declarative subagent does not include the subagent middleware, so the child does not have the task tool and cannot delegate in turn.&lt;/p>
&lt;p>And there is no concurrency limit of its own. The parallelism is that of the engine&amp;rsquo;s parallel tool calls, and the task tool&amp;rsquo;s prompt explicitly invites launching them in parallel. Anyone with a per-tenant GPU quota will want to put the brake where it belongs, because it is not here.&lt;/p>
&lt;h2 id="code-execution">Code execution&lt;/h2>
&lt;p>The sandbox protocol is well defined and the packaged implementations are nearly all paid services: four commercial providers, plus an embedded JavaScript interpreter and a sandbox from the platform vendor itself.&lt;/p>
&lt;p>That leaves a local shell backend that is worth looking at closely before considering it. It runs the command with the system shell, and its own docstring lists what it does not do: no isolation, no process separation, no resource limits, and it lists production and multi-tenant environments as inappropriate use cases. It also has an option to inherit the process environment which, enabled, hands the model every variable, including the gateway credentials and the database connection string. It comes disabled, and it is an easy foot to shoot.&lt;/p>
&lt;p>The reasonable way out on a cluster of your own is to implement the protocol yourself, and it is smaller than it looks. The sandbox base class requires four things: an identifier, execute, upload files and download files. Everything else, list, read, write, edit, delete and search, is built by the base class on top of execute. Looking at one of the commercial implementations, it is on the order of a hundred and fifty lines.&lt;/p>
&lt;p>One image requirement that constrains the Dockerfile and is worth knowing beforehand: the base class helpers inject small encoded Python scripts to resolve searches and checks, so the sandbox image &lt;strong>needs Python and a POSIX shell&lt;/strong> or half the calls fail.&lt;/p>
&lt;p>On how to implement it, the shape that fits a Kubernetes of your own is an ephemeral container per session with a bounded time to live, resource limits, no service account token mounted and a network policy that only allows egress towards the inference gateway. Execute is resolved against the cluster&amp;rsquo;s exec API, and upload and download through the same channel. The alternative with less code is an isolated runtime class for the agent pod, one pod per tenant, and the shell backend inside; you lose multi-tenancy in a single pod and you have nothing to write.&lt;/p>
&lt;h2 id="observability-and-the-package-it-goes-through">Observability, and the package it goes through&lt;/h2>
&lt;p>Here there is a fact that surprises and that is worth being clear about before designing the trace pipeline.&lt;/p>
&lt;p>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry.&lt;/strong> Zero references in the whole package. Its native tracer talks to the vendor&amp;rsquo;s observability service and that is that.&lt;/p>
&lt;p>The OTLP exporter does exist, but it lives inside that service&amp;rsquo;s SDK. And there is the good news: that SDK has a tracing mode that emits &lt;strong>only&lt;/strong> over OTLP, without talking to the service, reading the destination and headers from OpenTelemetry&amp;rsquo;s standard environment variables. The transport is HTTP with protobuf, which is exactly what a self-hosted Langfuse accepts on its ingestion route.&lt;/p>
&lt;p>That is: to take the agent&amp;rsquo;s traces to your own observability you have to install the vendor&amp;rsquo;s package and ask it not to talk to the vendor. It works, it is supported and it is documented, but it is a dependency you have to declare in the supply chain analysis, not a configuration detail. And if the OpenTelemetry packages are missing, it emits a warning and silently stops tracing, so start-up should check for it.&lt;/p>
&lt;p>There is the alternative of the callback handler that Langfuse itself publishes, with fewer pieces. The practical difference is correlation: over the OTLP route the agent&amp;rsquo;s spans share a trace identifier with the gateway&amp;rsquo;s and the inference engine&amp;rsquo;s, and you see a whole request end to end. Over the callback route, you do not. For anyone who already has a collector deployed, the OTLP route is the one that pays.&lt;/p>
&lt;h2 id="the-server-here-is-where-the-free-part-ends">The server: here is where the free part ends&lt;/h2>
&lt;p>Everything above is an architecture decision. This is a licence decision, and it is the one that gives the article its title.&lt;/p>
&lt;p>The harness SDK is MIT. The graph engine is MIT. The memory, SQLite and Postgres checkpointers are MIT. All of that runs anywhere, with no key, no registration and without calling anybody.&lt;/p>
&lt;p>The server that exposes a graph over HTTP is another matter. The package that implements it carries an &lt;strong>Elastic 2.0 licence&lt;/strong>, whose relevant clauses forbid offering the software to third parties as a managed service and forbid modifying or circumventing the licence key functionality. The command-line tool&amp;rsquo;s own command prints the conditions: for local development it asks for a service API key, and &lt;strong>for production use it asks for a licence key&lt;/strong> in an environment variable.&lt;/p>
&lt;p>There is also usage reporting. The server code contains a beacon endpoint to which it sends metadata, with a header carrying the licence key. The self-hosting documentation confirms it from the other side, listing among the requirements network egress to that domain for licence verification and usage reporting &lt;strong>if not running in isolated mode&lt;/strong>. Isolated mode is a contractual variant, not a box you tick.&lt;/p>
&lt;p>And it drags infrastructure along: besides Postgres, it asks for Redis.&lt;/p>
&lt;p>The development tool, which is the one that appears in every tutorial, describes itself as development mode with hot reload and an in-memory server. It is not a production server and it does not claim to be.&lt;/p>
&lt;p>None of this is a reproach. It is a legitimate and fairly common business model: permissive core, commercial control plane. What is not legitimate is finding out late. Anyone building a platform with a sovereignty requirement, with no dependencies on external services and with the argument that the whole stack is open, has to know that &lt;strong>the piece that turns the graph into a service does not meet that requirement&lt;/strong>.&lt;/p>
&lt;h2 id="what-you-have-to-build">What you have to build&lt;/h2>
&lt;p>The way out is to write the HTTP layer yourself, and the good news is that it is boring. A service that compiles the graph once with the checkpointer pointing at the database, and exposes an endpoint that validates the identity provider&amp;rsquo;s token, derives the thread identifier and the tenant context, and invokes the graph. Event streaming if needed.&lt;/p>
&lt;p>What you additionally have to write, and which the commercial server gave you ready-made:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Per-thread authorisation.&lt;/strong> The engine does not have it. It is the most important and the easiest to forget, because nothing fails if it is missing.&lt;/li>
&lt;li>&lt;strong>Retention and deletion.&lt;/strong> A scheduled job that deletes whole threads, never loose rows, for the reasons in the state section.&lt;/li>
&lt;li>&lt;strong>Migrations.&lt;/strong> Schema setup has to be called explicitly and needs data-definition permissions, so it goes in a separate job with a different role from the runtime one. And since it creates indexes concurrently, it cannot go through a connection pooler in transaction mode; point it at the direct write service.&lt;/li>
&lt;li>&lt;strong>Health probes and draining.&lt;/strong> The engine has a cooperative draining mechanism, but wiring it to the container&amp;rsquo;s termination signal is your own work.&lt;/li>
&lt;li>&lt;strong>Run queue and cancellation&lt;/strong>, if you need long background runs. This is where the absence is most felt, and the reasonable answer is a queue on the same database rather than another system.&lt;/li>
&lt;li>&lt;strong>Durability decided deliberately.&lt;/strong> There are three modes: persist before the next step, persist in parallel, or persist only on exit. The last one is no good on Kubernetes: if the pod dies, the whole turn is lost.&lt;/li>
&lt;/ul>
&lt;p>On that last point it is worth being explicit, because it affects tool design. The engine stores each task&amp;rsquo;s writes as soon as it finishes, and on resume it reapplies those that were already there and does not repeat them. A tool that finished is not run again. A tool &lt;strong>that was in flight when the pod died&lt;/strong> is, because its write never landed. The semantics are at-least-once, so every tool with an external effect has to be idempotent or carry an idempotency key. There is no exactly-once.&lt;/p>
&lt;p>And the same applies to interrupts for human confirmation: on resume, the node is &lt;strong>re-executed in full&lt;/strong>, so any side effect that sat before the interrupt inside the same node is repeated. The rule is that the interrupt goes first.&lt;/p>
&lt;h2 id="how-it-fits-with-activity-logging">How it fits with activity logging&lt;/h2>
&lt;p>Two things about this setup touch compliance head on, and both come out of earlier sections.&lt;/p>
&lt;p>The first is that the checkpoint is, de facto, a repository of personal data. It contains the whole conversation and, with the default backend, the files the agent has written. If the platform serves identifiable people, that database needs encryption, retention of its own and separate access control, exactly like the gateway&amp;rsquo;s spend table. The difference is that here there is no redaction switch that helps: the state is the state.&lt;/p>
&lt;p>The second is traceability. An agent that delegates to subagents and executes tools produces a chain of actions that you have to be able to reconstruct, and the only place where that chain is complete is the trace. Traces going out over OTLP to your own observability stops being a technical preference and becomes the logging mechanism. It is worth designing it that way from the start, and not adding it afterwards.&lt;/p>
&lt;p>As always in this series, the annex&amp;rsquo;s specific codes have to be checked against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls article&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the filesystem backend before the first demo, because the default one puts the content in the database.&lt;/li>
&lt;li>Pass the model profile by hand with the real window, and check it at start-up.&lt;/li>
&lt;li>Lower the eviction thresholds if the model&amp;rsquo;s window is below 64,000 tokens.&lt;/li>
&lt;li>Build the model as an instance, never as a string with a provider prefix, if there is an OpenAI-compatible endpoint behind it.&lt;/li>
&lt;li>Write per-thread authorisation before opening the service to more than one user.&lt;/li>
&lt;li>One deployment per tenant if you use the filesystem backend; a namespace computed per request if you use the store one.&lt;/li>
&lt;li>Compute that namespace from your own context, not from the server-information field, which is null outside the managed platform.&lt;/li>
&lt;li>A scheduled retention job that deletes whole threads and only whole threads.&lt;/li>
&lt;li>Schema migrations in a separate job, with a role of their own and against the direct write service.&lt;/li>
&lt;li>Synchronous durability, and tools with external effects made idempotent.&lt;/li>
&lt;li>Traces over OTLP with the mode that does not talk to the vendor&amp;rsquo;s service, and a start-up check that the packages are there.&lt;/li>
&lt;li>Put a concurrency limit on subagents if there is a GPU quota involved.&lt;/li>
&lt;li>If you need code execution, implement the sandbox protocol against an ephemeral container; the local shell backend is advised against by its own documentation.&lt;/li>
&lt;li>Note in the supply chain analysis the hard dependencies on commercial providers that the package drags in even when unused.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The planning tool was removed in 0.7.0.&lt;/strong> You have to add it by hand, and in each subagent too.&lt;/li>
&lt;li>&lt;strong>The default backend stores files inside the checkpoint&lt;/strong>, binaries included in base64.&lt;/li>
&lt;li>&lt;strong>Evicting a large result to a file does not get it out of the database&lt;/strong> with that backend, only out of the context window.&lt;/li>
&lt;li>&lt;strong>One checkpoint is written per superstep&lt;/strong>, not per turn.&lt;/li>
&lt;li>&lt;strong>Of the four declared cleanup operations, only delete-a-thread exists in Postgres.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A partial pruning can empty the history without raising any error.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The delta channel is in beta&lt;/strong> and its on-disk representation may change.&lt;/li>
&lt;li>&lt;strong>There is no owner check in the checkpointer.&lt;/strong> The thread identifier is not a credential.&lt;/li>
&lt;li>&lt;strong>The filesystem backend&amp;rsquo;s root is fixed when the agent is built&lt;/strong> and it separates no tenants.&lt;/li>
&lt;li>&lt;strong>The documentation&amp;rsquo;s example for the per-user namespace does not work outside the managed platform.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The filesystem middleware and the subagent middleware cannot be removed.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>If the model does not declare its window, compaction does not trigger until 170,000 tokens&lt;/strong>, silently.&lt;/li>
&lt;li>&lt;strong>Passing the model as a string with a provider prefix forces an API that a vLLM does not implement.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The SDK does not integrate MCP&lt;/strong>; the integration lives in the command-line tool.&lt;/li>
&lt;li>&lt;strong>Subagents do not delegate&lt;/strong>: depth is one level.&lt;/li>
&lt;li>&lt;strong>LangChain&amp;rsquo;s core does not emit OpenTelemetry&lt;/strong>; the exporter lives in the vendor&amp;rsquo;s service SDK.&lt;/li>
&lt;li>&lt;strong>The HTTP server carries an Elastic 2.0 licence&lt;/strong>, asks for a licence key for production and reports usage unless there is an isolation agreement.&lt;/li>
&lt;li>&lt;strong>The development tool is for development&lt;/strong>, with an in-memory server.&lt;/li>
&lt;li>&lt;strong>Resumption is at-least-once&lt;/strong> for in-flight tools.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The question this article started with was whether &lt;code>deepagents&lt;/code> fits into a platform of your own. The answer is that it does, and that it fits better than I expected, precisely because it is less than it appears. A middleware stack on top of a graph is easy to host, easy to understand and easy to replace. What does not fit is the piece next to it.&lt;/p>
&lt;p>That asymmetry is the pattern I have been seeing all year across the ecosystem, and it deserves naming. The core is published under a permissive licence because the core is where you compete for adoption. The control plane is published under a restrictive licence because the control plane is where you charge. It happened with the gateways, it is happening with observability, and it happens here. Anyone building a sovereign platform cannot assess a whole project by the licence of its main repository: you have to look package by package, and look at which of them is the one you are going to need the day the thing has users.&lt;/p>
&lt;p>The good part is that the bill, in this case, is paid in work and not in money. What you have to build to do without the commercial server is an HTTP service with no mystery to it, a retention job, a migration job and an authorisation layer. None of that is research. All of it is a week of an engineer who knows what they are doing, and in exchange you are left with a system that deploys the same way in a school as in a classified datacenter, with no internet egress and with nobody counting runs on the other side.&lt;/p>
&lt;p>What I do recommend is making that list before the first demo and not after. Because the front-page example works in twenty lines, and the twenty lines have the default backend, the model without a profile and no authorisation at all.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing an inference gateway&lt;/a> — the piece underneath, and the same licence pattern seen somewhere else.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — where the tools this harness consumes come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/">MCP tool priority&lt;/a> — how it is decided which subset of the catalogue each agent sees.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the real load a tool loop generates on the gateway and the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a> — the network isolation this article takes as necessary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of agents&lt;/a> — durable execution and what a loop that fails halfway costs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where logging and retention fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the destination of the OTLP traces the article talks about.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, código y documento de arquitectura: &lt;a href="https://github.com/langchain-ai/deepagents">https://github.com/langchain-ai/deepagents&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/deepagents&lt;/code>, registro de cambios de la rama del SDK (eliminación de la herramienta de planificación en la 0.7.0): &lt;a href="https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md">https://github.com/langchain-ai/deepagents/blob/main/libs/deepagents/CHANGELOG.md&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/langgraph&lt;/code>, motor, canales y checkpointers: &lt;a href="https://github.com/langchain-ai/langgraph">https://github.com/langchain-ai/langgraph&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint-postgres&lt;/code>, esquema de tablas y operaciones de mantenimiento: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres&lt;/a>.&lt;/li>
&lt;li>&lt;code>langgraph-checkpoint&lt;/code>, clase base del checkpointer y aviso sobre poda con canales de deltas: &lt;a href="https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint">https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint&lt;/a>.&lt;/li>
&lt;li>LangChain, despliegue de servidor autónomo (requisitos de licencia, Postgres, Redis y salida de red): &lt;a href="https://docs.langchain.com/langsmith/deploy-standalone-server">https://docs.langchain.com/langsmith/deploy-standalone-server&lt;/a>.&lt;/li>
&lt;li>LangChain, trazado con OpenTelemetry: &lt;a href="https://docs.langchain.com/langsmith/trace-with-opentelemetry">https://docs.langchain.com/langsmith/trace-with-opentelemetry&lt;/a>.&lt;/li>
&lt;li>Elastic License 2.0: &lt;a href="https://www.elastic.co/licensing/elastic-license">https://www.elastic.co/licensing/elastic-license&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración nativa OpenTelemetry (ruta de ingesta y autenticación): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, integración con LangChain por retrollamadas: &lt;a href="https://langfuse.com/integrations/frameworks/langchain">https://langfuse.com/integrations/frameworks/langchain&lt;/a>.&lt;/li>
&lt;li>&lt;code>langchain-ai/helm&lt;/code>, chart del servidor: &lt;a href="https://github.com/langchain-ai/helm">https://github.com/langchain-ai/helm&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Making the model prefer our tools: the priority that exists in no layer, the two primitives that do, and the `_meta` that crosses the gateway</title><link>https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/</link><pubDate>Tue, 15 Sep 2026 03:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mcp-tool-priority-visibility-and-exclusion/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>, which treated the tool catalogue as cost and as surface. This one treats the next question, the one that arrives in the meeting afterwards: so then, how do I get the model to use mine. Verified against LiteLLM 1.102.0 and MCP specification &lt;code>2026-07-28&lt;/code>, with a test of our own run on 14 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Tool priority does not exist as a concept in any layer of the stack.&lt;/strong> Not in the specification, not in the clients, not in the providers&amp;rsquo; APIs. What exist are two distinct primitives: removing the alternative from the catalogue, and leaving it behind a search step. Everything else is persuasion, and the model ignores it when it suits.&lt;/p>
&lt;p>&lt;strong>The specification has no ranking field.&lt;/strong> The &lt;code>Tool&lt;/code> object is name, title, icons, description, schemas, five annotations that are unreliable hints by their own declaration, and &lt;code>_meta&lt;/code>. There is a trap I see repeated: MCP does define a numeric &lt;code>priority&lt;/code>, but in another structure, the one that applies to content blocks, resources and prompts. It does not govern tool selection.&lt;/p>
&lt;p>&lt;strong>The only filtering the specification blesses is by credential.&lt;/strong> The current revision says the set of tools must not vary by connection, and that it may vary according to the authorisation presented. That is exactly what a gateway with per-key lists does.&lt;/p>
&lt;p>&lt;strong>The client is the one that decides, and only one of them has anything resembling priority.&lt;/strong> Claude Code lets you exempt a server from deferral with &lt;code>alwaysLoad&lt;/code>, so that its tools load in full at start-up while the rest require a prior search. It is not called priority and it works as such.&lt;/p>
&lt;p>&lt;strong>We proved that the per-tool mark crosses the gateway.&lt;/strong> We set up an MCP server with one marked tool, served it through LiteLLM 1.102.0 and captured the wire: the &lt;code>_meta&lt;/code> arrives whole and with the correct key. The chain is clean on purpose, with a comment in the code that says so.&lt;/p>
&lt;p>&lt;strong>And it is lost on exactly three routes, which are the gateway&amp;rsquo;s three catalogue-trimming modes.&lt;/strong> Proxy mode, tool search driven by key permissions, and the REST listing. In other words: the per-tool mark and LiteLLM&amp;rsquo;s catalogue trimming are incompatible. You have to choose one.&lt;/p>
&lt;p>&lt;strong>There is a library trap that makes the field disappear silently.&lt;/strong> The SDK&amp;rsquo;s &lt;code>Tool&lt;/code> model declares the alias without allowing population by field name, so building it with the field name puts the dictionary in the extras and serialises it under the wrong key. There is no error. The field simply does not arrive.&lt;/p>
&lt;p>&lt;strong>The cheap lever is the description, not the name.&lt;/strong> Swapping descriptions shifts the selection distribution substantially; changing only the name has minimal and inconsistent effects. And the gateway&amp;rsquo;s overrides really do replace what is sent to the client.&lt;/p>
&lt;p>&lt;strong>Reordering the catalogue is a weak lever.&lt;/strong> The model is already attending to the correct tool eighty per cent of the time when it fails. Interventions on prompt order repair at most 23 % of the failures.&lt;/p>
&lt;p>&lt;strong>And the asymmetry you build is attack surface.&lt;/strong> Whoever controls the order controls the selection: with injection rates of 1.2 % you can take over the head of a tool ranking between 91 % and 97 % of the time.&lt;/p>
&lt;h2 id="you-are-here-the-question-that-arrives-after-the-third-server">You are here: the question that arrives after the third server&lt;/h2>
&lt;p>The previous article closed with a decision ladder for the catalogue: trim, rewrite, split by route, and use progressive disclosure when the catalogue grows. That ladder answers how much catalogue to show.&lt;/p>
&lt;p>The question that arrives afterwards is a different one, and it is more uncomfortable. A real agent has native tools from the client hosting it, it has the in-house gateway with the house servers, and it has two or three third-party servers that somebody connected. All of them compete. And what you want is not to show less catalogue: you want that, faced with a query three tools could serve, yours wins. The audited one, the one with access control in place, the one that leaves a trace.&lt;/p>
&lt;p>The answer everybody gives is to write a rule in the agent&amp;rsquo;s instruction file politely asking it to prefer the in-house server. There is even a rule published in a template marketplace that does exactly that. It is text in the prompt and the model follows it when it feels like it.&lt;/p>
&lt;p>This article is what lies underneath.&lt;/p>
&lt;h2 id="the-analogy-the-counter-with-two-trays">The analogy: the counter with two trays&lt;/h2>
&lt;p>Let us go back to the switchboard operator from the previous article, the one who also hands out keys. Now the building has three key providers: the house cabinet, the maintenance contractor&amp;rsquo;s, and the cleaning service&amp;rsquo;s. All three have a key to the store room and all three open it.&lt;/p>
&lt;p>The head of security wants the house one used, because it is the one with a log. And he discovers he can do three things, not one more.&lt;/p>
&lt;p>He can take the other two off the counter. It always works and it annoys whoever needed them.&lt;/p>
&lt;p>He can leave his own in the front tray and the other two in a drawer, so that to take one you have to ask. It works almost always and annoys nobody.&lt;/p>
&lt;p>And he can change the label on his key so that it describes better when it is the right one. It works sometimes, it is the cheapest, and it is the only one that also protects against the contractor changing his label without warning.&lt;/p>
&lt;p>What he cannot do is put a number from one to ten on each key and trust the operator to respect it. That number does not exist. The rest of the article is why it does not exist and what to do instead.&lt;/p>
&lt;h2 id="what-the-specification-says-nothing">What the specification says: nothing&lt;/h2>
&lt;p>The current revision is &lt;code>2026-07-28&lt;/code>. Read against the source schema, the &lt;code>Tool&lt;/code> object has name, title, icons, description, input schema, output schema, annotations and &lt;code>_meta&lt;/code>. There is no field for priority, weight, rank, cost, group or tag.&lt;/p>
&lt;p>The annotations are exactly five: title, and the read-only, destructive, idempotent and open-world hints. The schema itself warns that all of them are hints and that a client should not make tool-use decisions based on them when they come from untrusted servers.&lt;/p>
&lt;h3 id="the-priority-that-exists-and-is-no-use">The &lt;code>priority&lt;/code> that exists and is no use&lt;/h3>
&lt;p>Here is the mistake I see repeated most. MCP &lt;strong>does&lt;/strong> define a &lt;code>priority&lt;/code> field, numeric between zero and one. It is in the annotations interface that applies to content blocks, to resources and to prompts, alongside the audience and the last-modified date.&lt;/p>
&lt;p>It is not in &lt;code>Tool&lt;/code>. It has no relation whatsoever to which tool the model picks. Anyone who finds it by searching the word in the schema and concludes that tool priority is standardised has the wrong structure.&lt;/p>
&lt;p>The only precedence the specification defines for a tool is one of presentation: to display the name, first the title, then the annotations title, then the name.&lt;/p>
&lt;h3 id="the-only-blessed-filtering-is-by-credential">The only blessed filtering is by credential&lt;/h3>
&lt;p>There is a new sentence in the current revision that is genuinely useful and that goes unnoticed. The set of tools must not vary by connection nor as a side effect of other requests, and it may vary according to the authorisation presented in the request.&lt;/p>
&lt;p>Translated: segmenting the catalogue by key, by team or by token is the legitimate way to do it. Segmenting it by session state stopped being legitimate when the July revision removed protocol sessions.&lt;/p>
&lt;h3 id="discovery-does-not-discover-tools">Discovery does not discover tools&lt;/h3>
&lt;p>&lt;code>server/discover&lt;/code>, which the current revision adds and which servers have to implement, does not return tools. It returns supported versions, capabilities, server instructions, identity and the cache freshness fields. It accepts neither filters nor a cursor.&lt;/p>
&lt;p>Listing is still asking for everything and paginating. The listing request accepts only a cursor: there is no query, no filter, no limit, no groups, no tags.&lt;/p>
&lt;p>And on collisions between servers, the specification explicitly and reasonedly washes its hands: clients or proxies that aggregate tools from several servers will encounter collisions and should implement a disambiguation strategy, for example prefixing. And it adds that the server name is not guaranteed to be unique and should not be used to disambiguate. There is no precedence rule between servers. There is not going to be one soon.&lt;/p>
&lt;h3 id="what-there-is-in-proposals">What there is in proposals&lt;/h3>
&lt;p>Nothing accepted. The official proposals index only lists those in a final state, and none of them deals with priority, groups or search. In draft there is one asking for a free-text query in the listing and a filtering capability; another on groups and tags that ended up superseded; and one carrying the word priority but which is per-tool model routing, which is another thing.&lt;/p>
&lt;p>The interest group exploring the grouping of primitives declares in its own minutes that it is not going to pick a canonical pattern soon, and the documents in its repository are empty. The year&amp;rsquo;s roadmap does not mention the problem.&lt;/p>
&lt;h2 id="the-layer-that-decides-the-client">The layer that decides: the client&lt;/h2>
&lt;p>If the specification gives you nothing, what is left is whatever each client has built on its own. And here there is a clear winner.&lt;/p>
&lt;h3 id="claude-code">Claude Code&lt;/h3>
&lt;p>Tool search is on by default, and what it does is defer: MCP tools are not loaded in full at start-up, the model sees just enough and discovers them when it searches. It requires a model that supports tool reference blocks, that is Claude Sonnet 4.5, Haiku 4.5, Opus 4.5 and later.&lt;/p>
&lt;p>On top of that, the lever:&lt;/p>
&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;mcpServers&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="nt">&amp;#34;gateway-casa&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="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;http&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;url&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;https://litellm.interna.svc/mcp&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;alwaysLoad&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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;p>The documentation describes it bluntly: if a server&amp;rsquo;s tools must always be visible without a search step, you set &lt;code>alwaysLoad&lt;/code> to true and all of that server&amp;rsquo;s tools are loaded into context at the start of the session, regardless of the global search setting. And it recommends using it for a small number of tools that the model needs on every turn, because each tool loaded up front consumes context.&lt;/p>
&lt;p>That is visibility asymmetry, and it works as priority even if it is not called that. Ours in front, the rest behind one step.&lt;/p>
&lt;p>There is an operational consequence that the documentation mentions and that matters more in a gateway than in an ordinary server: setting &lt;code>alwaysLoad&lt;/code> makes start-up &lt;strong>wait&lt;/strong> for that server to return its tools, capped by the standard five-second connection timeout. A gateway that aggregates eight upstream servers and does not cache the listing, as is the case, lists live against all eight every time. If one of them is slow, the session&amp;rsquo;s start-up feels it.&lt;/p>
&lt;p>There is also the fine-grained variant. An MCP server can mark individual tools as always loaded by including &lt;code>&amp;quot;anthropic/alwaysLoad&amp;quot;: true&lt;/code> in the tool&amp;rsquo;s &lt;code>_meta&lt;/code> object, with the same effect for that tool alone. That would let you load three tools from our gateway up front and leave the other forty from the same gateway deferred. It is the lever that matters, and it is the one that motivated the test in the next section.&lt;/p>
&lt;p>The rest of this client&amp;rsquo;s arsenal is pure exclusion, and it is worth knowing one detail that decides whether it works or not. A deny rule with the bare tool name &lt;strong>removes it from the model&amp;rsquo;s context&lt;/strong>; with parentheses, it does not. The documentation puts it in a table: plain &lt;code>WebFetch&lt;/code> removes the tool and the model cannot search at all, whereas &lt;code>WebFetch(domain:*)&lt;/code> keeps the tool and rejects every request. The difference is not cosmetic: in the second case the model still sees the tool, still tries it and still spends the context its definition occupies.&lt;/p>
&lt;p>The &lt;code>--tools&lt;/code> flag is the scalpel: it accepts the empty string to disable all the built-ins, and it does not affect MCP tools. That is, &lt;code>--tools &amp;quot;&amp;quot;&lt;/code> leaves the model with only what the gateway serves. It is a command-line flag and has no equivalent as a settings-file key; the functional equivalent there is deny rules.&lt;/p>
&lt;h3 id="the-other-clients">The other clients&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Client&lt;/th>
&lt;th>Disable the native ones&lt;/th>
&lt;th>Filter the MCP ones&lt;/th>
&lt;th>Priority&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude Code&lt;/td>
&lt;td>&lt;code>--tools &amp;quot;&amp;quot;&lt;/code>, deny by bare name&lt;/td>
&lt;td>deny by pattern, subagents&lt;/td>
&lt;td>&lt;code>alwaysLoad&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Codex CLI&lt;/td>
&lt;td>&lt;code>features.shell_tool&lt;/code> to false&lt;/td>
&lt;td>&lt;code>enabled_tools&lt;/code> and &lt;code>disabled_tools&lt;/code> per server&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gemini CLI&lt;/td>
&lt;td>&lt;code>tools.core&lt;/code> as an allowlist&lt;/td>
&lt;td>&lt;code>includeTools&lt;/code> and &lt;code>excludeTools&lt;/code> per server&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Copilot in VS Code&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>picker and tool sets&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cursor&lt;/td>
&lt;td>No&lt;/td>
&lt;td>per-server switches&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two notes. Copilot cuts off at 128 tools per request and its virtualisation threshold does not go above that; its embedding-based routing is internal and has no configuration surface. And Cursor&amp;rsquo;s rules are purely indicative: they are text prepended to the context.&lt;/p>
&lt;h2 id="the-api-layer">The API layer&lt;/h2>
&lt;p>If the client is in-house, the margin is wider, though narrower than it looks.&lt;/p>
&lt;p>&lt;strong>Forcing a set is not possible.&lt;/strong> In Anthropic&amp;rsquo;s case, tool choice accepts auto, any, a specific one by name, or none, and the documentation says explicitly that pointing at a set of MCP tools or at a member is not supported. In vLLM it is the same: a specific one, all of them or none. There is no way to say &amp;ldquo;any of mine from the gateway&amp;rdquo;. The proposal that would introduce grammars restricting tool names to the request&amp;rsquo;s set has been open for months and is not merged.&lt;/p>
&lt;p>&lt;strong>Filtering the catalogue is possible, in all three of the big ones.&lt;/strong> In Anthropic, with an MCP tool-set block where the default configuration disables and specific ones are enabled. In OpenAI, with the allowed tools list inside the MCP block itself, which the documentation justifies on latency and cost grounds, avoiding the model seeing unnecessary definitions. And in Gemini, the remote MCP server configuration accepts restricting which of the server&amp;rsquo;s tools the agent may call.&lt;/p>
&lt;p>It is worth not confusing two things that are named almost identically in OpenAI: the allowed tools list &lt;strong>inside&lt;/strong> the MCP block filters the catalogue, and that is what matters here; the identically named tool-choice type is another thing, it is a selection restriction, and there is evidence that it does not combine well with hosted tools, without the current documentation recording it. If anyone depends on that, let them test it against their model: it fails as a request error, so it shows up on a cold start.&lt;/p>
&lt;p>&lt;strong>And there is a 2026 lever that almost nobody is using.&lt;/strong> Anthropic has in beta a mechanism for mid-conversation tool changes, with add and remove blocks that accept referencing an entire MCP set. The reason it exists is in its documentation: the tools array sits even earlier in the chunked prefix than the system field, so editing it invalidates the cache for the whole conversation; by declaring the full set at the start and using the blocks, the array never changes and the cached prefix stays intact.&lt;/p>
&lt;p>Applied to our case: you can remove a rival server&amp;rsquo;s set hot, mid-session, without paying for the whole prefix. It is not available on every model.&lt;/p>
&lt;h2 id="the-test-the-_meta-crosses-the-gateway">The test: the &lt;code>_meta&lt;/code> crosses the gateway&lt;/h2>
&lt;p>The fine-grained variant of Claude Code&amp;rsquo;s lever depends on a question no documentation answers: whether a gateway that aggregates upstream servers propagates each tool&amp;rsquo;s &lt;code>_meta&lt;/code> all the way to the client, or loses it along the way.&lt;/p>
&lt;p>We checked.&lt;/p>
&lt;h3 id="the-setup">The setup&lt;/h3>
&lt;p>A minimal MCP server with the Python SDK, serving two tools over HTTP, one of them with the mark in place:&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="n">types&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Tool&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &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;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;always_loaded_tool&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;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool with _meta&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;inputSchema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;properties&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="s2">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;anthropic/alwaysLoad&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;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;UPSTREAM_META_MARKER&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;/code>&lt;/pre>&lt;/div>&lt;p>In front of it, a LiteLLM from the main branch with that server declared in &lt;code>config.yaml&lt;/code>, with no database, with a master key. And an MCP client querying the listing against &lt;code>/mcp&lt;/code>.&lt;/p>
&lt;h3 id="the-result">The result&lt;/h3>
&lt;p>This is what comes out on the wire:&lt;/p>
&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;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;litellm.ai/server_outcomes&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;status&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_count&amp;#34;&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="nt">&amp;#34;tools&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;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;probe-always_loaded_tool&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;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool with _meta&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;inputSchema&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;properties&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="nt">&amp;#34;_meta&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;anthropic/alwaysLoad&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="nt">&amp;#34;probe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;UPSTREAM_META_MARKER&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;/code>&lt;/pre>&lt;/div>&lt;p>The mark arrives whole and with the correct key. The client parses it without trouble.&lt;/p>
&lt;p>Tracing the code, the chain is clean on purpose. LiteLLM keeps the SDK objects as they are when it lists against the upstream, instead of rebuilding them. The name prefixing does a deep copy and mutates only the name, with a comment in the code declaring the intention to preserve every field including the &lt;code>_meta&lt;/code> by avoiding mutation. The two permission filters are list comprehensions that reuse the same objects. And the name and description overrides mutate in place, so they do not lose anything either. Somebody thought about this.&lt;/p>
&lt;p>One operational detail: the name arrives prefixed with the server alias, governed by the configurable separator and the short prefix mode.&lt;/p>
&lt;h3 id="the-three-routes-where-it-is-lost">The three routes where it is lost&lt;/h3>
&lt;p>And here is the finding that changes the previous article&amp;rsquo;s recommendation.&lt;/p>
&lt;p>Proxy mode returns only its three fixed tools, with no &lt;code>_meta&lt;/code> and with no upstream tool at all. Verified against the wire as well.&lt;/p>
&lt;p>Tool search activated by key permissions does the same with four virtual tools.&lt;/p>
&lt;p>And the REST listing builds its response object by hand and discards the field: the tool that did carry a mark comes out with the field null. The class inherits from the type that has the field; it simply is not filled in. The fix fits on one line.&lt;/p>
&lt;p>Those three routes are, exactly, the three ways LiteLLM has of trimming the catalogue. The operational conclusion is uncomfortable and it is worth saying plainly: &lt;strong>either you use the gateway&amp;rsquo;s catalogue trimming, or you use the client&amp;rsquo;s per-tool mark. They cannot be combined.&lt;/strong> The previous article recommended proxy mode as the fifth rung of the ladder; with this in hand, that rung and the fine-grained mark are mutually exclusive.&lt;/p>
&lt;h3 id="the-library-trap">The library trap&lt;/h3>
&lt;p>This deserves its own section because it takes out anyone writing a server or a proxy, and it gives no warning.&lt;/p>
&lt;p>The SDK&amp;rsquo;s tool model declares the field with an alias, and does not enable populating it by name. The consequences, both checked by running them:&lt;/p>
&lt;p>Building with the field name &lt;strong>populates nothing&lt;/strong>. The dictionary slips into the model&amp;rsquo;s extras and comes out serialised under the wrong key, without the leading underscore, which is a key no client interprets.&lt;/p>
&lt;p>And dumping and revalidating without asking for aliases &lt;strong>loses the field&lt;/strong>, for the same reason.&lt;/p>
&lt;p>There is no exception, no warning in the log, nothing. The field disappears. If someone marks their tools and does not see them marked in the client, this is the first place to look, before the gateway.&lt;/p>
&lt;h3 id="what-we-have-not-tested">What we have not tested&lt;/h3>
&lt;p>For honesty&amp;rsquo;s sake, and because it is the link that remains: we have shown that the mark survives the gateway. We have not shown that the client acts on it when the tool arrives with the name prefixed by the gateway. Claude Code&amp;rsquo;s documentation describes the per-tool mark in a single sentence and says nothing about aggregating servers or about prefixes.&lt;/p>
&lt;p>It is a half-hour test for anyone who has the setup in front of them: two tools from the same gateway, one marked, and look at which one appears loaded at the start of the turn. If someone does it before I do, I am interested in the result.&lt;/p>
&lt;h2 id="what-litellm-can-and-cannot-do">What LiteLLM can and cannot do&lt;/h2>
&lt;p>With the above, the gateway&amp;rsquo;s inventory of levers comes out like this.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Preserves the &lt;code>_meta&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>allowed_tools&lt;/code> and &lt;code>disallowed_tools&lt;/code> per server&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tool sets per route&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Proxy mode and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Search by key permissions&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>REST listing&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Not applicable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications on the previous article&amp;rsquo;s table, now that the code has been read again.&lt;/p>
&lt;p>The allowed and disallowed lists &lt;strong>are applied in the listing&lt;/strong>, not only on the call. They are the blunt, effective instrument, and they do not break the cache because they are static.&lt;/p>
&lt;p>And the overrides have a limit that was not accounted for: they are applied in the MCP protocol listing, but they are &lt;strong>skipped in proxy mode and not applied on the REST route&lt;/strong>. Anyone rewriting descriptions and also turning on proxy mode is not serving what they think.&lt;/p>
&lt;p>What the gateway cannot do is inject &lt;code>_meta&lt;/code>. There is no setting equivalent to the ones for name and description. The mark has to be put there by the origin server, which for in-house servers is trivial because we write them. To mark third-party tools you would have to patch, and it is small: one field in the type and three lines in the overrides function if configuring it by file is enough.&lt;/p>
&lt;p>There is also an ordering lever that did not appear in the previous article. In the proxy&amp;rsquo;s search mode there is a core tools setting that puts them at the front of the ranking and that also does not count against the results cap. It is the closest thing to a declarative priority in the whole stack, and it lives inside the one mode that discards the &lt;code>_meta&lt;/code>.&lt;/p>
&lt;p>And a nuance about proxy mode&amp;rsquo;s results cap. The client cannot ask for more than five, that is true, but the operator can raise it by configuration. The previous article implied it was immovable.&lt;/p>
&lt;h2 id="the-cheap-lever-the-description-not-the-name">The cheap lever: the description, not the name&lt;/h2>
&lt;p>If you cannot exclude, what is left is to bias. And bias comes in through the description.&lt;/p>
&lt;p>The work that measures selection bias between functionally equivalent tools puts the combined bias of the evaluated models between 0.25 and 0.38, that is, you would have to redistribute between 25 % and 38 % of the probability mass for equivalent tools to be picked equally. On that basis, swapping two tools&amp;rsquo; descriptions shifts selection substantially, whereas perturbations that touch only the name produce smaller and more inconsistent effects.&lt;/p>
&lt;p>Another piece of work measures that a single pass of automatic description rewriting improves the metric over a large corpus, almost as much as iterative refinement, and with two orders of magnitude less time. And it adds a warning worth retaining: descriptions tuned against a fixed set of candidates do not generalise to the dynamically retrieved set. You have to optimise for the regime you serve in.&lt;/p>
&lt;p>Applied: the gateway&amp;rsquo;s description override is at once token trimming, selection bias and mitigation of description change after approval. It is the lever with the best effort-to-effect ratio in the whole article, and it is static, so it does not touch the prompt cache.&lt;/p>
&lt;p>Name prefixing, by contrast, costs tokens and probably does not change which tool the model goes to.&lt;/p>
&lt;h2 id="what-does-not-work">What does not work&lt;/h2>
&lt;p>&lt;strong>Reordering the catalogue.&lt;/strong> A June paper measures that, when the model fails, it was already attending to the correct tool eighty per cent of the time, well above chance. The bottleneck is not at the input but in the late layers. Interventions on prompt order recover at most 23 % of the failures, against the 59 % to 91 % of those acting on the final readout. Reordering is cheap and that is why it gets recommended a lot; it is also weak.&lt;/p>
&lt;p>&lt;strong>Rules and instructions.&lt;/strong> They are text. They help and they do not decide.&lt;/p>
&lt;p>&lt;strong>Your own per-request top-K filters.&lt;/strong> This is covered in the previous article and it still holds: four out of five measured strategies perform worse than not filtering, and on top of that rewriting the tool block on every turn invalidates the entire cached prefix.&lt;/p>
&lt;h2 id="the-ladder-updated">The ladder, updated&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Exclude in the client.&lt;/strong> It is the only deterministic thing. &lt;code>--tools &amp;quot;&amp;quot;&lt;/code> or deny by bare name for the competing native tools.&lt;/li>
&lt;li>&lt;strong>Mark your own as always loaded.&lt;/strong> At server level with &lt;code>alwaysLoad&lt;/code>, or per tool with the mark in the &lt;code>_meta&lt;/code> if you control the origin server. Counting on start-up waiting for the server.&lt;/li>
&lt;li>&lt;strong>Trim with static lists&lt;/strong> per server and per key, and split a catalogue by agent profile across routes.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the gateway&amp;rsquo;s overrides.&lt;/li>
&lt;li>&lt;strong>Remove hot&lt;/strong> the rival sets, if the client is in-house and the model supports it.&lt;/li>
&lt;li>&lt;strong>Choose&lt;/strong>: the gateway&amp;rsquo;s catalogue trimming, or the per-tool mark. Not both.&lt;/li>
&lt;li>&lt;strong>Do not&lt;/strong>: reorder, rename, or trust prompt rules.&lt;/li>
&lt;/ol>
&lt;h2 id="the-risk-that-has-to-be-declared">The risk that has to be declared&lt;/h2>
&lt;p>The asymmetry you build is also a surface. Whoever controls which tools head a ranking controls the selection, and that can be attacked: there is work measuring that by injecting adversarial tools at rates of 1.2 % you take over the head of the ranking between 91 % and 97 % of the time.&lt;/p>
&lt;p>In a system under ENS (Esquema Nacional de Seguridad, Spain&amp;rsquo;s national security framework) this fits under change management, not access control: the set of tools a model sees and the order in which it sees them are security configuration, and today nobody versions them. Neither does the specification fix a hash of the description or of the schema, nor does the gateway. The specific Annex II codes are worth checking against the current text before taking them into a compliance document; the approach is developed in &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway article&lt;/a>.&lt;/p>
&lt;p>There is a precedent within the ecosystem itself that points the way. The MCP skills extension, which is in a final state, requires a manifest with a per-file hash, mandatory verification before use, and approval tied to the set of files and their digests, so that any change revokes the approval. And it obliges hosts to prevent two skills with the same name from silently replacing one another. It is exactly the pattern missing for tools. Anyone who needs it today has to implement it in their gateway.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Decide the strategy before touching anything: catalogue trimming in the gateway, or the per-tool mark in the client. They are mutually exclusive.&lt;/li>
&lt;li>If you pick the mark, put it in the origin server, because the gateway cannot inject it.&lt;/li>
&lt;li>Build the tool object with the alias key, never with the field name, or the field disappears without warning.&lt;/li>
&lt;li>Do not revalidate tool objects from a dump without asking for aliases.&lt;/li>
&lt;li>Count on marking a server as always loaded making session start-up wait for that server, and on a gateway listing live against all of its upstreams.&lt;/li>
&lt;li>Exclude in the client the native tools that compete, by bare name and not with a pattern in parentheses.&lt;/li>
&lt;li>Rewrite the descriptions of your own tools, and do not waste time renaming.&lt;/li>
&lt;li>Do not turn on proxy mode if you depend on the description overrides, because they are not applied there.&lt;/li>
&lt;li>Version the exposed tool set and its order as security configuration, with a hash of the description and the schema.&lt;/li>
&lt;li>If you use the gateway&amp;rsquo;s REST route for anything, know that it discards the &lt;code>_meta&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP&amp;rsquo;s &lt;code>priority&lt;/code> exists, and it is not about tools.&lt;/strong> It lives in the annotations for content, resources and prompts.&lt;/li>
&lt;li>&lt;strong>Tool annotations are hints and the schema itself says they are not reliable&lt;/strong> from untrusted servers.&lt;/li>
&lt;li>&lt;strong>The specification gives no precedence rule between servers&lt;/strong>, and it says besides that the server name is no use for disambiguating.&lt;/li>
&lt;li>&lt;strong>The tool set may indeed vary by authorisation&lt;/strong>, and that is the only blessed segmentation.&lt;/li>
&lt;li>&lt;strong>&lt;code>server/discover&lt;/code> does not return tools.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>A deny rule with parentheses does not remove the tool from the context&lt;/strong>, it only rejects the calls.&lt;/li>
&lt;li>&lt;strong>&lt;code>--tools&lt;/code> is a command-line flag&lt;/strong>, with no equivalent in the settings file.&lt;/li>
&lt;li>&lt;strong>Marking a server as always loaded delays start-up&lt;/strong> by up to five seconds per server.&lt;/li>
&lt;li>&lt;strong>&lt;code>Tool(meta=...)&lt;/code> does not populate the field&lt;/strong> and serialises it under the wrong key.&lt;/li>
&lt;li>&lt;strong>Proxy mode, search by permissions and the REST listing discard the &lt;code>_meta&lt;/code>.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Description overrides are skipped in proxy mode&lt;/strong> and are not applied on REST.&lt;/li>
&lt;li>&lt;strong>Proxy mode&amp;rsquo;s cap of five results can be raised by the operator&lt;/strong>, even though the client cannot ask for more.&lt;/li>
&lt;li>&lt;strong>The core tools of the proxy&amp;rsquo;s ranking are the only declarative priority in the stack&lt;/strong>, and they live in the mode that loses the &lt;code>_meta&lt;/code>.&lt;/li>
&lt;li>&lt;strong>You cannot force &amp;ldquo;any of mine from the gateway&amp;rdquo;&lt;/strong> in any API.&lt;/li>
&lt;li>&lt;strong>In OpenAI, the allowed list inside the MCP block and the identically named tool-choice type are different things.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>Gemini does accept a per-tool allowlist&lt;/strong> on its remote MCP servers.&lt;/li>
&lt;li>&lt;strong>Reordering the catalogue repairs at most 23 % of the failures.&lt;/strong>&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The starting question had a trap in it, and the trap is the word. When someone asks how you prioritise an MCP server, they are assuming a dial exists somewhere. It does not, and I have spent the whole article showing the places where it is not.&lt;/p>
&lt;p>What there is instead is poorer and more manageable: you remove what competes, you leave your own in front, and you write the description better. Three things, none of them elegant, all three effective in that order.&lt;/p>
&lt;p>What does seem to me worth taking away is the shape of the problem. The tool catalogue a model sees is security configuration in the full sense, because it determines what the agent can do and which system it is going to talk to. And today it is managed like a list of connections: things get added, it is not versioned, it is not signed, and nobody detects a change. The ecosystem has already solved that problem once, for skills, with hashes and tied approval. For tools, not yet.&lt;/p>
&lt;p>In the meantime, the asymmetry has to be built by hand, server by server, and knowing that it rests on the &lt;code>_meta&lt;/code> of an object that a library can empty without saying anything.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a> — the article this one is a direct follow-up to, with the catalogue as cost and the ladder that gets corrected here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the separation by key and team that credential-based filtering rests on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — what it costs in proxy CPU to serialise the catalogue on every request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — why touching the start of the prompt is paid for so dearly.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the protected resource side and audience validation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for ENS, 42001 and the AI Act&lt;/a> — the framework where change management over the catalogue fits.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Model Context Protocol, herramientas en la revisión &lt;code>2026-07-28&lt;/code>: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/tools">https://modelcontextprotocol.io/specification/2026-07-28/server/tools&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;code>server/discover&lt;/code>: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/discover">https://modelcontextprotocol.io/specification/2026-07-28/server/discover&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, paginación: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination">https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, caché de listados: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching">https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, esquema fuente de la revisión: &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts">https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2026-07-28/schema.ts&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, extensión de habilidades: &lt;a href="https://modelcontextprotocol.io/extensions/skills/overview">https://modelcontextprotocol.io/extensions/skills/overview&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, grupo de interés sobre agrupación de primitivas: &lt;a href="https://modelcontextprotocol.io/community/interest-groups/primitive-grouping">https://modelcontextprotocol.io/community/interest-groups/primitive-grouping&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code y MCP (&lt;code>alwaysLoad&lt;/code>, búsqueda de herramientas): &lt;a href="https://code.claude.com/docs/en/mcp">https://code.claude.com/docs/en/mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code, referencia de línea de comandos (&lt;code>--tools&lt;/code>): &lt;a href="https://code.claude.com/docs/en/cli-reference">https://code.claude.com/docs/en/cli-reference&lt;/a>.&lt;/li>
&lt;li>Anthropic, Claude Code, permisos (denegación por nombre pelado): &lt;a href="https://code.claude.com/docs/en/permissions">https://code.claude.com/docs/en/permissions&lt;/a>.&lt;/li>
&lt;li>Anthropic, conector MCP y conjuntos de herramientas: &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector">https://platform.claude.com/docs/en/agents-and-tools/mcp-connector&lt;/a>.&lt;/li>
&lt;li>Anthropic, cambios de herramientas a mitad de conversación: &lt;a href="https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages">https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages&lt;/a>.&lt;/li>
&lt;li>Anthropic, búsqueda de herramientas: &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, MCP y conectores (lista de herramientas permitidas): &lt;a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp">https://developers.openai.com/api/docs/guides/tools-connectors-mcp&lt;/a>.&lt;/li>
&lt;li>OpenAI, búsqueda de herramientas y caché: &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>Google, llamada a funciones en la Interactions API: &lt;a href="https://ai.google.dev/gemini-api/docs/function-calling">https://ai.google.dev/gemini-api/docs/function-calling&lt;/a>.&lt;/li>
&lt;li>vLLM, llamada a herramientas y decodificación restringida: &lt;a href="https://docs.vllm.ai/en/latest/features/tool_calling.html">https://docs.vllm.ai/en/latest/features/tool_calling.html&lt;/a>.&lt;/li>
&lt;li>vLLM, propuesta de decodificación guiada por regiones y gramáticas de herramienta: &lt;a href="https://github.com/vllm-project/vllm/issues/39848">https://github.com/vllm-project/vllm/issues/39848&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código del gateway MCP: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>tool_search.py&lt;/code>, &lt;code>litellm/experimental_mcp_client/tools.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>SDK de Python de MCP, modelo &lt;code>Tool&lt;/code> y serialización por alias: &lt;a href="https://github.com/modelcontextprotocol/python-sdk">https://github.com/modelcontextprotocol/python-sdk&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307v2">https://arxiv.org/html/2510.00307v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>Looking Is Not Picking&lt;/em>, atención frente a lectura final y techo de las intervenciones sobre el prompt: &lt;a href="https://arxiv.org/html/2606.16364v1">https://arxiv.org/html/2606.16364v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>A Single Rewrite Suffices&lt;/em>, reescritura de descripciones y generalización al conjunto recuperado: &lt;a href="https://arxiv.org/html/2606.30775">https://arxiv.org/html/2606.30775&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Completing Keycloak for MCP: the protected resource no SDK hands you ready-made, and the enterprise extension that skips the whole flow</title><link>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</link><pubDate>Sat, 12 Sep 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/</guid><description>&lt;blockquote>
&lt;p>A follow-up to &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>, which flagged the gap: resource indicators are not supported and protected resource metadata belongs to the MCP server. This post tries to close it. Verified against revision 2026-07-28 of the specification, the official Python and TypeScript SDKs, Keycloak 26.7.3 and LiteLLM 1.102.0.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The specification splits three obligations and the authorisation server covers only one.&lt;/strong> The MCP server must publish its protected resource metadata. The client must send the resource indicator in both requests, and must do so even if the authorisation server does not support it. The MCP server must validate that the token was issued for it. Keycloak does not understand the indicator and considers the metadata someone else&amp;rsquo;s, so all three end up in the resource layer.&lt;/p>
&lt;p>&lt;strong>The Python SDK mounts the metadata on its own, but degraded.&lt;/strong> As soon as the resource server URL is configured, the route appears. What that automatic route publishes carries a single authorisation server, reuses as its scope catalogue the ones the middleware requires, and leaves the name and the documentation null. To publish a complete document you have to mount the route by hand.&lt;/p>
&lt;p>&lt;strong>Audience validation comes switched off in Python and does not exist in TypeScript.&lt;/strong> In Python, if the resource URL is configured and the corresponding flag is not enabled, the SDK emits a deprecation warning and behaves as if it were disabled; the docstring itself promises that version 3 will set it to true. In TypeScript the bearer verification does three things, and none of them is looking at the audience. Neither of the two ships a signature verifier with public keys.&lt;/p>
&lt;p>&lt;strong>Token passthrough is forbidden in writing, and the substitute has a name.&lt;/strong> The specification says the MCP server must not accept or relay tokens that were not issued for it, and that if it calls upstream APIs the token must be a different one. The mechanism is token exchange, which Keycloak supports in its standard version with a limitation you need to know: the audience is a client identifier, not a resource URL.&lt;/p>
&lt;p>&lt;strong>There is an official extension that changes the whole picture, and it is stable.&lt;/strong> Enterprise-managed authorisation replaces the redirect to the MCP authorisation server with an exchange at the corporate identity provider, which evaluates the policy and issues a grant based on an identity assertion. It has been stable since June 2026 and there are servers in production.&lt;/p>
&lt;p>&lt;strong>Keycloak implements it halfway and in experimental.&lt;/strong> It only knows how to act as receiver, not as issuer, behind a feature flag, and against draft 01 when the working group is on 04. The official documentation literally says not to use it in production.&lt;/p>
&lt;p>&lt;strong>LiteLLM already implements it on the client side and does not document it.&lt;/strong> In 1.102.0 there is an authentication mode towards MCP servers that runs the two stages of that grant. There is not a single documentation file that mentions it.&lt;/p>
&lt;h2 id="you-are-here-the-resource-side">You are here: the resource side&lt;/h2>
&lt;p>The previous post walked through the identity provider. This one walks through what sits on the other side of the token, which is where the MCP specification puts almost all its normative obligations.&lt;/p>
&lt;p>The natural reading order is &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up&lt;/a> for the basic setup, &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> for the identity piece, and this one for the missing layer. The MCP gateway seen from the inside is &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in its own post&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-visa-and-the-border-checkpoint">The analogy: the visa and the border checkpoint&lt;/h2>
&lt;p>A consulate issues visas. A border post checks them. They are two different offices, and the classic mistake consists of assuming that because the visa is genuine it is good for entering through any door.&lt;/p>
&lt;p>The visa has written on it which country it is good for. That is the token&amp;rsquo;s audience, and here the problem appears: the consulate we work with does not know how to write the destination the traveller asks for, because it does not understand that field on the form. It knows how to write a destination if asked for it under a different name, by way of a pre-agreed stamp, which is the workaround of scopes and the audience mapper.&lt;/p>
&lt;p>And then there is the other half, the one almost nobody builds. The border post has to exist, it has to announce where it is and from which consulates it accepts visas, and it has to read the destination written on the visa before letting anyone through. If the border post limits itself to checking that the stamp is genuine and does not look at the destination, any valid visa from the same consulate is good for entering. That is exactly what the two official SDKs do by default.&lt;/p>
&lt;h2 id="part-1-the-split-of-obligations">Part 1. The split of obligations&lt;/h2>
&lt;p>From revision 2026-07-28, with the normative words as they are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Obligation&lt;/th>
&lt;th>Whose&lt;/th>
&lt;th>Status with Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Publish protected resource metadata (RFC 9728)&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Out of its scope, by design&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Send the resource indicator (RFC 8707) in authorisation and in token&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>, even if the authorisation server does not support it&lt;/td>
&lt;td>It does not understand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate that the token was issued for oneself&lt;/td>
&lt;td>MCP server, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not accept or relay other tokens&lt;/td>
&lt;td>MCP server, &lt;strong>MUST NOT&lt;/strong>&lt;/td>
&lt;td>The resource&amp;rsquo;s responsibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Validate the issuer of the authorisation response (RFC 9207)&lt;/td>
&lt;td>Client, &lt;strong>MUST&lt;/strong>; authorisation server &lt;strong>SHOULD&lt;/strong> emit it&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Consent for each dynamically registered client&lt;/td>
&lt;td>MCP proxy with a static identifier, &lt;strong>MUST&lt;/strong>&lt;/td>
&lt;td>Configurable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most confusing part of that split is the second line. The specification obliges the client to send the parameter &lt;strong>regardless of whether the authorisation server supports it&lt;/strong>. With Keycloak, that parameter is lost: the official documentation says it cannot recognise it, and the standard behaviour towards an unknown parameter is to ignore it. The client complies, the token comes out, and what does not come out is the correct audience. The failure appears later and somewhere else, which is the worst kind of failure.&lt;/p>
&lt;p>It is worth knowing where that work stands. The matter is issue 14355 of the project, open, with a milestone in 26.8.0. There was a complete implementation in pull request 35711, with a mapper of its own and an extension point for resolving resources, which &lt;strong>went back to draft in October 2025&lt;/strong> because of a total lack of tests and because it was decided to redo it in phases, starting with a single resource per client. In March 2026 a new issue was opened for experimental support, also with a milestone in 26.8.0 and with no associated pull request. In 26.7.x there is no feature flag that enables it.&lt;/p>
&lt;h3 id="what-is-new-in-the-revision-and-affects-the-setup">What is new in the revision and affects the setup&lt;/h3>
&lt;p>Four things changed with respect to the previous revision and are worth collecting:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>RFC 9207.&lt;/strong> A new section on validating the authorisation response. The client must record the issuer from the validated metadata document, and the comparison is literal: you are not allowed to normalise case, elide the default port, add or remove the trailing slash or re-encode characters before comparing.&lt;/li>
&lt;li>&lt;strong>Offline tokens leave the catalogue.&lt;/strong> A new section on refresh tokens: MCP servers &lt;strong>should not&lt;/strong> include the corresponding scope either in the challenge header or in the supported scopes of their metadata.&lt;/li>
&lt;li>&lt;strong>Dynamic registration becomes deprecated.&lt;/strong> It goes from optional to optional and deprecated, retained for compatibility with authorisation servers that do not support client identifier metadata documents. And a new requirement is added to registration: the application type is mandatory, and a client must not reuse credentials from another authorisation server, it has to register again.&lt;/li>
&lt;li>&lt;strong>Step-up elevation is rewritten.&lt;/strong> The menu of three server strategies disappears and is replaced by two rules: the challenge&amp;rsquo;s scope attribute describes what is needed for the requested resource, with no obligation to include what has already been granted, and scope accumulation becomes the client&amp;rsquo;s responsibility. With a new requirement for the server: it &lt;strong>must&lt;/strong> take scope hierarchies into account, where a broad one implies the narrow ones.&lt;/li>
&lt;/ul>
&lt;h2 id="part-2-what-the-sdks-give-and-what-they-do-not">Part 2. What the SDKs give, and what they do not&lt;/h2>
&lt;p>Here is the uncomfortable part, and it is the main reason for writing this post. Verified by reading the code of the Python SDK and of the TypeScript 2.0.0-alpha one.&lt;/p>
&lt;h3 id="protected-resource-metadata">Protected resource metadata&lt;/h3>
&lt;p>&lt;strong>In Python the complete model exists and it mounts on its own.&lt;/strong> The metadata class carries the RFC fields: resource, authorisation servers with a minimum of one, key set URL, supported scopes, bearer methods with a default value of header, name, documentation, policy, terms, and the client certificate and DPoP fields. The handler serves the document with a one-hour cache directive, and the URL is built by inserting the well-known path in front of the resource path, as the RFC requires.&lt;/p>
&lt;p>As soon as the resource server URL is configured, the route appears with no further work. &lt;strong>But what that automatic route publishes is degraded in three points&lt;/strong>: it passes a single authorisation server, it takes as its scope catalogue the ones the middleware requires (which are not the same as the ones the resource supports), and it passes neither the name nor the documentation, which come out null. To publish a complete document you have to call the route creation function by hand.&lt;/p>
&lt;p>&lt;strong>In TypeScript it exists and does not mount on its own.&lt;/strong> The function that builds the document emits &lt;strong>only five fields&lt;/strong>: resource, authorisation servers, supported scopes, name and documentation. &lt;strong>It does not emit the supported bearer methods&lt;/strong>, nor the key set URL, nor anything about DPoP. And serving it is explicit: either you call the response function from your own handler, or you mount the metadata router. There is no point in the SDK that does it on its own.&lt;/p>
&lt;h3 id="audience-validation">Audience validation&lt;/h3>
&lt;p>This is the one to fix on day one.&lt;/p>
&lt;p>&lt;strong>In Python it comes disabled.&lt;/strong> The token verifier is a single-method protocol, that is to say a hole the implementer fills. The access token&amp;rsquo;s resource field is the indicator you set yourself. And the comparison only happens if you ask for it: there is a function that normalises as a URL and ignores the trailing slash, but the resource server URL reaches it empty unless the validation flag is enabled.&lt;/p>
&lt;p>The detail to read twice is in the configuration: &lt;strong>if the resource URL is set and the flag is not, the SDK raises a deprecation warning and behaves as if the flag were false&lt;/strong>. The docstring says version 3 will set the default to true. Until then, a Python server configured with authorisation accepts tokens issued for another resource, unless the implementer enables the flag or validates the audience inside their own verifier.&lt;/p>
&lt;p>&lt;strong>In TypeScript it is not validated at all.&lt;/strong> Bearer token verification does exactly three things: strip the prefix, check the required scopes, and require that the expiry is present and not past. A search for audience or key set across the server and middleware packages returns no validation. It is all left in the hands of whichever verifier you plug in.&lt;/p>
&lt;p>&lt;strong>Neither of the two ships a signature verifier with public keys.&lt;/strong> In Python the only example is introspection against the authorisation server, and its audience check sits behind a flag that also comes out false. In TypeScript the interface is empty. That is: the component that validates the token, which is the one holding up the most important normative requirement of the specification, is your own code in both cases.&lt;/p>
&lt;h3 id="the-authentication-challenge">The authentication challenge&lt;/h3>
&lt;p>&lt;strong>Python&lt;/strong> builds the challenge with the error and its description, and adds the metadata URL only if it is configured. It returns 401 with invalid token and 403 with insufficient scope, both from the same function, so both carry the metadata URL. &lt;strong>What it never emits is the scope parameter&lt;/strong>, which revision 2026-07-28 asks to be included, and which is exactly what the client needs in order to know what to ask for.&lt;/p>
&lt;p>&lt;strong>TypeScript does emit the scope&lt;/strong> when there are required scopes, in addition to the metadata URL, and it correctly maps invalid token to 401 and insufficient scope to 403.&lt;/p>
&lt;p>That is, each SDK gets a different half right. The Python one validates better and warns worse; the TypeScript one warns better and does not validate.&lt;/p>
&lt;h3 id="scopes-which-are-not-per-tool">Scopes, which are not per tool&lt;/h3>
&lt;p>In both SDKs scopes are &lt;strong>a static list at the transport mounting level&lt;/strong>, not per tool. There is no per-tool scope declaration and no step-up elevation help on the server side. Emitting a 403 with the specific scope that call requires, which is what the specification describes, is your own code.&lt;/p>
&lt;p>That is the origin of almost all the fine-grained authorisation you have to build, and it is why there is a Part 6.&lt;/p>
&lt;h3 id="which-revision-each-one-announces">Which revision each one announces&lt;/h3>
&lt;p>&lt;strong>Python is already on 2026-07-28.&lt;/strong> The revision appears in the known versions and in the list of modern versions, described as the ones that use the stateless envelope per request. The server discovery method is registered with a default handler, and the method and name headers are validated against the body.&lt;/p>
&lt;p>&lt;strong>TypeScript has it, but in a separate list.&lt;/strong> The latest protocol version constant is still 2025-11-25, because that list is only the one for the initial handshake. The modern era lives in a separate module, with a comment explaining the reason: keeping them deliberately separate so that adding a revision there never leaks a modern version string into a 2025-era handshake.&lt;/p>
&lt;p>And it is worth recalling the mismatch that already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>: LiteLLM 1.102.0 still announces 2025-06-18.&lt;/p>
&lt;h2 id="part-3-building-the-protected-resource">Part 3. Building the protected resource&lt;/h2>
&lt;p>With the above, the list of what you have to write is short and concrete.&lt;/p>
&lt;p>&lt;strong>1. A token verifier that validates the signature and the audience.&lt;/strong> Against the realm&amp;rsquo;s key set, checking issuer, expiry and that the audience contains the MCP server&amp;rsquo;s canonical URL and &lt;strong>only&lt;/strong> things that concern it. Here the side effect of the Keycloak workaround comes in: if a client asks for two scopes from two different resources, out comes a token with two audiences, because each mapper contributes its own to the array. There is no client policy condition or executor in 26.7 that limits the number of audiences per token. The practical defence is the resource&amp;rsquo;s: reject tokens whose audience includes other resources, instead of merely checking that its own is present.&lt;/p>
&lt;p>&lt;strong>2. The complete metadata document&lt;/strong>, with all the authorisation servers you accept, the real catalogue of scopes the resource understands, and the name and documentation filled in. In Python, mounting the route by hand instead of leaving the automatic one. In TypeScript, mounting it, full stop.&lt;/p>
&lt;p>&lt;strong>3. The authentication challenge with the scope.&lt;/strong> In Python you have to add it, because the SDK does not emit it. And you have to take scope hierarchies into account, which is a new requirement of the revision.&lt;/p>
&lt;p>&lt;strong>4. The decision between local validation and introspection.&lt;/strong> Validating the signature locally costs no network, but &lt;strong>revocation does not take effect until the token expires&lt;/strong>: there are no revocation lists for signed tokens. Introspection costs a round trip per request and only confidential clients can invoke it. The reasonable combination is a short token life and introspection on the operations that change state.&lt;/p>
&lt;p>&lt;strong>5. Binding state to the user.&lt;/strong> The new revision is explicit about this, because with protocol sessions gone, state is carried in handlers that travel as an ordinary tool argument. Servers &lt;strong>must&lt;/strong> verify every incoming request and &lt;strong>must not&lt;/strong> treat possession of a handler as authentication; and they &lt;strong>should&lt;/strong> bind the handler to the user on the server side, for example storing the state under a key that combines the user identifier derived from the verified token with the handler, and reject the handler if someone else presents it. It is a change in the way of working compared with the sessions of before.&lt;/p>
&lt;h2 id="part-4-the-hop-from-the-gateway-to-the-mcp-server">Part 4. The hop from the gateway to the MCP server&lt;/h2>
&lt;p>The real shape in an inference platform is not client against MCP server: it is client against gateway, and gateway against MCP server. That second hop is where it is decided whether the architecture is correct.&lt;/p>
&lt;p>The specification leaves no room. The MCP server &lt;strong>must not&lt;/strong> accept or relay tokens that were not issued for it. And if it makes requests to upstream APIs, it may act as an OAuth client of theirs, but &lt;strong>the token it uses there is a different token, issued by the upstream authorisation server&lt;/strong>, and it must not forward the one it received.&lt;/p>
&lt;p>The standard mechanism for that is token exchange, and here you need to know three Keycloak details.&lt;/p>
&lt;p>&lt;strong>First, the permission model changed.&lt;/strong> The old version required fine-grained administration permissions and an explicit exchange authorisation on the target client. The standard version, supported since 26.2, does not require them: it is enough for the requesting client to be confidential and to have the corresponding switch enabled. In exchange there is a surprising condition: &lt;strong>the subject token has to carry the requesting client in its audience&lt;/strong>, unless it exchanges its own token. That is, the gateway needs to appear in the audience of the user token, which is achieved with an audience mapper in a default scope of the gateway. Only then can it narrow the audience to that of the specific MCP server.&lt;/p>
&lt;p>&lt;strong>Second, the audience is a client identifier, not a resource URL.&lt;/strong> The parameter filters audiences, that is to say it narrows them, which is what you want. But it takes the identifier of a client registered in the realm. The practical consequence is that &lt;strong>the MCP server has to be registered as a client, and its identifier should be its canonical URL&lt;/strong>, so that the resulting audience matches what the resource validates. It is a naming trick, and it has to be documented for whoever comes after. The documentation itself acknowledges it: token exchange does not yet support the resource parameter.&lt;/p>
&lt;p>&lt;strong>Third, the subject token type is limited.&lt;/strong> The standard version only accepts access tokens as subject.&lt;/p>
&lt;p>There is also a new experimental feature in 26.7 that adds a parameterised scope type for validating whether the requesting user is authorised to act on behalf of another. Interesting for delegation, but experimental.&lt;/p>
&lt;h3 id="what-the-gateway-does-today">What the gateway does today&lt;/h3>
&lt;p>In LiteLLM 1.102.0 the authentication modes towards MCP servers number twelve, with a default value of none. The ones that matter for this discussion are four.&lt;/p>
&lt;p>&lt;code>oauth2_token_exchange&lt;/code> implements the standard exchange. It sends the correct grant type, the subject token and its type, and &lt;strong>the audience, never the resource&lt;/strong>. The omission is deliberate according to the code itself: fabricating a target risks an invalid target error. It caches the resulting token under a key that combines the subject token with the whole configuration, and with a lifetime equal to the token&amp;rsquo;s minus one minute. And it &lt;strong>blocks, never degrades&lt;/strong>: with no incoming token it returns 401, which the edge turns into a challenge with the resource metadata URL; a rejection from the identity provider returns 401; a gateway configuration error returns 500; a transport failure, 503.&lt;/p>
&lt;p>&lt;code>true_passthrough&lt;/code> and &lt;code>oauth_delegate&lt;/code> forward the client&amp;rsquo;s authorisation header as is. That is token passthrough, by the name the specification gives it, and its legitimate use is narrow: when the token the client presents was already issued for the target MCP server and the gateway is a mere transport. Outside that case, it is non-compliant.&lt;/p>
&lt;p>&lt;code>oauth2_id_jag&lt;/code> is the surprise, and it deserves a part of its own.&lt;/p>
&lt;h2 id="part-5-the-enterprise-extension-which-changes-the-picture">Part 5. The enterprise extension, which changes the picture&lt;/h2>
&lt;p>In June 2026 the MCP project published an authorisation extension that solves a different problem from the one the standard flow solves, and which in an organisation with an identity provider of its own is the real problem.&lt;/p>
&lt;p>The normal flow is per user: each employee authorises each client against each MCP server. It works for consumer applications and it does not work in a company, because onboarding a person requires authorising dozens of services one by one and offboarding requires revoking them one by one.&lt;/p>
&lt;p>&lt;strong>The &lt;code>io.modelcontextprotocol/enterprise-managed-authorization&lt;/code> extension inverts that.&lt;/strong> It is in the stable specifications directory of the extensions repository, it comes from SEP-990, and the flow is this:&lt;/p>
&lt;ol>
&lt;li>The MCP client authenticates the user against the corporate identity provider through the normal flow, and &lt;strong>stores the identity assertion&lt;/strong>, which may be an OpenID identity token or a SAML assertion.&lt;/li>
&lt;li>When the server indicates that enterprise-managed authorisation is needed, the client &lt;strong>exchanges that assertion at the corporate provider for an authorisation grant based on an identity assertion&lt;/strong>. The provider evaluates the organisation&amp;rsquo;s policy there: group membership, roles, conditional access.&lt;/li>
&lt;li>The client presents that grant to the MCP authorisation server and obtains an access token.&lt;/li>
&lt;li>The sentence that defines the extension, verbatim: &lt;strong>the user is not redirected to the authorisation endpoint of the MCP authorisation server&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>The MCP authorisation server validates the signature against the corporate provider&amp;rsquo;s key set, plus audience, issuer and expiry, and uses the subject as the user&amp;rsquo;s stable identifier, with the email as a fallback for linking earlier accounts.&lt;/p>
&lt;p>What that buys is exactly what a compliance file asks for: policy in a single place, an auditable decision at the identity provider, and &lt;strong>centralised revocation that takes effect across all clients at once&lt;/strong>. The employee who loses access stops receiving grants, without touching any server.&lt;/p>
&lt;p>The underlying standard is a draft from the IETF&amp;rsquo;s OAuth working group, under the name of JWT identity assertion authorisation grant, revision 04 of May 2026, signed by people from Okta, Ping Identity and an independent author. It profiles identity chaining across trust domains by combining token exchange with the JWT profile.&lt;/p>
&lt;h3 id="and-here-comes-the-problem">And here comes the problem&lt;/h3>
&lt;p>&lt;strong>Keycloak implements it only halfway.&lt;/strong> It has its own documentation page, behind a feature flag. And three limitations you need in front of you before designing anything:&lt;/p>
&lt;ul>
&lt;li>It only acts as receiver. It accepts assertions issued by an external provider and issues local tokens. Native support for acting as issuer, the documentation says, is not yet fully implemented. In the extension&amp;rsquo;s flow, the issuer is the corporate provider: if that provider is Keycloak, the missing piece is exactly the one you need.&lt;/li>
&lt;li>It is based on draft 01 while the working group is on 04.&lt;/li>
&lt;li>The official documentation says not to use it in production.&lt;/li>
&lt;/ul>
&lt;p>The asymmetry is striking and worth pointing out: Keycloak does not support a standard published eight years ago such as resource indicators, and does partially support a draft from a few months back.&lt;/p>
&lt;h3 id="what-is-already-in-the-gateway-undocumented">What is already in the gateway, undocumented&lt;/h3>
&lt;p>In LiteLLM 1.102.0, the &lt;code>oauth2_id_jag&lt;/code> mode runs the two stages of the flow. In the first it asks the corporate provider for a token exchange with the requested token type specific to this grant, sending the user&amp;rsquo;s identity token as subject and, optionally, audience, &lt;strong>resource&lt;/strong> and scope. In the second it presents the resulting assertion to the resource&amp;rsquo;s authorisation server with the JWT bearer grant type. In both the gateway authenticates as a client with a JWT signed by private key.&lt;/p>
&lt;p>Two operational warnings that come out of the code and are nowhere else:&lt;/p>
&lt;ul>
&lt;li>The subject comes from the caller&amp;rsquo;s identity token, or from the one captured at login. And only the generic OIDC provider captures assertions: with Google, Microsoft or SAML there is none, and &lt;strong>all users fail&lt;/strong>. The code raises a warning when loading the configuration.&lt;/li>
&lt;li>A badly configured server refuses with a 500 instead of falling back to the static credential. It is the right decision, and it is worth knowing before it happens.&lt;/li>
&lt;/ul>
&lt;p>And the main warning: &lt;strong>there is no documentation&lt;/strong>. Not a single file in the repository that mentions this mode. Anyone using it is reading code, like this post.&lt;/p>
&lt;h2 id="part-6-per-tool-authorisation">Part 6. Per-tool authorisation&lt;/h2>
&lt;p>None of the previous pieces answers the question the business ends up asking: whether this person may call this tool with these arguments. The identity provider gives roles and scopes, and with that you do not model a graph of relations, as was already argued &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the previous post&lt;/a>.&lt;/p>
&lt;p>What is published today, with sources:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenFGA&lt;/strong> is the only one with a dedicated product guide, updated on 9 September 2026. The pattern is a tool type with an invocation relation, checked on each call, and a listing query so the client only sees the tools it may invoke. It is a pattern and a modelling language, not a reference implementation.&lt;/li>
&lt;li>&lt;strong>Pomerium&lt;/strong> ships a per-tool policy criterion, with exact, prefix, suffix and list matching.&lt;/li>
&lt;li>&lt;strong>Kong&lt;/strong> offers it as a product, with per-tool control lists.&lt;/li>
&lt;li>&lt;strong>Traefik Hub&lt;/strong> goes as far as parameter-level restrictions, which is the finest published grain.&lt;/li>
&lt;li>&lt;strong>Envoy&lt;/strong> has an MCP filter that extracts protocol attributes for fine-grained access control, declared to be in active development and with no OAuth of its own: the hook is external authorisation towards a policy engine.&lt;/li>
&lt;/ul>
&lt;p>The lowest-friction option in a platform that already has a service mesh is external authorisation against a policy engine. The most expressive one, the relations model.&lt;/p>
&lt;h2 id="part-7-what-still-has-no-standard">Part 7. What still has no standard&lt;/h2>
&lt;p>Two gaps the specification does not cover and that have to be closed from outside.&lt;/p>
&lt;p>&lt;strong>Tool definition change.&lt;/strong> Neither revision 2026-07-28 nor the SDKs pin a hash of the description or of the input schema. The best practices page covers confused deputy, token passthrough, server-side request forgery in discovery, state handler hijacking, server confusion and validation of the authorisation URL scheme, but &lt;strong>there is no integrity or pinning requirement&lt;/strong>. The tool that does it is the Invariant Labs scanner, under the Apache 2.0 licence, which pins hashes to detect definition change after approval and also ships a proxy mode with guardrails. It already came up &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a> and it remains the only answer.&lt;/p>
&lt;p>&lt;strong>The public guidance that does get into this&lt;/strong> is the NSA&amp;rsquo;s on MCP security design considerations, from May 2026, done with Carnegie Mellon&amp;rsquo;s software engineering institute. Two of its observations are directly actionable: that authorisation in MCP is optional and that the specification imposes no requirement on token lifecycle management, so expiry and rotation are left in the organisation&amp;rsquo;s hands; and the warning against dynamic tool discovery with no origin verification and no authorisation checks, which is the most direct acknowledgement of the risk in a state guidance document.&lt;/p>
&lt;h2 id="part-8-who-covers-what">Part 8. Who covers what&lt;/h2>
&lt;p>With all of the above, the decision table for the layer you put in front of the MCP server:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Publishes resource metadata&lt;/th>
&lt;th>Validates audience&lt;/th>
&lt;th>Token exchange&lt;/th>
&lt;th>Per-tool authorisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Python SDK, automatic route&lt;/td>
&lt;td>Yes, degraded&lt;/td>
&lt;td>&lt;strong>Not by default&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TypeScript SDK&lt;/td>
&lt;td>Does not mount on its own&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traefik Hub (commercial)&lt;/td>
&lt;td>Yes, automatic&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, down to parameter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong, OAuth plugin for MCP&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pomerium&lt;/td>
&lt;td>Partial&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, per-tool criterion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>agentgateway (Solo.io)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes, by JWT policy&lt;/td>
&lt;td>Yes, including the assertion grant&lt;/td>
&lt;td>Yes, with expressions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>mcp-context-forge (IBM)&lt;/td>
&lt;td>Not in release notes&lt;/td>
&lt;td>Not documented&lt;/td>
&lt;td>Yes, since 1.0.6&lt;/td>
&lt;td>Yes, per-role control&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy, MCP filter&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Via external authorisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM 1.102.0&lt;/td>
&lt;td>Yes, and authorisation server metadata too&lt;/td>
&lt;td>&lt;strong>Not on the incoming JWT&lt;/strong>&lt;/td>
&lt;td>Yes, and the assertion grant&lt;/td>
&lt;td>Yes, per-key and per-team permissions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two warnings about that table. The Kong plugin requires a minimum version of 3.12, is in technical preview and needs a licence for its AI edition; it also introduces a breaking change in 3.13, which starts treating all traffic as MCP in order to close a possible authentication bypass. And on oauth2-proxy I found no primary documentation either for or against protected resource mode: I am not claiming it does not have it, I am claiming it is not documented where I could look.&lt;/p>
&lt;h2 id="reference-configuration">Reference configuration&lt;/h2>
&lt;p>&lt;strong>Keycloak: the per-resource scope, optional, with its audience.&lt;/strong> That it is optional and not default is the key piece, because it is what makes the client have to ask for it explicitly and makes it work as a substitute for the resource indicator:&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">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp:inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&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">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&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> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the MCP server registered as a client &lt;strong>with its canonical URL as identifier&lt;/strong>, so that token exchange produces the right audience:&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">kcadm.sh create clients -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">clientId&lt;/span>&lt;span class="o">=&lt;/span>https://mcp.ejemplo.es/inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">enabled&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> -s &lt;span class="nv">publicClient&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">false&lt;/span> -s &lt;span class="nv">consentRequired&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The gateway, with token exchange instead of token passthrough:&lt;/strong>&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="nt">mcp_servers&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">inventario&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">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&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">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http&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">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;oauth2_token_exchange&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">client_id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm-gateway&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">client_secret&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/GW_SECRET&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">token_exchange_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://sso.ejemplo.es/realms/plataforma/protocol/openid-connect/token&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">audience&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&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">subject_token_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;urn:ietf:params:oauth:token-type:access_token&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">token_exchange_profile&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;rfc8693&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>The resource verifier, which is what nobody hands you ready-made.&lt;/strong> The essential bit is the last check, the one that rejects other resources&amp;rsquo; audiences instead of settling for finding its own:&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="n">RECURSO&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://mcp.ejemplo.es/inventario&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="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">verificar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&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="n">AccessToken&lt;/span> &lt;span class="o">|&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&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&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">jwks&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&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">audience&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># validates that I am there&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&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">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;aud&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">aud&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nb">isinstance&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">aud&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># and that nobody else is: the scopes workaround allows&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># tokens with two audiences, and that reopens the confused deputy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">aud&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">CLIENTE_GATEWAY&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="kc">None&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">AccessToken&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&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;azp&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">scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&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;scope&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 class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">expires_at&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">claims&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;exp&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">resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&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>And when mounting the server, the flag that does not come set:&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="n">auth&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">AuthSettings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">ISSUER&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resource_server_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">RECURSO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">validate_token_resource&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># without this there is only a warning&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required_scopes&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;mcp:inventario&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;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Enable audience validation in the Python SDK, or write it into the verifier if it is TypeScript. Without that, the central normative requirement is not met.&lt;/li>
&lt;li>Reject tokens with other resources&amp;rsquo; audiences, not just check that your own is present.&lt;/li>
&lt;li>Mount the metadata document by hand, with all the authorisation servers and the real catalogue of scopes.&lt;/li>
&lt;li>Add the scope parameter to the challenge, in Python, and account for scope hierarchies.&lt;/li>
&lt;li>Register each MCP server as a Keycloak client with its canonical URL as identifier, so that token exchange produces the right audience.&lt;/li>
&lt;li>Replace any token passthrough mode with token exchange, except the narrow case where the token was already issued for the target.&lt;/li>
&lt;li>Check that the gateway appears in the audience of the user token, or the exchange will fail.&lt;/li>
&lt;li>Bind state handlers to the user on the server side, and never treat them as authentication.&lt;/li>
&lt;li>Decide between local validation and introspection knowing that the first delays revocation until expiry, and shorten the token life accordingly.&lt;/li>
&lt;li>Pin a hash of the description and the input schema from outside, because the specification does not cover it.&lt;/li>
&lt;li>Put a policy engine in for per-tool authorisation, and do not try to model it with roles.&lt;/li>
&lt;li>If you are considering the enterprise extension, check first who issues the grant: Keycloak today only knows how to receive it, in experimental and against an earlier draft.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The Python SDK mounts the metadata document on its own, and that is why it looks solved.&lt;/strong> What it publishes carries a single authorisation server and no name and no documentation.&lt;/p>
&lt;p>&lt;strong>The audience validation deprecation warning does not enable anything.&lt;/strong> It warns and carries on behaving as disabled.&lt;/p>
&lt;p>&lt;strong>The TypeScript SDK does not validate the audience at all.&lt;/strong> It is not configuration, it is that there is no code for it.&lt;/p>
&lt;p>&lt;strong>Python&amp;rsquo;s challenge does not carry the scope&lt;/strong>, so a client that receives a 403 does not know what to ask for.&lt;/p>
&lt;p>&lt;strong>The scopes workaround allows two audiences in the same token.&lt;/strong> Each mapper contributes its own, and there is no client policy that limits it. The defence is in the resource.&lt;/p>
&lt;p>&lt;strong>The token exchange audience is a client identifier, not a URL.&lt;/strong> If the MCP server is not registered with its URL as identifier, the resulting audience will not match what the resource validates.&lt;/p>
&lt;p>&lt;strong>Token exchange requires the gateway to be in the audience of the user token.&lt;/strong> It is the condition that wastes the most time the first time round.&lt;/p>
&lt;p>&lt;strong>Dynamic client registration is deprecated&lt;/strong> as of this revision, and the substitute, client identifier metadata documents, is experimental in Keycloak and has an open bug with documents carrying unknown fields, which blocks login with real clients.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s identity assertion grant depends on the generic OIDC provider.&lt;/strong> With Google, Microsoft or SAML no assertion is captured and all users fail.&lt;/p>
&lt;p>&lt;strong>Step-up elevation is no longer the server&amp;rsquo;s responsibility.&lt;/strong> Scope accumulation moved to the client in this revision.&lt;/p>
&lt;p>&lt;strong>The authentication elevation signal goes in a 401, not a 403.&lt;/strong> Insufficient scope is 403; asking for a higher authentication level is a different thing and a different code.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The summary of this post fits in one sentence: the identity provider is the cheap half of the problem, and the expensive half is the protected resource, which no SDK hands you ready-made and which in the two official ones comes with audience validation disabled or absent.&lt;/p>
&lt;p>From that comes an order of work. First the token verifier, with the audience validated in both directions, because without it everything else is decoration. Second, the complete metadata document, mounted by hand. Third, replacing any token passthrough with exchange, with the MCP server registered with its URL as identifier. Fourth, a policy engine for what roles do not model.&lt;/p>
&lt;p>And a scheduling decision worth taking with the information in front of you. The enterprise-managed authorisation extension is the right route for an organisation with a provider of its own, it is stable and it has servers in production behind it. What is not ready is Keycloak as issuer. Anyone who wants that flow today has two honest options: delegate the mechanics to a gateway that implements it, or wait. Putting it into production with a feature the official documentation marks as experimental and expressly advises against is not one of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside, and the gap this post closes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the basic setup, predating this revision of the specification.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions, its cost and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: the audience validation that does not happen on the gateway side either.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: workload identity with attestation, the other plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its observability&lt;/a>: the protocol and its primitives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>: the network isolation everything above presupposes.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Especificación MCP, revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">autorización&lt;/a>, &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration">registro de clientes&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization">Extensión de autorización gestionada por la empresa&lt;/a> y su &lt;a href="https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx">especificación estable&lt;/a>, del SEP-990. Anuncio de 18 de junio de 2026.&lt;/li>
&lt;li>&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-identity-assertion-authz-grant/">draft-ietf-oauth-identity-assertion-authz-grant&lt;/a>, revisión 04 de 21 de mayo de 2026, grupo de trabajo OAuth del IETF.&lt;/li>
&lt;li>Código de los SDK oficiales de MCP: &lt;code>python-sdk&lt;/code> y &lt;code>typescript-sdk&lt;/code> 2.0.0-alfa, clonados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración de Keycloak con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/securing-apps/identity-assertion-jwt-authorization-grant">concesión de autorización por aserción de identidad&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Incidencias de Keycloak &lt;a href="https://github.com/keycloak/keycloak/issues/14355">14355&lt;/a>, &lt;a href="https://github.com/keycloak/keycloak/issues/47117">47117&lt;/a> y &lt;a href="https://github.com/keycloak/keycloak/issues/51039">51039&lt;/a>, y petición de cambios &lt;a href="https://github.com/keycloak/keycloak/pull/35711">35711&lt;/a>.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">NSA y SEI, Model Context Protocol: Security Design Considerations&lt;/a>, mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://openfga.dev/docs/use-cases/mcp-server-authorization">OpenFGA, autorización de servidores MCP&lt;/a>, actualizado el 9 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://invariantlabs-ai.github.io/docs/mcp-scan/">mcp-scan de Invariant Labs&lt;/a>, Apache 2.0.&lt;/li>
&lt;li>Documentación de &lt;a href="https://docs.solo.io/agentgateway/2.3.x/mcp/auth/setup/">agentgateway&lt;/a>, &lt;a href="https://doc.traefik.io/traefik-hub/mcp-gateway/mcp">Traefik Hub&lt;/a>, &lt;a href="https://developer.konghq.com/plugins/ai-mcp-oauth2/">Kong&lt;/a> y &lt;a href="https://www.pomerium.com/docs/capabilities/mcp">Pomerium&lt;/a>, consultadas el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>Keycloak in an AI platform: where it fits, what it solves and the two standards MCP requires and it does not implement</title><link>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</link><pubDate>Sat, 12 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/</guid><description>&lt;blockquote>
&lt;p>This post opens the identity block of the operations track. &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The previous one&lt;/a> covered the gateway side: the JWT that does not validate audience, the user who never reaches the trace. This one covers the piece on the other side. Verified against Keycloak 26.7.3, revision 2026-07-28 of the MCP specification and the text of Royal Decree 311/2022 in the BOE.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Keycloak 26.7.3 was released on 31 August 2026&lt;/strong> and is still incubating at the CNCF, which it entered in April 2023. The cadence is four minor releases a year and support in practice covers a single branch: the twenty security bugs fixed in 26.7.3 were not backported to the 26.4, 26.5 or 26.6 branches at the time of release.&lt;/p>
&lt;p>&lt;strong>The Quarkus-based distribution separates build and runtime configuration.&lt;/strong> What is set in the build phase is frozen into the image, and changing it at startup forces a rebuild. In containers, the pattern is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Storage is relational and only relational.&lt;/strong> The alternative engine that was in development was withdrawn in version 25.0. Since 26.0 sessions are persisted to the database by default, which changes the load profile: the dominant cost in the official benchmarks is database CPU.&lt;/p>
&lt;p>&lt;strong>Organisations solves multi-tenancy inside a realm&lt;/strong>, fully supported since 26.0. It groups members, associates email domains and links identity providers per organisation. It is the alternative to replicating realms per customer.&lt;/p>
&lt;p>&lt;strong>The measured bottleneck is password hashing.&lt;/strong> Since 25.0 the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. The official sizing guide gives one vCPU for every 15 password logins per second, against 120 per second for client credentials and for refreshes.&lt;/p>
&lt;p>&lt;strong>Keycloak does not implement two standards the MCP specification marks as mandatory.&lt;/strong> Protected resource metadata belongs to the MCP server, not the authorisation server, so there is nothing to hold against it there. Resource indicators do belong to it and are &lt;strong>not supported&lt;/strong>. Keycloak&amp;rsquo;s own documentation declares partial conformance with the last three MCP revisions for that reason. The practical substitute is scopes plus an audience mapper.&lt;/p>
&lt;p>&lt;strong>There is a recent critical bug worth looking at today.&lt;/strong> &lt;code>CVE-2026-18963&lt;/code>, scored 9.1, allowed resetting any user&amp;rsquo;s password without authenticating, administrators included. Fixed in 26.7.2, released on 19 August 2026.&lt;/p>
&lt;h2 id="you-are-here-the-cross-cutting-layer">You are here: the cross-cutting layer&lt;/h2>
&lt;p>Identity is not a stage of the pipeline, it is a layer that crosses all of them. It shows up in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a> as one of the seven layers of defence, in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the model&amp;rsquo;s chain of trust&lt;/a> as workload identity, in &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the gateway&lt;/a> as token validation, and in &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">the post about authenticating MCP servers&lt;/a> as authorisation server.&lt;/p>
&lt;p>What was missing was the post about the piece itself, and above all the decision of where to put it and what to expect from it.&lt;/p>
&lt;h2 id="the-analogy-the-civil-registry-and-building-permits">The analogy: the civil registry and building permits&lt;/h2>
&lt;p>A small town hall has a population register, which says who lives there, and it has permits, which say who can do what. They are two different things and it is worth not confusing them, because the register is good for the whole town while a permit is good for one specific job on one specific street.&lt;/p>
&lt;p>Keycloak is the register. It knows who each person is, identifies them reliably, knows which groups they belong to and issues a credential document that expires. What it does not do is decide whether this person can modify this particular file: that is the permit, and it lives in each service.&lt;/p>
&lt;p>Confusing the two produces the most expensive design mistake in platforms of this kind, which consists of putting into the token everything that will need to be decided later. The register document grows, gets handed out everywhere, and when a permission changes you have to wait for everyone&amp;rsquo;s document to expire.&lt;/p>
&lt;p>There is a third element the analogy captures well. A credential document issued for presenting at the tax office should not be good for entering the municipal depot. That is the token&amp;rsquo;s audience, and it is the central subject of the second half of this post.&lt;/p>
&lt;h2 id="part-1-what-keycloak-is-today">Part 1. What Keycloak is today&lt;/h2>
&lt;h3 id="project-status">Project status&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Item&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Latest stable version&lt;/td>
&lt;td>26.7.3, released on 31 August 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Previous ones&lt;/td>
&lt;td>26.7.2 (19 August), 26.7.0 (9 July), 26.6.0 (8 April)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cadence&lt;/td>
&lt;td>four minor releases a year&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNCF&lt;/td>
&lt;td>incubating since 10 April 2023, not graduated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support&lt;/td>
&lt;td>the current branch; earlier ones do not receive fixes reliably&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>That last point deserves underlining because it conditions the upgrade policy. The analysis of 26.7.3 shows that its twenty security fixes were not backported to earlier branches at release time. Operating Keycloak means keeping up to date with the current branch, not staying on a &amp;ldquo;stable&amp;rdquo; version for a year.&lt;/p>
&lt;p>Since 26.0 there is a backward compatibility guarantee across minor versions for features marked as fully supported, and breaking changes are opt-in.&lt;/p>
&lt;h3 id="the-internal-architecture-in-five-pieces">The internal architecture in five pieces&lt;/h3>
&lt;p>&lt;strong>Two startup modes.&lt;/strong> The development one enables HTTP, does not resolve the hostname strictly and disables the theme cache. The production one requires a hostname and TLS, or it will not start. Confusing them in production is the first antipattern on the final list.&lt;/p>
&lt;p>&lt;strong>The build phase.&lt;/strong> The build command materialises the optimisations into the image. Build options, such as the database engine, the enabled features or metrics, are frozen. Runtime ones, such as the database password or the hostname, are applied at startup. Changing a build option at startup forces a rebuild with the corresponding latency cost. The correct pattern in containers is to build into the image and start with the optimised flag.&lt;/p>
&lt;p>&lt;strong>Extensions via SPI.&lt;/strong> Authenticators, protocol mappers, user storage, event listeners, hashing algorithms and secret vaults. They are packaged as JARs and resolved in the build phase.&lt;/p>
&lt;p>&lt;strong>Infinispan cache.&lt;/strong> Embedded by default, with database-based discovery as the recommended mechanism. The discovery alternatives via Kubernetes DNS, TCP and UDP are deprecated. There are local caches, with default capacities of 10,000 entries in several of them, and replicated caches for sessions, client sessions, offline sessions, authentication sessions, login failures and action tokens. Remote mode, with an external Infinispan, is needed for first-generation multi-site deployment.&lt;/p>
&lt;p>&lt;strong>Relational storage.&lt;/strong> JPA over a database. The alternative engine was removed in version 25.0. The databases supported today include PostgreSQL from 14 to 18, MariaDB and MySQL in their extended support versions, SQL Server 2019 and 2022, Oracle 19c and 23.x, and the managed variants Aurora PostgreSQL and Azure SQL.&lt;/p>
&lt;h3 id="the-minimum-conceptual-model">The minimum conceptual model&lt;/h3>
&lt;p>Before touching anything you need to be clear on eight concepts, and the order in which they relate:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Realm&lt;/strong>: the isolation boundary. Its own users, its own signing keys, its own policies. Two realms share nothing.&lt;/li>
&lt;li>&lt;strong>Client&lt;/strong>: an application that asks for tokens. Public if it cannot keep a secret, such as a browser application; confidential if it can, such as a service; and bearer-only if it merely validates tokens without requesting them.&lt;/li>
&lt;li>&lt;strong>Client scope&lt;/strong>: a reusable bundle of scopes and mappers, assigned to clients as default or as optional. It is the unit with which you bound what a token carries.&lt;/li>
&lt;li>&lt;strong>Protocol mapper&lt;/strong>: what projects an attribute, a role or an audience into the token. The audience mapper is the one that solves the central problem of this post.&lt;/li>
&lt;li>&lt;strong>Role&lt;/strong>, realm or client, and &lt;strong>composite role&lt;/strong>, which aggregates others.&lt;/li>
&lt;li>&lt;strong>Group&lt;/strong>, hierarchical, with inherited roles and attributes.&lt;/li>
&lt;li>&lt;strong>Identity provider&lt;/strong>: federation towards another external OIDC or SAML. &lt;strong>User federation&lt;/strong>: LDAP, Active Directory and Kerberos, with synchronisation and attribute mapping.&lt;/li>
&lt;li>&lt;strong>Authentication flow&lt;/strong>: a sequence of executions with required, alternative, conditional or disabled requirements, and subflows. It is where you build a second factor or a check of your own.&lt;/li>
&lt;/ul>
&lt;p>And one feature that changes architectures: &lt;strong>organisations&lt;/strong>, fully supported since 26.0. It solves multi-tenancy inside a single realm, grouping members, associating email domains and linking identity providers per organisation, so that provider discovery is done by the user&amp;rsquo;s email domain. The classic alternative, one realm per customer, multiplies administration and shares no configuration.&lt;/p>
&lt;h2 id="part-2-who-can-authenticate-against-it">Part 2. Who can authenticate against it&lt;/h2>
&lt;p>This is the table you need before drawing anything. The right-hand column is usually the one that decides.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Protocol&lt;/th>
&lt;th>Cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Kubernetes API&lt;/td>
&lt;td>native OIDC, or the structured authentication configuration file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>OIDC and JWT, plus UI SSO&lt;/td>
&lt;td>&lt;strong>JWT and SSO above five users: Enterprise&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM&lt;/td>
&lt;td>static key only&lt;/td>
&lt;td>No OIDC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>generic OIDC and Keycloak provider&lt;/td>
&lt;td>SSO free; per-project access control: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grafana&lt;/td>
&lt;td>generic OAuth &lt;strong>yes in the open edition&lt;/strong>&lt;/td>
&lt;td>Team synchronisation: Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Backstage&lt;/td>
&lt;td>generic OIDC and community-maintained Keycloak provider&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Open WebUI&lt;/td>
&lt;td>OIDC, with role management by claim&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LibreChat&lt;/td>
&lt;td>generic OIDC, with Keycloak documented&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Argo CD&lt;/td>
&lt;td>native OIDC or Dex, with groups into its RBAC file&lt;/td>
&lt;td>Free&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MinIO&lt;/td>
&lt;td>OIDC with role assumption via web identity&lt;/td>
&lt;td>See the note below&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two clarifications that change decisions.&lt;/p>
&lt;p>&lt;strong>Kubernetes.&lt;/strong> The modern mechanism is structured authentication configuration, which allows several JWT issuers plus validations and mappings with CEL expressions. The corresponding proposal, KEP-3331, went alpha in 1.29, beta in 1.30 and is &lt;strong>stable since 1.34&lt;/strong>. It is the route to use today, and it replaces the loose API server flags.&lt;/p>
&lt;p>&lt;strong>vLLM has no identity.&lt;/strong> All there is is a static key via environment variable. This is not a defect to fix, it is an architectural fact: the engine sits behind the gateway and its access control is network-based, not identity-based. Any design that exposes vLLM directly to users is giving up per-person traceability.&lt;/p>
&lt;p>About MinIO a note of honesty is in order: the public documentation now redirects to the commercial edition, so the detail of how it maps policies from a claim is verified there and not in the open edition. Anyone depending on that mechanism should confirm it against their own version before designing on top of it.&lt;/p>
&lt;h2 id="part-3-humans-and-machines">Part 3. Humans and machines&lt;/h2>
&lt;p>They are two different problems and it is worth solving them separately.&lt;/p>
&lt;h3 id="people">People&lt;/h3>
&lt;p>Authorization code with PKCE, with the S256 method. The MCP specification requires it when the client is technically capable, and it goes further: it mandates &lt;strong>rejecting the flow&lt;/strong> if the authorisation server metadata does not publish the supported challenge methods.&lt;/p>
&lt;h3 id="workloads-with-no-person-behind-them">Workloads with no person behind them&lt;/h3>
&lt;p>Here the obvious option is client credentials with a Keycloak service account, authenticated with a secret or, better, with a JWT signed by private key.&lt;/p>
&lt;p>But there is a less known and better option for workloads inside the cluster: &lt;strong>federated client authentication&lt;/strong>, introduced in January 2026 under the banner of doing away with secrets. It lets a client authenticate with a token issued by an external provider, and one of those providers is Kubernetes. The announcement placed full support for the Kubernetes service account variant and for OIDC in version 26.6; the SPIFFE variant, by contrast, expressly says it will stay in preview until the SPIFFE client authentication standard is finalised at the IETF.&lt;/p>
&lt;p>That means a pod can authenticate against Keycloak with the token the kubelet already projects into it, with no additional secret to rotate. It is worth confirming the exact status and the flag name against the release notes of whichever version you have deployed, because the feature is recent and has changed status between 26.5 and 26.6. It is preferable to token exchange, whose standard version is supported but is internal-to-internal only, while the old one, which covered external-to-internal and impersonation, is deprecated and in preview.&lt;/p>
&lt;h3 id="keycloak-against-spiffe">Keycloak against SPIFFE&lt;/h3>
&lt;p>The question always comes up and the answer is that they solve different things with a small overlap.&lt;/p>
&lt;p>SPIRE attests the node and the process, and issues short-lived identity documents with no prior secret. Keycloak does not attest workloads: it trusts a credential someone had to place there, except in the federated authentication case just mentioned, where the one attesting is Kubernetes. In exchange, Keycloak federates people, issues tokens with business data and governs consent, things SPIFFE does not do.&lt;/p>
&lt;p>The practical rule: SPIFFE for service-to-service identity inside the perimeter, Keycloak for anything that has a person behind it or crosses the perimeter. The SPIFFE detail is in &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">the close of the chain of trust series&lt;/a>.&lt;/p>
&lt;h2 id="part-4-mcp-and-the-two-missing-standards">Part 4. MCP, and the two missing standards&lt;/h2>
&lt;p>This part has the most consequences and is the worst documented outside the primary sources.&lt;/p>
&lt;h3 id="what-the-specification-requires">What the specification requires&lt;/h3>
&lt;p>From revision 2026-07-28, with the normative words as they appear:&lt;/p>
&lt;ul>
&lt;li>The MCP server &lt;strong>must&lt;/strong> implement RFC 9728 protected resource metadata, and the client &lt;strong>must&lt;/strong> use it to discover the authorisation server.&lt;/li>
&lt;li>The client &lt;strong>must&lt;/strong> implement RFC 8707 resource indicators, including the &lt;code>resource&lt;/code> parameter &lt;strong>in both the authorisation request and the token request&lt;/strong>, and &lt;strong>must&lt;/strong> send it regardless of whether the authorisation server supports it.&lt;/li>
&lt;li>The MCP server &lt;strong>must&lt;/strong> validate that the access token was issued specifically for it as the audience, and &lt;strong>must not&lt;/strong> accept or relay any other token.&lt;/li>
&lt;li>Authorisation servers and clients &lt;strong>should&lt;/strong> support client identifier metadata documents. RFC 7591 dynamic registration becomes optional and is declared deprecated, retained for compatibility.&lt;/li>
&lt;/ul>
&lt;p>The revision also adds the issuer in the authorisation response, from RFC 9207, and a stateless model with state handlers where it is expressly stated that holding a state handler is not equivalent to being authenticated.&lt;/p>
&lt;h3 id="what-keycloak-supports">What Keycloak supports&lt;/h3>
&lt;p>From its own MCP integration guide:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Standard&lt;/th>
&lt;th>Keycloak&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>OAuth 2.1 and authorisation server metadata&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 9207, issuer in the response&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RFC 7591, dynamic registration&lt;/td>
&lt;td>Supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client identifier metadata document&lt;/td>
&lt;td>Supported, &lt;strong>experimental&lt;/strong>, after enabling the feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 8707, resource indicators&lt;/strong>&lt;/td>
&lt;td>&lt;strong>Not supported&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>RFC 9728, protected resource metadata&lt;/strong>&lt;/td>
&lt;td>Out of scope, it belongs to the MCP server&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conformance declared by the project itself is &amp;ldquo;partially supported, without resource indicators&amp;rdquo; for the last three MCP revisions.&lt;/p>
&lt;p>Two practical conclusions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The protected resource metadata document is published by the MCP server or a proxy in front of it&lt;/strong>, never by Keycloak. That is correct and there is nothing to fix, but you have to remember to implement it, because the client needs it to discover where to go.&lt;/li>
&lt;li>&lt;strong>Without resource indicators, the audience is bounded with scopes.&lt;/strong> The official route is to use the &lt;code>scope&lt;/code> parameter instead of &lt;code>resource&lt;/code>, and to hang from that client scope an audience mapper with the MCP server URL as the included custom audience.&lt;/li>
&lt;/ol>
&lt;p>It is a workaround, it works, and it has to be documented in the design because anyone coming after will look for the standard parameter and will not find it.&lt;/p>
&lt;h2 id="part-5-the-confused-deputy">Part 5. The confused deputy&lt;/h2>
&lt;p>With the above on the table, the classic mistake becomes obvious on its own. A token with a generic audience turns any compromised service into a master key: whoever obtains the token a user presented to the dashboard can present it to the gateway, and from there to the MCP server.&lt;/p>
&lt;p>The MCP specification is explicit in three sentences worth quoting verbatim in any architecture document:&lt;/p>
&lt;ul>
&lt;li>MCP servers &lt;strong>must not&lt;/strong> accept tokens that were not explicitly issued for them.&lt;/li>
&lt;li>If the MCP server calls upstream APIs, it &lt;strong>must not&lt;/strong> forward the token it received from the client.&lt;/li>
&lt;li>MCP proxies that use static client identifiers &lt;strong>must&lt;/strong> obtain user consent for each dynamically registered client before forwarding to third-party authorisation servers.&lt;/li>
&lt;/ul>
&lt;p>Translated into the specific figure this track has been dealing with: &lt;strong>the gateway must not forward the user&amp;rsquo;s JWT to third-party MCP servers&lt;/strong>. It has to obtain a different token with the right audience, or act as an OAuth client in its own right. By default, as already documented &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway post&lt;/a>, the behaviour is to use the server&amp;rsquo;s own credential, which is the correct one; there are two modes that forward the caller&amp;rsquo;s token and both have a name in the specification.&lt;/p>
&lt;h2 id="part-6-two-hours-of-task-against-five-minutes-of-token">Part 6. Two hours of task against five minutes of token&lt;/h2>
&lt;p>The specification pushes towards short-lived tokens and mandatory refresh token rotation in public clients. A two-hour agentic task comfortably exceeds the life of an access token and may also exceed session idle time if nobody refreshes.&lt;/p>
&lt;p>Three real options, with their cost:&lt;/p>
&lt;p>&lt;strong>Offline tokens.&lt;/strong> They survive session expiry, and expire through their own idle timeout or through a maximum if that limit is enabled. The risk is obvious: a near-permanent credential, hard to inventory. And the MCP specification adds that MCP servers &lt;strong>should not&lt;/strong> include that scope either in the authentication header or in the supported scopes of their metadata.&lt;/p>
&lt;p>&lt;strong>Automatic refresh in the client.&lt;/strong> It is the right answer when there is a client that can implement it, with refresh token rotation and secure storage. For an agent with no person behind it, it is better to renew via client credentials.&lt;/p>
&lt;p>&lt;strong>Long-lived virtual keys in the gateway.&lt;/strong> It works, and it is what most people end up doing. The price has to be written down in the risk analysis: identity ends up decoupled from the provider, so deactivating someone in Keycloak does not invalidate their key, and traceability back to the real person depends on the discipline with which the keys were created. The levers that compensate are expiry, automatic rotation and a mandatory alias, covered &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">in the virtual keys post&lt;/a>.&lt;/p>
&lt;p>A note of honesty about the default values. The numbers going around (five minutes of access token, thirty of session idle time, ten hours of maximum) do not appear published in the administration guide and I did not locate them in the code. Treat them as a console observation and confirm them in your own realm before quoting them in a document.&lt;/p>
&lt;h2 id="part-7-sizing-and-operating">Part 7. Sizing and operating&lt;/h2>
&lt;h3 id="on-kubernetes">On Kubernetes&lt;/h3>
&lt;p>The operator exposes three custom resources: the server, the realm import and the OIDC client. Probes and metrics live on &lt;strong>management port 9000&lt;/strong>, with the health and readiness paths, and metrics in Prometheus format provided the corresponding option was enabled in the build phase. That port is not exposed publicly.&lt;/p>
&lt;p>For high availability there are three shapes. A single cluster spread across zones, with no additional infrastructure. First-generation multi-cluster, with an external load balancer and an external Infinispan at each site. And second-generation multi-cluster, introduced in 26.7.0 and still in preview, which connects clusters &lt;strong>without external Infinispan&lt;/strong>, using the synchronously replicated database as the source of truth, with less cache and more database load.&lt;/p>
&lt;h3 id="the-official-sizing-figures">The official sizing figures&lt;/h3>
&lt;p>From the CPU and memory sizing concepts guide, which is still published:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>1 vCPU for every 15 password logins per second&lt;/strong>, tested up to 300 per second.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 client credential grants per second&lt;/strong>, tested up to 2,000.&lt;/li>
&lt;li>&lt;strong>1 vCPU for every 120 token refresh requests per second&lt;/strong>, tested up to 435.&lt;/li>
&lt;li>Leave a &lt;strong>150 % CPU headroom&lt;/strong> for spikes, startup and failover.&lt;/li>
&lt;li>&lt;strong>1,250 MB of memory per pod&lt;/strong> with realm data and 10,000 cached sessions. Keycloak allocates 70 % of the limit to the heap and around 300 MB outside it, so the limit comes from subtracting those 300 MB from the expected usage and dividing by 0.7.&lt;/li>
&lt;/ul>
&lt;p>The stated assumptions behind those figures include Argon2 with five iterations, sessions in the database and the default cache of 10,000 entries.&lt;/p>
&lt;p>The difference between 15 and 120 is the whole story of Keycloak&amp;rsquo;s performance: &lt;strong>the cost is in password hashing&lt;/strong>. Since 25.0, the default algorithm outside FIPS environments is Argon2, with around 7 MB of memory per operation. Before it was PBKDF2, whose iterations were multiplied by ten in version 24. In FIPS mode PBKDF2 is still used.&lt;/p>
&lt;p>From that comes an architectural consequence for an AI platform: &lt;strong>machine workloads, which go through client credentials, cost eight times less than a human login&lt;/strong>. Sizing by number of services is cheap; sizing by morning login peaks is the expensive part.&lt;/p>
&lt;h3 id="the-benchmark">The benchmark&lt;/h3>
&lt;p>From the 26.4 performance report, published on 1 October 2025: up to 2,000 logins per second and 10,000 refreshes per second with three pods, 74 vCPU in total and 8 GB per pod, over an Aurora PostgreSQL database, with almost linear vertical scaling.&lt;/p>
&lt;p>Two useful findings from that report. With 20 ms of round-trip time between sites, the 99th percentile rises from 51 to 130 ms, and the project &lt;strong>advises against deploying across different regions&lt;/strong>. And raising the cache from 10,000 to 200,000 entries brought the database CPU peak down from 77.77 % to 63.77 %, in exchange for somewhat longer garbage collection pauses.&lt;/p>
&lt;h3 id="security">Security&lt;/h3>
&lt;p>The urgent matter, if the deployment is below 26.7.2:&lt;/p>
&lt;p>&lt;strong>&lt;code>CVE-2026-18963&lt;/code>, score 9.1, critical.&lt;/strong> A bypass of the email action token in the credential reset flow allowed resetting any user&amp;rsquo;s password with no prior authentication, administrators included. Fixed in 26.7.2, released on 19 August 2026. The temporary mitigation, if you cannot upgrade, is to disable password recovery in all realms. No known exploitation was recorded as of the end of August.&lt;/p>
&lt;p>In 26.7.3 there are another nineteen, among them the missing hostname verification in the TLS certificate towards LDAP with a score of 8.8, two tenant restriction bypasses in token exchange with Microsoft and Google brokers, and two access control bypasses in organisations.&lt;/p>
&lt;p>Official hardening comes down to four decisions:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Strict hostname&lt;/strong>, which is the default value since the second generation of that configuration. The server does not infer the URL from headers, which prevents poisoning email links and redirects.&lt;/li>
&lt;li>&lt;strong>Administration console on a different hostname&lt;/strong>, and network-restricted.&lt;/li>
&lt;li>&lt;strong>Proxy headers declared explicitly&lt;/strong>, trusting only a controlled reverse proxy.&lt;/li>
&lt;li>&lt;strong>Separate management port, not exposed.&lt;/strong>&lt;/li>
&lt;/ol>
&lt;h2 id="part-8-the-mapping-to-the-ens">Part 8. The mapping to the ENS&lt;/h2>
&lt;p>The codes and titles below are verified against the official text of Royal Decree 311/2022 published in the BOE, Annex II.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Code&lt;/th>
&lt;th>Literal title&lt;/th>
&lt;th>What a central IdP contributes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>op.acc.1&lt;/td>
&lt;td>Identificación&lt;/td>
&lt;td>A unique identifier per entity, user or process that gains access&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.2&lt;/td>
&lt;td>Requisitos de acceso&lt;/td>
&lt;td>Permissions are granted by role and group, not by loose credential&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.3&lt;/td>
&lt;td>Segregación de funciones y tareas&lt;/td>
&lt;td>It supports it with roles, but the control is organisational&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.4&lt;/td>
&lt;td>Proceso de gestión de derechos de acceso&lt;/td>
&lt;td>Least privilege and a specific remote access policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.5&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios externos)&lt;/td>
&lt;td>Flows and factors configurable per flow&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.acc.6&lt;/td>
&lt;td>Mecanismo de autenticación (usuarios de la organización)&lt;/td>
&lt;td>Reinforcements required already at the low level&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.8&lt;/td>
&lt;td>Registro de la actividad&lt;/td>
&lt;td>It contributes part of the events, not all of them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>op.exp.10&lt;/td>
&lt;td>Protección de claves criptográficas&lt;/td>
&lt;td>Management and rotation of the realm signing keys&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two nuances an auditor is going to look for.&lt;/p>
&lt;p>The first, about &lt;strong>op.acc.1&lt;/strong>: the text requires every entity that accesses the system, whether user or process, to have a unique identifier. That includes agents. A virtual key shared between several agents does not comply, and that is exactly what usually happens when JWT authentication falls outside the budget.&lt;/p>
&lt;p>The second, about &lt;strong>op.exp.8&lt;/strong>: the control requires recording the identifier of the user or entity associated with the event, the date and time, what information it is performed on, the type and the result. Keycloak contributes the authentication events. The usage events are contributed by the gateway and the MCP servers, and there the two already documented gaps reappear: &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">adding and removing MCP servers generates no audit log&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the trace user is set by the client&lt;/a>.&lt;/p>
&lt;p>The correspondence between control and product in this table is my own interpretation and must be validated by the certification body. The codes and titles are not: those are literal from the BOE.&lt;/p>
&lt;h2 id="part-9-the-configuration-to-leave-written-down">Part 9. The configuration to leave written down&lt;/h2>
&lt;p>Three concrete pieces, so as not to stay at the level of concepts.&lt;/p>
&lt;p>&lt;strong>Hardened startup&lt;/strong>, as container arguments:&lt;/p>
&lt;pre tabindex="0">&lt;code>kc.sh start --optimized \
--hostname=https://sso.ejemplo.es \
--hostname-admin=https://sso-admin.interna.ejemplo.es \
--proxy-headers=xforwarded \
--health-enabled=true \
--metrics-enabled=true \
--http-management-port=9000 \
--cache=ispn --cache-stack=jdbc-ping \
--db=postgres
&lt;/code>&lt;/pre>&lt;p>The metrics, health, cache and database options are build options: they go into the image, and passing them here forces a rebuild if they do not match.&lt;/p>
&lt;p>&lt;strong>The client scope with audience&lt;/strong>, which is the workaround forced by the lack of resource indicators. Created with the administration tool:&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"># one scope per protected resource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create client-scopes -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>mcp-inventario -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;attributes.&amp;#34;include.in.token.scope&amp;#34;=true&amp;#39;&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 mapper that sets the token audience&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kcadm.sh create &lt;span class="s2">&amp;#34;client-scopes/&amp;lt;id&amp;gt;/protocol-mappers/models&amp;#34;&lt;/span> -r plataforma &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">name&lt;/span>&lt;span class="o">=&lt;/span>aud-mcp-inventario &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocol&lt;/span>&lt;span class="o">=&lt;/span>openid-connect &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="nv">protocolMapper&lt;/span>&lt;span class="o">=&lt;/span>oidc-audience-mapper &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -s &lt;span class="s1">&amp;#39;config.&amp;#34;included.custom.audience&amp;#34;=https://mcp.ejemplo.es/inventario&amp;#39;&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> -s &lt;span class="s1">&amp;#39;config.&amp;#34;access.token.claim&amp;#34;=true&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for that scope and receives a token whose audience is only that MCP server. The MCP server validates the audience and rejects any other token, as the specification requires.&lt;/p>
&lt;p>&lt;strong>Realm import via GitOps&lt;/strong>, with the operator&amp;rsquo;s custom resource, so that the configuration does not live only in the console:&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">k8s.keycloak.org/v2beta1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">KeycloakRealmImport&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">identidad&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">spec&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">keycloakCRName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sso&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">realm&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">realm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">plataforma&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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="c"># without this, the recovery email is attack surface&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">resetPasswordAllowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">bruteForceProtected&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">sslRequired&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">all&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="part-10-what-keycloak-does-not-solve">Part 10. What Keycloak does not solve&lt;/h2>
&lt;p>&lt;strong>Fine-grained authorisation over resources.&lt;/strong> The question &amp;ldquo;can this person see this particular trace&amp;rdquo; is a relation between objects, not a role. That problem is solved by Zanzibar-style engines, with OpenFGA and its object, relation and user tuples, or a policy language such as Cedar. Keycloak gives roles and scopes, and with those you do not model a graph of relations.&lt;/p>
&lt;p>&lt;strong>Machine secrets.&lt;/strong> Storage credentials, provider keys, database passwords. That is a secrets manager, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">stack hardening&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Cryptographic workload identity with attestation.&lt;/strong> That is SPIFFE and SPIRE. In Keycloak it exists as an identity provider in preview, which is a different thing.&lt;/p>
&lt;p>&lt;strong>Authorisation inside the model.&lt;/strong> Which MCP tool an agent may invoke, and with what arguments. The specification offers scopes, the insufficient scope error and step-up elevation, but the per-tool and per-argument decision is taken by the MCP server or a policy engine, not by the identity provider.&lt;/p>
&lt;h2 id="antipatterns">Antipatterns&lt;/h2>
&lt;ol>
&lt;li>Forwarding the user&amp;rsquo;s token from the gateway to third-party MCP servers. Explicitly forbidden by the specification.&lt;/li>
&lt;li>Static client identifier with dynamic registration and no per-client consent, which is the exact recipe for the confused deputy.&lt;/li>
&lt;li>General scopes, of the full-access kind, and publishing the entire catalogue of supported scopes in the metadata.&lt;/li>
&lt;li>Treating a state handler as authentication, which the new revision expressly forbids.&lt;/li>
&lt;li>Following OAuth discovery towards any URL, which opens the door to requests towards internal addresses. It also applies to the authorisation server when it downloads a client metadata document.&lt;/li>
&lt;li>Trust headers badly exposed in chat front ends. The Open WebUI documentation warns that an incorrect configuration allows authenticating as any user.&lt;/li>
&lt;li>Starting in development mode in production, with the hostname not resolved strictly.&lt;/li>
&lt;li>A single shared static key as the only control between the gateway and the engine.&lt;/li>
&lt;/ol>
&lt;h2 id="startup-checklist">Startup checklist&lt;/h2>
&lt;ol>
&lt;li>Check the version. Below 26.7.2 there is a critical password reset bug.&lt;/li>
&lt;li>Build the image with the build phase done and start with the optimised flag.&lt;/li>
&lt;li>Strict hostname, administration console on another hostname, separate management port and not exposed.&lt;/li>
&lt;li>Decide between organisations and one realm per tenant before creating the second tenant.&lt;/li>
&lt;li>One client scope per resource, with an audience mapper, because resource indicators are not supported.&lt;/li>
&lt;li>In the gateway, define the two audience and issuer variables. Without them none of that is validated.&lt;/li>
&lt;li>For workloads inside the cluster, look at the Kubernetes identity provider before handing out client secrets.&lt;/li>
&lt;li>Size by login peaks, not by number of services: the difference is eightfold.&lt;/li>
&lt;li>Export the Keycloak events to the same place the gateway&amp;rsquo;s go, because the activity logging control needs them together.&lt;/li>
&lt;li>Write into the design what the system does with a two-hour task, before someone solves the problem with an offline token.&lt;/li>
&lt;/ol>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Keycloak is a mature piece that solves one bounded problem well: knowing who the caller is, and saying so verifiably in a document that expires. In a sovereign inference platform that is worth a lot, because the alternative is an inventory of static keys spread across ten services that no audit can walk through.&lt;/p>
&lt;p>What is not advisable is asking more of it. Fine-grained authorisation lives in each service, workload identity with attestation lives in SPIFFE, and the decision of which tool an agent may call lives in the MCP server.&lt;/p>
&lt;p>And there is one concrete gap worth noting down, because it does not close on its own: the MCP specification marks two standards as mandatory, and of the two that would fall to an authorisation server, resource indicators are not implemented. The workaround with scopes and an audience mapper works, but it is a workaround, and whoever comes after will look for the standard parameter. Leaving it written in the design saves an afternoon.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: how the gateway validates the tokens this piece issues, and the two variables you have to define.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows up: giving it authentication with Keycloak&lt;/a>: the concrete setup for MCP servers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second front door, its permissions and its lack of audit logging.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">The model&amp;rsquo;s chain of trust (4 of 4)&lt;/a>: SPIFFE, SPIRE and workload identity with attestation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: where identity fits among the seven layers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a>: the alternative when JWT falls outside the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a>: the follow-up: the protected resource side you have to build, token exchange and the enterprise authorisation extension.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.keycloak.org/2026/08/keycloak-2673-released">Keycloak 26.7.3&lt;/a>, 31 de agosto de 2026, y &lt;a href="https://www.keycloak.org/downloads">descargas&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/server/configuration">Configuración del servidor&lt;/a>, &lt;a href="https://www.keycloak.org/server/caching">caché&lt;/a>, &lt;a href="https://www.keycloak.org/server/db">bases de datos&lt;/a> y &lt;a href="https://www.keycloak.org/server/hostname">nombre de host&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/securing-apps/mcp-authz-server">Integración con Model Context Protocol&lt;/a>, &lt;a href="https://www.keycloak.org/securing-apps/token-exchange">intercambio de tokens&lt;/a> y &lt;a href="https://www.keycloak.org/2026/01/federated-client-authentication">autenticación federada de clientes&lt;/a>, 27 de enero de 2026.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/high-availability/multi-cluster/concepts-memory-and-cpu-sizing">Dimensionado de CPU y memoria&lt;/a> e &lt;a href="https://www.keycloak.org/high-availability/introduction">introducción a alta disponibilidad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.keycloak.org/2025/10/keycloak-benchmark">Bancos de pruebas de rendimiento de Keycloak 26.4&lt;/a>, 1 de octubre de 2025.&lt;/li>
&lt;li>&lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">Especificación de MCP, revisión 2026-07-28, autorización&lt;/a> y &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">buenas prácticas de seguridad&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-auth/3331-structured-authentication-configuration">KEP-3331, configuración estructurada de autenticación&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.boe.es/diario_boe/xml.php?id=BOE-A-2022-7191">Real Decreto 311/2022, Anexo II&lt;/a>.&lt;/li>
&lt;li>Registro de &lt;code>CVE-2026-18963&lt;/code> en la base de datos de Red Hat y notas de la versión 26.7.3 en GitHub.&lt;/li>
&lt;li>&lt;a href="https://www.cncf.io/projects/keycloak/">CNCF, proyecto Keycloak&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse v4, day 2 (1 of 10): the data model changed underneath, and what really goes into a trace</title><link>https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/</link><pubDate>Sat, 12 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/</guid><description>&lt;blockquote>
&lt;p>First article in a series of ten about operating Langfuse v4 in production. The architecture of version 3 and its ten performance levers were already covered in &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>; the seam with the gateway, in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the operational pair&lt;/a>. This series is about day 2. Verified against Langfuse 4.35.0, commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Version 4 is observations first.&lt;/strong> There is no trace table. A trace is a set of observations that share an identifier, and the trace attributes live denormalised in every row: name, user, session, tags, version, release and environment (&lt;code>clickhouse/migrations/canonical/0039_create_events_full.up.sql&lt;/code>). The whole intuition of &amp;ldquo;first I create the trace and then I hang things off it&amp;rdquo; stops describing the storage.&lt;/p>
&lt;p>&lt;strong>There are ten observation types, not three.&lt;/strong> Alongside the classic &lt;code>SPAN&lt;/code>, &lt;code>EVENT&lt;/code> and &lt;code>GENERATION&lt;/code> come &lt;code>AGENT&lt;/code>, &lt;code>TOOL&lt;/code>, &lt;code>CHAIN&lt;/code>, &lt;code>RETRIEVER&lt;/code>, &lt;code>EVALUATOR&lt;/code>, &lt;code>EMBEDDING&lt;/code> and &lt;code>GUARDRAIL&lt;/code> (&lt;code>packages/shared/src/domain/observations.ts:5&lt;/code>). Using the right type changes what the interface knows how to group.&lt;/p>
&lt;p>&lt;strong>The real limits are in the code and they do not match what people assume.&lt;/strong> The body of a request to the classic ingestion route is capped at &lt;strong>4.5 MB&lt;/strong> (&lt;code>api/public/ingestion.ts:40&lt;/code>). The OTLP body, at &lt;strong>512 MiB&lt;/strong>, compressed and decompressed, with a 413 when it is exceeded. An OTLP span above 9.5 MB is only written to the log, not rejected. Identifiers accept 800 characters. The trace name, 1,000.&lt;/p>
&lt;p>&lt;strong>The ingester looks for the user in five places and the session in six.&lt;/strong> With strict precedence order, starting from Langfuse&amp;rsquo;s own attributes and ending in the metadata of other frameworks. Among the session ones sits &lt;code>gen_ai.conversation.id&lt;/code>, which is the OpenTelemetry standard, so a conformant instrumentation works without translation.&lt;/p>
&lt;p>&lt;strong>Scores do not travel over OTLP.&lt;/strong> Zero occurrences of the word across the 3,864 lines of the OTLP ingestion processor. They come in through the classic ingestion route, through the scores endpoint or through the interface. Any design that assumes an evaluator can write its score down the same channel as the trace is wrong.&lt;/p>
&lt;p>&lt;strong>The classic masking function does not cover what almost everyone thinks it covers.&lt;/strong> It only acts on data that passes through the SDK calls. The attributes produced by a third-party automatic instrumentation come out unmasked. There is a new function that does act on the batch attributes, but it leaves out span events, which is where the recent conventions put the messages.&lt;/p>
&lt;p>&lt;strong>Filtering by tags or by status message is a scan.&lt;/strong> In the listings table, the indexed fields are the span identifier, the trace identifier, the user identifier, the session identifier, the dates, the model and the metadata names. The name, the tags, the version and the status message have no index.&lt;/p>
&lt;h2 id="you-are-here-observe-day-2">You are here: OBSERVE, day 2&lt;/h2>
&lt;p>The previous series about this pair covered selection and setup. The &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">six-service architecture&lt;/a> explained version 3; &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">tracing with OpenTelemetry&lt;/a> explained the pipeline; the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with the gateway&lt;/a> explained where the trace gets lost between the two pieces.&lt;/p>
&lt;p>What is left uncovered is the work of the next three years: migrating, sizing ClickHouse, retaining and deleting, taking backups, alerting, getting the data out. This series is about that, and it starts with the question that conditions everything else, which is what gets stored.&lt;/p>
&lt;h2 id="the-analogy-the-archive-that-changed-system">The analogy: the archive that changed system&lt;/h2>
&lt;p>An archive with a folder per case file and documents inside each folder is easy to understand. You open the folder, you look at what is there. The problem shows up when you have to find every document of one kind over the last two years: you have to open every folder.&lt;/p>
&lt;p>Large archives solve that the other way around. There is no folder: each document carries, copied into its header, the case reference, the client name and the date. Searching by client is immediate. Reconstructing a case file is grouping by reference. In exchange, each document weighs a little more, and what gets written into that header matters a great deal, because everything you can search for comes from there.&lt;/p>
&lt;p>Langfuse v4 made exactly that change. What used to be a folder with attributes of its own now travels repeated in the header of every document. The two operational consequences of this article come from there: which fields are worth filling in and which fields are not worth filling with content.&lt;/p>
&lt;h2 id="the-data-model-as-it-is-in-the-code">The data model, as it is in the code&lt;/h2>
&lt;h3 id="the-tables">The tables&lt;/h3>
&lt;p>Three new pieces in ClickHouse:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>events_full&lt;/code>&lt;/strong> (migration 0039): the table of record, with the full input and output, compressed with ZSTD level 3 and indexed with full-text indexes over the lowercase versions. &lt;code>ReplacingMergeTree&lt;/code> engine ordered by event timestamp and deletion flag, partitioned by month of the start time.&lt;/li>
&lt;li>&lt;strong>&lt;code>events_core&lt;/code>&lt;/strong> (migration 0040): the listings table, lighter.&lt;/li>
&lt;li>&lt;strong>&lt;code>events_core_mv&lt;/code>&lt;/strong> (migration 0041): the materialised view that fills the previous one, and which &lt;strong>truncates to two hundred characters&lt;/strong> the input, the output and each metadata value.&lt;/li>
&lt;/ul>
&lt;p>That truncation to two hundred characters is the first thing to know. What you see in a listing is not what is stored; it is a clipping. And when reading a full trace a second cap applies on top, &lt;code>LANGFUSE_SERVER_SIDE_IO_CHAR_LIMIT&lt;/code>, with a default value of a thousand (&lt;code>packages/shared/src/env.ts:513&lt;/code>).&lt;/p>
&lt;p>In the &lt;code>events_full&lt;/code> table there is a configuration line that says a lot: &lt;code>index_granularity_bytes = '64Mi'&lt;/code>, with the comment that it avoids very small granules because of large rows. It is the schema itself acknowledging that the rows of this system are bulky.&lt;/p>
&lt;h3 id="the-ten-observation-types">The ten observation types&lt;/h3>
&lt;p>&lt;code>SPAN&lt;/code>, &lt;code>EVENT&lt;/code>, &lt;code>GENERATION&lt;/code>, &lt;code>AGENT&lt;/code>, &lt;code>TOOL&lt;/code>, &lt;code>CHAIN&lt;/code>, &lt;code>RETRIEVER&lt;/code>, &lt;code>EVALUATOR&lt;/code>, &lt;code>EMBEDDING&lt;/code> and &lt;code>GUARDRAIL&lt;/code> (&lt;code>domain/observations.ts:5&lt;/code>), with their corresponding ingestion events (&lt;code>server/ingestion/types.ts:279&lt;/code>).&lt;/p>
&lt;p>For a platform with agents, the difference between marking a tool call as a generic &lt;code>SPAN&lt;/code> or as a &lt;code>TOOL&lt;/code> is the difference between being able to count tool calls per session and having to reconstruct it by hand. It is worth reviewing the instrumentation with this list in front of you.&lt;/p>
&lt;p>The level is still &lt;code>DEBUG&lt;/code>, &lt;code>DEFAULT&lt;/code>, &lt;code>WARNING&lt;/code> and &lt;code>ERROR&lt;/code> (&lt;code>domain/observations.ts:31&lt;/code>), with a free-form status message.&lt;/p>
&lt;h3 id="the-limits-with-their-source">The limits, with their source&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Limit&lt;/th>
&lt;th>File&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Trace, span and observation identifiers&lt;/td>
&lt;td>800 characters, no carriage return&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:10&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Trace name&lt;/td>
&lt;td>1,000 characters&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:428&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Environment&lt;/td>
&lt;td>40 characters, pattern of lowercase, digits, hyphen and underscore&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:226&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Text-type score&lt;/td>
&lt;td>500 characters&lt;/td>
&lt;td>&lt;code>domain/scores.ts:44&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Score config name&lt;/td>
&lt;td>35 characters&lt;/td>
&lt;td>&lt;code>domain/score-configs.ts:11&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Classic ingestion body&lt;/td>
&lt;td>4.5 MB per request&lt;/td>
&lt;td>&lt;code>api/public/ingestion.ts:40&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTLP body&lt;/td>
&lt;td>512 MiB, adjustable&lt;/td>
&lt;td>&lt;code>web/src/env.mjs:583&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTLP span considered large&lt;/td>
&lt;td>9.5 MB, only logged&lt;/td>
&lt;td>&lt;code>packages/shared/src/env.ts:193&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name, status message, version, release, tags&lt;/td>
&lt;td>&lt;strong>no declared limit&lt;/strong>&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:424&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metadata&lt;/td>
&lt;td>&lt;strong>no declared limit&lt;/strong>&lt;/td>
&lt;td>&lt;code>ingestion/types.ts:425&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last two are the dangerous ones, because they have no schema cap and the real ceiling ends up being the one on the request body. A three-megabyte metadata value passes validation and reaches ClickHouse.&lt;/p>
&lt;p>A note on the &lt;code>langfuse&lt;/code> prefix in the environment field: it is reserved for the product&amp;rsquo;s own internal traces and is stripped idempotently from public inputs (&lt;code>ingestion/types.ts:228&lt;/code>), including the case of someone trying to sneak it in duplicated. The default value is &lt;code>default&lt;/code>.&lt;/p>
&lt;p>On rate limits: in the cloud there are per-plan quotas, but &lt;strong>in self-hosted they are not applied&lt;/strong>. The service returns directly that there is no limiting (&lt;code>features/public-api/server/RateLimitService.ts&lt;/code>). It is good news for bulk exports and bad news for anyone relying on that mechanism as protection.&lt;/p>
&lt;h2 id="the-two-instrumentation-routes">The two instrumentation routes&lt;/h2>
&lt;h3 id="what-gets-lost-over-otlp-and-it-is-one-specific-thing">What gets lost over OTLP, and it is one specific thing&lt;/h3>
&lt;p>The check is simple and the result is blunt: &lt;strong>there is not a single occurrence of the word &lt;code>score&lt;/code> in the OTLP ingestion processor&lt;/strong>, a file of 3,864 lines. Scores only come in through three places: the &lt;code>score-create&lt;/code> event of the classic ingestion route, the &lt;code>POST /api/public/scores&lt;/code> endpoint, or the interface.&lt;/p>
&lt;p>This has a design consequence worth being clear about before building evaluators: the trace can travel through the OpenTelemetry collector with everything good that brings, on-disk queue, retries and sampling, but the score an evaluator puts on it has to go down a different channel and with the project keys.&lt;/p>
&lt;p>Everything else does travel over OTLP. Whatever is not in the mapping lists is kept as metadata converted to a string, visible but outside the indexed fields.&lt;/p>
&lt;h3 id="the-precedence-chain-which-is-the-map-you-need-to-have">The precedence chain, which is the map you need to have&lt;/h3>
&lt;p>Verified in &lt;code>packages/shared/src/server/otel/OtelIngestionProcessor.ts&lt;/code>:&lt;/p>
&lt;p>&lt;strong>User identifier&lt;/strong> (line 2545), in this order: &lt;code>langfuse.user.id&lt;/code>, &lt;code>user.id&lt;/code>, the observation metadata, the trace metadata, and lastly the Vercel SDK metadata.&lt;/p>
&lt;p>&lt;strong>Session identifier&lt;/strong> (line 2565): &lt;code>langfuse.session.id&lt;/code>, &lt;code>session.id&lt;/code>, &lt;strong>&lt;code>gen_ai.conversation.id&lt;/code>&lt;/strong>, observation metadata, trace metadata, Vercel metadata.&lt;/p>
&lt;p>&lt;strong>Environment&lt;/strong> (line 2383): &lt;code>langfuse.environment&lt;/code>, &lt;code>deployment.environment.name&lt;/code>, &lt;code>deployment.environment&lt;/code>; for each key the span attributes are checked first and the resource attributes after.&lt;/p>
&lt;p>&lt;strong>Model&lt;/strong> (line 2753): &lt;code>langfuse.observation.model.name&lt;/code>, &lt;code>gen_ai.response.model&lt;/code>, &lt;code>ai.model.id&lt;/code>, &lt;code>gen_ai.request.model&lt;/code>, &lt;code>llm.response.model&lt;/code>, &lt;code>llm.model_name&lt;/code>, &lt;code>model&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Level&lt;/strong> (line 1250): the native attribute, with accepted aliases where &lt;code>SUCCESS&lt;/code> and &lt;code>OK&lt;/code> become &lt;code>DEFAULT&lt;/code>, &lt;code>WARN&lt;/code> becomes &lt;code>WARNING&lt;/code> and &lt;code>FATAL&lt;/code> or &lt;code>CRITICAL&lt;/code> become &lt;code>ERROR&lt;/code>; if there is no attribute and the span status is an error, the result is &lt;code>ERROR&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Tags&lt;/strong> (line 3227): &lt;code>langfuse.trace.tags&lt;/code>, &lt;code>langfuse.tags&lt;/code>, observation and trace metadata, Vercel metadata, and &lt;code>tag.tags&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Input and output&lt;/strong> (line 1790): a chain of some twenty frameworks in strict order, starting from Langfuse&amp;rsquo;s own attributes and going through Genkit, the Vercel SDK, the standard inference operation details event of the conventions from 1.37 onwards, the per-role message events, the prompt and completion attributes, Logfire, MLflow, TraceLoop, OpenInference and the &lt;code>gen_ai.input.messages&lt;/code> and &lt;code>gen_ai.output.messages&lt;/code> attributes.&lt;/p>
&lt;p>That this chain has twenty entries says something about the real state of the conventions: it was &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">already noted&lt;/a> that the generative AI conventions were extracted into their own repository without any published version. While that remains the case, the backend has to guess, and it guesses with a precedence list.&lt;/p>
&lt;p>The practical part: &lt;strong>if Langfuse&amp;rsquo;s own attributes are emitted, they always win&lt;/strong>. If you want backend-neutral instrumentation, &lt;code>gen_ai.conversation.id&lt;/code> and &lt;code>deployment.environment.name&lt;/code> are the two standard attributes that Langfuse does understand.&lt;/p>
&lt;h3 id="metadata-and-how-it-ends-up-in-the-table">Metadata, and how it ends up in the table&lt;/h3>
&lt;p>A metadata value is flattened into two parallel arrays, one of names and one of values, with dot-separated paths and everything converted to a string (&lt;code>otel/utils.ts:239&lt;/code>, applied in &lt;code>worker/src/services/IngestionService/index.ts:363&lt;/code>). A nested object with twenty keys is twenty entries in each array.&lt;/p>
&lt;p>There is a cap of 10,001 positions when reconstructing arrays from OTel attributes (&lt;code>OtelIngestionProcessor.ts:271&lt;/code>), which is high but it exists.&lt;/p>
&lt;h2 id="sessions-users-and-cardinality">Sessions, users and cardinality&lt;/h2>
&lt;p>A session is a text column with a Bloom filter index at 1 %. The same goes for the user. The Postgres table that accompanies sessions stores only whether it is bookmarked, whether it is public and its environment; there is no counter and no cap. &lt;strong>There is no limit on traces per session in the code&lt;/strong>, and the documentation recommends session identifiers below 200 ASCII characters.&lt;/p>
&lt;p>The cardinality difference matters and it is not intuitive: user and session are plain text, so high cardinality does not break the schema, it only inflates the Bloom filters. &lt;strong>The environment, by contrast, is declared low cardinality.&lt;/strong> Generating a dynamic environment value per tenant degrades ClickHouse measurably.&lt;/p>
&lt;p>Aggregated cost comes out of a cost details map with materialised input, output and total columns. The classic sessions endpoint is deprecated; the replacement is the version 2 observations one with a filter.&lt;/p>
&lt;h2 id="scores-five-types-and-three-sources">Scores: five types and three sources&lt;/h2>
&lt;p>The types are &lt;code>NUMERIC&lt;/code>, &lt;code>CATEGORICAL&lt;/code>, &lt;code>BOOLEAN&lt;/code>, &lt;code>CORRECTION&lt;/code> and &lt;code>TEXT&lt;/code> (&lt;code>domain/scores.ts:45&lt;/code>). The boolean requires a value of zero or one. The correction does not accept an associated config. The text one is capped at 500 characters.&lt;/p>
&lt;p>The sources are &lt;code>API&lt;/code>, &lt;code>EVAL&lt;/code> and &lt;code>ANNOTATION&lt;/code>. The &lt;code>EVAL&lt;/code> source is &lt;strong>reserved for the internal evaluators&lt;/strong>: the public API only accepts the other two. Annotation scores require an associated config except for the correction one.&lt;/p>
&lt;p>On what costs money, a clarification that corrects a widespread belief: in 4.35.0 the entitlement for the number of model-based evaluators is unlimited &lt;strong>on every plan&lt;/strong>, including the open edition (&lt;code>features/entitlements/constants/entitlements.ts:60&lt;/code>). What is limited by plan is the number of annotation queues and the days of data access. And there is a version nuance: trace-level evaluators are discontinued in version 4.&lt;/p>
&lt;h2 id="masking-and-what-it-does-not-cover">Masking, and what it does not cover&lt;/h2>
&lt;p>This is the part to read twice if the deployment has to comply with anything.&lt;/p>
&lt;p>The classic Python SDK function has the signature &lt;code>masking_function(*, data, **kwargs)&lt;/code> and covers &lt;strong>only&lt;/strong> the data that passes through the SDK calls: observation creation, update, and setting the trace input and output. &lt;strong>It does not cover the raw OTel attributes&lt;/strong> produced by a third-party automatic instrumentation.&lt;/p>
&lt;p>The replacement is a function that acts on the attributes and the resource attributes in the export batch. That one does reach automatic instrumentation, with a limitation declared in the documentation: it cannot change the span name, the identifiers, the parent relationship, the resource attributes, &lt;strong>the events&lt;/strong> or the links.&lt;/p>
&lt;p>And there is the gap that matters: the generative AI conventions from 1.37 onwards put messages and responses into &lt;strong>span events&lt;/strong>. That is, the route by which prompts arrive in a modern instrumentation is precisely the one the new masking does not touch.&lt;/p>
&lt;p>For a deployment with personal data, the conclusion is that client-side masking is not enough and a redaction processor has to go into the collector. It is the same reasoning that led &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">in the previous article&lt;/a> to recommending the collector in the middle, and it adds to the already documented problem of MCP tool arguments being written in clear, bypassing the message redaction switch.&lt;/p>
&lt;p>On the server side there is no safety net: &lt;strong>Langfuse has no server-side personal data masking&lt;/strong>. The encryption key that gets configured protects secrets, that is, model provider credentials, integration headers and SSO configurations. &lt;strong>Trace inputs and outputs are not encrypted at application level.&lt;/strong>&lt;/p>
&lt;h2 id="volume-control">Volume control&lt;/h2>
&lt;p>Three levers, in order of how high up they act:&lt;/p>
&lt;p>&lt;strong>In the SDK&lt;/strong>: &lt;code>sample_rate&lt;/code>, or the &lt;code>LANGFUSE_SAMPLE_RATE&lt;/code> variable, with head sampling at trace level. If the trace is not sampled, neither its observations nor its scores travel. &lt;code>LANGFUSE_TRACING_ENABLED&lt;/code> set to false switches the whole thing off.&lt;/p>
&lt;p>&lt;strong>In the collector&lt;/strong>: probabilistic sampling and tail sampling. It is the only layer where you can decide based on the outcome, that is, keep every error and a fraction of the rest.&lt;/p>
&lt;p>&lt;strong>In the server&lt;/strong>: a deterministic per-project sampling, with a SHA-256 hash of the trace identifier (&lt;code>server/ingestion/sampling.ts&lt;/code>). Deterministic per trace, so it does not cut traces in half.&lt;/p>
&lt;h2 id="what-not-to-put-into-a-trace">What not to put into a trace&lt;/h2>
&lt;p>With the schema in front of you, the list is concrete.&lt;/p>
&lt;p>&lt;strong>Do not filter by tags or by status message.&lt;/strong> In the listings table the indexed fields are the span identifier, the trace identifier, the user identifier, the session identifier, the creation and update dates, the model name, the experiment identifier and the metadata names. The name, the tags, the version, the release and the status message are not. Filtering by them scans.&lt;/p>
&lt;p>&lt;strong>Do not use metadata as a store.&lt;/strong> It gets flattened into string arrays, truncated to two hundred characters in the listings table but stored whole in the full table, and on top of that it is indexed with a full-text index segmented on non-alphabetic characters. Bulky metadata multiplies the inverted index, not just the data.&lt;/p>
&lt;p>&lt;strong>Do not store full prompts without thinking about it.&lt;/strong> The input and the output carry ZSTD level 3 compression and two full-text indexes over their lowercase versions. Storing the entire context of a 142,000-token agentic call on every turn inflates the inverted index disproportionately. Client-side truncation is the cheap lever.&lt;/p>
&lt;p>&lt;strong>Do not use the environment to separate tenants.&lt;/strong> It is low cardinality, it has a 40-character cap and it isolates nothing: same project, same keys, same limits, same retention. It is there to segment dashboards. Real isolation means separate projects, as was already argued &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">in the article on the seams&lt;/a>.&lt;/p>
&lt;h2 id="version-3-and-the-calendar-date">Version 3 and the calendar date&lt;/h2>
&lt;p>The constant is in the code: &lt;code>V3_SUNSET_DATE = &amp;quot;2026-11-16&amp;quot;&lt;/code> (&lt;code>features/public-api/server/deprecations.ts:11&lt;/code>).&lt;/p>
&lt;p>It is worth being precise about what it means, because the public documentation and the code do not say exactly the same thing. The code comment is literal: the classic ingestion route &lt;strong>is never switched off&lt;/strong> and continues to accept score events; trace and observation events fail only in version 4&amp;rsquo;s exclusive write mode, not in the mixed or classic modes. And when they fail, they fail &lt;strong>per event&lt;/strong>, not per batch.&lt;/p>
&lt;p>The &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header activates direct writing to the events table, which is the real-time route. Without it the deferred path is taken. Recent SDKs send it by themselves. Any value above four is rejected.&lt;/p>
&lt;p>As for names, trace-level input and output attributes are deprecated in favour of the observation ones on the root span. The classic read endpoints carry a deprecation marker in the response body, and their replacements are version 2 of observations, version 3 of scores and version 2 of metrics. And dataset runs are renamed experiments.&lt;/p>
&lt;p>There is a curious piece that deserves a mention because it shows up in the Redis dashboards and is misleading: a queue called version 4 classic API usage, which &lt;strong>ingests nothing&lt;/strong>. It is a periodic task every fifteen minutes that scans the ClickHouse query log to detect which projects are still using the old API, and materialises the result into Redis for the interface&amp;rsquo;s migration notice.&lt;/p>
&lt;h2 id="how-this-looks-in-code">How this looks in code&lt;/h2>
&lt;p>Two pieces, the client one and the collector one.&lt;/p>
&lt;p>On the application side, what changes compared with the usual instrumentation is the observation type and where the identity comes from:&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">get_client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">propagate_attributes&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">lf&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">get_client&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"># identity propagates by context, not observation by observation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">propagate_attributes&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">id_conversacion&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># the same across every turn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">sujeto_del_token&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># from the claim, not from the body&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">environment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;produccion&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">tags&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;soporte&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;n2&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="c1"># the type matters: the interface groups by it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_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">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;agent&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;resolver-incidencia&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">raiz&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">with&lt;/span> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">start_as_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">as_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;buscar_en_inventario&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">herramienta&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">resultado&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mcp&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;buscar&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">args&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># truncate here, not on the server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">herramienta&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">update&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">resultado&lt;/span>&lt;span class="p">)[:&lt;/span>&lt;span class="mi">4000&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"># the score goes down its own channel, never over OTLP&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score_current_trace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;resuelta&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BOOLEAN&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>as_type&lt;/code> parameter accepts nine literals: &lt;code>span&lt;/code>, &lt;code>generation&lt;/code>, &lt;code>embedding&lt;/code>, &lt;code>agent&lt;/code>, &lt;code>tool&lt;/code>, &lt;code>chain&lt;/code>, &lt;code>retriever&lt;/code>, &lt;code>evaluator&lt;/code> and &lt;code>guardrail&lt;/code>. The tenth type in the model, the event, has its own constructor because it does not open a scope.&lt;/p>
&lt;p>On the collector side, the redaction processor that compensates for what the SDK masking does not reach:&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="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">redaction&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">allow_all_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">blocked_values&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="s1">&amp;#39;\b\d{8}[A-HJ-NP-TV-Z]\b&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># national ID number&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="s1">&amp;#39;\b[\w.+-]+@[\w-]+\.[\w.]+\b&amp;#39;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># email&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">silent&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">tail_sampling&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">policies&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">errores&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">status_code&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">status_code&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">status_codes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">ERROR]}&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">resto&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">probabilistic&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">probabilistic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">sampling_percentage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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>&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="l">http://langfuse-web.observabilidad.svc:3000/api/public/otel&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 ${LANGFUSE_BASIC}&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">x-langfuse-ingestion-version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&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">sending_queue&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">file_storage&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">retry_on_failure&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">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The endpoint carries the base path without the signal suffix: the exporter adds its own. And the on-disk queue is the main reason for putting the collector in the middle, because the SDK&amp;rsquo;s batch processor drops when it fills up.&lt;/p>
&lt;h2 id="instrumentation-checklist">Instrumentation checklist&lt;/h2>
&lt;ol>
&lt;li>Review the type of every observation against the list of ten. Tool calls as &lt;code>TOOL&lt;/code>, agents as &lt;code>AGENT&lt;/code>, guardrails as &lt;code>GUARDRAIL&lt;/code>.&lt;/li>
&lt;li>Decide where the user identifier comes from and set it in a single place, high up the precedence chain.&lt;/li>
&lt;li>Emit &lt;code>gen_ai.conversation.id&lt;/code> if you want neutral instrumentation, or Langfuse&amp;rsquo;s own attribute if coupling does not matter.&lt;/li>
&lt;li>Set the environment with &lt;code>deployment.environment.name&lt;/code>, with few distinct values and never one per tenant.&lt;/li>
&lt;li>Get scores out through their own channel, with the project keys, and do not expect them to travel with the trace.&lt;/li>
&lt;li>Truncate input and output on the client before sending them, especially with long agentic contexts.&lt;/li>
&lt;li>Put a redaction processor in the collector, because the SDK masking leaves out span events.&lt;/li>
&lt;li>Do not filter by tags in dashboards that get queried often.&lt;/li>
&lt;li>Send the ingestion version header, or use recent SDKs that send it by themselves.&lt;/li>
&lt;li>Review the size of the metadata: there is no schema cap, the cap is the one on the request body.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>What you see in a listing is truncated to two hundred characters&lt;/strong> by the materialised view, and the full read has another cap of a thousand. The data is whole in the full events table.&lt;/p>
&lt;p>&lt;strong>Metadata has no size limit in the schema.&lt;/strong> A blob of megabytes passes validation.&lt;/p>
&lt;p>&lt;strong>An OTLP span over 9.5 MB is not rejected, it is only logged.&lt;/strong> There is no signal back to the client.&lt;/p>
&lt;p>&lt;strong>The environment is declared low cardinality.&lt;/strong> One value per tenant degrades ClickHouse.&lt;/p>
&lt;p>&lt;strong>Scores do not travel over OTLP.&lt;/strong> It is by absence in the code, not by configuration.&lt;/p>
&lt;p>&lt;strong>The classic masking does not cover automatic instrumentation&lt;/strong>, and the new one does not cover span events, which is where the recent conventions put the messages.&lt;/p>
&lt;p>&lt;strong>Langfuse does not encrypt trace inputs or outputs.&lt;/strong> The encryption key protects integration credentials.&lt;/p>
&lt;p>&lt;strong>In self-hosted there are no rate limits on the API.&lt;/strong> It is no use as protection.&lt;/p>
&lt;p>&lt;strong>The classic ingestion route is not switched off on 16 November&lt;/strong>, it changes behaviour depending on the write mode, and it fails per event instead of per batch.&lt;/p>
&lt;p>&lt;strong>Model-based evaluators are not limited by plan&lt;/strong> in this version, contrary to what is usually assumed. What is limited are the annotation queues and the days of data access.&lt;/p>
&lt;h2 id="the-series-the-ten-articles">The series: the ten articles&lt;/h2>
&lt;p>This is the first. The order is designed so that each one can be read on its own and so that together they cover the whole of day 2.&lt;/p>
&lt;ol>
&lt;li>What goes into a trace (this article): version 4&amp;rsquo;s data model, limits, precedences, scores, masking and indexes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-instrumenting-langgraph/">Putting LangGraph in front&lt;/a>: instrumenting an agentic platform and the measured cost of one turn.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-real-agents-odr-gptr/">Two well-known agents instrumented&lt;/a>: Open Deep Research and GPT Researcher, with the measured figures of a real request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-migrating-with-no-window/">Migrating from version 3 to 4 with no window&lt;/a>: the three write-mode steps, the resumable background migrations and where the rollback point of no return sits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-worker-queues/">The worker queues&lt;/a>: the map of all thirty-nine, which pool to dedicate to each group, the per-queue switches, sharding and concurrency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/clickhouse-capacity-langfuse-v4-bytes-per-observation/">Real ClickHouse capacity and cost&lt;/a>: how to measure bytes per observation with the system tables, the difference between the full table and the listings table, and the merge cost of full-text indexes.&lt;/li>
&lt;li>Retention, deletion and data protection: why a deletion does not free disk, the mask cleaner that ships disabled, the pending deletion queue and the S3 lifecycle that has to be implemented by hand.&lt;/li>
&lt;li>Backup and cross recovery: restore ordering across Postgres, ClickHouse and object storage, what each mismatch breaks, and how far event replay goes.&lt;/li>
&lt;li>Saturation runbook: what to alert on from the queue metrics, the stuck probes, draining through the readiness endpoint and the dead letter queue.&lt;/li>
&lt;li>Getting the data out: the blob storage integration to Parquet, batch exports and the metrics API, to build the data lake.&lt;/li>
&lt;/ol>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the six-service architecture and the ten performance levers of version 3.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the four queues where the trace gets lost between the two pieces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>: why the trace user is not the Keycloak one, and when to put a collector in the middle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>: the pipeline and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a>: the tool&amp;rsquo;s other layer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a>: where scores fit.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/changelog/2026-08-17-langfuse-v4">Anuncio de Langfuse v4&lt;/a>, 17 de agosto de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/compatibility">Compatibilidad y migración&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/observability/features/sessions">Sesiones&lt;/a>, &lt;a href="https://langfuse.com/docs/observability/features/sampling">muestreo&lt;/a> y &lt;a href="https://langfuse.com/docs/observability/features/masking">enmascarado&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración nativa con OpenTelemetry&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>The gateway does not live alone: the JWT that does not validate the audience, the user who never reaches the trace, and the seams you have to write by hand</title><link>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</link><pubDate>Sat, 12 Sep 2026 06:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/</guid><description>&lt;blockquote>
&lt;p>Eighth article in the operational track of the control layer. The previous seven deal with the gateway from the inside: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">sizing for agents&lt;/a>. This one deals with what surrounds it. Verified against LiteLLM 1.102.0 and Langfuse 4.35.0, both with a commit of 11 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The gateway has five neighbours: a database, a cache, an identity provider, a trace backend and a collector. Each neighbourhood has its own physics, and these are the seven things it decides.&lt;/p>
&lt;p>&lt;strong>The proxy starts without Postgres, but then only the master key works.&lt;/strong> The &lt;code>DATABASE_URL&lt;/code> variable is optional (&lt;code>proxy_server.py:1163&lt;/code>); without it, any key other than the master one returns a 400 for a missing connection (&lt;code>user_api_key_auth.py:1886&lt;/code>). With a database configured but down, the default behaviour is to return 503, unless &lt;code>general_settings.allow_requests_on_db_unavailable&lt;/code> is switched on.&lt;/p>
&lt;p>&lt;strong>Redis is never mandatory, and that is precisely the trap.&lt;/strong> There is a circuit breaker with a threshold of five failures and a recovery of sixty seconds (&lt;code>constants.py:456&lt;/code>). When it opens, the proxy fails no request: it degrades to local memory. What is lost silently is that rate limits stop being global and become per pod.&lt;/p>
&lt;p>&lt;strong>No observability backend can bring a request down.&lt;/strong> All callbacks are launched with &lt;code>asyncio.create_task&lt;/code> and their exceptions are caught and logged (&lt;code>litellm_logging.py:3235&lt;/code>). If Langfuse does not respond, events are discarded as the queue fills up. There is no added latency, and there is no reliable record either: the pair&amp;rsquo;s observability is a statistical figure, not an audit record.&lt;/p>
&lt;p>&lt;strong>JWT authentication validates neither the audience nor the issuer unless two variables are defined.&lt;/strong> In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>) the audience comes from &lt;code>JWT_AUDIENCE&lt;/code> and the issuer from &lt;code>JWT_ISSUER&lt;/code>; if they are empty, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code> and emits a warning once only. Any valid token signed by that provider, issued for another service, is accepted.&lt;/p>
&lt;p>&lt;strong>Role-based access control ships disabled.&lt;/strong> &lt;code>enforce_rbac&lt;/code> has a default value of &lt;code>False&lt;/code> (&lt;code>_types.py:4848&lt;/code> onwards). With that value, a token whose role resolves to nothing does not produce a 403: it carries on through the flow. Real authorisation, then, comes from team membership, if there is a team claim configured at all.&lt;/p>
&lt;p>&lt;strong>The user identifier that reaches Langfuse is not the one from Keycloak.&lt;/strong> The &lt;code>user_id&lt;/code> field of the trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code>), that is, with the &lt;code>user&lt;/code> field of the OpenAI body, which the client sets. The subject of the token and the owner of the key do not travel. Anyone who wants per-person traceability needs to configure &lt;code>end_user_id_jwt_field&lt;/code> or make the client send that field.&lt;/p>
&lt;p>&lt;strong>Langfuse provisions nothing from a claim.&lt;/strong> There is no variable that maps an OIDC claim to an organisation or to a project. The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code>, the same for everyone. On top of that, per-project access control sits behind the Enterprise licence in the self-hosted edition, as do audit logs and per-project retention.&lt;/p>
&lt;h2 id="you-are-here-between-the-four-pieces">You are here: between the four pieces&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse pair&lt;/a> walked through the seam between the gateway and the trace backend: integration routes, cost that arrives as zero, correlation and queues with discarding. This article widens the focus to the other three neighbourhoods, with identity at the centre, because that is the one that orders everything else and the one an auditor asks about.&lt;/p>
&lt;p>The identity piece itself, that is, Keycloak, is dealt with in &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">the next article&lt;/a>. Here we deal with the gateway side.&lt;/p>
&lt;h2 id="the-analogy-the-building-site-with-five-trades">The analogy: the building site with five trades&lt;/h2>
&lt;p>On a small building site, the problem is rarely inside each trade&amp;rsquo;s work, it is at the joins: where the plumber leaves the pipe sleeve and the bricklayer closes up the partition wall, where the electrician wants to run through the same gap. Each trade does its part well and the join ends up badly resolved because it belongs to nobody.&lt;/p>
&lt;p>An inference platform has five trades. The gateway knows about tokens, the identity provider knows about people, the trace backend knows about events, the database knows about spend and the cache knows about counters. Each one has good documentation. The joins have nobody&amp;rsquo;s documentation, and that is where the user&amp;rsquo;s identity gets lost between the token and the trace.&lt;/p>
&lt;p>There is one more detail of the analogy that is useful. On a building site, the part that causes the most problems is not the one that looks badly finished, it is the one that gets covered up. The same happens here: the five seams in this article fail silently.&lt;/p>
&lt;h2 id="seam-1-what-brings-things-down-and-what-degrades-them">Seam 1. What brings things down and what degrades them&lt;/h2>
&lt;p>It pays to have written down what happens when each neighbour falls over, because intuition fails in almost every case.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Neighbour&lt;/th>
&lt;th>Does it start without it?&lt;/th>
&lt;th>If it falls over&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>Yes, master key only&lt;/td>
&lt;td>503 by default; degraded with &lt;code>allow_requests_on_db_unavailable&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Circuit breaker after 5 failures; limits become per pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Events discarded as the queue fills; no impact on latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Same as the previous one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity provider&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Access to the interface and the JWT flow goes down; virtual keys carry on&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>Without &lt;code>DATABASE_URL&lt;/code>, &lt;code>_setup_prisma_client&lt;/code> returns &lt;code>None&lt;/code> without error (&lt;code>proxy_server.py:10375&lt;/code>). The proxy serves &lt;code>/chat/completions&lt;/code> with the master key. Any other key gets a 400. There is no global budget either, and start-up itself warns about it: Redis does not replace the database (&lt;code>proxy_server.py:9240&lt;/code>).&lt;/p>
&lt;p>With a database configured and down, the exception handler re-raises unless the degradation flag is active (&lt;code>db/exception_handler.py:56&lt;/code>). There is also a &lt;code>DISABLE_PRISMA_HEALTH_CHECK_ON_STARTUP&lt;/code> and a watcher that reconnects. The detail that matters for diagnosis: &lt;strong>a transient database failure returns 503, not 401&lt;/strong>. If during an incident you see 401s, the problem is credentials; if you see 503s, it is the database or admission.&lt;/p>
&lt;h3 id="redis">Redis&lt;/h3>
&lt;p>The circuit breaker is enabled by default, with a threshold of five failures, a recovery of sixty seconds and a minimum duration of five (&lt;code>constants.py:456&lt;/code>, logic in &lt;code>caching/redis_cache.py:153&lt;/code>). The socket has a timeout of 0.1 seconds (&lt;code>constants.py:453&lt;/code>). With the circuit open, &lt;code>DualCache&lt;/code> serves from memory without propagating an exception (&lt;code>dual_cache.py:215&lt;/code>).&lt;/p>
&lt;p>The consequence has to be measured, not assumed: with four workers and three replicas, a limit of one hundred requests per minute becomes one thousand two hundred. It is a failure mode that generates no errors and that can last for days without anyone noticing, until the bill arrives or the engine saturates.&lt;/p>
&lt;p>The right alert is not about Redis, it is about &lt;code>litellm_service_latency&lt;/code> labelled by service (&lt;code>integrations/prometheus_services.py:119&lt;/code>), which exposes Redis and database latency separately.&lt;/p>
&lt;h3 id="the-callbacks">The callbacks&lt;/h3>
&lt;p>Everything behind, that is, Langfuse and the collector, is launched outside the response path and its failures are caught. The classic integration uses the v2 SDK with its own background thread and a flush interval of one second, adjustable with &lt;code>LANGFUSE_FLUSH_INTERVAL&lt;/code> (&lt;code>langfuse.py:198&lt;/code>). There is a cap of fifty instantiated Langfuse clients (&lt;code>constants.py:554&lt;/code>), because each client is a thread.&lt;/p>
&lt;p>The OTLP integration uses the batch processor of the OpenTelemetry SDK (&lt;code>opentelemetry.py:3060&lt;/code>) with its default values. In both cases, saturation means discarding, and discarding generates no error towards the client.&lt;/p>
&lt;h2 id="seam-2-identity-the-three-doors-and-what-each-one-validates">Seam 2. Identity: the three doors and what each one validates&lt;/h2>
&lt;p>Everything goes through a single authentication constructor (&lt;code>user_api_key_auth.py:1245&lt;/code>), in this order:&lt;/p>
&lt;ol>
&lt;li>Custom authentication from the enterprise edition.&lt;/li>
&lt;li>Public routes.&lt;/li>
&lt;li>Opaque OAuth2, if enabled, with a licence check.&lt;/li>
&lt;li>JWT, if &lt;code>general_settings.enable_jwt_auth&lt;/code> is active and the token has three parts.&lt;/li>
&lt;li>Master key, with constant-time comparison.&lt;/li>
&lt;li>Virtual key, with SHA-256 hash and lookup in the verification table.&lt;/li>
&lt;/ol>
&lt;p>A JWT works for &lt;code>/chat/completions&lt;/code>, not just for administration: the routes allowed by default for a team include &lt;code>openai_routes&lt;/code> (&lt;code>_types.py:4886&lt;/code>), which contains the chat endpoint. Administration, on the other hand, is only reachable by a token with an administrator role.&lt;/p>
&lt;h3 id="the-first-thing-to-know-it-is-paid-for">The first thing to know: it is paid for&lt;/h3>
&lt;p>The JWT branch carries an explicit licence check, with the literal message that JWT authentication is an enterprise-only feature (&lt;code>user_api_key_auth.py:1416&lt;/code>). SSO for the administration interface is free up to five billable users and requires a licence above that (&lt;code>ui_sso.py:976&lt;/code>).&lt;/p>
&lt;p>This conditions the architecture of any sovereign deployment that does not want to pay for a licence: the Keycloak integration is left for the administration interface and for provisioning, while inference traffic authenticates with virtual keys. Virtual keys are not worse, but they are a different thing, and it has to be said in the risk analysis: they are credentials decoupled from the identity provider, so removing a person from Keycloak does not invalidate their key.&lt;/p>
&lt;h3 id="the-litellm_jwtauth-fields-that-decide-security">The &lt;code>litellm_jwtauth&lt;/code> fields that decide security&lt;/h3>
&lt;p>From the &lt;code>LiteLLM_JWTAuth&lt;/code> class (&lt;code>_types.py:4848&lt;/code> onwards), these are the ones that matter, with their default values:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>What it does&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>enforce_rbac&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, an unresolved role does not produce a 403&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_scope_based_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Without this, &lt;code>scope_mappings&lt;/code> is not applied&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>enforce_team_based_model_access&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Model access by team&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_jwt_field&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Claim the team comes from&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>team_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>If false, a team that does not exist gives a 404&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id_upsert&lt;/code>&lt;/td>
&lt;td>&lt;code>False&lt;/code>&lt;/td>
&lt;td>Same with the user&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>600&lt;/code>&lt;/td>
&lt;td>Cache of the public keys&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>public_key_stale_ttl&lt;/code>&lt;/td>
&lt;td>&lt;code>3600&lt;/code>&lt;/td>
&lt;td>Extra margin if the provider is down&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>admin_jwt_scope&lt;/code>&lt;/td>
&lt;td>&lt;code>litellm_proxy_admin&lt;/code>&lt;/td>
&lt;td>Scope that grants administration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_allowed_email_domain&lt;/code>&lt;/td>
&lt;td>&lt;code>None&lt;/code>&lt;/td>
&lt;td>Restriction by email domain&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The accepted algorithms are RS256/384/512, PS256/384/512, ES256/384/512 and EdDSA (&lt;code>handle_jwt.py:163&lt;/code>). There is no symmetric one, which eliminates the algorithm confusion class of attack. It is a good design decision and it deserves recognition.&lt;/p>
&lt;p>Key download supports several comma-separated URLs in &lt;code>JWT_PUBLIC_KEY_URL&lt;/code>, resolves discovery if the URL points to the well-known OpenID configuration, retries three times with increasing waits and memoises the failure for thirty seconds (&lt;code>handle_jwt.py:680&lt;/code>, &lt;code>:751&lt;/code>, &lt;code>:88&lt;/code>). If the provider is down, it serves the stale copy up to the sum of the two TTLs, that is, up to seventy minutes. For availability that is fine; for revocation it is not, and it has to be written down.&lt;/p>
&lt;p>One detail of key rotation: with more than one key in the set, an exact match of the key identifier is required, but &lt;strong>with a single key and no identifier it is accepted without checking&lt;/strong> (&lt;code>handle_jwt.py:913&lt;/code>).&lt;/p>
&lt;h3 id="the-validation-that-does-not-happen">The validation that does not happen&lt;/h3>
&lt;p>This is the part to fix on day one. In &lt;code>_build_decode_kwargs&lt;/code> (&lt;code>handle_jwt.py:1003&lt;/code>), the audience is taken from the &lt;code>JWT_AUDIENCE&lt;/code> environment variable and the issuer from &lt;code>JWT_ISSUER&lt;/code>. If they are not defined, the code sets &lt;code>verify_aud=False&lt;/code> and &lt;code>verify_iss=False&lt;/code>, and emits a warning once only.&lt;/p>
&lt;p>What that means in a real deployment with Keycloak: a token issued for the Grafana client, or for the Backstage one, signed by the same realm, is accepted by the gateway as a valid credential. Expiry is always validated, with zero margin. So is the signature. The audience is not.&lt;/p>
&lt;p>There is a second route, &lt;code>issuers&lt;/code>, with per-issuer configuration, which does validate issuer and audience unless explicitly disabled (&lt;code>handle_jwt.py:1186&lt;/code>). That is the one to use when there is more than one provider.&lt;/p>
&lt;p>The minimum fix is two lines of environment:&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="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Keycloak side, an audience mapper in a dedicated client scope that injects that audience. The detail is &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>, because the standard that would resolve this cleanly, resource indicators, is not supported.&lt;/p>
&lt;h3 id="from-claim-to-permission">From claim to permission&lt;/h3>
&lt;p>The administrator role comes from &lt;code>admin_jwt_scope&lt;/code> appearing in the &lt;code>scope&lt;/code> claim, and it returns a result without going through a team (&lt;code>handle_jwt.py:309&lt;/code>, &lt;code>:1373&lt;/code>). The rest is resolved by team.&lt;/p>
&lt;p>If the team in the claim does not exist in the database, with &lt;code>team_id_upsert&lt;/code> false a 404 is returned with the message that the team has to be created (&lt;code>auth_checks.py:3035&lt;/code>). With the flag active, it is created by invoking team creation with a synthetic administrator identity (&lt;code>auth_checks.py:2874&lt;/code>). That is the only automatic provisioning route that exists from Keycloak towards the gateway, and it works well. It pays to know that it creates teams with no budget and no limits, so a default value is needed in the configuration.&lt;/p>
&lt;h3 id="the-vulnerability-worth-knowing-about">The vulnerability worth knowing about&lt;/h3>
&lt;p>&lt;code>CVE-2026-35030&lt;/code>, scoring 9.1 on CVSS 3.1 and 9.4 on CVSS 4.0, advisory &lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code>. With JWT authentication active, the cache of the OIDC user info endpoint used the first twenty characters of the token as the key, so that two tokens with the same prefix were confused with each other. Fixed in 1.83.0.&lt;/p>
&lt;p>In 1.102.0 the code uses the full SHA-256 hash (&lt;code>handle_jwt.py:962&lt;/code>). A search for similar patterns across the tree finds no authentication cache indexed by token prefix: the truncations that remain are masked for logging. And it is worth recalling what has already been published &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">about the MCP gateway&lt;/a>: below 1.83.14 there is unauthenticated remote execution or SQL injection.&lt;/p>
&lt;h2 id="seam-3-identity-does-not-reach-the-trace">Seam 3. Identity does not reach the trace&lt;/h2>
&lt;p>Here lies the most expensive design flaw in the arrangement, because it is discovered at the first audit.&lt;/p>
&lt;p>The &lt;code>user_id&lt;/code> field of a Langfuse trace is filled in with &lt;code>user_api_key_end_user_id&lt;/code> (&lt;code>integrations/langfuse/langfuse.py:604&lt;/code> and &lt;code>:724&lt;/code>), which comes from the &lt;code>user&lt;/code> field of the OpenAI request body or from &lt;code>end_user_id_jwt_field&lt;/code> (&lt;code>litellm_pre_call_utils.py:1584&lt;/code>). It is not the subject of the token nor the owner of the virtual key.&lt;/p>
&lt;p>The knock-on consequences:&lt;/p>
&lt;ul>
&lt;li>If the client does not send &lt;code>user&lt;/code>, the trace has no user.&lt;/li>
&lt;li>If the client does send &lt;code>user&lt;/code>, the trace has whatever value the client cares to put there, unverified.&lt;/li>
&lt;li>An agent calling with a service key leaves traces with no person behind them.&lt;/li>
&lt;/ul>
&lt;p>There are three ways to fix it, in order of robustness:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Configure &lt;code>end_user_id_jwt_field&lt;/code>&lt;/strong> so that the end user comes from a token claim and not from the body. It is the only option in which the value is not controlled by the client, and it requires JWT authentication, that is, a licence.&lt;/li>
&lt;li>&lt;strong>Force the &lt;code>trace_user_id&lt;/code> metadata field&lt;/strong>, which overwrites the field (&lt;code>langfuse.py:726&lt;/code>; on the OTLP route, &lt;code>langfuse_otel.py:96&lt;/code>). It can be set per team.&lt;/li>
&lt;li>&lt;strong>Contractually require the client to send &lt;code>user&lt;/code>&lt;/strong> and validate it in a guardrail. It works, but it is a client declaration, not a proof.&lt;/li>
&lt;/ol>
&lt;p>Something similar happens towards the engine. The caller&amp;rsquo;s identity does not travel to vLLM unless &lt;code>litellm.add_user_information_to_llm_headers&lt;/code> is switched on, whose default value is &lt;code>None&lt;/code> (&lt;code>litellm/__init__.py:223&lt;/code>). With it, &lt;code>x-litellm-*&lt;/code> headers are injected with user identifier, team identifier and key hash (&lt;code>litellm_pre_call_utils.py:1356&lt;/code>). Forwarding client headers requires &lt;code>general_settings.forward_client_headers_to_llm_api&lt;/code>, and the &lt;code>authorization&lt;/code> header is always filtered out, which is correct.&lt;/p>
&lt;p>Towards MCP servers, by default the server&amp;rsquo;s own credential is used, not the user&amp;rsquo;s token. Only two modes forward the caller&amp;rsquo;s token, and doing so has a name in the MCP specification: token passthrough, which is explicitly forbidden. It comes up again &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">in the Keycloak article&lt;/a>.&lt;/p>
&lt;h2 id="seam-4-langfuse-as-a-neighbour">Seam 4. Langfuse as a neighbour&lt;/h2>
&lt;h3 id="what-can-be-automated-and-what-cannot">What can be automated and what cannot&lt;/h3>
&lt;p>The exact list of variables for SSO in Langfuse 4.x, taken from &lt;code>web/src/env.mjs&lt;/code>:&lt;/p>
&lt;p>With the Keycloak provider: &lt;code>AUTH_KEYCLOAK_CLIENT_ID&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_SECRET&lt;/code>, &lt;code>AUTH_KEYCLOAK_ISSUER&lt;/code>, &lt;code>AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/code>, &lt;code>AUTH_KEYCLOAK_CLIENT_AUTH_METHOD&lt;/code>, &lt;code>AUTH_KEYCLOAK_CHECKS&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG&lt;/code>, &lt;code>AUTH_KEYCLOAK_SCOPE&lt;/code>, &lt;code>AUTH_KEYCLOAK_ID_TOKEN&lt;/code>, &lt;code>AUTH_KEYCLOAK_NAME&lt;/code>.&lt;/p>
&lt;p>With generic OIDC: the same set with the &lt;code>AUTH_CUSTOM_&lt;/code> prefix, plus &lt;code>AUTH_CUSTOM_FETCH_USERINFO&lt;/code> and claim mapping with &lt;code>LANGFUSE_CUSTOM_SSO_SUB_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM&lt;/code>, &lt;code>LANGFUSE_CUSTOM_SSO_NAME_CLAIM&lt;/code> and &lt;code>LANGFUSE_CUSTOM_SSO_IMAGE_CLAIM&lt;/code>.&lt;/p>
&lt;p>Global ones: &lt;code>AUTH_DISABLE_USERNAME_PASSWORD&lt;/code>, &lt;code>AUTH_DISABLE_SIGNUP&lt;/code>, &lt;code>AUTH_DOMAINS_WITH_SSO_ENFORCEMENT&lt;/code>, &lt;code>AUTH_SESSION_MAX_AGE&lt;/code>.&lt;/p>
&lt;p>And now what is missing. &lt;strong>There is no variable that maps a claim to an organisation or a project.&lt;/strong> The only thing available is static assignment at sign-up with &lt;code>LANGFUSE_DEFAULT_ORG_ID&lt;/code>, &lt;code>LANGFUSE_DEFAULT_ORG_ROLE&lt;/code> with a default value of &lt;code>VIEWER&lt;/code>, &lt;code>LANGFUSE_DEFAULT_PROJECT_ID&lt;/code> and &lt;code>LANGFUSE_DEFAULT_PROJECT_ROLE&lt;/code> (&lt;code>features/auth/lib/createProjectMembershipsOnSignup.ts:61&lt;/code>). The documentation itself confirms it: enterprise SSO does not provision roles automatically at sign-up.&lt;/p>
&lt;p>On top of that, in the per-edition permissions table (&lt;code>features/entitlements/constants/entitlements.ts:139&lt;/code>), the open and self-hosted professional editions &lt;strong>do not include&lt;/strong> per-project access control or the administration API. They belong to the enterprise edition, as do audit logs and per-project retention. In the open edition, every user inherits the role of their organisation.&lt;/p>
&lt;h3 id="the-declarative-bootstrap-which-does-exist">The declarative bootstrap, which does exist&lt;/h3>
&lt;p>For on-premise without a licence, the workable route is bootstrapping through variables (&lt;code>web/src/initialize.ts&lt;/code>), which is idempotent: &lt;code>LANGFUSE_INIT_ORG_ID&lt;/code> (mandatory, if missing the rest is ignored with a warning), &lt;code>LANGFUSE_INIT_ORG_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_ID&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_RETENTION&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_PROJECT_SECRET_KEY&lt;/code>, &lt;code>LANGFUSE_INIT_USER_EMAIL&lt;/code>, &lt;code>LANGFUSE_INIT_USER_NAME&lt;/code>, &lt;code>LANGFUSE_INIT_USER_PASSWORD&lt;/code>.&lt;/p>
&lt;p>With that, one project per tenant is created from GitOps with one initialisation container per tenant and the keys in Kubernetes secrets.&lt;/p>
&lt;h3 id="langfuse-credentials-per-team">Langfuse credentials per team&lt;/h3>
&lt;p>This part is well resolved on the gateway side and few people use it. &lt;code>TeamCallbackMetadata.callback_vars&lt;/code> (&lt;code>_types.py:2152&lt;/code>) accepts &lt;code>langfuse_public_key&lt;/code>, &lt;code>langfuse_secret_key&lt;/code>, &lt;code>langfuse_host&lt;/code> and &lt;code>langfuse_environment&lt;/code>, with an allow list in &lt;code>initialize_dynamic_callback_params.py:67&lt;/code>. The gateway caches one logger per credential set (&lt;code>langfuse_handler.py:24&lt;/code>) and, on the OTLP route, builds a full exporter per key (&lt;code>langfuse_otel.py:400&lt;/code>).&lt;/p>
&lt;p>That is to say: &lt;strong>one Langfuse project per LiteLLM team is possible today&lt;/strong>, without a licence, and it is the correct way to isolate traces between tenants. What does not exist is automatic project creation, nor the correspondence between the team and the project. That is glue you have to write.&lt;/p>
&lt;h3 id="the-ingestion-route">The ingestion route&lt;/h3>
&lt;p>Langfuse accepts &lt;strong>only OTLP over HTTP&lt;/strong>, in &lt;code>http/protobuf&lt;/code> or &lt;code>http/json&lt;/code>. gRPC is not supported. The base route is &lt;code>/api/public/otel&lt;/code> and the signal lives at &lt;code>/api/public/otel/v1/traces&lt;/code>, verified in the repository. Authentication is Basic with the project&amp;rsquo;s key pair, plus the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header for the version 4 route.&lt;/p>
&lt;p>The gateway builds it exactly like that (&lt;code>langfuse_otel.py:328&lt;/code>, &lt;code>:347&lt;/code>, &lt;code>:359&lt;/code>) and then normalises the endpoint by adding the signal suffix (&lt;code>opentelemetry.py:3237&lt;/code>). The result matches the real route, so the integration works without touching anything. The detail matters when a collector is put in the middle and somebody copies the endpoint by hand.&lt;/p>
&lt;p>And the calendar warning that already appeared &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">in the operational pair&lt;/a> still stands: the classic callback is tied to the v2 SDK and the date marked is 16 November 2026. With a nuance worth reading before scheduling an emergency migration, which is detailed &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">in the data model article&lt;/a>: that date applies to Langfuse Cloud, and in the code of the self-hosted version the old route is not switched off, it changes behaviour according to the write mode.&lt;/p>
&lt;h3 id="when-to-put-a-collector-in">When to put a collector in&lt;/h3>
&lt;p>Direct if Langfuse is the only trace destination. Via a collector if there is more than one consumer, or if any of these three things is needed that the proxy&amp;rsquo;s batch processor does not provide:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Retries with increasing waits and an on-disk queue&lt;/strong>, with the &lt;code>sending_queue&lt;/code> and &lt;code>file_storage&lt;/code> processors. With that, a Langfuse maintenance window stops losing traces.&lt;/li>
&lt;li>&lt;strong>Tail sampling&lt;/strong>, which lets you keep 100 % of errors and 1 % of the rest. The proxy cannot sample by outcome, because at the point it decides it does not yet know how things ended.&lt;/li>
&lt;li>&lt;strong>Attribute redaction&lt;/strong> before they leave the namespace. It is the only layer where you can delete the MCP tool arguments which, as has already been documented, are written in the clear, bypassing the message redaction switch.&lt;/li>
&lt;/ul>
&lt;h2 id="seam-5-postgres-redis-and-the-memory-policy">Seam 5. Postgres, Redis and the memory policy&lt;/h2>
&lt;p>&lt;strong>Separate Postgres, always.&lt;/strong> Each piece applies its own Prisma migrations over the public schema of the database it is given. Two Prisma clients migrating the same schema collide over table names and over migration history. On top of that, Langfuse 4.x requires Postgres 15 as a minimum, recommends 16 and wants UTC by default. The load profile is also the opposite: the gateway writes spend records at high frequency, while Langfuse does light transactional work because trace data goes to ClickHouse.&lt;/p>
&lt;p>&lt;strong>Separate Redis, and the argument is not the one it looks like.&lt;/strong> There is no key collision by design: the gateway uses hash tags of the form &lt;code>{api_key:...}:requests&lt;/code> and an optional namespace, and Langfuse uses the BullMQ prefix plus its own prefix for its key cache. The decisive argument is another one: &lt;strong>Langfuse requires &lt;code>maxmemory-policy=noeviction&lt;/code>&lt;/strong> because its queues are data, not cache, while the gateway assumes a disposable cache. Sharing an instance means that a spike of rate limit keys can fill the memory and cause write errors in the ingestion queues instead of a harmless eviction.&lt;/p>
&lt;p>If it is shared anyway, two minimum measures: &lt;code>REDIS_KEY_PREFIX&lt;/code> in Langfuse, &lt;code>namespace&lt;/code> in the gateway, and different logical databases.&lt;/p>
&lt;p>And a reminder about surface area: Langfuse 4.x &lt;strong>does not start without ClickHouse or without an S3-compatible bucket&lt;/strong>. &lt;code>CLICKHOUSE_URL&lt;/code> and &lt;code>LANGFUSE_S3_EVENT_UPLOAD_BUCKET&lt;/code> are mandatory and have no default value (&lt;code>packages/shared/src/env.ts:120&lt;/code> and &lt;code>:271&lt;/code>). Its failure surface is far larger than the gateway&amp;rsquo;s, and that changes where to put the high availability effort.&lt;/p>
&lt;h2 id="ports-for-the-networkpolicy">Ports, for the NetworkPolicy&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Port&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM (API, interface and metrics)&lt;/td>
&lt;td>4000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse web&lt;/td>
&lt;td>3000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse worker&lt;/td>
&lt;td>3030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ClickHouse&lt;/td>
&lt;td>8123 HTTP, 9000 native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>S3-compatible storage&lt;/td>
&lt;td>9000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Redis&lt;/td>
&lt;td>6379&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel collector&lt;/td>
&lt;td>4317 gRPC, 4318 HTTP&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The pairs to allow under a default-deny policy, following the method from &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening the stack&lt;/a>:&lt;/p>
&lt;pre tabindex="0">&lt;code>litellm → postgres:5432, redis:6379
litellm → collector:4318 (or langfuse-web:3000 if going direct)
litellm → vllm:8000
litellm → keycloak:8443 (interface and JWT flow only)
collector → langfuse-web:3000
langfuse-web → postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-worker→ postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-web → keycloak:8443
ingress → litellm:4000, langfuse-web:3000
prometheus → litellm:4000
&lt;/code>&lt;/pre>&lt;p>The Langfuse worker needs no inbound traffic beyond the health probe.&lt;/p>
&lt;h2 id="the-glue-you-have-to-write">The glue you have to write&lt;/h2>
&lt;p>Put into a table, the real state of the tenant chain:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Segment&lt;/th>
&lt;th>Automatic?&lt;/th>
&lt;th>How&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Keycloak → LiteLLM team&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>team_ids_jwt_field&lt;/code> plus &lt;code>team_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → LiteLLM user&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>&lt;code>user_id_jwt_field&lt;/code> plus &lt;code>user_id_upsert: true&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse organisation&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Static assignment at sign-up only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM team → Langfuse project&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>callback_vars&lt;/code> by hand, or an operator of your own&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keycloak user → trace &lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>&lt;code>end_user_id_jwt_field&lt;/code>, or a contract with the client&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The minimum viable architecture without a licence, then, is this:&lt;/p>
&lt;ol>
&lt;li>One Langfuse project per tenant, created with the initialisation variables from GitOps.&lt;/li>
&lt;li>That project&amp;rsquo;s keys in a Kubernetes secret.&lt;/li>
&lt;li>A periodic job of your own that reads the teams through the gateway&amp;rsquo;s administration API and writes &lt;code>callback_vars&lt;/code> with the corresponding keys.&lt;/li>
&lt;li>People entering Langfuse through Keycloak, but assigned by hand to their organisation.&lt;/li>
&lt;li>The end user identifier resolved by contract with the clients, and validated.&lt;/li>
&lt;/ol>
&lt;p>That is about one hundred and fifty lines of your own code. It pays to budget for them from the start, because the gap does not close by itself.&lt;/p>
&lt;h2 id="reference-configuration">Reference 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="nt">general_settings&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="c"># only with a licence; without one, virtual keys&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">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">litellm_jwtauth&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">team_ids_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&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">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&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">user_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">end_user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;preferred_username&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">user_allowed_email_domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ejemplo.es&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">admin_jwt_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;litellm_proxy_admin&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="c"># the three that ship switched off&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">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">enforce_scope_based_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">allow_requests_on_db_unavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">litellm_settings&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">callbacks&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;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&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 class="c"># without this, the engine does not know who is calling&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">add_user_information_to_llm_headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">cache_params&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&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">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&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"># the two that enable the validation that by default does not happen&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_AUDIENCE&lt;/span>&lt;span class="o">=&lt;/span>litellm-gateway
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">JWT_PUBLIC_KEY_URL&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma/.well-known/openid-configuration
&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="nv">LANGFUSE_HOST&lt;/span>&lt;span class="o">=&lt;/span>http://langfuse-web.observabilidad.svc:3000
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_PUBLIC_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SECRET_KEY&lt;/span>&lt;span class="o">=&lt;/span>...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the Langfuse side:&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="nv">AUTH_KEYCLOAK_CLIENT_ID&lt;/span>&lt;span class="o">=&lt;/span>langfuse
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ISSUER&lt;/span>&lt;span class="o">=&lt;/span>https://sso.ejemplo.es/realms/plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_USERNAME_PASSWORD&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">AUTH_DISABLE_SIGNUP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ID&lt;/span>&lt;span class="o">=&lt;/span>plataforma
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_DEFAULT_ORG_ROLE&lt;/span>&lt;span class="o">=&lt;/span>VIEWER
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">ENCRYPTION_KEY&lt;/span>&lt;span class="o">=&lt;/span>... &lt;span class="c1"># 64 hexadecimal characters&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Define &lt;code>JWT_AUDIENCE&lt;/code> and &lt;code>JWT_ISSUER&lt;/code> on day one, or use the per-issuer configuration. Without that, neither audience nor issuer is validated.&lt;/li>
&lt;li>Set &lt;code>enforce_rbac&lt;/code>, &lt;code>enforce_scope_based_access&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code> to true, and test that a token from another client of the same realm is rejected.&lt;/li>
&lt;li>Decide where the end user of the traces comes from and write it into the design. If it is not decided, traces come out with no person.&lt;/li>
&lt;li>Separate Postgres. Separate Redis. If Redis is shared, different prefix and logical database, and review the memory policy.&lt;/li>
&lt;li>Check that the proxy starts with Postgres down only if that is what you want, and that the on-call team can tell an admission 503 from a credential 401.&lt;/li>
&lt;li>Alert on &lt;code>litellm_service_latency&lt;/code> per service, not on Redis availability, because the circuit breaker hides the problem.&lt;/li>
&lt;li>One Langfuse project per tenant with the initialisation variables, and the per-team keys in &lt;code>callback_vars&lt;/code>.&lt;/li>
&lt;li>Put a collector in the middle if you need an on-disk queue, tail sampling or attribute redaction.&lt;/li>
&lt;li>Check that the gateway version is above 1.83.14 because of the earlier critical vulnerabilities.&lt;/li>
&lt;li>Write into the risk analysis that virtual keys are credentials decoupled from the identity provider, and define the offboarding procedure.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>A properly signed JWT is not a JWT addressed to this service.&lt;/strong> Without the two environment variables, the audience is not checked.&lt;/p>
&lt;p>&lt;strong>&lt;code>enforce_rbac&lt;/code> set to false does not mean there is no authorisation&lt;/strong>, it means that authorisation comes only from team membership, if there is a team claim. With no team claim and without the flag, a valid token reaches &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The stale copy of the public keys lasts up to seventy minutes&lt;/strong>, adding the two TTLs. Revoking a key at the provider does not take effect immediately.&lt;/p>
&lt;p>&lt;strong>With a single key in the JWKS and no key identifier, the identifier is not checked.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>The trace user is set by the client.&lt;/strong> Any per-person spend report built on that field is a declaration, not a measurement.&lt;/p>
&lt;p>&lt;strong>Redis going down generates no errors&lt;/strong>, it generates per-pod limits. It is the most expensive of the failures that give no warning.&lt;/p>
&lt;p>&lt;strong>Langfuse does not start without ClickHouse or without an S3 bucket.&lt;/strong> Its failure surface is bigger than the gateway&amp;rsquo;s, and that changes how the high availability effort is shared out.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s per-project access control is paid for in self-hosted&lt;/strong>, as are audit logs. For an ENS file, that gets decided before building, not after.&lt;/p>
&lt;p>&lt;strong>Sharing Redis does not break through key collision, it breaks through memory policy.&lt;/strong> It is a failure that shows up under load and gets diagnosed badly.&lt;/p>
&lt;p>&lt;strong>Langfuse&amp;rsquo;s ingestion route is OTLP over HTTP only.&lt;/strong> A collector configured with the gRPC exporter points at somewhere that does not exist.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The five seams share a common pattern: they fail without warning. The token from another service gets in, the trace user comes from the client, the rate limit stops being global, the spend queue discards on reaching 64 MB and the Langfuse project does not create itself. None of those five things produces a visible error.&lt;/p>
&lt;p>Day 2 work, then, consists above all of turning silences into signals. Two environment variables so that the audience is validated, three flags so that authorisation is real, an alert on service latency instead of on Redis availability, and an explicit contract on where the end user&amp;rsquo;s identity comes from.&lt;/p>
&lt;p>And one decision worth taking early, before the budget is closed. The gateway&amp;rsquo;s JWT authentication and Langfuse&amp;rsquo;s per-project access control are both behind a licence. A sovereign platform can live perfectly well without either, with virtual keys and one project per tenant, but it is a different architecture and it has to be drawn as such from the start.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>: the seam between gateway and traces, with the four queues that discard.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>: the previous article in the track, with the size of each piece.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>: the identity piece from the inside and its position in the architecture.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the second door, with its permissions and its privacy problem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a>: the default-deny method that orders the port table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: adding authentication with Keycloak&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>Código de Langfuse 4.35.0, commit &lt;code>39e3cd7d&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/authentication-and-sso">Autenticación y SSO en Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/docs/administration/scim-and-org-api">SCIM y API de organización&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/native/opentelemetry">Integración OpenTelemetry de Langfuse&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/postgres">Postgres&lt;/a> y &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/cache">caché&lt;/a> en la documentación de self-hosting, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">Autenticación por token en LiteLLM&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">ajustes de configuración&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;code>GHSA-jjhc-v7c2-5hh6&lt;/code> y registro de &lt;code>CVE-2026-35030&lt;/code> en NVD.&lt;/li>
&lt;/ul></description></item><item><title>Sizing for agents: 275 input tokens for every output token, and why the bottleneck is still decode</title><link>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</link><pubDate>Sat, 12 Sep 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/</guid><description>&lt;blockquote>
&lt;p>Seventh article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">The fourth&lt;/a> dealt with the coexistence of human traffic and agentic traffic on a fixed fleet. This one deals with the prior question: how big the fleet has to be, and how big the gateway in front of it. Verified against LiteLLM 1.102.0 (commit of 11 September 2026) and vLLM 0.29.0, with load data published between May and September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Until this year, sizing for agents meant extrapolating a chat profile and adding margin. In 2026 the first production-scale characterisations were published and the profile turned out to be something else. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>The median prompt of an agentic call is 68,000 tokens, of which 63,000 arrive cached.&lt;/strong> The measurement covers 13.5 million sessions, 3.2 million users, 760.5 million model calls and 44.9 trillion prompt tokens in one week of June 2026. The median input-to-output ratio exceeds 275 to 1. Any calculation done on a profile of 800 prompt tokens and 250 output tokens describes a different system.&lt;/p>
&lt;p>&lt;strong>The load is prefill-dominant when counting tokens and decode-dominant when counting time.&lt;/strong> The genuinely new tokens that have to be prefilled on each turn are only between 1.5 and 7.3 times the output, not 275. With the prefix cache working, the split of execution time runs from 91 % to 98.6 % in decode. You size by memory bandwidth and by KV cache capacity, not by prefill FLOPs.&lt;/p>
&lt;p>&lt;strong>The sizing risk is not the average, it is the collapse of the prefix cache.&lt;/strong> The median hit rate within a turn is 98 %, it falls to 55 % at the boundary between turns and to 8 % when the client changes model. With dynamically retrieved context, prefix reuse drops to 1 %. One miss turns 142,000 prefix tokens into real prefill. Session affinity per replica stops being an optimisation and becomes a capacity requirement.&lt;/p>
&lt;p>&lt;strong>The intra-turn concurrency measured in production is 1.15 at the median and 1.4 at the 90th percentile.&lt;/strong> 93 % of tool batches contain a single invocation. The image of the agent opening twenty calls in parallel does not appear in the data. The real multiplier comes from the duty cycle and from the fact that 87 % of calls are started by the agent, not by the person.&lt;/p>
&lt;p>&lt;strong>The gateway is sized per worker, and each worker is a universe.&lt;/strong> One vCPU and 4 GiB per worker, with requests equal to limits. The limit counters, the caches and the cooldowns live in the worker&amp;rsquo;s memory unless there is Redis, so a limit of 100 requests per minute with four workers and three replicas allows 1,200. The Prisma pool is also per process: the number of connections open against Postgres is workers times &lt;code>connection_limit&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The CPU cost that grows with agentic load is the token counter.&lt;/strong> It serialises the entire tool catalogue on every request and tokenises it. The project itself measured 46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens, and the operation passes through a concurrency limiter of four per worker. With agents sending long catalogues and six-figure contexts, that limit is the ceiling before the CPU is.&lt;/p>
&lt;p>&lt;strong>Autoscaling by memory does not work on this piece and the startup probe takes five minutes.&lt;/strong> The official chart deliberately leaves memory without a target because Prisma&amp;rsquo;s query engine never returns the maximum reached. That leaves CPU at 60 %, with a &lt;code>startupProbe&lt;/code> of 300 seconds that makes any new replica arrive late. For agents there are two better metrics, and they are in &lt;code>/metrics&lt;/code>.&lt;/p>
&lt;h2 id="you-are-here-the-arithmetic-that-comes-before-buying">You are here: the arithmetic that comes before buying&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Classic capacity planning&lt;/a> starts from a chat SLO and crosses two budgets, the VRAM one and the time one. That method is still correct. What changes with agents are the inputs: the prompt length, the number of calls per unit of human work, the split between prefill and decode, and the very definition of an SLO. This article replaces those inputs with measured figures and drags the result through to two numbers: gateway replicas and GPUs.&lt;/p>
&lt;p>Out of scope is the splitting of traffic once it is built, which was already covered by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the coexistence of humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>.&lt;/p>
&lt;h2 id="the-analogy-the-repair-shop-and-the-front-desk">The analogy: the repair shop and the front desk&lt;/h2>
&lt;p>An appliance repair shop sizes its staff by counting faults, not phone calls. The difference matters because the person who brings in the washing machine occupies the front desk for three minutes, while the technician who takes it apart goes back to consult the machine&amp;rsquo;s data sheet fourteen times over the afternoon. If the shop manager sizes by visits to the front desk, they end up short of technicians and overstaffed at reception.&lt;/p>
&lt;p>The inference platform has the same asymmetry. The person sends one turn. The agent sends fifteen calls for that turn, and each one drags along the complete file of everything done so far. The front desk is the gateway and the technicians are the GPUs, and the usual mistake is to size both with the same counter.&lt;/p>
&lt;p>There is a second detail the analogy captures well. The technician does not read the whole data sheet each time: they have it open on the bench. That is the prefix cache. The day somebody moves them to a different bench between consultations, they have to fetch it from the archive again, and the work multiplies without the number of faults having increased.&lt;/p>
&lt;h2 id="part-1-the-real-load-profile">Part 1. The real load profile&lt;/h2>
&lt;p>For two years, any capacity calculation for agents was done on assumptions. In 2026 three pieces of work appeared that measure the matter, and it is worth reading them together because they measure different scaffolds and their figures are not averageable.&lt;/p>
&lt;h3 id="the-three-sources">The three sources&lt;/h3>
&lt;p>The characterisation at scale was published by a research team working on GitHub Copilot production telemetry (&lt;a href="https://arxiv.org/html/2608.00101v1">arXiv 2608.00101&lt;/a>, 30 July 2026): 13.5 million sessions, 3.2 million users, 95.1 million user turns, 760.5 million model calls and 44.9 trillion prompt tokens, in one week of June 2026. It is the largest sample published to date.&lt;/p>
&lt;p>The characterisation by benchmark suites comes from an architecture team (&lt;a href="https://arxiv.org/html/2605.26297">arXiv 2605.26297&lt;/a>, 25 May 2026), which instruments five of them (ADE-Bench, DABStep, GAIA, SWE-bench Pro and Terminal-Bench 2.0) and measures the internal split of time.&lt;/p>
&lt;p>The production measurement on a different scaffold was published by the vLLM project itself together with AgentX (&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM blog&lt;/a>, 8 September 2026).&lt;/p>
&lt;p>The three disagree on turns per session: 15 calls at Copilot&amp;rsquo;s median, 43 turns in AgentX, 62.4 in SWE-bench Pro with Qwen. It is not a contradiction, they are scaffolds with different loops. The sizing lesson is that the number of calls per unit of human work depends on the client that connects, and that is why you have to measure it at home before fixing the size.&lt;/p>
&lt;h3 id="the-figures-you-can-actually-use">The figures you can actually use&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Quantity&lt;/th>
&lt;th>Measured value&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Median prompt per call&lt;/td>
&lt;td>68K tokens, 63K of them cached&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Input:output ratio (median)&lt;/td>
&lt;td>above 275:1&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median session&lt;/td>
&lt;td>3 user turns, 15 calls, 4.2 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mean session&lt;/td>
&lt;td>6.1 turns, 40.6 calls, 62.6 min&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls per turn (median)&lt;/td>
&lt;td>4.5&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Calls started by the agent&lt;/td>
&lt;td>87 %&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Range across user profiles&lt;/td>
&lt;td>50× (23K to 1.1M tokens per turn)&lt;/td>
&lt;td>2608.00101&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Median input context&lt;/td>
&lt;td>142K tokens; output 444 tokens per turn&lt;/td>
&lt;td>AgentX&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Accumulated context in SWE-bench Pro&lt;/td>
&lt;td>mean 68.7 to 80.1K, maximum 146 to 166K&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasoning tokens over the output&lt;/td>
&lt;td>29.0 % to 67.6 %&lt;/td>
&lt;td>2605.26297&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two operational observations come out of that and appear in no sizing guide.&lt;/p>
&lt;p>The first: &lt;strong>agents that fail accumulate up to 1.8 times more average context than those that finish well&lt;/strong> (2605.26297). The cost does not correlate with success, it correlates with difficulty and with getting stuck. A sizing done on the task that goes well falls short.&lt;/p>
&lt;p>The second: the range across user profiles is 50 to 1. A user who only converses spends 23,000 tokens per turn; one who leaves the agent in a long loop spends 1.1 million. Segmenting by usage profile pays off more than any difficulty-based routing, because the variance is in the people, not in the requests.&lt;/p>
&lt;h3 id="what-to-measure-before-calculating">What to measure before calculating&lt;/h3>
&lt;p>Three of your own figures, taken from a week of real traffic, are worth more than any table in this article:&lt;/p>
&lt;ol>
&lt;li>Model calls per hour of human work, by user profile.&lt;/li>
&lt;li>Prompt length distribution (median and 95th percentile), not the mean.&lt;/li>
&lt;li>Aggregate prefix cache hit rate, with the time series, not the single number.&lt;/li>
&lt;/ol>
&lt;p>All three come from what the gateway already stores. In &lt;code>LiteLLM_SpendLogs&lt;/code> you have the tokens per request and the key and team identifiers; in &lt;code>proxy_server_request&lt;/code> you have the &lt;code>tools&lt;/code> block of each call; and in vLLM&amp;rsquo;s &lt;code>/metrics&lt;/code> you have &lt;code>vllm:prefix_cache_queries&lt;/code> and &lt;code>vllm:prefix_cache_hits&lt;/code>.&lt;/p>
&lt;h2 id="part-2-prefill-decode-and-the-cache-trap">Part 2. Prefill, decode and the cache trap&lt;/h2>
&lt;p>Here is the finding that corrects almost everyone&amp;rsquo;s intuition, including mine when I started this article.&lt;/p>
&lt;p>Counting tokens, agentic load is overwhelmingly input: 275 to 1. The apparently obvious conclusion would be to size by prefill capacity, that is by FLOPs. It is false.&lt;/p>
&lt;p>What has to be prefilled on each turn is not the whole prompt, but what was added since the previous turn. That addition, measured, sits between 1.5 and 7.3 times the output length (2605.26297). The rest is served by the prefix cache. And the real split of the model&amp;rsquo;s execution time comes out like this: &lt;strong>between 91.0 % and 98.6 % in decode, between 1.4 % and 9.0 % in prefill&lt;/strong> (2605.26297).&lt;/p>
&lt;p>The hit rate that sustains that split is high and has been measured in three places: theoretical from 87.9 % to 99.3 % and empirical from 84.6 % to 99.5 % (2605.26297); above 96 % in production (AgentX); and a median of 98 % within a turn in Copilot (2608.00101).&lt;/p>
&lt;h3 id="where-it-falls-over">Where it falls over&lt;/h3>
&lt;p>The same Copilot work measures the degradation, and it is the most actionable figure in the article:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>98 %&lt;/strong> median hit rate within a turn. The detail matters: the first call of a turn hits around 45 %, the second rises to 86 % and from the third onwards it stabilises between 92 % and 94 %.&lt;/li>
&lt;li>&lt;strong>55 %&lt;/strong> at the boundary between turns, with idle gaps of 4.1 minutes at the median.&lt;/li>
&lt;li>&lt;strong>8 %&lt;/strong> when the client changes model.&lt;/li>
&lt;/ul>
&lt;p>And there is a fourth, worse regime: with dynamically retrieved context, that is when the scaffold injects search or RAG results that change on every iteration, prefix reuse falls to 1 % or less (&lt;a href="https://arxiv.org/html/2608.15127">arXiv 2608.15127&lt;/a>, 15 August 2026).&lt;/p>
&lt;p>A cache miss with a context of 142,000 tokens turns a prefill of a few thousand tokens into a six-figure one. The work multiplies by a factor of between 20 and 60 without the number of requests having changed. That is the difference between a fleet that meets its targets and one that seizes up mid-afternoon.&lt;/p>
&lt;p>Two sizing rules come from that:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Without session affinity towards the same replica, the calculated sizing is worthless.&lt;/strong> LiteLLM&amp;rsquo;s concrete mechanism is &lt;code>session_affinity&lt;/code>, which is not active by default and which was already covered &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">in the prefix routing article&lt;/a>. Without it, the calculation has to be redone assuming a prefill regime, which is between 20 and 60 times more expensive.&lt;/li>
&lt;li>&lt;strong>Changing model halfway through a session costs the entire cache.&lt;/strong> Any difficulty-based routing policy that moves a live conversation from one model group to another pays an 8 % hit rate on the next call. If you want to route by difficulty, do it at the start of the session.&lt;/li>
&lt;/ol>
&lt;h3 id="co-batching-which-is-the-other-effect-nobody-measures">Co-batching, which is the other effect nobody measures&lt;/h3>
&lt;p>Mixing a short request into the same batch as long-context requests degrades the short one&amp;rsquo;s inter-token latency. The measured figure: &lt;strong>one long request in the batch raises time per token by 38.3 %; two raise it by 79.7 %&lt;/strong> (2608.15127). It is the technical reason why separating human and agent pools improves the human experience more than the capacity split suggests, and it is also the reason not to raise &lt;code>--max-num-seqs&lt;/code> above what the KV budget allows.&lt;/p>
&lt;h2 id="part-3-the-kv-budget-with-the-numbers-of-the-agentic-case">Part 3. The KV budget, with the numbers of the agentic case&lt;/h2>
&lt;p>The formula does not change with respect to &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">classic capacity planning&lt;/a>, but the inputs do, and the result is surprising:&lt;/p>
$$\text{KV}_{\text{bytes/token}} = 2 \times n_{\text{layers}} \times n_{\text{kv heads}} \times d_{\text{head}} \times \text{bytes}_{\text{elem}}$$
&lt;p>For a 70B-class geometry (80 layers, 8 KV heads with grouped attention, head dimension 128, 2-byte elements) that gives 0.3125 MiB per token. With the median agentic context of 142,000 tokens:&lt;/p>
$$142{,}000 \times 0.3125\ \text{MiB} \approx 43.3\ \text{GiB per sequence}$$
&lt;p>For a 30B-class geometry (48 layers, everything else the same) that gives 0.1875 MiB per token, that is around 26.0 GiB per sequence.&lt;/p>
&lt;p>It is worth pausing on what that means. An 80 GB H100 with the weights of a 70B model in FP8 already taking up space does not have 43 GiB free for a single sequence. The conclusion is harsh and has to be written down: &lt;strong>serving real 70B-class agentic context demands tensor parallelism of at least 2 and a quantised KV cache&lt;/strong>. And the number of simultaneous sequences that fit, which is what sets &lt;code>--max-num-seqs&lt;/code>, comes from dividing the available KV by that figure, not from a chat heuristic.&lt;/p>
$$\text{sequences} \approx \frac{\text{VRAM}_{\text{KV available}}}{\text{KV}_{\text{bytes/token}} \times \text{max context}}$$
&lt;p>With FP8 in the KV, the divisor halves. It is the cheapest capacity lever there is in this load profile, and its cost in quality is validated with evals, not assumed.&lt;/p>
&lt;h2 id="part-4-from-people-to-concurrent-requests">Part 4. From people to concurrent requests&lt;/h2>
&lt;p>This is where most calculations overshoot, because it is assumed that an agent equals many simultaneous requests. The data says otherwise.&lt;/p>
&lt;p>The concurrency of calls within a turn, measured in production, is &lt;strong>1.15 at the median and 1.4 at the 90th percentile&lt;/strong>, and 93 % of tool batches contain a single invocation (2608.00101). Intra-turn parallelism is small. Subagents appear in 44 % of AgentX sessions, with a median of 4 deployments among those that use them, so there is a long tail, but it is not the usual regime.&lt;/p>
&lt;p>What does multiply is the duty cycle. The mean session lasts 62.6 minutes and contains 40.6 calls. At 444 output tokens and an interactivity of around 60 tokens per second, each call occupies around 7.4 seconds of decode, which gives between 5 and 7 active minutes out of those 62.6.&lt;/p>
$$C = D \times \rho \times A$$
&lt;p>where $D$ is the people working, $\rho$ the duty cycle and $A$ the amplification. With $\rho \approx 0.10$ to $0.12$ derived from the previous figures, twenty developers give between 2 and 2.4 concurrent sequences in the steady state. It is the peak, not the average, that has to be sized for.&lt;/p>
&lt;h3 id="the-amplification">The amplification&lt;/h3>
&lt;p>Three factors published separately:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tool failures&lt;/strong>: 9 % of turns trigger them, with a compute amplification of 4 times (2608.00101).&lt;/li>
&lt;li>&lt;strong>Subagents&lt;/strong>: 44 % of sessions, median of 4 deployments (AgentX).&lt;/li>
&lt;li>&lt;strong>Internal variance&lt;/strong>: the same task from the same agent varies by up to 30 times in cost within a single trace (2608.15127).&lt;/li>
&lt;/ul>
&lt;p>The product of the first two gives $(1 + 0.09 \times 3) \times (1 + 0.44 \times 3) \approx 2.9$. Nobody publishes that product as a single figure, so it is flagged as my own derivation, but &lt;strong>an amplification factor of 3 is the reasonable design provision&lt;/strong> and it fits the measured variance.&lt;/p>
&lt;p>For the twenty developers in the example: between 6 and 7 concurrent design sequences, with 142K contexts. At 43.3 GiB of KV per sequence in the 70B class, that is around 300 GiB of KV cache, that is four H100s dedicated to KV alone with the weights on top. With KV in FP8, half. With a 30B-class model, a third.&lt;/p>
&lt;p>That calculation, done with the chat profile of 800 prompt tokens, would have given a single GPU. That is the distance between the two regimes.&lt;/p>
&lt;h2 id="part-5-the-gateway-which-also-has-to-be-sized">Part 5. The gateway, which also has to be sized&lt;/h2>
&lt;p>The proxy consumes no GPU, but it has its own arithmetic and it is the part that produces the most surprises in production, because almost all of its state is per process.&lt;/p>
&lt;h3 id="a-worker-is-a-universe">A worker is a universe&lt;/h3>
&lt;p>The default value of &lt;code>--num_workers&lt;/code> is &lt;strong>1&lt;/strong> (&lt;code>litellm/constants.py:57&lt;/code>). The usual startup is uvicorn directly with uvloop (&lt;code>proxy_cli.py:1477&lt;/code>), and there are alternatives with gunicorn, hypercorn and granian.&lt;/p>
&lt;p>What lives in each worker&amp;rsquo;s memory, and therefore multiplies:&lt;/p>
&lt;ul>
&lt;li>The in-memory cache, limited to &lt;strong>200 elements&lt;/strong> with a TTL of 600 seconds (&lt;code>litellm/caching/in_memory_cache.py:27&lt;/code>). With many deployments, the cooldowns and the counters compete for that same space and get evicted before they expire.&lt;/li>
&lt;li>The key authentication cache, with a TTL of &lt;strong>60 seconds&lt;/strong> (&lt;code>proxy_server.py:1650&lt;/code>), adjustable with &lt;code>general_settings.user_api_key_cache_ttl&lt;/code>.&lt;/li>
&lt;li>The limit and budget counters when there is no Redis (&lt;code>parallel_request_limiter_v3.py&lt;/code>, &lt;code>local_only=True&lt;/code> paths).&lt;/li>
&lt;li>The router cooldowns, with &lt;code>DEFAULT_COOLDOWN_TIME_SECONDS = 5&lt;/code> (&lt;code>constants.py:75&lt;/code>).&lt;/li>
&lt;li>A periodic task scheduler of its own per worker (&lt;code>proxy_server.py:9674&lt;/code>), coordinated between pods with a lock in Redis.&lt;/li>
&lt;/ul>
&lt;p>The practical consequence: &lt;strong>a limit of 100 requests per minute with four workers and three replicas allows up to 1,200 without Redis&lt;/strong>. With Redis, the v3 limiter registers seven Lua scripts at startup (&lt;code>parallel_request_limiter_v3.py:608&lt;/code>) and the limits become global.&lt;/p>
&lt;p>One detail of the v3 limiter that matters with agentic load: &lt;code>LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/code> comes enabled by default and reserves estimated tokens before the call, which adds a round trip to Redis per request. And there is an &lt;code>asyncio.Lock&lt;/code> per instance that serialises the atomic batch checks (&lt;code>:665&lt;/code>), documented by the code itself as a 99th-percentile risk under contention.&lt;/p>
&lt;p>If Redis goes down, requests do not fail: there is a circuit breaker with a threshold of 5 failures and a recovery of 60 seconds (&lt;code>constants.py:456&lt;/code>), and the proxy degrades to local memory. What is lost silently is the globality of the limits.&lt;/p>
&lt;h3 id="postgres">Postgres&lt;/h3>
&lt;p>The pool is also per process. The default values are &lt;code>database_connection_pool_limit = 10&lt;/code> and &lt;code>database_connection_pool_timeout = 60&lt;/code> (&lt;code>proxy_cli.py:62&lt;/code>), injected as parameters of the Prisma URL. A pod with four workers opens forty connections. Three pods, one hundred and twenty. It is the fast route to exhausting Postgres&amp;rsquo;s &lt;code>max_connections&lt;/code> without having served any traffic, and the reason the official chart offers PgBouncer inside the pod itself with &lt;code>maxDbConnections: 20&lt;/code> and &lt;code>maxClientConn: 1000&lt;/code>.&lt;/p>
&lt;p>On the request path there is only the authentication, which is cached for 60 seconds, and the rate limiting. Spend is deferred. The &lt;code>SpendLogs&lt;/code> queue is capped at &lt;strong>64 MB&lt;/strong> and on overflow it discards the oldest elements with an error in the log (&lt;code>utils.py:6704&lt;/code>). The write interval is &lt;code>proxy_batch_write_at&lt;/code>, with a default value of 10 seconds plus a random spread of up to 5 (&lt;code>constants.py:1719&lt;/code>, &lt;code>proxy_server.py:9704&lt;/code>); the production guide recommends raising it to 60.&lt;/p>
&lt;p>With a slow Postgres, the queue grows to those 64 MB and then loses billing data without returning any error to the client. It is the silent failure mode you have to alert on.&lt;/p>
&lt;h3 id="the-token-counter-which-is-the-agentic-trap">The token counter, which is the agentic trap&lt;/h3>
&lt;p>This is the piece that turns a correct sizing into an insufficient one, and it appears in no guide.&lt;/p>
&lt;p>Before admitting a request, the proxy counts tokens. With chat load the cost is negligible. With agentic load it is not, for two reasons that add up.&lt;/p>
&lt;p>The first is the prompt length. The project itself measured the admission counting cost at &lt;strong>46, 53 and 100 ms for prompts of 50K, 75K and 100K tokens&lt;/strong> (&lt;a href="https://docs.litellm.ai/docs/benchmarks">benchmarks&lt;/a>, consulted on 12 September 2026), and published that with a counter written in Rust those figures fall to 4.9, 6.8 and 10.2 ms.&lt;/p>
&lt;p>The second is the tool catalogue. The function that prepares the definitions serialises the entire catalogue to a pseudo-TypeScript and tokenises it (&lt;code>litellm_core_utils/token_counter.py:934&lt;/code>), plus nine fixed tokens. The cost is linear in the size of the catalogue and it is paid on every request. That is exactly the pattern of an agent connected to an MCP gateway: a long catalogue, resent on every turn. The token bill of that catalogue was already covered &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">in the MCP gateway article&lt;/a>; here its other face appears, which is proxy CPU.&lt;/p>
&lt;p>And on top of that there is an explicit ceiling. The counting runs in a thread with a capacity limiter whose default value is &lt;strong>4 per worker&lt;/strong> (&lt;code>token_counter.py:340&lt;/code>, &lt;code>constants.py:407&lt;/code>). With 100K prompts, four concurrent counts of 100 ms mean a theoretical ceiling of around 40 admissions per second per worker before the limiter starts queueing, and that is without having called the model yet.&lt;/p>
&lt;p>What is cached is the tokeniser, with an LRU of &lt;strong>64&lt;/strong> entries (&lt;code>utils.py:2206&lt;/code>), not the result of the count.&lt;/p>
&lt;p>Three levers, in order of return: raise &lt;code>TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/code> if CPU is spare, reduce the tool catalogue exposed per key, and watch the gateway overhead figure in &lt;code>/metrics&lt;/code> to know whether this is happening.&lt;/p>
&lt;h3 id="streaming-and-admission">Streaming and admission&lt;/h3>
&lt;p>There is no connection limit by default. &lt;code>--limit_concurrency&lt;/code> exists but is optional, and it returns 503. The built-in admission control is only activated if &lt;code>general_settings.max_in_flight_requests_per_worker&lt;/code> is defined (&lt;code>middleware/admission_control_middleware.py:285&lt;/code>), with &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code> at 1.0 by default, rejecting with 503 and a &lt;code>retry-after: 1&lt;/code> header.&lt;/p>
&lt;p>For agents, that header matters: an agentic client that retries without respecting it multiplies the load exactly when the gateway is saying it cannot take any more. The combination of gateway retries was already calculated by &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the fourth article&lt;/a> and it reached 45 calls per turn in the worst case.&lt;/p>
&lt;p>The default request timeout is &lt;strong>6,000 seconds&lt;/strong> (&lt;code>constants.py:512&lt;/code>). The production guide recommends 600. With long agentic tasks, leaving it at 6,000 means a hung connection occupies a slot for an hour and a half.&lt;/p>
&lt;h3 id="resources-and-autoscaling">Resources and autoscaling&lt;/h3>
&lt;p>The official chart recommends &lt;strong>1 vCPU and 4 GiB per worker, with requests equal to limits&lt;/strong> (&lt;code>helm/litellm-helm/values.yaml:235&lt;/code>), and the production documentation suggests one worker per pod scaling horizontally. The project&amp;rsquo;s own high-performance benchmark, by contrast, uses four workers per pod with PgBouncer in front.&lt;/p>
&lt;p>The HPA comes with &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>. And there is a comment in the chart worth reading twice: &lt;strong>memory is deliberately left without a target&lt;/strong> because the resident consumption of Prisma&amp;rsquo;s query engine is a high-water mark that is never returned, so that an HPA on memory would add replicas and never remove them (&lt;code>values.yaml:260&lt;/code>).&lt;/p>
&lt;p>The other problem is startup. The &lt;code>startupProbe&lt;/code> has &lt;code>failureThreshold: 30&lt;/code> with a period of 10 seconds, that is &lt;strong>up to 300 seconds&lt;/strong> before the pod is considered ready (&lt;code>values.yaml:109&lt;/code>). An HPA that reacts at 60 % CPU and takes five minutes to add capacity arrives late to any agentic burst.&lt;/p>
&lt;p>The alternative, optional in the chart, is to scale by Prometheus metrics: &lt;code>targetRequestsPerSecond&lt;/code> and &lt;code>targetTokensPerSecond&lt;/code> over &lt;code>rate(litellm_proxy_total_requests_metric_total[1m])&lt;/code> and &lt;code>rate(litellm_total_tokens_metric_total[1m])&lt;/code>. The chart itself warns that the token metric lags with long streaming responses, because tokens are accounted for on completion. For agents, that is almost always.&lt;/p>
&lt;h2 id="part-6-what-to-watch-the-metrics-that-work-and-the-ones-that-broke">Part 6. What to watch: the metrics that work and the ones that broke&lt;/h2>
&lt;h3 id="at-the-gateway">At the gateway&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>litellm_in_flight_requests&lt;/code>&lt;/td>
&lt;td>Real saturation per worker. It is the scaling metric&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_latency_metric&lt;/code>&lt;/td>
&lt;td>Proxy cost per request; it rises with token counting&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_overhead_with_guardrails_latency_metric&lt;/code>&lt;/td>
&lt;td>The same including guardrails&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>Arrival through to pre-call, includes authentication and ASGI queueing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_deployment_cooled_down&lt;/code>&lt;/td>
&lt;td>Replicas out of the pool&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm_service_latency&lt;/code>&lt;/td>
&lt;td>Redis and database latency, per service&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>/health/backlog&lt;/code> endpoint returns &lt;code>in_flight_requests&lt;/code>, &lt;code>admitted_requests&lt;/code>, &lt;code>queued_requests&lt;/code> and &lt;code>rejected_requests&lt;/code> (&lt;code>health_endpoints/_health_endpoints.py:1871&lt;/code>).&lt;/p>
&lt;p>What it does &lt;strong>not&lt;/strong> expose: the depth of the &lt;code>SpendLogs&lt;/code> queue or its bytes, the Prisma connections in use against the limit, nor the depth of the logging worker&amp;rsquo;s queue. All three are real failure modes and have to be instrumented from outside.&lt;/p>
&lt;h3 id="at-the-engine">At the engine&lt;/h3>
&lt;p>Names verified in the vLLM 0.29.0 metrics documentation:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:e2e_request_latency_seconds&lt;/code>, &lt;code>vllm:request_queue_time_seconds&lt;/code>, &lt;code>vllm:inter_token_latency_seconds&lt;/code>&lt;/li>
&lt;li>&lt;code>vllm:prefix_cache_queries&lt;/code>, &lt;code>vllm:prefix_cache_hits&lt;/code>, &lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>And the ones that break dashboards on upgrade:&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:time_in_queue_requests&lt;/code> is duplicated by &lt;code>vllm:request_queue_time_seconds&lt;/code>.&lt;/li>
&lt;li>The direct prefix cache hit rate indicator disappeared. It is now computed as &lt;code>rate(vllm:prefix_cache_hits) / rate(vllm:prefix_cache_queries)&lt;/code>.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_swapped&lt;/code> and &lt;code>vllm:cpu_cache_usage_perc&lt;/code> are deprecated along with CPU swapping.&lt;/li>
&lt;li>&lt;code>vllm:gpu_cache_usage_perc&lt;/code> became &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which also breaks any HPA that uses it.&lt;/li>
&lt;/ul>
&lt;h3 id="the-slo-worth-chasing">The SLO worth chasing&lt;/h3>
&lt;p>A 95th percentile of time to first token describes nobody&amp;rsquo;s experience when the unit of work is 43 turns. Coding tasks frequently exceed ten minutes and research tasks reach hours (2608.15127).&lt;/p>
&lt;p>Two indicators serve better:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>90th percentile of full-task latency&lt;/strong>, measured per session, not per request. It comes from grouping &lt;code>LiteLLM_SpendLogs&lt;/code> by session identifier.&lt;/li>
&lt;li>&lt;strong>90th percentile of interactivity&lt;/strong>, in tokens per second per user. It is the one AgentX uses, with values between 58.3 and 74.2 in its runs.&lt;/li>
&lt;/ol>
&lt;p>To autoscale the engine, the official production-stack example uses &lt;code>vllm:num_requests_waiting&lt;/code> with a threshold of 5, polling every 15 seconds and a cooldown of 360. With agentic load it is worth adding &lt;code>vllm:kv_cache_usage_perc&lt;/code> as a second trigger, because with 26 to 43 GiB of KV per sequence the memory pressure arrives before the queue does.&lt;/p>
&lt;h2 id="part-7-the-engine-levers-ordered-by-return">Part 7. The engine levers, ordered by return&lt;/h2>
&lt;p>Verified against vLLM 0.29.0, released on 9 September 2026.&lt;/p>
&lt;p>&lt;strong>&lt;code>--long-prefill-token-threshold 512&lt;/code>.&lt;/strong> The AgentX measurement attributes to it &lt;strong>+93 % in tokens per GPU per second, with a 2.3-times improvement at the 90th percentile&lt;/strong>, by attacking the head-of-line blocking that a long prefill produces in front of short requests. It is the highest-return lever documented for this profile.&lt;/p>
&lt;p>&lt;strong>KV cache in FP8.&lt;/strong> It halves the memory budget per sequence, which is the limiting resource. Validate with evals.&lt;/p>
&lt;p>&lt;strong>&lt;code>--max-num-queued-reqs&lt;/code> and &lt;code>--max-num-queued-tokens&lt;/code>.&lt;/strong> New in 0.29. They are admission control in the engine, and they are the right mechanism to protect the SLO instead of letting the queue grow without limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_num_batched_tokens&lt;/code> downwards.&lt;/strong> High values improve time to first token; low values improve inter-token latency. In a regime where 91 % of the time is decode, inter-token latency is what is perceived. Chunked prefill is enabled by default whenever possible in the V1 engine.&lt;/p>
&lt;p>&lt;strong>Session affinity at the gateway.&lt;/strong> It is not an engine setting, but it is what holds up everything above. Without it, that 98 % hit rate turns into random distribution across replicas.&lt;/p>
&lt;p>Two version warnings: Model Runner V2 becomes the default in 0.29 and V1 is deprecated with removal planned for 0.32; and starting with &lt;code>python -m vllm.entrypoints.openai.api_server&lt;/code> is deprecated.&lt;/p>
&lt;h2 id="the-full-calculation-from-start-to-finish">The full calculation, from start to finish&lt;/h2>
&lt;p>Case: twenty developers, a coding scaffold, a 30B-class model in the fleet, maximum context of 128K, KV in FP8.&lt;/p>
&lt;p>&lt;strong>1. Steady-state concurrency.&lt;/strong> $C = 20 \times 0.11 \times 3 \approx 6.6$ sequences.&lt;/p>
&lt;p>&lt;strong>2. KV budget per sequence.&lt;/strong> 0.1875 MiB per token in BF16, half in FP8, that is around 0.094 MiB. At 128K of context: around 12.0 GiB per sequence. For 6.6 sequences with margin, rounding to 8: 96 GiB of KV.&lt;/p>
&lt;p>&lt;strong>3. GPUs.&lt;/strong> The weights of a 30B in FP8 take up around 30 GB. On an 80 GB H100 with &lt;code>gpu_memory_utilization&lt;/code> of 0.90 there are around 72 GB usable per card. With tensor parallelism of 2, the weights are split: around 15 GB per GPU, so around 57 GB of KV per GPU and around 114 GB in the replica. That covers the 96 GiB calculated, and TP=2 also adds memory bandwidth, which is the resource that dominates decode. &lt;strong>Two H100s, one replica, TP=2.&lt;/strong> It is worth seeing the contrast: on a single H100 the full weights leave around 42 GB of KV, that is three and a half sequences, less than half of what is needed. With a second replica for availability and maintenance windows: four GPUs.&lt;/p>
&lt;p>&lt;strong>4. Requests per second at the gateway.&lt;/strong> 20 people, a mean session of 40.6 calls in 62.6 minutes, duty cycle included: of the order of 0.2 to 0.3 requests per second on average, with peaks of 2 to 3.&lt;/p>
&lt;p>&lt;strong>5. Gateway workers.&lt;/strong> The ceiling is not set by throughput but by token counting: four concurrent counts per worker, at 100 ms with long prompts. With peaks of 3 requests per second and six-figure prompts, &lt;strong>two replicas of one worker each&lt;/strong> cover the case with margin for the rolling update, at 1 vCPU and 4 GiB each. Postgres will see 20 connections.&lt;/p>
&lt;p>&lt;strong>6. What needs switching on.&lt;/strong> Session affinity, admission control at the gateway and at the engine, &lt;code>--long-prefill-token-threshold&lt;/code>, and Redis so that the limits are global.&lt;/p>
&lt;p>The same calculation with the chat profile would have given one GPU and one worker. The distance is not in the safety margin, it is in the inputs.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&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="nt">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-30b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/qwen3-coder-30b&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&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">rpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&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">model_info&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">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&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">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&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">router_settings&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">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&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="c"># without this, the capacity calculation in this article is worthless&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">optional_pre_call_checks&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;session_affinity&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 class="nt">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&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">allowed_fails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&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">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&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">general_settings&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="c"># admission control: below this the queue grows without limit&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">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">24&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">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">48&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">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="c"># the spend queue discards at 64 MB; shorter interval, less backlog&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">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">enable_drain_endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">drain_endpoint_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/DRAIN_TOKEN&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_connection_pool_limit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&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">litellm_settings&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="c"># 6000 s by default: a hung connection holds a slot for an hour and a half&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">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&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">callbacks&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;langfuse_otel&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;prometheus&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 class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">cache_params&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">redis&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">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/REDIS_HOST&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Environment variables that go with 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">&lt;span class="nv">NUM_WORKERS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">TOKEN_COUNTER_MAX_CONCURRENT_COUNTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">8&lt;/span> &lt;span class="c1"># default 4; raise only if CPU is spare&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_TPM_TOKEN_RESERVATION_ENABLED&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">PROXY_CONFIG_RELOAD_INTERVAL_SECONDS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">30&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the engine side:&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/Qwen3-Coder-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tensor-parallel-size &lt;span class="m">2&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-model-len &lt;span class="m">131072&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> --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> --long-prefill-token-threshold &lt;span class="m">512&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-queued-reqs &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> --gpu-memory-utilization 0.90
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Measure three figures of your own over a week: calls per hour of human work and per profile, prompt length distribution, and the time series of prefix cache hit rate.&lt;/li>
&lt;li>Calculate the KV per sequence with the model&amp;rsquo;s real geometry, taken from its &lt;code>config.json&lt;/code>, not from a table.&lt;/li>
&lt;li>Set &lt;code>--max-num-seqs&lt;/code> from that budget, not from a chat heuristic.&lt;/li>
&lt;li>Switch on session affinity before accepting the calculated number of GPUs.&lt;/li>
&lt;li>Switch on admission control in both layers and check that the client respects &lt;code>retry-after&lt;/code>.&lt;/li>
&lt;li>Lower &lt;code>request_timeout&lt;/code> from 6,000 to 600.&lt;/li>
&lt;li>Raise &lt;code>proxy_batch_write_at&lt;/code> to 60 and alert on the growth of the spend queue from outside, because there is no metric.&lt;/li>
&lt;li>Leave the HPA on CPU at 60 %, never on memory, and assume the 300 seconds of the &lt;code>startupProbe&lt;/code> when planning the margin.&lt;/li>
&lt;li>Watch &lt;code>litellm_in_flight_requests&lt;/code> and &lt;code>litellm_overhead_latency_metric&lt;/code>; if the overhead rises with the size of the tool catalogue, the culprit is the token counter.&lt;/li>
&lt;li>Go over the dashboards for the vLLM metric renames before upgrading.&lt;/li>
&lt;li>Check that no routing policy moves a live session between model groups.&lt;/li>
&lt;li>Redo the calculation when a new user profile arrives: the range across profiles is 50 to 1.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>The ratio of 275 to 1 does not mean the system is prefill-bound.&lt;/strong> The new tokens per turn are between 1.5 and 7.3 times the output. The time is between 91 % and 98.6 % in decode.&lt;/p>
&lt;p>&lt;strong>The aggregate prefix cache hit rate hides the problem.&lt;/strong> An aggregate 90 % may be 98 % intra-turn and 55 % at boundaries. You have to look at the series, not the number, and segment by request type.&lt;/p>
&lt;p>&lt;strong>vLLM&amp;rsquo;s hit rate counts the reprefill after an eviction as a hit.&lt;/strong> The counter exists internally and is not exposed, so the published number is optimistic under a memory-pressure regime.&lt;/p>
&lt;p>&lt;strong>Agents that fail cost more than those that succeed&lt;/strong>, up to 1.8 times more context. Sizing on the task that goes well leaves the calculation short.&lt;/p>
&lt;p>&lt;strong>The database pool is per process, not per pod.&lt;/strong> Four workers are forty connections.&lt;/p>
&lt;p>&lt;strong>The HPA on memory does not work with this piece&lt;/strong>, and not because of a configuration mistake: Prisma&amp;rsquo;s query engine does not return the memory.&lt;/p>
&lt;p>&lt;strong>The 300-second &lt;code>startupProbe&lt;/code> turns any autoscaling into late reaction.&lt;/strong> For agentic peaks, it is better to over-provision than to trust the scaling.&lt;/p>
&lt;p>&lt;strong>Raising &lt;code>--max-num-seqs&lt;/code> to &amp;ldquo;make use of the GPU&amp;rdquo; degrades perceived latency&lt;/strong>: two long requests in the batch raise time per token by 79.7 %.&lt;/p>
&lt;p>&lt;strong>The token counter has a limiter of four per worker&lt;/strong> and it appears in no sizing guide. With large tool catalogues and long contexts it is the first ceiling you hit.&lt;/p>
&lt;p>&lt;strong>The figures in this article come from three different scaffolds and are not averageable.&lt;/strong> Fifteen, forty-three and sixty-two turns per session are three systems, not three measurements of the same one.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The arithmetic of sizing has not changed. What has changed are the inputs, and now there are public measurements at scale instead of intuitions. Three figures sum up the change of regime: 68,000 tokens of median prompt, 87 % of calls started by the agent rather than by the person, and a time split that is decode for more than 90 % as long as the prefix cache hits.&lt;/p>
&lt;p>From that comes an order of work that is not the usual one. First, session affinity, because without it the rest of the calculation describes a different system. Second, the KV budget with the model&amp;rsquo;s real geometry, which is what sets how many sequences fit. Third, the gateway, where the ceiling is not throughput but a token counter with four threads per worker.&lt;/p>
&lt;p>And a warning about your own figures. The measured range across user profiles is 50 to 1. Any platform that does not segment its measurement by profile is averaging two different populations and producing a number that describes neither of them.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>: coexistence on a fleet that is already sized.&lt;/li>
&lt;li>&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 affinity mechanism on which this article&amp;rsquo;s entire calculation depends.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>: the other face of the tool catalogue, the one paid in tokens.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a>: the classic method whose inputs this article replaces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate engineering&lt;/a>: the two pieces that dominate the budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">Day 2 of the proxy&lt;/a>: availability, draining and deployments.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/html/2608.00101v1">Agentic Coding in the Wild: Characterizing GitHub Copilot at Production Scale&lt;/a>, arXiv 2608.00101, 30 de julio de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2605.26297">Agentic AI Workload Characteristics&lt;/a>, arXiv 2605.26297, 25 de mayo de 2026.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2608.15127">From LLM Inference to Agentic Workloads&lt;/a>, arXiv 2608.15127, 15 de agosto de 2026.&lt;/li>
&lt;li>&lt;a href="https://vllm.ai/blog/2026-09-08-vllm-agentx">vLLM x AgentX: serving agentic workloads&lt;/a>, 8 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM metrics design&lt;/a> y &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">optimization guide&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/benchmarks">LiteLLM benchmarks&lt;/a> y &lt;a href="https://docs.litellm.ai/docs/proxy/prod">production setup&lt;/a>, consultados el 12 de septiembre de 2026.&lt;/li>
&lt;li>Código de LiteLLM 1.102.0, commit &lt;code>9071ca50&lt;/code> del 11 de septiembre de 2026.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/use_cases/autoscaling-keda.html">Autoescalado con KEDA en vLLM production-stack&lt;/a>, consultado el 12 de septiembre de 2026.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM's MCP gateway: the second front door, the tool catalogue nobody bills, and why filtering it can make selection worse</title><link>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</link><pubDate>Fri, 11 Sep 2026 01:45:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/</guid><description>&lt;blockquote>
&lt;p>Sixth post in the operational track on the control layer. The previous five treat the gateway as the piece the tokens pass through: &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the pair with Langfuse&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">prefix routing&lt;/a>. This one is about the other door. Verified against LiteLLM 1.102.0, commit of 10 September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>The MCP gateway is not a module, it is a complete second surface.&lt;/strong> A native MCP endpoint with four route spellings, a proxy mode with three fixed tools, four REST routes, some thirty-five administration routes and a long set of OAuth discovery endpoints. It still lives under &lt;code>_experimental&lt;/code> and it is nearly 20,000 lines.&lt;/p>
&lt;p>&lt;strong>I correct what I said in the previous post: access is NOT open by default.&lt;/strong> In 1.102.0, a key with no declared permissions and no team resolves zero servers. What does open things up is a per-server flag, &lt;code>allow_all_keys&lt;/code>, and that flag &lt;strong>overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that is off by default.&lt;/p>
&lt;p>&lt;strong>Permissions are enforced on execution, not just on the listing.&lt;/strong> Calling a tool that did not appear in &lt;code>tools/list&lt;/code> returns 403. There are three chained checks before the call, including one on the allowed arguments. The same predicate governs listing and calling, by declared design.&lt;/p>
&lt;p>&lt;strong>The hierarchy is built out of intersections, with two escapes and a fail-open.&lt;/strong> The key&amp;rsquo;s access groups are additive on top of the key and team ceiling. The organisation ceiling replaces rather than intersects when there is nothing below it. And on an indeterminate error while resolving tool permissions, the code returns &amp;ldquo;unrestricted&amp;rdquo;. It is a deliberate decision and it has to be declared in the risk analysis.&lt;/p>
&lt;p>&lt;strong>Per-tool cost is worth zero and that is why its metric disappears.&lt;/strong> There are exactly two Prometheus metrics for MCP, and the spend one is only incremented if the cost is greater than zero. With the default configuration, that series never exists. There is no listing metric, no latency metric, no error metric, no server health metric.&lt;/p>
&lt;p>&lt;strong>The cost that does matter is not attributed to MCP anywhere.&lt;/strong> It is the tokens of the tool definitions, resent on every turn inside the &lt;code>prompt_tokens&lt;/code> of the model call. Five typical MCP servers add up to 58 tools and some 55,000 tokens. The tool&amp;rsquo;s spend row, with its zero cost, has nothing to do with that.&lt;/p>
&lt;p>&lt;strong>And the gateway&amp;rsquo;s semantic filter does not act on the MCP door.&lt;/strong> It only applies when it is LiteLLM calling the model. A client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not. And even if it did act, filtering per request rewrites the cached prefix of the prompt, which is the opposite of what Anthropic and OpenAI do with their own tool searches.&lt;/p>
&lt;p>&lt;strong>The good observability ships switched off.&lt;/strong> Without &lt;code>LITELLM_OTEL_V2&lt;/code>, a tool call comes out as a generic span with the model set to &lt;code>MCP: name&lt;/code>. With it enabled there is a dedicated span and correct attributes, and the arguments and the result stay hidden unless you opt in. And in neither case is trace context propagated towards the upstream MCP server: the trace is cut at the gateway.&lt;/p>
&lt;p>&lt;strong>Tool arguments are written, and they are written in the clear.&lt;/strong> They go in full into the &lt;code>metadata&lt;/code> column of the spend table, without passing through the switch that governs prompt storage nor through the redaction function, which only knows about &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. Whoever enabled redaction for GDPR probably believes they are covered.&lt;/p>
&lt;p>&lt;strong>The 1.83 branch concentrated four serious vulnerabilities, and one of them belongs to the MCP gateway itself.&lt;/strong> The endpoint for testing a connection before saving accepted the stdio transport&amp;rsquo;s command, args and environment in the request body. A low-privilege key got command execution on the proxy host.&lt;/p>
&lt;p>&lt;strong>And the specification moved underneath.&lt;/strong> The revision in force since July 2026 removed protocol sessions, the initialisation handshake and the session header. LiteLLM 1.102.0 advertises the June 2025 revision and its version enumeration does not even contemplate the two later ones.&lt;/p>
&lt;h2 id="you-are-here-the-door-nobody-chose">You are here: the door nobody chose&lt;/h2>
&lt;p>The gateway was chosen for the reasons in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">the switchboard post&lt;/a>: licence, fit inside the cluster, maturity. What was evaluated was the inference traffic.&lt;/p>
&lt;p>Later, somebody connects an agent, the agent needs tools, and the same process that is already authenticated and deployed turns out to speak MCP too. The decision to turn it into the tool door is almost never taken: it is inherited. And that door has neither the same controls, nor the same observability, nor the same cost model.&lt;/p>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows&lt;/a> covered the identity of your own MCP servers. &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside&lt;/a> covered the protocol and its instrumentation. This post covers the piece in between.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-that-also-hands-out-keys">The analogy: the switchboard that also hands out keys&lt;/h2>
&lt;p>An office building&amp;rsquo;s telephone switchboard. Its original job is to route calls: it decides who can call outside, how much each department spends, and it leaves a record of every conference call.&lt;/p>
&lt;p>Over time, the switchboard operator is also given the key cabinet. They are already at reception, they already know who everybody is, it looks like the natural place. And from that moment there are two services at the same desk with very different rules.&lt;/p>
&lt;p>Calls are priced by the minute and show up on the bill. Keys have no tariff, so on the department&amp;rsquo;s bill they come out at zero, and somebody might conclude that the key cabinet costs nothing. It does cost: it costs the operator&amp;rsquo;s time, it costs having to read the whole key catalogue every time somebody asks what is available, and it costs that nobody checks that the label on a key still says what it said the day its loan was authorised.&lt;/p>
&lt;p>The analogy ends at the interesting spot. When somebody takes a key, the record says which key it was and what they said they wanted it for. That record is simultaneously the evidence of the action and a piece of data that perhaps should not be stored in the clear.&lt;/p>
&lt;h2 id="the-real-routes">The real routes&lt;/h2>
&lt;p>The first thing that surprises you when you look at the code is how many spellings get you to the same place.&lt;/p>
&lt;h3 id="the-native-endpoint">The native endpoint&lt;/h3>
&lt;p>The MCP sub-application is mounted at &lt;code>/mcp&lt;/code> and inside it has four mounts: the root as a wildcard, &lt;code>/mcp&lt;/code>, &lt;code>/{name}/mcp&lt;/code> and &lt;code>/sse&lt;/code>.&lt;/p>
&lt;p>And there is also a hand-declared route for bare &lt;code>/mcp&lt;/code> on the main application, with a comment that explains why: the mount cannot match its own bare prefix, and the resulting 307 redirect breaks MCP clients sitting behind a proxy that terminates TLS. It is the kind of detail you discover in production on a Friday.&lt;/p>
&lt;p>&lt;code>/mcp/sse&lt;/code> holds a surprise. It does not use the classic SSE transport: it uses the same HTTP session manager in stateless mode. The &lt;code>SseServerTransport&lt;/code> object is constructed and is not referenced on any other line of the file. It is dead code, and the &lt;code>/mcp/sse/messages&lt;/code> endpoint that this object advertises does not exist as a working route.&lt;/p>
&lt;p>The &lt;code>/{name}/mcp&lt;/code> route resolves in a documented order: server alias, comma-separated list with a maximum of 16 elements, tool set, access group. If nothing matches, 404.&lt;/p>
&lt;p>And &lt;code>/mcp/proxy&lt;/code> exposes a fixed surface of three tools. It is the progressive disclosure pattern, and it will come up again further down because it is the most important mitigation in this post.&lt;/p>
&lt;h3 id="the-rest-routes">The REST routes&lt;/h3>
&lt;p>Four, under the &lt;code>/mcp-rest&lt;/code> prefix:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Route&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GET&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/list&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/tools/call&lt;/code>&lt;/td>
&lt;td>Virtual key&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/connection&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>POST&lt;/td>
&lt;td>&lt;code>/mcp-rest/test/tools/list&lt;/code>&lt;/td>
&lt;td>Key plus administrator role&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The variants without &lt;code>-rest&lt;/code> that appear in the internal types have no router registering them. The &lt;code>/mcp/*&lt;/code> wildcard absorbs them as JSON-RPC.&lt;/p>
&lt;p>The last two are the stars of the security section.&lt;/p>
&lt;h3 id="administration-and-discovery">Administration and discovery&lt;/h3>
&lt;p>Some thirty-five routes under &lt;code>/v1/mcp&lt;/code>, almost all of them with virtual key authentication. Three details worth knowing:&lt;/p>
&lt;p>&lt;code>GET /v1/mcp/registry.json&lt;/code> &lt;strong>carries no authentication dependency&lt;/strong>. It returns 404 if the public registry is not enabled, which is the real protection.&lt;/p>
&lt;p>&lt;code>POST /v1/mcp/server&lt;/code> requires the proxy administrator role. &lt;code>POST /v1/mcp/server/register&lt;/code> does not: it is the proposal path, which requires a key with a team, explicitly forbids the stdio transport and leaves the entry pending review.&lt;/p>
&lt;p>And on top of that there is a long set of OAuth &lt;code>.well-known&lt;/code> endpoints, with dynamic client registration, authorisation, token, revocation and introspection, plus their per-server variants.&lt;/p>
&lt;h3 id="configuration-and-transport">Configuration and transport&lt;/h3>
&lt;p>The &lt;code>mcp_servers&lt;/code> schema in &lt;code>config.yaml&lt;/code> has fifty-odd fields, all of them optional. A warning for anyone coming from earlier versions: &lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in 1.102.0.&lt;/p>
&lt;p>The default transport when omitted is &lt;code>http&lt;/code>, that is, streamable HTTP. The other two values are &lt;code>sse&lt;/code> and &lt;code>stdio&lt;/code>. The string &lt;code>streamable_http&lt;/code> is not valid in the configuration file, although it is accepted as a synonym when importing connector files.&lt;/p>
&lt;p>Tool prefixing uses the format &lt;code>{server}{separator}{tool}&lt;/code> with &lt;code>-&lt;/code> as the default separator, configurable through an environment variable read at import time. The prefix chosen is the alias, or the name, or the identifier, in that order. There is an optional short mode that derives three characters from the hash of the server identifier and resolves collisions by rehashing.&lt;/p>
&lt;h2 id="access-control-and-the-correction-to-the-previous-post">Access control, and the correction to the previous post&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents post&lt;/a> I closed the list of traps with this one: &amp;ldquo;access to MCP servers is open by default if no level defines a list&amp;rdquo;. Verified in 1.102.0, that is false.&lt;/p>
&lt;p>The function that resolves the allowed servers does the opposite. If the team declares nothing, it inherits from the key; if the key declares nothing either, the base set is empty and the scope is marked as unbounded with zero servers. There is no branch that expands to &amp;ldquo;the whole registry&amp;rdquo; other than the administration role one.&lt;/p>
&lt;p>What does open things up, and this is the important part, is something else.&lt;/p>
&lt;h3 id="allow_all_keys">&lt;code>allow_all_keys&lt;/code>&lt;/h3>
&lt;p>It is a per-server flag, in configuration, in the API and in the database, defaulting to false. When set to true, that server joins the set of open servers and any key can see it.&lt;/p>
&lt;p>The detail to know: &lt;strong>by default it overrides the key&amp;rsquo;s explicit scope&lt;/strong>. A key that declares access only to server A will also see server B if B has the flag. The key&amp;rsquo;s scope is only respected if you enable &lt;code>general_settings.mcp_allow_all_keys_respects_mcp_scope&lt;/code>, which is off by default.&lt;/p>
&lt;p>There is a second opening case: anonymous subjects, with no user identifier, role or key, additionally receive the servers configured with authentication delegation towards the upstream and the pass-through ones. It is consistent with the design of those two modes, and it is a surface you have to know about.&lt;/p>
&lt;h3 id="require_key_mcp_access_defined">&lt;code>require_key_mcp_access_defined&lt;/code>&lt;/h3>
&lt;p>It lives in &lt;code>general_settings&lt;/code>, defaults to false, and needs no licence. What it inverts is &lt;strong>only&lt;/strong> the empty-key inheritance towards the team: with the flag on, a key with no list of its own gets an empty set instead of inheriting the team&amp;rsquo;s.&lt;/p>
&lt;p>It does not affect keys that do declare servers, which still intersect with the team. And it does not close off the access group grants, which are additive and are the escape route you have to audit separately.&lt;/p>
&lt;p>There is a sibling, &lt;code>require_end_user_mcp_access_defined&lt;/code>, with the same shape and applied to the end user.&lt;/p>
&lt;h3 id="the-hierarchy">The hierarchy&lt;/h3>
&lt;p>The function&amp;rsquo;s docstring is normative and says that all the rules are intersections. In detail:&lt;/p>
&lt;ul>
&lt;li>Key and team: intersection when both declare. Inheritance in one direction when one is empty.&lt;/li>
&lt;li>Key access groups: &lt;strong>additive union&lt;/strong> on top of the previous result.&lt;/li>
&lt;li>End user: intersection if it declares anything.&lt;/li>
&lt;li>Agent: intersection.&lt;/li>
&lt;li>Internal user: ceiling that only narrows.&lt;/li>
&lt;li>Organisation: ceiling that intersects if there are restrictions below, and that &lt;strong>replaces&lt;/strong> by becoming the ceiling when there are none.&lt;/li>
&lt;/ul>
&lt;p>For subjects with no key, admitted by SSO or by the gateway, the model changes to a per-source union instead of an intersection.&lt;/p>
&lt;h3 id="the-fail-open">The fail-open&lt;/h3>
&lt;p>This is the point you have to take to the risk analysis and not leave in a technical note.&lt;/p>
&lt;p>On an indeterminate failure while resolving the tool permissions of a key or a JWT, the code returns &amp;ldquo;unrestricted&amp;rdquo;, that is, all tools allowed. The organisation ceiling is skipped in the same situation. For subjects with no key the behaviour is the opposite, fail-closed.&lt;/p>
&lt;p>There is a well-resolved intermediate case: when a permission is named but cannot be read, a specific error is raised and it is denied in both cases.&lt;/p>
&lt;p>The doctrine is documented in the class docstring, which is appreciated. And it remains a behaviour that, in a system under the ENS, you have to declare in writing instead of discovering it during an audit.&lt;/p>
&lt;h3 id="what-is-done-well">What is done well&lt;/h3>
&lt;p>Permissions are enforced on the call, not just on the listing. The execution path chains three checks before contacting the server: allowed or forbidden tools, per key and team tool permission, and validation of the arguments against the allowed ones. All three return 403.&lt;/p>
&lt;p>Listing and calling share the same predicate, and the docstring declares it as an explicit invariant. A client that calls a tool directly when it did not appear in its listing receives 403, it does not execute. The same over REST and over the responses API route.&lt;/p>
&lt;p>The &lt;code>x-mcp-servers&lt;/code> and &lt;code>x-mcp-access-groups&lt;/code> headers only narrow, and they fail closed if they do not resolve.&lt;/p>
&lt;p>Header forwarding towards the upstream is an explicit per-server allowlist, with a centralised decision about when to strip the caller&amp;rsquo;s &lt;code>Authorization&lt;/code>, and a protection against cross-forwarding: in a multi-server listing, the global header is withheld if more than one server would consume it. That is RFC 9700 applied.&lt;/p>
&lt;h2 id="the-bill-that-never-shows-up">The bill that never shows up&lt;/h2>
&lt;p>Here is, in my opinion, the most important thing in this post, and it has nothing to do with security.&lt;/p>
&lt;p>Every tool the gateway aggregates takes up space in the model&amp;rsquo;s context. Name, description and parameter schema travel in the request&amp;rsquo;s &lt;code>tools&lt;/code> block, &lt;strong>on every turn&lt;/strong>, and they are paid for as input tokens of the real model.&lt;/p>
&lt;p>The figures published by Anthropic in November 2025 are the best public reference there is. Five connected MCP servers add up to 58 tools and some 55,000 tokens. The breakdown: GitHub, 35 tools and some 26,000 tokens. Slack, 11 tools and some 21,000. Sentry, five and some 3,000. Grafana, five and some 3,000. Splunk, two and some 2,000.&lt;/p>
&lt;p>With tool search enabled, that set drops from some 72,000 to some 8,700 tokens, and the usable context goes from 122,800 to 191,300.&lt;/p>
&lt;p>The second cost of a large catalogue is not economic and is not billed: it is that the model chooses worse. It has its own section further down, because of the two it is the one that decides whether the agent is any good.&lt;/p>
&lt;h3 id="how-to-measure-it-with-what-is-already-there">How to measure it with what is already there&lt;/h3>
&lt;p>The gateway stores enough data, even if it does not cross-reference it for you.&lt;/p>
&lt;p>In the listing&amp;rsquo;s spend row, with call type &lt;code>list_mcp_tools&lt;/code>, it enriches the metadata with &lt;code>allowed_server_count&lt;/code>, &lt;code>tool_count_total&lt;/code>, &lt;code>per_server_tool_counts&lt;/code> and &lt;code>per_server_list_outcomes&lt;/code>.&lt;/p>
&lt;p>In the model call rows, the &lt;code>proxy_server_request&lt;/code> column keeps the request body with the &lt;code>tools&lt;/code> block inside it.&lt;/p>
&lt;p>The practical method: pair the listing rows with the LLM rows of the same session by &lt;code>session_id&lt;/code>, read the total number of tools from the first, and pass the &lt;code>tools&lt;/code> array of the second through &lt;code>litellm.token_counter(model=..., tools=...)&lt;/code>, which accepts that argument. Multiply by the number of turns, because the block is resent on all of them.&lt;/p>
&lt;p>For the relative cost, a quicker comparison: mean &lt;code>prompt_tokens&lt;/code> of keys with MCP enabled against those without, same model and same period.&lt;/p>
&lt;h2 id="tool-selection">Tool selection&lt;/h2>
&lt;p>The previous section treats the catalogue as a bill. This one treats it as what determines whether the agent gets it right. This is ground where the talk runs well ahead of the measurement, so it is worth separating what has been measured from what gets repeated.&lt;/p>
&lt;h3 id="how-many-tools-a-model-can-take">How many tools a model can take&lt;/h3>
&lt;p>Anthropic&amp;rsquo;s official figure is that Claude&amp;rsquo;s ability to pick the right tool degrades when going from 30 to 50 available tools. The same documentation gives the inverse criterion, which is the more useful one: the full catalogue with nothing else is the right option with fewer than ten tools, when all of them are used on every request, or when the definitions add up to fewer than a hundred tokens.&lt;/p>
&lt;p>There is also an administrative ceiling before the semantic one. VS Code with Copilot cuts off at &lt;strong>128 tools per request&lt;/strong> and returns an error when you exceed it.&lt;/p>
&lt;p>The full curve gets cited far more than it has been measured. The soundest primary data I found is from May 2025: varying the number of candidates from 1 to 11,100 over a registry of more than 4,400 servers, below some 30 tools the success rate is above 90 %, between 31 and 70 intermittent failures appear, and past the hundred mark the degradation becomes dominated by retrieval. It is a qualitative heat map, not a numeric table.&lt;/p>
&lt;p>The figure that circulates most (78 % with ten tools, 40 % with a hundred, 13.6 % with seven hundred) comes from a conference talk with no paper, no repository and no published methodology. Do not take it to a client presentation.&lt;/p>
&lt;p>And there is a warning that affects everything else. An audit of 496 tasks from four tool calling benchmarks found &lt;strong>18.5 % misalignment between the label and reality&lt;/strong>. In the BFCL v4 subset, 80 % of the failures assigned to the agent came from fragile state comparisons. In one of the benchmarks with an automatic judge, 23 identical re-runs gave a range from 57.9 % to 76.8 %, almost 19 points of spread, enough to reorder the ranking. Any absolute figure in this section deserves a generous margin of error.&lt;/p>
&lt;h3 id="how-it-fails">How it fails&lt;/h3>
&lt;p>The work with the cleanest taxonomy evaluates 36 servers, 220 tools and 1,000 tasks against twenty frontier models, with an average of 15.2 tools exposed per task of which 4.1 are relevant.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Failure mode&lt;/th>
&lt;th>Share&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Not using a tool when one was needed&lt;/td>
&lt;td>10.5 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Choosing the wrong tool&lt;/td>
&lt;td>9.0 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Malformed parameters&lt;/td>
&lt;td>6.9 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Not recovering from an error&lt;/td>
&lt;td>10.3 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cognitive failures, not tool failures&lt;/td>
&lt;td>63.3 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The interesting part is in the per-model breakdown: &lt;strong>the dominant failure mode changes with the model, not with the size of the catalogue&lt;/strong>. In one of them, 40.1 % of the failures are simply not calling any tool. In others the malformed parameter dominates. In another, the wrong tool. There is no canonical failure to attack.&lt;/p>
&lt;p>On tools that do the same thing, which is the real case of a gateway with fifteen servers, there is a good measurement. A study with ten groups of five functionally equivalent tools and a thousand query pairs puts the selection bias between 0.3 and 0.4, that is, &lt;strong>you would have to redistribute between 30 and 40 % of the probability mass for equivalent tools to be chosen equally&lt;/strong>. The same work shows that a biased continued training takes the selection of a specific endpoint from 0.6 % to 12.8 %.&lt;/p>
&lt;p>That study contributes another figure that reorders priorities: shuffling a tool&amp;rsquo;s &lt;strong>description&lt;/strong> moves the selection distribution substantially, whereas &lt;strong>changing only the name has minimal and inconsistent effects&lt;/strong>. Applied to the gateway&amp;rsquo;s &lt;code>server-tool&lt;/code> prefixing: it costs tokens and probably does not change which tool the model goes to. There is no study isolating the effect of name spacing, and it is a real gap in the literature.&lt;/p>
&lt;p>If the description is what carries weight, the state of the estate is the problem. An audit of 856 tools from 103 MCP servers found undeclared limitations in 89.8 %, missing usage guidance in 89.3 % and opaque parameters in 84.3 %. &lt;strong>Only 2.9 % were free of problems across all their components.&lt;/strong>&lt;/p>
&lt;h3 id="the-optimum-is-not-a-number">The optimum is not a number&lt;/h3>
&lt;p>The May 2026 work I cited above proposes a chance-corrected metric and uses it to size the list adaptively. Over 370 functions it reaches 90.3 % coverage while seeing an average of 7.4 tools, against 90.8 % seeing a fixed 50.&lt;/p>
&lt;p>But the same paper publishes its own counterexample, and it is honest to cite it: over another benchmark of 3,251 tools, &lt;strong>the fixed K of 5 wins in aggregate&lt;/strong>, 64.7 % against 61.9 %. The adaptive advantage concentrates in the hard queries, where the right tool sits between sixth and twentieth by similarity: there it finds 16.7 % of the cases where the fixed K finds 0 %.&lt;/p>
&lt;p>The March 2026 work over 121 tools puts the plateau at K=3, with 97.1 % that does not improve going up to K=5. With the caveat that it measures retrieval against labels, not model accuracy.&lt;/p>
&lt;p>And a July 2026 work gives the formulation that seems to me the right one: ranking by score is inconsistent with optimal acquisition when costs are heterogeneous, so &lt;strong>the optimal K is a function of cost, not a constant&lt;/strong>. Trimming from 7 to 4.4 exposed tools while keeping success is possible; fixing a universal number is not.&lt;/p>
&lt;h3 id="filtering-can-make-the-result-worse">Filtering can make the result worse&lt;/h3>
&lt;p>This is the part that appears in no product blog. A June 2026 benchmark with three registry sizes (25, 100 and 250 tools), six filtering methods, seven models and 26,460 runs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Strategy&lt;/th>
&lt;th>Success&lt;/th>
&lt;th>Visible tools&lt;/th>
&lt;th>Tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expose the whole catalogue&lt;/td>
&lt;td>32.1 %&lt;/td>
&lt;td>125.00&lt;/td>
&lt;td>56,062&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-5&lt;/td>
&lt;td>22.1 %&lt;/td>
&lt;td>4.80&lt;/td>
&lt;td>3,200&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Keyword, top-10&lt;/td>
&lt;td>22.4 %&lt;/td>
&lt;td>9.54&lt;/td>
&lt;td>5,356&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>State-aware&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>25.95&lt;/td>
&lt;td>13,522&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full causal path&lt;/td>
&lt;td>24.0 %&lt;/td>
&lt;td>26.24&lt;/td>
&lt;td>13,697&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Four of the five filtering strategies perform worse than not filtering.&lt;/strong> The fifth, which does win comfortably, exposes 0.99 tools per step and requires hand-written precondition and effect contracts for every tool, plus a search of the causal path to the goal state. It is a planner with the goal given, not a retrieval filter, and it is not comparable with anything a gateway offers.&lt;/p>
&lt;p>There is more evidence in the same direction. A work over a corpus of 43,000 tools measured that the agent&amp;rsquo;s completion rate drops when using retrieved sets against hand-annotated sets: the filter introduces its own recall ceiling. And top-K breaks the tasks that need several tools: on a benchmark of 16,464 endpoints, a fine-tuned retriever reaches a Recall@3 of 68.6 % but only 39.7 % of the queries have their &lt;strong>complete&lt;/strong> tool set within the first three. Almost 29 points of gap between &amp;ldquo;the one I needed is there&amp;rdquo; and &amp;ldquo;all the ones I needed are there&amp;rdquo;.&lt;/p>
&lt;p>Filtering is not free on security either. A March 2026 work attacks the retrieval layer by injecting adversarial tools that saturate the top-k: with injection rates of 1.2 % it manages to dominate the top-k between 91 % and 97 % of the time. It is an attack surface that exposing the whole catalogue does not have.&lt;/p>
&lt;p>The most elegant counterpoint comes from Anthropic&amp;rsquo;s own numbers. With the same catalogue of more than fifty tools, the gain from tool search drops from 25 points on one model to 8.6 on the next, because the unfiltered baseline rises from 49 % to 79.5 %. &lt;strong>The margin left by filtering shrank to a third in one generation of models.&lt;/strong> Any architecture decision taken here has a short shelf life.&lt;/p>
&lt;h3 id="the-prompt-cache-trap">The prompt cache trap&lt;/h3>
&lt;p>Here is what makes a homemade top-K filter a bad idea even if it worked.&lt;/p>
&lt;p>Tool definitions do not go just anywhere in the prompt: they go at the beginning. Anthropic documents the order of the cacheable prefix as &lt;code>tools&lt;/code>, then &lt;code>system&lt;/code>, then &lt;code>messages&lt;/code>, with a hierarchy where each level rests on the previous one. And the invalidation rule is explicit: &lt;strong>modifying tool definitions, be it names, descriptions or parameters, invalidates the entire cache&lt;/strong>, and a change at one level invalidates that level and all subsequent ones.&lt;/p>
&lt;p>OpenAI says the same and adds a detail: the prefix must remain unchanged, and &lt;strong>order counts&lt;/strong>. The same set of tools served in a different order is a cache miss.&lt;/p>
&lt;p>With vLLM serving an open model there is no tool block of its own. The &lt;code>tools&lt;/code> parameter is serialised by the chat template inside the first system message, behind the system prompt text. For automatic prefix caching the effect is identical, because it is still near the absolute start of the prompt.&lt;/p>
&lt;p>The consequence is the one that matters. A filter that returns a different set of tools on the next turn does not just invalidate the tool block: it invalidates &lt;strong>everything behind it&lt;/strong>, which is the system prompt and the whole conversation, which is exactly the part that grows with the turns. At list prices, a prefix token goes from costing 0.1 times when read from cache to 1.25 times when written. An agent with sixty thousand tokens of stable prefix goes from some six thousand equivalent tokens per turn to some seventy-five thousand, plus the prefill that gets paid again in latency.&lt;/p>
&lt;p>The two big providers have separately arrived at the same solution, and it is the opposite of filtering in the prefix. Anthropic excludes deferred tools from the prefix and, when the model discovers one through search, injects it inline inside the conversation: &amp;ldquo;the prefix stays intact, so prompt caching is preserved&amp;rdquo;. OpenAI: &amp;ldquo;all tools are loaded at the end of the model&amp;rsquo;s context window (&amp;hellip;) this allows the cache to be preserved from one request to the next&amp;rdquo;. Anthropic&amp;rsquo;s operational recommendation is the stable core pattern: leave the three to five most used tools undeferred.&lt;/p>
&lt;p>The closest academic work, from January 2026, states the problem without measuring it and recommends exactly that, keeping a fixed set of reusable general-purpose functions and implementing dynamic capability through code generation.&lt;/p>
&lt;p>The July 2026 MCP specification picks up both halves of the matter. Servers &amp;ldquo;&lt;strong>should&lt;/strong> return the tools from &lt;code>tools/list&lt;/code> in a deterministic order to allow caching in the client and improve the hit rate of the model&amp;rsquo;s prompt cache&amp;rdquo;. And it adds mandatory &lt;code>ttlMs&lt;/code> and &lt;code>cacheScope&lt;/code> in listing results, so that the client can freeze the catalogue for a known window instead of re-querying it and propagating any variation to the prompt.&lt;/p>
&lt;p>Nobody has publicly measured the cost in euros of this effect. The only attempt I found is a self-published audit of ten thousand turns that puts it at 2.4 % to 3.5 % of cache misses, with non-reproducible methodology. It is a gap in the literature, and the order of magnitude in the previous paragraph comes from applying list prices, not from a measurement.&lt;/p>
&lt;h3 id="and-now-litellm">And now, LiteLLM&lt;/h3>
&lt;p>With all of the above in mind, the gateway&amp;rsquo;s levers read differently.&lt;/p>
&lt;p>&lt;strong>The semantic filter does not act on the MCP endpoint.&lt;/strong> It is registered as a generic LiteLLM callback, and its only dispatcher is the proxy&amp;rsquo;s precall hook, which additionally discards anything that is not &lt;code>completion&lt;/code>, &lt;code>acompletion&lt;/code> or &lt;code>aresponses&lt;/code>. The &lt;code>tools/list&lt;/code> path does not invoke that hook at any point; there is a comment in the code that admits it in passing, when explaining why the listing&amp;rsquo;s JWT signature is done separately. An external MCP client connected to &lt;code>/mcp&lt;/code> receives the whole catalogue, filter enabled or not.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Serves external MCP clients&lt;/th>
&lt;th>Breaks the prompt cache&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Per-server &lt;code>allowed_tools&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Name and description overrides&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, it is static&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Selection by route or header&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, if it is stable per agent&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/mcp/proxy&lt;/code> and virtual tools&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No, the surface is fixed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Semantic filter&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>Yes, it rewrites the block per request&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>When it does act, the filter has details you need to know before enabling it. It embeds only each tool&amp;rsquo;s description, with the name as a fallback if it is missing; the parameter schema does not go in. Of the query it embeds &lt;strong>only the last user message&lt;/strong>: on turn twelve of an agent conversation, that might be &amp;ldquo;yes, go ahead&amp;rdquo;. The tool embeddings are cached and indexed at start-up; the query&amp;rsquo;s is computed on every request, with a synchronous call inside a coroutine.&lt;/p>
&lt;p>Its failure modes are almost all open, which is the reasonable thing: if the embedding model does not respond, if nothing passes the threshold, if the names do not match the catalogue, it returns all the tools. The exception is context overflow, which fails closed with a 400, and which if it happens while building the index during start-up is memoised and makes &lt;strong>all&lt;/strong> subsequent requests fail until you restart. The defaults are &lt;code>text-embedding-3-small&lt;/code>, &lt;code>top_k&lt;/code> of 10 and a threshold of 0.3, under &lt;code>litellm_settings.mcp_semantic_tool_filter&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>/mcp/proxy&lt;/code> does serve external clients&lt;/strong>, and that is where its real value lies. It replaces the catalogue with three tools with opaque identifiers. How &lt;code>search_tools&lt;/code> searches deserves a line, because it is not what you would assume: by default &lt;strong>it is neither a regular expression nor a semantic match, it is a keyword counter&lt;/strong>, how many tokens of the query appear as a substring in the concatenated name and description. It only moves to embeddings if you configure a model in &lt;code>mcp_tool_search&lt;/code>. The result cap is hard-coded at five in this mode, and the client cannot ask for more. In exchange, &lt;code>call_tool&lt;/code> goes through the same permission checks as a normal call plus a validation of the argument schema that the normal route does not do at that point.&lt;/p>
&lt;p>There is a related variant: with &lt;code>mcp_tool_search_enabled&lt;/code> in the key&amp;rsquo;s permissions, the catalogue is replaced by four virtual tools, and there the search one does expose &lt;code>top_k&lt;/code> to the client.&lt;/p>
&lt;p>&lt;strong>The catalogue order is not deterministic, and that breaks the client&amp;rsquo;s cache without anybody filtering anything.&lt;/strong> The list of allowed servers comes from iterating a Python set of strings, so with the random hash seed the order differs between processes, between uvicorn workers and between restarts. There is no sorting anywhere in the listing path, and there is no tool cache: every &lt;code>tools/list&lt;/code> queries the upstreams live, so the order within each server also depends on what the upstream returns that time. The gateway does not offer today the guarantee that the July specification recommends. The fix, sorting the catalogue before serving it, fits on one line.&lt;/p>
&lt;p>Nor is there any pagination towards the client. The catalogue is returned whole, always.&lt;/p>
&lt;p>&lt;strong>And the lever almost nobody uses is the static one.&lt;/strong> Every MCP server accepts &lt;code>tool_name_to_display_name&lt;/code> and &lt;code>tool_name_to_description&lt;/code>, and those overrides &lt;strong>really do replace what is sent to the client&lt;/strong> in &lt;code>tools/list&lt;/code>, not just what the administrator sees in the interface. The &lt;code>tools/call&lt;/code> routing translates from the new name to the real one before any permission check. With that you can trim a four-hundred-token description written by a third party, rename an ambiguous tool so it does not compete with a similar one, and do it without touching the prompt cache, because it is static and holds for all turns.&lt;/p>
&lt;p>It is also the rug pull mitigation left pending from the security section: if the description the model sees is the one the operator wrote, whatever the upstream server changes stops reaching the context. Two limits. They are configured through the database or the management API, &lt;strong>not through &lt;code>config.yaml&lt;/code>&lt;/strong>. And the reverse name translation is by exact equality and is not disambiguated between servers, so two servers with the same display name route unstably, made worse by the non-deterministic order of the previous paragraph.&lt;/p>
&lt;p>Alongside that, per-server &lt;code>allowed_tools&lt;/code> remains the blunt and effective instrument: it filters the listing the client sees, reduces tokens, and is static.&lt;/p>
&lt;h3 id="the-ladder-in-order">The ladder, in order&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Trim.&lt;/strong> Per-server &lt;code>allowed_tools&lt;/code>, plus a per-key allowlist. Cheap, static, does not break the cache and reduces the permission surface at the same time.&lt;/li>
&lt;li>&lt;strong>Rewrite the descriptions&lt;/strong> with the overrides. That is where the evidence is, because the description carries weight and the name almost none, and along the way it pins the contract against the rug pull.&lt;/li>
&lt;li>&lt;strong>Split by route.&lt;/strong> Each agent to &lt;code>/{server}/mcp&lt;/code> or to a tool set, never to the aggregated endpoint. Stable per agent, so the cache survives.&lt;/li>
&lt;li>&lt;strong>Sort the catalogue&lt;/strong> before serving it if you control the deployment, while the gateway does not do it.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/proxy&lt;/code>&lt;/strong> when the catalogue goes past thirty or fifty tools. It is progressive disclosure and it works with external clients, which is more than you can say for the semantic filter.&lt;/li>
&lt;li>&lt;strong>The semantic filter&lt;/strong>, only when the one calling the model is LiteLLM. With a generous &lt;code>top_k&lt;/code>, knowing that it embeds only the last user message and that context overflow is a closed and sticky failure.&lt;/li>
&lt;li>&lt;strong>What not to do&lt;/strong>: a home-grown top-K filter that rewrites the &lt;code>tools&lt;/code> array on every turn. It costs the whole prefix and, according to four of the five measured strategies, probably makes selection worse.&lt;/li>
&lt;/ol>
&lt;h2 id="the-declared-cost-which-is-worth-zero">The declared cost, which is worth zero&lt;/h2>
&lt;p>The calculation has four precedence steps: cost set by a post-call hook, per-tool cost in &lt;code>tool_name_to_cost_per_query&lt;/code>, server default cost, and &lt;strong>0.0&lt;/strong>.&lt;/p>
&lt;p>Without configuring &lt;code>mcp_server_cost_info&lt;/code>, all MCP traffic is accounted at zero and consumes neither key nor team budget.&lt;/p>
&lt;p>There are exactly two Prometheus metrics for MCP:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_mcp_tool_calls_total&lt;/code>&lt;/li>
&lt;li>&lt;code>litellm_mcp_tool_call_spend_metric&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>Both with the same eight labels: tool name, server name, key hash, key alias, team, team alias, user and end user.&lt;/p>
&lt;p>And the second is only incremented if the cost is greater than zero. With the default configuration, &lt;strong>that time series never appears in Prometheus&lt;/strong>. It is not that it is worth zero: it is that it does not exist.&lt;/p>
&lt;p>Nor is there a &lt;code>tools/list&lt;/code> metric, or MCP latency, or MCP errors, or upstream server health. For that you are left with the generic request metrics.&lt;/p>
&lt;p>In the reports, &lt;code>/spend/calculate&lt;/code> has no MCP branch: it accepts a model and messages, or a completion response, and it always calls the LLM cost calculation. The per-team report does include MCP calls, grouped by the &lt;code>model&lt;/code> column, which for these rows is worth &lt;code>MCP: tool_name&lt;/code>. That is: every tool appears as if it were a model, with zero input and output tokens. Only the sessions endpoint separates the two call types explicitly.&lt;/p>
&lt;h2 id="observability-and-the-trace-that-gets-cut">Observability, and the trace that gets cut&lt;/h2>
&lt;p>There are two OpenTelemetry stacks in LiteLLM and which one runs depends on an environment variable.&lt;/p>
&lt;p>Without &lt;code>LITELLM_OTEL_V2=true&lt;/code>, the old logger runs, and it has no MCP handling: the only appearance of MCP in the whole file is the metadata key in a list. The tool call comes out as a generic &lt;code>litellm_request&lt;/code> span with the model set to &lt;code>MCP: name&lt;/code>.&lt;/p>
&lt;p>With v2 enabled there is a dedicated span, with a dedicated role, client kind and a parent in the proxy&amp;rsquo;s request span. The span name is &lt;code>tools/call tool-name&lt;/code>. The attributes are correct and follow the conventions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Attribute&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>gen_ai.operation.name&lt;/code>&lt;/td>
&lt;td>&lt;code>execute_tool&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.method.name&lt;/code>&lt;/td>
&lt;td>&lt;code>tools/call&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mcp.session.id&lt;/code>&lt;/td>
&lt;td>MCP session identifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.name&lt;/code>&lt;/td>
&lt;td>Tool name&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.arguments&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gen_ai.tool.call.result&lt;/code>&lt;/td>
&lt;td>Only with content capture enabled&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>server.address&lt;/code>, &lt;code>server.port&lt;/code>&lt;/td>
&lt;td>From the upstream URL, redacted&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>litellm.cost.total&lt;/code>&lt;/td>
&lt;td>The cost that is probably worth zero&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Content capture is off by default. Without it you see neither the arguments nor the result in the trace.&lt;/p>
&lt;p>A note for anyone working with semantic conventions: &lt;code>mcp.tool.name&lt;/code> &lt;strong>does not exist&lt;/strong>. The MCP registry has exactly four attributes (&lt;code>mcp.method.name&lt;/code>, &lt;code>mcp.session.id&lt;/code>, &lt;code>mcp.protocol.version&lt;/code> and &lt;code>mcp.resource.uri&lt;/code>), all four in development, and the tool name goes in &lt;code>gen_ai.tool.name&lt;/code>. LiteLLM gets it right. And the GenAI conventions were extracted into their own repository which as of today &lt;strong>has no release at all&lt;/strong>, so there is no version number to cite.&lt;/p>
&lt;p>In Langfuse, the vendor mapper returns an empty dictionary for any span that is not an LLM call. An MCP call appears as a raw span with its attributes, with no native input, output or model fields.&lt;/p>
&lt;h3 id="the-trace-is-cut-at-the-gateway">The trace is cut at the gateway&lt;/h3>
&lt;p>Incoming context is read. The &lt;code>traceparent&lt;/code> the client puts in &lt;code>params._meta&lt;/code>, following the specification&amp;rsquo;s propagation proposal, is extracted and turned into a &lt;strong>link&lt;/strong> on the span, never into its parent. The reason is documented in the code: parenting to the remote trace would leave the span hanging off a trace whose root never reaches the backend. The client&amp;rsquo;s baggage is discarded on purpose to avoid forging identity attributes.&lt;/p>
&lt;p>Outgoing context is not propagated. There is no propagator injection call anywhere in the tree. The headers that go out towards the upstream MCP server are built with authentication and the authorised extra headers, and nothing else.&lt;/p>
&lt;p>The operational consequence: there is no end-to-end trace. If the upstream MCP server is instrumented, its spans live in another trace and there is no way to correlate them automatically.&lt;/p>
&lt;h2 id="auditing-and-the-conflict-with-privacy">Auditing and the conflict with privacy&lt;/h2>
&lt;p>A tool call leaves a normal spend row, with call type &lt;code>call_mcp_tool&lt;/code>, spend equal to the cost per query, model &lt;code>MCP: name&lt;/code>, zero tokens in the three token columns, a dedicated &lt;code>mcp_namespaced_tool_name&lt;/code> column, and the usual key, team, user and session fields.&lt;/p>
&lt;p>And in the &lt;code>metadata&lt;/code> column, in JSON, the complete structure of the call: name, &lt;strong>arguments&lt;/strong>, result, server, namespaced name, MCP session identifier, authentication mode and server resource with the URL redacted.&lt;/p>
&lt;h3 id="the-problem">The problem&lt;/h3>
&lt;p>That structure is written into the metadata column &lt;strong>without passing through the privacy switch&lt;/strong>. The assignment is direct and is not conditioned by the function that governs whether prompts and responses are stored, which does control the &lt;code>messages&lt;/code> and &lt;code>response&lt;/code> columns.&lt;/p>
&lt;p>And the redaction function does not cover it either: it only touches &lt;code>messages&lt;/code> and &lt;code>response&lt;/code>. A search for MCP in the redaction file returns nothing.&lt;/p>
&lt;p>The practical result: an operator who enables &lt;code>turn_off_message_logging&lt;/code> for GDPR keeps sending the full arguments of every tool to every logging destination they have configured. To the SIEM, to Langfuse, to S3.&lt;/p>
&lt;p>If an argument carries a national ID number, an address, a case number or a medical record, it has been exported.&lt;/p>
&lt;p>There is an aggravating factor that does not depend on LiteLLM. The revision of the specification in force introduces an extension that allows mirroring tool arguments into HTTP headers visible to load balancers, proxies and web application firewalls. The specification recommends not marking sensitive parameters that way, and it is a recommendation, and the upstream server decides.&lt;/p>
&lt;h3 id="the-fit-with-the-ens">The fit with the ENS&lt;/h3>
&lt;p>The conflict is real and has a familiar shape: activity logging (&lt;code>op.exp.8&lt;/code>) requires the call to be traced and non-repudiable; protection of personal data (&lt;code>mp.info.1&lt;/code>) requires that log not to become a secondary repository of personal data with a different purpose. The tool argument is simultaneously the evidence of the action and the data. Redacting it destroys non-repudiation; storing it in the clear creates the repository.&lt;/p>
&lt;p>The defensible way out you can build today: a hash of the argument in the audit log, the argument in the clear with encryption (&lt;code>mp.info.3&lt;/code>), separate retention and its own access control. The hash sustains non-repudiation and the clear text is only opened under procedure.&lt;/p>
&lt;p>There are two other measures this material touches directly. Change management (&lt;code>op.exp.5&lt;/code>) because registering an MCP server and, above all, changing the description of an already approved tool are changes to the security configuration. And maintenance and updates (&lt;code>op.exp.4&lt;/code>) because, as you can see in the next section, an old version of the gateway is a non-compliance with a name and a number.&lt;/p>
&lt;p>Two honesty warnings. The codes follow the numbering of Annex II of RD 311/2022 and you have to check them against the text in force before citing them in a compliance document. And on NIS2: as of the date of this post the directive &lt;strong>is still not transposed in Spain&lt;/strong>, with the draft bill on cybersecurity coordination and governance going through the process and a reasoned opinion from the Commission over the delay. What is enforceable today in the public sector and its suppliers is the ENS.&lt;/p>
&lt;h3 id="what-is-not-logged">What is not logged&lt;/h3>
&lt;p>An administrative gap: registering, modifying and deleting MCP servers &lt;strong>generates no audit log&lt;/strong>. There are three &lt;code>TODO&lt;/code> comments in the management code that say so, one of them with an &lt;code>if is_audit_logging_enabled(): pass&lt;/code>. In the same deletion block there are two more TODOs about not cleaning up orphaned permissions on keys and teams.&lt;/p>
&lt;h2 id="security">Security&lt;/h2>
&lt;h3 id="the-vulnerability-you-need-to-know-about">The vulnerability you need to know about&lt;/h3>
&lt;p>&lt;code>CVE-2026-42271&lt;/code>, published on 8 May 2026. Affects LiteLLM from 1.74.2 up to versions prior to 1.83.7. CVSS 3.1 of 8.8.&lt;/p>
&lt;p>The &lt;code>POST /mcp-rest/test/connection&lt;/code> and &lt;code>POST /mcp-rest/test/tools/list&lt;/code> endpoints, which exist to preview an MCP server before saving it, accepted the stdio transport&amp;rsquo;s &lt;code>command&lt;/code>, &lt;code>args&lt;/code> and &lt;code>env&lt;/code> in the request body. A low-privilege key executed arbitrary commands on the proxy host. It also affects Red Hat OpenShift AI on several branches.&lt;/p>
&lt;p>It is the story of this post condensed into one line: the gateway you put in to contain MCP was the hole.&lt;/p>
&lt;p>It does not come alone. The 1.83 branch concentrates three more serious ones:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>CVE&lt;/th>
&lt;th>Component&lt;/th>
&lt;th>CVSS&lt;/th>
&lt;th>Fixed in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>CVE-2026-42208&lt;/code>&lt;/td>
&lt;td>SQL injection in key validation, &lt;strong>with no prior authentication&lt;/strong>&lt;/td>
&lt;td>9.8&lt;/td>
&lt;td>1.83.7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-35030&lt;/code>&lt;/td>
&lt;td>OIDC userinfo cache keyed on &lt;code>token[:20]&lt;/code>, collision and impersonation&lt;/td>
&lt;td>9.1&lt;/td>
&lt;td>1.83.0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>CVE-2026-40217&lt;/code>&lt;/td>
&lt;td>Remote execution through bytecode rewriting in the guardrails test endpoint&lt;/td>
&lt;td>8.8&lt;/td>
&lt;td>no version recorded&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Plus two privilege escalations fixed in 1.83.10 and 1.83.14.&lt;/p>
&lt;p>The operational conclusion is short: &lt;strong>a deployment below 1.83.14 is exposed to remote execution or to an unauthenticated SQL injection&lt;/strong>. The current branch is 1.102.0.&lt;/p>
&lt;p>And a note for context: the whole MCP ecosystem has the same pattern. The unauthenticated remote execution in MCP Inspector, the command injection in &lt;code>mcp-remote&lt;/code> when connecting to an untrusted server, DNS rebinding disabled by default in the Python and TypeScript SDKs until late 2025, the directory escapes in the reference servers. Connecting to an untrusted MCP server compromises the client, not just the other way round.&lt;/p>
&lt;h3 id="the-rug-pull-nobody-covers">The rug pull nobody covers&lt;/h3>
&lt;p>Documented since April 2025: a server can change a tool&amp;rsquo;s description &lt;strong>after&lt;/strong> the client has approved it. The description is what the model reads to decide when and how to use it, so changing it is changing the behaviour without asking for permission again.&lt;/p>
&lt;p>The specification in force does not address it. It defines list change notification and cache freshness fields, which are recency mechanisms, not integrity ones. The closest normative warning is that clients must treat tool annotations as untrusted unless they come from trusted servers.&lt;/p>
&lt;p>LiteLLM does not cover it either. The only &amp;ldquo;pinning&amp;rdquo; in the code is of the configuration&amp;rsquo;s server identifier, so that it does not change when you edit the YAML. There is no hash or comparison of the description or the input schema between successive listings. A search for related terms in the MCP module: nothing functional.&lt;/p>
&lt;p>The control exists outside the gateway. &lt;code>mcp-scan&lt;/code>, from the same researchers who documented the attack, implements description hash pinning precisely to detect this.&lt;/p>
&lt;p>In ENS terms it is an unauthorised change to the security configuration that today goes undetected. And the home-made fix fits in a cron job: store the hash of the description and schema of every approved tool, compare against &lt;code>tools/list&lt;/code> periodically, alert on the difference.&lt;/p>
&lt;h3 id="three-more-holes-in-the-code">Three more holes in the code&lt;/h3>
&lt;p>&lt;strong>No URL validation towards the upstream.&lt;/strong> A new server&amp;rsquo;s &lt;code>url&lt;/code> field has no validator. The SSRF protection utilities exist in the tree and are used in OAuth discovery and in downloading OpenAPI specifications, but &lt;strong>not&lt;/strong> in the egress of &lt;code>tools/call&lt;/code>. A server pointing at a private range or at the cloud metadata IP is accepted as is.&lt;/p>
&lt;p>&lt;strong>The stdio transport is local execution on the proxy host.&lt;/strong> There is a command allowlist: &lt;code>npx&lt;/code>, &lt;code>uvx&lt;/code>, &lt;code>python&lt;/code>, &lt;code>python3&lt;/code>, &lt;code>node&lt;/code>, &lt;code>docker&lt;/code>, &lt;code>deno&lt;/code>, extendable through an environment variable. The code&amp;rsquo;s own comment admits the residual risk: the permitted runtimes can execute code through arguments. With &lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> in there, it is de facto arbitrary execution for any proxy administrator.&lt;/p>
&lt;p>&lt;strong>Two server modes skip the gateway&amp;rsquo;s authentication.&lt;/strong> Pass-through and authentication delegation to the upstream completely omit virtual key validation. They are well hardened (fail-closed if the target is mixed or unresolvable, strict comparison against truthy values, explicit blocking of the client credentials flow so as not to lend out the proxy&amp;rsquo;s service account), and the result is still that those servers have no gateway access control.&lt;/p>
&lt;h3 id="the-limit-that-truncates-in-silence">The limit that truncates in silence&lt;/h3>
&lt;p>The &lt;code>tools/list&lt;/code> pagination stops and returns what it has accumulated in three cases: repeated cursor, one thousand pages, or expiry of a global deadline. The docstring declares it: a slow or faulty upstream produces a partial catalogue instead of an error.&lt;/p>
&lt;p>The first two log a warning. The third, the deadline, exits &lt;strong>without logging anything at all&lt;/strong>. A slow server makes part of its tools disappear from the catalogue with no trace left behind.&lt;/p>
&lt;h3 id="correction-on-the-slot-leak">Correction on the slot leak&lt;/h3>
&lt;p>In the previous post I took issue 34534 at face value, according to which every MCP tool call acquires a &lt;code>max_parallel_requests&lt;/code> slot and does not release it. Tracing the 1.102.0 code, that diagnosis no longer holds: the release path exists, on both success and failure, and the limiter is registered as a litellm callback and not just as a proxy hook.&lt;/p>
&lt;p>With one honest caveat: I found no regression test covering the full cycle for MCP, and the definitive confirmation is a real proxy with &lt;code>max_parallel_requests: 2&lt;/code> and five MCP calls in a row. If anybody reproduces it today, I would like to know.&lt;/p>
&lt;h2 id="the-specification-moved-underneath">The specification moved underneath&lt;/h2>
&lt;p>The MCP revision in force is &lt;code>2026-07-28&lt;/code>, published on 28 July 2026, and in between there was another one, &lt;code>2025-11-25&lt;/code>. The changes in the latest one are not cosmetic:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Protocol sessions and the session header are removed.&lt;/strong> Cross-request state becomes an explicit identifier in the tool arguments.&lt;/li>
&lt;li>&lt;strong>The initialisation handshake is removed.&lt;/strong> Every request carries version and capabilities in &lt;code>_meta&lt;/code>. MCP becomes stateless.&lt;/li>
&lt;li>&lt;code>server/discover&lt;/code> appears as a mandatory call.&lt;/li>
&lt;li>Elicitation changes shape: out go server-initiated requests, in comes a multi-round pattern where the server returns an &amp;ldquo;input required&amp;rdquo; result and the client retries with the answers.&lt;/li>
&lt;li>&lt;code>ping&lt;/code>, &lt;code>logging/setLevel&lt;/code> and the roots change notification are removed. SSE stream resumption is removed.&lt;/li>
&lt;li>Standard HTTP headers are mandatory on POST, which lets a gateway or a web application firewall decide without parsing the JSON.&lt;/li>
&lt;li>On authorisation: mandatory issuer validation in the client, and &lt;strong>dynamic client registration is deprecated&lt;/strong> in favour of client identifier metadata documents.&lt;/li>
&lt;li>Roots, sampling and logging are deprecated, with a minimum window of twelve months.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM 1.102.0 advertises &lt;code>2025-06-18&lt;/code>. Its version enumeration has three values and none of them is later than that date. The literal is sent in the &lt;code>initialize&lt;/code> against the upstream.&lt;/p>
&lt;p>One nuance that is not entirely bad: the version the gateway advertises to &lt;strong>its&lt;/strong> clients is not hard-coded in LiteLLM, the SDK negotiates it. And there is partial implementation of the intermediate revision, because the elicitation and sampling handlers reference it in their docstrings. That is: partial implementation of one revision while advertising an earlier one.&lt;/p>
&lt;p>In practical interoperability it does not hurt yet, because the client ecosystem is still mostly on the 2025 revisions. It hurts in planning: July&amp;rsquo;s stateless redesign invalidates the gateway&amp;rsquo;s session model, and that is work ahead.&lt;/p>
&lt;p>Two ecosystem notes for anyone choosing. There are clients that speak remote streamable HTTP natively (Claude and Claude Code, ChatGPT with OAuth and dynamic registration, VS Code with Copilot, Cursor, Zed, Kiro) and others that still demand stdio or a local bridge with &lt;code>mcp-remote&lt;/code>, among them Windsurf, Cline and Continue.&lt;/p>
&lt;h2 id="tool-execution-from-the-proxy-itself">Tool execution from the proxy itself&lt;/h2>
&lt;p>Besides serving MCP outwards, the proxy can consume it itself. It is enabled with an &lt;code>mcp&lt;/code> block in the body, with &lt;code>server_url&lt;/code> equal to the &lt;code>litellm_proxy&lt;/code> sentinel, and it works both on &lt;code>/v1/responses&lt;/code> and on &lt;code>/chat/completions&lt;/code>.&lt;/p>
&lt;p>The flow: it separates the gateway&amp;rsquo;s tools, lists them, filters by allowed, deduplicates, translates them into OpenAI function format and concatenates them. It calls the model. If there are tool calls and auto-execution is permitted, it runs them against the MCP servers. It builds the follow-up and makes &lt;strong>one&lt;/strong> second call.&lt;/p>
&lt;p>Two limits you need to know before designing on top of this.&lt;/p>
&lt;p>&lt;strong>The loop is a single hop.&lt;/strong> There is no multi-step iteration. The calls from the first response are executed and the follow-up is returned. A plan that chains tools requires the client to call again.&lt;/p>
&lt;p>&lt;strong>Auto-execution fails closed and as a block.&lt;/strong> It requires all MCP references to have approval set to &amp;ldquo;never&amp;rdquo;; a single one requiring approval disables auto-execution for the whole request.&lt;/p>
&lt;p>And a third one, which is the one that bites: &lt;strong>the multi-turn state of &lt;code>/v1/responses&lt;/code> is rehydrated from the spend table&lt;/strong>. The query is literal, it looks up the session of the previous &lt;code>request_id&lt;/code> and retrieves all the rows of that session ordered by time.&lt;/p>
&lt;p>With &lt;code>disable_spend_logs: true&lt;/code> nothing is written, the query comes out empty and the history is lost &lt;strong>in silence&lt;/strong>, with no 4xx error. The code itself acknowledges it: it does not expect retries because deployments that do not write spend logs have nothing to wait for. It is a coupling between a performance option and a conversation feature that nobody expects to find.&lt;/p>
&lt;h2 id="health-and-day-to-day-operation">Health and day-to-day operation&lt;/h2>
&lt;p>Health checks exist, they are on demand and they are not periodic. &lt;code>GET /v1/mcp/server/health&lt;/code> accepts repeated identifiers and returns per-server status. The implementation opens a client and runs an empty operation inside the session, with a ten-second timeout.&lt;/p>
&lt;p>It skips the check entirely if the server requires per-user authentication, and those always return unknown status.&lt;/p>
&lt;p>The surprising part: the status, last check and error columns have existed in the database since a May 2025 migration, and the check function &lt;strong>returns an object without writing to the database&lt;/strong>. Status is recalculated on every request. There is no periodic MCP health job.&lt;/p>
&lt;p>When an upstream server is down, the behaviour is to degrade and not block. The listing fans out in parallel and each failing server contributes an empty list plus a result classified into six categories. Those results reach the client in the response&amp;rsquo;s &lt;code>_meta&lt;/code> under a vendor key, without filtering the upstream&amp;rsquo;s text or URL. The reason for the design is in the code: an empty contribution with no signal makes a broken upstream indistinguishable from a healthy server with no tools.&lt;/p>
&lt;p>There is no cache of the previous list. A server that is down disappears from that turn&amp;rsquo;s tools.&lt;/p>
&lt;p>On registering and deregistering without restarting, there are two worlds. Database servers can be created, edited and deleted through the API, and every operation reloads the registry with an atomic swap. Propagation between replicas goes through Redis publication on the configuration change channel, if there is Redis, and through polling every 30 seconds if there is not.&lt;/p>
&lt;p>Servers declared in &lt;code>config.yaml&lt;/code> are loaded once, at start-up. There is no re-read. Changing them requires a restart.&lt;/p>
&lt;p>And there is a precedence trap between the two worlds, with two different warnings in the code. A database row with the same identifier as a configuration entry &lt;strong>hides the configuration one entirely&lt;/strong>. And the other way round, a configuration identifier that matches the name or alias of a database server captures its permissions, because identifiers are resolved before names. Both cases emit a log warning, once.&lt;/p>
&lt;h2 id="a-hardened-configyaml">A hardened &lt;code>config.yaml&lt;/code>&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="nt">general_settings&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="c"># Access is not open by default, but this closes the empty-key&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="c"># inheritance towards the team, which is the one that surprises people.&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">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">require_end_user_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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="c"># Without this, allow_all_keys overrides the key&amp;#39;s explicit scope.&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">mcp_allow_all_keys_respects_mcp_scope&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">litellm_settings&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="c"># Does not cover MCP arguments. See the auditing section.&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">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">callbacks&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;langfuse_otel&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">mcp_servers&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">inventario&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">url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">https://mcp-inventario.interna.svc/mcp&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">transport&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&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">auth_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bearer_token&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">auth_value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/MCP_INVENTARIO_TOKEN&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="c"># Explicit allowlist. Also enforced on tools/call.&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">allowed_tools&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="l">buscar_articulo&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="l">consultar_stock&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="c"># Never true on a server with data.&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">allow_all_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">access_groups&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;operaciones&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 class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&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">max_concurrent_requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&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">mcp_info&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">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Inventario de almacen, solo lectura&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="c"># Without this, MCP spend is zero and its metric does not exist.&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">mcp_server_cost_info&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">default_cost_per_query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0005&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">tool_name_to_cost_per_query&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">consultar_stock&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0002&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And in the process environment:&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"># Without this there is no MCP span, only a generic litellm_request.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_OTEL_V2&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Full block hashes, not truncated, if correlating&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># with engine events.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_CLIENT_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">45&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LITELLM_MCP_TOOL_LISTING_TIMEOUT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">20&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For clients, the rule is not to use the aggregated endpoint. Each agent points at &lt;code>/{server}/mcp&lt;/code> or at a tool set, or at the three-tool proxy mode if the catalogue is large.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Check the version. Below 1.83.14 there is remote execution and an unauthenticated SQL injection with an assigned CVE.&lt;/li>
&lt;li>Inventory which servers have &lt;code>allow_all_keys&lt;/code> and enable the setting that makes it respect the key&amp;rsquo;s scope.&lt;/li>
&lt;li>Audit the access groups separately, because they are additive on top of the key and team ceiling.&lt;/li>
&lt;li>Declare in writing the fail-open behaviour on tool permission resolution errors.&lt;/li>
&lt;li>Measure the real cost: number of tools per server from the listing row, and &lt;code>tools&lt;/code> block tokens from the stored request body.&lt;/li>
&lt;li>Choose a catalogue strategy before connecting the third server: three-tool proxy mode, semantic filter, or selection by route. Not the aggregated endpoint.&lt;/li>
&lt;li>Declare &lt;code>mcp_server_cost_info&lt;/code> even with a token value, or the spend metric will not exist.&lt;/li>
&lt;li>Enable the OTel v2 logger, and decide deliberately whether to enable content capture.&lt;/li>
&lt;li>Assume the trace is cut at the gateway and plan correlation with the upstream by other means.&lt;/li>
&lt;li>Treat the spend table&amp;rsquo;s metadata column as a personal data repository: encryption, its own retention and separate access control.&lt;/li>
&lt;li>Set up the rug pull check, because nobody does it: hash of the description and schema of every approved tool, periodic comparison against the listing.&lt;/li>
&lt;li>Restrict egress towards MCP servers at the network level, because the gateway does not validate the destination URL.&lt;/li>
&lt;li>Do not enable the stdio transport unless you need it, and if you do, review what is on the command allowlist.&lt;/li>
&lt;li>If you use &lt;code>/v1/responses&lt;/code> with multi-turn state, do not enable &lt;code>disable_spend_logs&lt;/code>.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MCP access is not open by default in 1.102.0.&lt;/strong> I got that wrong in the previous post. What opens things up is per-server &lt;code>allow_all_keys&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>allow_all_keys&lt;/code> overrides the key&amp;rsquo;s explicit scope&lt;/strong> unless you enable a setting that ships disabled.&lt;/li>
&lt;li>&lt;strong>&lt;code>require_key_mcp_access_defined&lt;/code> only closes key-to-team inheritance.&lt;/strong> Access group grants remain additive.&lt;/li>
&lt;li>&lt;strong>On an indeterminate failure resolving tool permissions, the code fails open&lt;/strong> for keys and JWTs.&lt;/li>
&lt;li>&lt;strong>The default cost of an MCP call is 0.0&lt;/strong>, and the spend metric is only emitted if the cost is greater than zero, so the series does not exist.&lt;/li>
&lt;li>&lt;strong>MCP calls appear in the per-team report as if they were models&lt;/strong>, named &lt;code>MCP: tool&lt;/code> and with zero tokens.&lt;/li>
&lt;li>&lt;strong>The OTel logger with MCP support is off by default.&lt;/strong> Without the environment variable there is no MCP span.&lt;/li>
&lt;li>&lt;strong>Trace context is not propagated towards the upstream MCP server.&lt;/strong> There is no end-to-end trace.&lt;/li>
&lt;li>&lt;strong>&lt;code>mcp.tool.name&lt;/code> does not exist&lt;/strong> in the semantic conventions. The name goes in &lt;code>gen_ai.tool.name&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Message redaction does not cover tool arguments.&lt;/strong> They are written in the clear into the spend table.&lt;/li>
&lt;li>&lt;strong>Registering, modifying and deleting MCP servers generates no audit log.&lt;/strong> There are three TODOs in the code.&lt;/li>
&lt;li>&lt;strong>The semantic tool filter is not applied on &lt;code>tools/list&lt;/code>.&lt;/strong> Only on &lt;code>/chat/completions&lt;/code> and &lt;code>/v1/responses&lt;/code>.&lt;/li>
&lt;li>&lt;strong>The catalogue order is not deterministic&lt;/strong>, because it comes from iterating a set of strings. It breaks the client&amp;rsquo;s prompt cache even though nobody filters anything.&lt;/li>
&lt;li>&lt;strong>&lt;code>search_tools&lt;/code> in proxy mode counts keywords by default&lt;/strong>, it uses neither regular expressions nor embeddings, and its cap of five results is hard-coded.&lt;/li>
&lt;li>&lt;strong>Description overrides really do replace what the client sees&lt;/strong>, and they are the only catalogue lever that does not break the cache. They are only configurable through the database or the API.&lt;/li>
&lt;li>&lt;strong>&lt;code>spec_version&lt;/code> no longer exists&lt;/strong> in the configuration schema.&lt;/li>
&lt;li>&lt;strong>&lt;code>/mcp/sse&lt;/code> does not use the SSE transport&lt;/strong>, and the messages endpoint its object advertises is dead code.&lt;/li>
&lt;li>&lt;strong>Listing pagination truncates in silence when the deadline expires&lt;/strong>, without leaving a single log line.&lt;/li>
&lt;li>&lt;strong>A database row hides a configuration entry with the same identifier&lt;/strong>, and the other way round a configuration identifier can capture the permissions of a database server.&lt;/li>
&lt;li>&lt;strong>Servers declared in &lt;code>config.yaml&lt;/code> require a restart.&lt;/strong> Only the database ones reload hot.&lt;/li>
&lt;li>&lt;strong>The health check does not write its result to the database&lt;/strong> even though the columns exist, and there is no periodic job.&lt;/li>
&lt;li>&lt;strong>The gateway does not validate the upstream MCP server&amp;rsquo;s URL.&lt;/strong> The anti-SSRF utilities exist and are not used on that path.&lt;/li>
&lt;li>&lt;strong>&lt;code>docker&lt;/code> and &lt;code>npx&lt;/code> are on the stdio command allowlist.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>The &lt;code>/v1/responses&lt;/code> tool loop is a single hop&lt;/strong>, and &lt;code>disable_spend_logs: true&lt;/code> breaks its multi-turn behaviour with no error.&lt;/li>
&lt;li>&lt;strong>LiteLLM advertises the June 2025 revision of the protocol&lt;/strong>, two behind the one in force.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>There is a thread running through the six posts in this track that is more visible here than anywhere else. The gateway is chosen for one job and ends up doing five, and each new job arrives with its own defaults, which are almost never the ones you would have chosen.&lt;/p>
&lt;p>In the case of MCP the asymmetry is twofold. On one side, the part that looks dangerous is better built than you would assume: access control is enforced on execution and not just on the listing, the hierarchy is built out of intersections, header forwarding is an allowlist, and default access is closed, contrary to what I myself wrote two days ago. On the other, the part nobody looks at is worse: tool arguments come out in the clear bypassing the privacy switch, spend is worth zero and its metric never gets to exist, the trace is cut, and a change to the description of an approved tool goes undetected by anybody in the whole chain, neither the specification nor the gateway.&lt;/p>
&lt;p>The architecture decision that comes out of this is that the MCP door has to be treated as what it is, an interconnection of systems with third parties, and not as one more function of the LLM proxy. That means its own inventory, formal registration, change control over every tool&amp;rsquo;s description, network isolation towards the upstream and a log designed knowing that it is going to contain personal data.&lt;/p>
&lt;p>And it means accepting that the cost you really pay for connecting fifteen MCP servers is in no column of the spend table. It is spread across the &lt;code>prompt_tokens&lt;/code> of every model call, on every turn, and across the accuracy you lose when the catalogue is too large for the model to choose well. That number can be computed with what the gateway already stores. The first step is wanting to compute it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — the other face of the same problem, when the agent is a traffic class and not an identity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">Prefix routing&lt;/a> — the sibling post, where the correction is about what the gateway does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — the identity hierarchy these permissions rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the trace path that MCP spans reach, or do not reach.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the identity of your own MCP servers, upstream of this gateway.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside and its deep observability&lt;/a> — the protocol, the six primitives and the semantic conventions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a> — the network isolation this post assumes is necessary and does not explain.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls for the ENS, 42001 and the AI Act&lt;/a> — the compliance framework that activity logging and change management fit into.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Secret hardening in a sovereign LLM stack&lt;/a> — the upstream credentials the gateway holds.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a> — what a tool loop that half fails costs you.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the other face of the tool catalogue, the one paid in proxy CPU when the token counter serialises it on every request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a> — the seams with the identity provider and with the trace backend.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the two standards the MCP specification requires and the authorisation server does not implement.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">Completing Keycloak for MCP&lt;/a> — the resource side: why the official SDKs do not validate the audience and what replaces token exchange.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>MCP Gateway&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/mcp">https://docs.litellm.ai/docs/mcp&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/proxy/_experimental/mcp_server/server.py&lt;/code>, &lt;code>mcp_server_manager.py&lt;/code>, &lt;code>rest_endpoints.py&lt;/code>, &lt;code>auth/user_api_key_auth_mcp.py&lt;/code>, &lt;code>cost_calculator.py&lt;/code>, &lt;code>faults/list_outcomes.py&lt;/code>, &lt;code>litellm/proxy/management_endpoints/mcp_management_endpoints.py&lt;/code>, &lt;code>litellm/proxy/spend_tracking/spend_tracking_utils.py&lt;/code>, &lt;code>litellm/litellm_core_utils/redact_messages.py&lt;/code>, &lt;code>litellm/integrations/otel/&lt;/code>, &lt;code>litellm/responses/litellm_completion_transformation/session_handler.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en llamadas MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42271&lt;/code>, ejecución de comandos por los endpoints de prueba de MCP: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42271">https://nvd.nist.gov/vuln/detail/CVE-2026-42271&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-42208&lt;/code>, inyección SQL sin autenticación en la validación de clave: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-42208">https://nvd.nist.gov/vuln/detail/CVE-2026-42208&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2026-35030&lt;/code>, colisión de clave de caché de userinfo OIDC: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-35030">https://nvd.nist.gov/vuln/detail/CVE-2026-35030&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-49596&lt;/code>, ejecución remota en MCP Inspector: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-49596">https://nvd.nist.gov/vuln/detail/CVE-2025-49596&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-6514&lt;/code>, inyección de comandos en &lt;code>mcp-remote&lt;/code>: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-6514">https://nvd.nist.gov/vuln/detail/CVE-2025-6514&lt;/a>.&lt;/li>
&lt;li>NVD, &lt;code>CVE-2025-66416&lt;/code>, protección de DNS rebinding desactivada por defecto en el SDK de Python: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-66416">https://nvd.nist.gov/vuln/detail/CVE-2025-66416&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>MCP Security Notification: Tool Poisoning Attacks&lt;/em> (tool poisoning, rug pull, shadowing): &lt;a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks">https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks&lt;/a>.&lt;/li>
&lt;li>Invariant Labs, &lt;em>Introducing mcp-scan&lt;/em> (fijado de herramientas por hash): &lt;a href="https://invariantlabs.ai/blog/introducing-mcp-scan">https://invariantlabs.ai/blog/introducing-mcp-scan&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2026-07-28: &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">https://modelcontextprotocol.io/specification/2026-07-28/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, changelog de la revisión 2025-11-25: &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/changelog">https://modelcontextprotocol.io/specification/2025-11-25/changelog&lt;/a>.&lt;/li>
&lt;li>Model Context Protocol, &lt;em>Security Best Practices&lt;/em> (confused deputy, indicadores de recurso, validación de audiencia): &lt;a href="https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices">https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices&lt;/a>.&lt;/li>
&lt;li>NSA, &lt;em>Model Context Protocol: Security Design Considerations for AI-Driven Automation&lt;/em>, mayo de 2026: &lt;a href="https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF">https://media.defense.gov/2026/Jun/02/2003943289/-1/-1/0/CSI_MCP_SECURITY.PDF&lt;/a>.&lt;/li>
&lt;li>CISA y agencias aliadas, &lt;em>Careful Adoption of Agentic AI Services&lt;/em>, mayo de 2026: &lt;a href="https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services">https://www.cisa.gov/resources-tools/resources/careful-adoption-agentic-ai-services&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Advanced tool use&lt;/em> (coste en tokens de las definiciones, búsqueda de herramientas, precisión de selección): &lt;a href="https://www.anthropic.com/engineering/advanced-tool-use">https://www.anthropic.com/engineering/advanced-tool-use&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Code execution with MCP&lt;/em>: &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp">https://www.anthropic.com/engineering/code-execution-with-mcp&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Prompt caching&lt;/em> (jerarquía &lt;code>tools&lt;/code> → &lt;code>system&lt;/code> → &lt;code>messages&lt;/code> e invalidación): &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">https://platform.claude.com/docs/en/build-with-claude/prompt-caching&lt;/a>.&lt;/li>
&lt;li>Anthropic, &lt;em>Tool search tool&lt;/em> (herramientas diferidas fuera del prefijo, umbral de 30 a 50 herramientas): &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool">https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-search-tool&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Prompt caching&lt;/em> (el orden de las herramientas cuenta): &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching">https://developers.openai.com/api/docs/guides/prompt-caching&lt;/a>.&lt;/li>
&lt;li>OpenAI, &lt;em>Tool search&lt;/em> (las herramientas descubiertas se cargan al final del contexto): &lt;a href="https://developers.openai.com/api/docs/guides/tools-tool-search">https://developers.openai.com/api/docs/guides/tools-tool-search&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em>: &lt;a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html">https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>Microsoft, &lt;em>Use tools in chat&lt;/em> (límite de 128 herramientas por petición en VS Code): &lt;a href="https://code.visualstudio.com/docs/copilot/agents/agent-tools">https://code.visualstudio.com/docs/copilot/agents/agent-tools&lt;/a>.&lt;/li>
&lt;li>&lt;em>RAG-MCP&lt;/em>, prueba de estrés de 1 a 11.100 servidores candidatos, mayo de 2025: &lt;a href="https://arxiv.org/abs/2505.03275">https://arxiv.org/abs/2505.03275&lt;/a>.&lt;/li>
&lt;li>&lt;em>Benchmarking the Benchmarks&lt;/em>, desalineación del 18,5 % entre etiqueta y realidad, junio de 2026: &lt;a href="https://arxiv.org/html/2607.02577v1">https://arxiv.org/html/2607.02577v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP-Atlas&lt;/em>, taxonomía de fallos sobre 220 herramientas y veinte modelos, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.00933v3">https://arxiv.org/html/2602.00933v3&lt;/a>.&lt;/li>
&lt;li>&lt;em>BiasBusters&lt;/em>, sesgo de selección entre herramientas equivalentes y peso de la descripción frente al nombre: &lt;a href="https://arxiv.org/html/2510.00307">https://arxiv.org/html/2510.00307&lt;/a>.&lt;/li>
&lt;li>&lt;em>MCP Tool Descriptions Are Smelly&lt;/em>, auditoría de 856 herramientas de 103 servidores, febrero de 2026: &lt;a href="https://arxiv.org/html/2602.14878v1">https://arxiv.org/html/2602.14878v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolMenuBench&lt;/em>, seis estrategias de filtrado y 26.460 ejecuciones, junio de 2026: &lt;a href="https://arxiv.org/html/2606.15508">https://arxiv.org/html/2606.15508&lt;/a>.&lt;/li>
&lt;li>&lt;em>Retrieval Models Aren&amp;rsquo;t Tool-Savvy&lt;/em> (ToolRet), corpus de 43.000 herramientas, marzo de 2025: &lt;a href="https://arxiv.org/abs/2503.01763">https://arxiv.org/abs/2503.01763&lt;/a>.&lt;/li>
&lt;li>&lt;em>Tools Are Not Islands&lt;/em>, brecha entre Recall@3 y conjunto completo, julio de 2026: &lt;a href="https://arxiv.org/html/2607.25718">https://arxiv.org/html/2607.25718&lt;/a>.&lt;/li>
&lt;li>&lt;em>ToolFlood&lt;/em>, saturación adversaria de la capa de recuperación, marzo de 2026: &lt;a href="https://arxiv.org/html/2603.13950">https://arxiv.org/html/2603.13950&lt;/a>.&lt;/li>
&lt;li>&lt;em>Scores Are Not Decisions&lt;/em>, el K óptimo como función del coste, julio de 2026: &lt;a href="https://arxiv.org/html/2607.27083v1">https://arxiv.org/html/2607.27083v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Don&amp;rsquo;t Break the Cache&lt;/em>, caché de prompts en tareas agénticas de horizonte largo, enero de 2026: &lt;a href="https://arxiv.org/html/2601.06007v2">https://arxiv.org/html/2601.06007v2&lt;/a>.&lt;/li>
&lt;li>&lt;em>How Many Tools Should an LLM Agent See? A Chance-Corrected Answer&lt;/em>, mayo de 2026: &lt;a href="https://arxiv.org/html/2605.24660v1">https://arxiv.org/html/2605.24660v1&lt;/a>.&lt;/li>
&lt;li>&lt;em>Semantic Tool Discovery for LLMs&lt;/em>, marzo de 2026: &lt;a href="https://arxiv.org/abs/2603.20313">https://arxiv.org/abs/2603.20313&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, convenciones semánticas de GenAI y MCP: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>Boletín Oficial del Estado, Real Decreto 311/2022, Esquema Nacional de Seguridad: &lt;a href="https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191">https://www.boe.es/buscar/act.php?id=BOE-A-2022-7191&lt;/a>.&lt;/li>
&lt;li>IBM ContextForge, gateway y registro MCP: &lt;a href="https://github.com/IBM/mcp-context-forge">https://github.com/IBM/mcp-context-forge&lt;/a>.&lt;/li>
&lt;li>Docker MCP Gateway: &lt;a href="https://github.com/docker/mcp-gateway">https://github.com/docker/mcp-gateway&lt;/a>.&lt;/li>
&lt;li>agentgateway: &lt;a href="https://github.com/agentgateway/agentgateway">https://github.com/agentgateway/agentgateway&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Prefix routing: what LiteLLM does not do, who does, and how much it really improves</title><link>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</link><pubDate>Fri, 11 Sep 2026 00:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/</guid><description>&lt;blockquote>
&lt;p>Fifth article in the operational track of the control layer. &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">The pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability, &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance and &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents&lt;/a> covered the coexistence of two classes of traffic. Here a claim of mine from June gets taken apart. Everything that follows is verified against LiteLLM 1.102.0 (commit of 10 September 2026), vLLM 0.29.0 and the main branches of production-stack, llm-d and Dynamo from the second week of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>LiteLLM does not do prefix-aware routing, neither in the open version nor in the paid one.&lt;/strong> It is not a licensing question. A search across the whole tree for &lt;code>kv_aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code> and &lt;code>block_hash&lt;/code> returns zero matches in the router. Nor is there any call from the router to &lt;code>/metrics&lt;/code> or to any vLLM endpoint. In June I wrote the opposite in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the inference router article&lt;/a> and I was wrong.&lt;/p>
&lt;p>&lt;strong>What LiteLLM has is affinity, which is a different thing.&lt;/strong> Four pre-call checks pin a request to a replica by session identifier, by key hash, by previous response identifier or by exact hash of a marked prefix. None is active by default. Affinity distributes conversations; prefix routing distributes prefixes shared between different conversations, which is the case that multiplies throughput.&lt;/p>
&lt;p>&lt;strong>The only one that looks at the prompt is fragile in three ways.&lt;/strong> If the client does not send &lt;code>cache_control&lt;/code>, the key comes out null and the check does nothing. If the cut point goes in the last turn, which is what the automatic injection does by default, the key changes on every turn and it never hits. And the pin&amp;rsquo;s TTL is 300 seconds hardcoded in two lines, while the provider offers one-hour caches.&lt;/p>
&lt;p>&lt;strong>The engine does not answer questions, it publishes events.&lt;/strong> There is no vLLM endpoint you can ask whether it has a prefix. What there is, is a ZMQ socket that emits stored and removed blocks in msgpack, switched off by default, with silent discarding when the consumer does not keep up. A router that wants to be exact has to replicate the engine&amp;rsquo;s hash in full: chained SHA256, deterministic seed, block granularity and the extra keys in their order.&lt;/p>
&lt;p>&lt;strong>And by default it does not publish the hits, only the additions.&lt;/strong> The index of a router that only listens drifts away from the engine&amp;rsquo;s real LRU without noticing. To see the hits you have to ask for it request by request with &lt;code>kv_cache_report_mode: full&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The three routers that do do it are very different from each other.&lt;/strong> The vLLM production-stack one indexes chunks of 128 raw characters in a trie that is never purged, models no eviction and dies on every restart. The llm-d one offers an approximate mode and an exact event-driven one, all its plugins are in alpha or beta, and its default tokeniser does not produce real tokens. NVIDIA Dynamo&amp;rsquo;s keeps an event-driven radix tree with a weighted cost function, and ships with the parameter that avoids starving a freshly scaled replica switched off.&lt;/p>
&lt;p>&lt;strong>The big improvements only appear in the best possible case.&lt;/strong> The two- and three-digit numbers are published by the projects themselves, with synthetic shared prefixes and a round-robin balancer as the reference. The most complete independent measurement, from Meta, obtains 2.3 times more throughput and also identifies load regimes where affinity leaves capacity at between half and two thirds of the reference. Another, on a multi-region deployment with 89 % reuse, measures single-digit improvements.&lt;/p>
&lt;p>&lt;strong>The correct architecture has two layers, not one.&lt;/strong> LiteLLM on top for identity, budget and audit, pointing at a single destination per model group. The KV-aware router underneath, inside the engine&amp;rsquo;s domain. Mixing it into a single piece is not an option that exists today.&lt;/p>
&lt;h2 id="you-are-here-between-the-gateway-and-the-engine">You are here: between the gateway and the engine&lt;/h2>
&lt;p>This article occupies the gap between two layers that already have an article of their own. Above, &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 switchboard&lt;/a> and the gateway that has been operated since the first article in this track. Below, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the fundamentals of the KV cache&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a>.&lt;/p>
&lt;p>The gap is the piece that decides which replica each request goes to, knowing what each replica has in memory. It is the only function in the stack that cannot be resolved in the gateway or in the engine separately.&lt;/p>
&lt;h2 id="the-analogy-the-library-with-four-counters">The analogy: the library with four counters&lt;/h2>
&lt;p>A library with four counters. Each librarian has in front of them a pile of the books they have consulted lately. When a reader comes in asking for the third volume of an encyclopaedia, if that volume is in the pile at counter two, serving them there takes seconds. At any other counter someone has to go down to the store room.&lt;/p>
&lt;p>A normal balancer sends the reader to the counter with the shortest queue. It is the right decision if all the counters are interchangeable, and it is the wrong decision here, because they are not: what distinguishes them is not the queue, it is the pile.&lt;/p>
&lt;p>Session affinity resolves one specific case of this. The returning reader is always sent to the same counter, because they will probably ask for the fourth volume of the same encyclopaedia. It works, and it does not resolve the general case: when a hundred different readers ask for the same third volume, session affinity spreads them across the four counters and four librarians go down to the store room for the same book.&lt;/p>
&lt;p>Prefix routing is looking at what the reader asks for before assigning them a counter. It has two costs that the analogy makes visible straight away. The first is that somebody has to keep track of what is in each pile, and that bookkeeping takes work. The second is that if everybody asks for the third volume, counter two becomes the library&amp;rsquo;s only desk while the other three look on.&lt;/p>
&lt;h2 id="the-correction-what-i-wrote-in-june">The correction: what I wrote in June&lt;/h2>
&lt;p>In the inference router article, on 2 June 2026, I closed the prefix routing section with this sentence: &amp;ldquo;vLLM Production Stack router implements it natively. NVIDIA Dynamo too. LiteLLM in its enterprise version has a beta.&amp;rdquo;&lt;/p>
&lt;p>The first two hold up. The third is false and was no less false in June. While preparing &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the humans and agents article&lt;/a> the first hint appeared, and full verification against 1.102.0 confirms it.&lt;/p>
&lt;p>Case-insensitive search over &lt;code>litellm/&lt;/code>, &lt;code>enterprise/&lt;/code> and &lt;code>gateway/&lt;/code>, for the terms &lt;code>kv_aware&lt;/code>, &lt;code>kv-aware&lt;/code>, &lt;code>cache_aware&lt;/code>, &lt;code>cache-aware&lt;/code>, &lt;code>radix&lt;/code>, &lt;code>prefix_cache&lt;/code>, &lt;code>prefix-cache&lt;/code>, &lt;code>kv_cache&lt;/code>, &lt;code>block_hash&lt;/code>, &lt;code>prefix_hash&lt;/code>, &lt;code>prefix_score&lt;/code> and &lt;code>longest_prefix&lt;/code>: zero matches. Search for &lt;code>vllm&lt;/code> inside &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_strategy/&lt;/code> and &lt;code>litellm/router_utils/&lt;/code>: zero. Search for &lt;code>num_requests_waiting&lt;/code>, &lt;code>gpu_cache_usage&lt;/code> or any scraping of the backends&amp;rsquo; &lt;code>/metrics&lt;/code> from the router: zero. LiteLLM&amp;rsquo;s router never asks the engine anything.&lt;/p>
&lt;p>Search for &lt;code>premium_user&lt;/code> in the files where those checks would live: a single result, and it is about something else. There is no paid version with this feature. The only routing piece that is behind a licence is per-tag budgets, in &lt;code>litellm/router_strategy/budget_limiter.py&lt;/code>.&lt;/p>
&lt;p>Where the error came from. LiteLLM&amp;rsquo;s documentation has an entry on dynamic TPM and RPM allocation marked as beta and enterprise, and a pre-call check called &lt;code>prompt_caching&lt;/code>. Skim-read and added together, they give the impression of prefix routing in beta. Read in the code, &lt;code>prompt_caching&lt;/code> is something else, and it deserves a section of its own because it is still recommended for this problem.&lt;/p>
&lt;h2 id="what-litellm-does-have-four-affinities">What LiteLLM does have: four affinities&lt;/h2>
&lt;p>The &lt;code>optional_pre_call_checks&lt;/code> field accepts eight values. None is enabled by default: &lt;code>apply_default_settings&lt;/code> passes an empty list. One of the eight, &lt;code>forward_client_headers_by_model_group&lt;/code>, is declared in the type and in the interface schema and is consumed by no branch of the code. It is a dead value.&lt;/p>
&lt;p>The four that pin a request to a replica are these.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Check&lt;/th>
&lt;th>What it pins&lt;/th>
&lt;th>Where it stores it&lt;/th>
&lt;th>TTL&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>session_affinity&lt;/code>&lt;/td>
&lt;td>Session identifier&lt;/td>
&lt;td>Redis with a Lua script, mirrored in memory&lt;/td>
&lt;td>&lt;code>deployment_affinity_ttl_seconds&lt;/code>, 3600 by default, refreshed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>deployment_affinity&lt;/code>&lt;/td>
&lt;td>API key hash&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>responses_api_deployment_check&lt;/code>&lt;/td>
&lt;td>&lt;code>previous_response_id&lt;/code>&lt;/td>
&lt;td>Same&lt;/td>
&lt;td>Same&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>prompt_caching&lt;/code>&lt;/td>
&lt;td>Exact hash of a marked prefix&lt;/td>
&lt;td>&lt;code>DualCache&lt;/code>&lt;/td>
&lt;td>&lt;strong>300 s, hardcoded&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The other four are &lt;code>router_budget_limiting&lt;/code>, &lt;code>encrypted_content_affinity&lt;/code>, &lt;code>enforce_model_rate_limits&lt;/code> and the dead value.&lt;/p>
&lt;h3 id="prompt_caching-why-it-is-no-use-for-this">&lt;code>prompt_caching&lt;/code>: why it is no use for this&lt;/h3>
&lt;p>The key is a SHA256 over the serialisation of the cacheable prefix, and &amp;ldquo;cacheable prefix&amp;rdquo; means everything up to and including the last block marked with &lt;code>cache_control: {&amp;quot;type&amp;quot;: &amp;quot;ephemeral&amp;quot;}&lt;/code>. Three consequences, checked by running the functions in isolation over the repository&amp;rsquo;s code.&lt;/p>
&lt;p>If the request carries no &lt;code>cache_control&lt;/code>, the extraction returns an empty list, the key comes out null and the check pins nothing. Automatic injection of cut points is only enabled with &lt;code>litellm.enable_anthropic_prompt_caching&lt;/code>, which defaults to false, and only for Anthropic and Bedrock providers.&lt;/p>
&lt;p>If the cut point is in the system prompt, the key does not change when turns are added and the pin works. This is the good case and it requires the client to mark it deliberately.&lt;/p>
&lt;p>If the cut point is in the last message, the key changes on every turn and the pin never hits. And this is exactly the default case of the automatic injection, which places one point on the &lt;code>system&lt;/code> and another at index &lt;code>-1&lt;/code>. Since the second is the last, the cacheable prefix becomes the whole conversation.&lt;/p>
&lt;p>On top of that there is the TTL. Two lines with &lt;code>ttl=300&lt;/code>, one of them with the comment &lt;code># store for 5 minutes&lt;/code>. There is no parameter to change it. Issue 28427 has had it open for months with no response from the maintainers, and it points to the mismatch with the one-hour ephemeral caches that LiteLLM itself knows how to request via &lt;code>cache_control.ttl: &amp;quot;1h&amp;quot;&lt;/code>.&lt;/p>
&lt;p>There is a nuance the issue does not capture and that makes diagnosis worse: in the default case the pin does not even last five minutes, because the key changes first.&lt;/p>
&lt;h3 id="session_affinity-the-one-to-use">&lt;code>session_affinity&lt;/code>: the one to use&lt;/h3>
&lt;p>For conversations, this is the right piece and it is reasonably built. The session identifier is looked for in this exact order of headers:&lt;/p>
&lt;ol>
&lt;li>&lt;code>x-litellm-trace-id&lt;/code>&lt;/li>
&lt;li>&lt;code>x-litellm-session-id&lt;/code>&lt;/li>
&lt;li>Any &lt;code>x-&amp;lt;vendor&amp;gt;-session-id&lt;/code>, with the regular expression &lt;code>^x-.+-session-id$&lt;/code> and a value of at least eight alphanumeric characters&lt;/li>
&lt;li>Only if the &lt;code>User-Agent&lt;/code> is Codex&amp;rsquo;s: &lt;code>session-id&lt;/code>, &lt;code>session_id&lt;/code>, &lt;code>thread-id&lt;/code>, &lt;code>conversation_id&lt;/code>&lt;/li>
&lt;li>Plain &lt;code>x-session-id&lt;/code>, for opencode&lt;/li>
&lt;/ol>
&lt;p>And if nothing matches, two fallbacks: a &lt;code>metadata.user_id&lt;/code> in Anthropic format, and the W3C &lt;code>baggage&lt;/code> with &lt;code>session.id=&lt;/code>. The third point is the one that captures Claude Code with nothing configured.&lt;/p>
&lt;p>Storage is a Lua script in Redis that does &lt;code>GET&lt;/code>, &lt;code>SET NX EX&lt;/code> and &lt;code>EXPIRE&lt;/code>, so the first writer wins and the TTL is refreshed on every request that confirms the pin. It is an inactivity TTL, not a total session duration. Without Redis it degrades to a pod-local reservation, which is atomic but not shared.&lt;/p>
&lt;p>A detail of the pipeline order that already came up in the previous article and still holds: the affinity checks run inside &lt;code>async_get_healthy_deployments&lt;/code> before tag filtering. If the pin returns a single deployment and that deployment does not satisfy the request&amp;rsquo;s tag, the later filter removes it and the request fails. Affinity gets no vote on the tags.&lt;/p>
&lt;h3 id="the-routers-that-do-read-the-prompt-and-why-they-are-no-good">The routers that do read the prompt, and why they are no good&lt;/h3>
&lt;p>LiteLLM has four pieces that read message content: &lt;code>AutoRouter&lt;/code> with semantic matching, &lt;code>RequestComplexityRouter&lt;/code>, &lt;code>AdaptiveRouter&lt;/code> and &lt;code>QualityRouter&lt;/code>. They all live in &lt;code>async_pre_routing_hook&lt;/code>, which runs before looking for healthy deployments.&lt;/p>
&lt;p>The reason they do not solve this problem is in what they choose. All four choose a &lt;strong>model group&lt;/strong>, not a replica. They decide whether an easy question goes to the thirty-billion-parameter model or to the small one. None has access to the replica list or knows what is in each one&amp;rsquo;s memory.&lt;/p>
&lt;h3 id="what-did-land-admission-control">What did land: admission control&lt;/h3>
&lt;p>A minor correction to the previous article, which placed this in the 1.101 branch as an upcoming feature. In 1.102.0 it is already there, in &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, with the three settings in &lt;code>general_settings&lt;/code>: &lt;code>max_in_flight_requests_per_worker&lt;/code>, &lt;code>max_queued_requests_per_worker&lt;/code> and &lt;code>admission_queue_timeout_seconds&lt;/code>, the last with a default value of 1.0 seconds.&lt;/p>
&lt;p>It stays inactive as long as the first one is not declared. It exposes an authenticated &lt;code>/health/backlog&lt;/code> and the three metrics &lt;code>litellm_admission_admitted_requests&lt;/code>, &lt;code>litellm_admission_queued_requests&lt;/code> and &lt;code>litellm_admission_rejected_requests_total&lt;/code>, the last with a reason label. It is a per-process semaphore, not coordinated across pods.&lt;/p>
&lt;h2 id="what-the-engine-exposes-upwards">What the engine exposes upwards&lt;/h2>
&lt;p>Before looking at the routers that do do this, you have to understand what raw material they work with. The natural question would be whether vLLM has an endpoint you can ask about a prefix. It does not.&lt;/p>
&lt;p>The only cache router in the entry points is &lt;code>vllm/entrypoints/serve/dev/cache/api_router.py&lt;/code>, and it only exposes destructive writes: &lt;code>POST /reset_prefix_cache&lt;/code>, &lt;code>/reset_mm_cache&lt;/code> and &lt;code>/reset_encoder_cache&lt;/code>. All three are behind &lt;code>VLLM_SERVER_DEV_MODE=1&lt;/code> and do not exist in a normal deployment. There is no read.&lt;/p>
&lt;p>What there is, is an event stream.&lt;/p>
&lt;h3 id="the-zmq-stream">The ZMQ stream&lt;/h3>
&lt;p>It is enabled with &lt;code>--kv-events-config&lt;/code>, and it is off by default. The configuration accepts a publisher (&lt;code>null&lt;/code> or &lt;code>zmq&lt;/code>), an endpoint (by default &lt;code>tcp://*:5557&lt;/code>, which binds), an optional replay endpoint, the number of batches retained for that replay (10,000), and a high water mark limit of 100,000.&lt;/p>
&lt;p>That limit is the one that matters operationally: above it, ZMQ discards events if the consumer does not keep up. Silently.&lt;/p>
&lt;p>The transport is a PUB socket with msgpack serialisation and three-part frames: topic, eight-byte sequence number and payload. The sequence number lets a subscriber ask for replay from where it left off through the optional ROUTER socket. With data parallelism the port shifts by rank, so you have to subscribe to &lt;code>5557+rank&lt;/code>.&lt;/p>
&lt;p>Three event types. &lt;code>BlockStored&lt;/code> carries the hashes, the parent hash, the token identifiers, the block size, the LoRA adapter name, the extra keys, the medium (GPU, CPU or storage) and a session identifier. &lt;code>BlockRemoved&lt;/code> carries only hashes. &lt;code>AllBlocksCleared&lt;/code> carries no payload and is emitted when the prefix cache is reset.&lt;/p>
&lt;p>They are published once per scheduler step, only if there are events. With the feature switched off the cost is zero. With it switched on, the full token identifiers of each block travel over the wire, so bandwidth scales with tokens per second and not with requests per second.&lt;/p>
&lt;p>An example in vLLM&amp;rsquo;s own repository declares itself experimental on its eighth line.&lt;/p>
&lt;h3 id="the-hash-that-has-to-be-replicated">The hash that has to be replicated&lt;/h3>
&lt;p>An exact router does not receive the engine&amp;rsquo;s state, it reconstructs it. For that it has to compute the same hashes, and the engine computes them like this:&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"># vllm/v1/core/kv_cache_utils.py&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">hash_block_tokens&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">hash_function&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">extra_keys&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="ow">not&lt;/span> &lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parent_block_hash&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">NONE_HASH&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">hash_function&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">parent_block_hash&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">curr_block_token_ids&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">extra_keys&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is chained: each hash identifies the complete prefix up to that boundary, not the isolated block. The default algorithm is SHA256, configurable with &lt;code>--prefix-caching-hash-algo&lt;/code> among four values.&lt;/p>
&lt;p>Five things a router has to get right for its hashes to match the engine&amp;rsquo;s:&lt;/p>
&lt;p>The seed. Since 0.29, &lt;code>NONE_HASH&lt;/code> is deterministic from a fixed seed, so two different processes produce the same hashes with nothing configured. It is a silent change of semantics from earlier versions, which required setting &lt;code>PYTHONHASHSEED&lt;/code>. With the &lt;code>xxhash&lt;/code> algorithms it is still needed.&lt;/p>
&lt;p>The block size. By default 16 tokens, but the attention backend can override it if the user did not pass &lt;code>--block-size&lt;/code>: 256 with sliding-window sparse attention, 64 with ROCm&amp;rsquo;s AITER, 64 or more with FlashAttention on XPU. A router must not assume 16, it has to read it from the event.&lt;/p>
&lt;p>The hashing granularity, which is not necessarily the physical block size. There is &lt;code>--prefix-match-unit&lt;/code>, which with several cache groups resolves to the greatest common divisor of the cacheable groups&amp;rsquo; sizes.&lt;/p>
&lt;p>The extra keys, in their order: LoRA adapter name (by name, not by identifier), multimodal identifiers with their relative offset inside the block, &lt;code>cache_salt&lt;/code> only in block zero, and the hash of the prompt embeddings. Sampling parameters do not go in, which is correct: the prefix KV does not depend on the temperature.&lt;/p>
&lt;p>And truncation. By default the hashes travel over the wire as 64-bit integers truncated from a SHA256, not as the full bytes. It can be disabled with &lt;code>VLLM_KV_EVENTS_USE_INT_BLOCK_HASHES=0&lt;/code>. With the default value, a router has a theoretical collision risk that the engine does not have.&lt;/p>
&lt;h3 id="the-hits-are-invisible-by-default">The hits are invisible by default&lt;/h3>
&lt;p>This is the detail that takes the most effort to discover and has the most consequences. The events are incremental: only what gets newly cached is announced. A reused block generates no event.&lt;/p>
&lt;p>A router that only listens sees the additions and the removals, but does not see the hits. Its notion of what is recent and what is cold drifts away from the engine&amp;rsquo;s real LRU with nothing to warn it.&lt;/p>
&lt;p>A full mode exists, and it is per request. &lt;code>sampling_params.extra_args[&amp;quot;kv_cache_report_mode&amp;quot;] = &amp;quot;full&amp;quot;&lt;/code> makes &lt;code>BlockStored&lt;/code> events be emitted for blocks that were hits too. The function&amp;rsquo;s docstring says so plainly: it generates events so that external consumers, &amp;ldquo;for example a gateway&amp;rdquo;, know which blocks were reused. That is to say: for this to work, the router has to inject a parameter into every request it forwards.&lt;/p>
&lt;h3 id="the-metrics-and-what-broke">The metrics, and what broke&lt;/h3>
&lt;p>For aggregate measurement two counters remain: &lt;code>vllm:prefix_cache_queries_total&lt;/code> and &lt;code>vllm:prefix_cache_hits_total&lt;/code>, both in tokens and not in requests, with model and engine labels. There are two equivalents for the external connector, which allow a hit in HBM to be distinguished from one brought in from CPU or NVMe.&lt;/p>
&lt;p>And there are two removals that break existing configurations. &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists: zero appearances in the repository. It was deprecated in 0.8, hidden in 0.9 and removed in 0.10. And &lt;code>vllm:gpu_cache_usage_perc&lt;/code> was renamed to &lt;code>vllm:kv_cache_usage_perc&lt;/code>, which breaks Grafana panels and autoscalers that depended on the old name.&lt;/p>
&lt;p>The hit rate has to be derived in PromQL with &lt;code>rate()&lt;/code> over the two counters. The one that appears in the text log is something else: it uses a window of the last thousand requests.&lt;/p>
&lt;p>And it has a bias you need to know about before taking decisions with it. When a request is evicted, its blocks return to the free queue keeping the hash, so the later reprefill finds them again and &lt;strong>counts as a hit&lt;/strong>. A high hit rate can mean useful reuse between users or it can mean the engine is evicting and recomputing in a loop. The internal statistics separate the two things with a &lt;code>preempted&lt;/code> field, which is not exposed as a Prometheus metric.&lt;/p>
&lt;h3 id="the-last-token">The last token&lt;/h3>
&lt;p>A small detail with an effect on long conversations: the maximum hit length is the request&amp;rsquo;s token count minus one. There is always something to recompute in order to get logits. Since the reservation requires alignment to the block size, in practice that can force recomputing the whole final block. With blocks of 16 it is noise. With a backend that prefers 256, it is up to 256 tokens recomputed per turn.&lt;/p>
&lt;h2 id="the-ones-that-do-do-it">The ones that do do it&lt;/h2>
&lt;h3 id="vllm-production-stack">vLLM production-stack&lt;/h3>
&lt;p>Eight routing logics in a single &lt;code>--routing-logic&lt;/code> selector: &lt;code>roundrobin&lt;/code>, &lt;code>session&lt;/code>, &lt;code>kvaware&lt;/code>, &lt;code>loadaware&lt;/code>, &lt;code>prefixaware&lt;/code>, &lt;code>disaggregated_prefill&lt;/code>, &lt;code>disaggregated_prefill_orchestrated&lt;/code> and &lt;code>priority&lt;/code>. Two of them, &lt;code>loadaware&lt;/code> and &lt;code>priority&lt;/code>, are not in the Helm chart&amp;rsquo;s enum and are only enabled through &lt;code>extraArgs&lt;/code>.&lt;/p>
&lt;p>The naming trap is that &lt;code>prefixaware&lt;/code> and &lt;code>kvaware&lt;/code> are radically different things.&lt;/p>
&lt;p>&lt;code>prefixaware&lt;/code> is a hash trie in the router&amp;rsquo;s process. It chunks the prompt string every 128 &lt;strong>characters&lt;/strong>, not tokens, and hashes each chunk with 64-bit xxhash. Its own docstring says so: the chunk size is &amp;ldquo;in number of characters&amp;rdquo;. That 128 is hardcoded, there is no flag or Helm value to change it.&lt;/p>
&lt;p>Four consequences come out of that. &lt;code>--prefix-min-match-length&lt;/code> is measured in characters and is quantised to multiples of 128. A shared prefix that differs by one character inside the first chunk breaks the whole match, because there is no partial match inside a chunk. For chat, it concatenates the content of all the messages with newlines and without applying the chat template, so the indexed string is not what the engine tokenises. And the tie-break between tied replicas is &lt;code>random.choice&lt;/code>, without looking at load.&lt;/p>
&lt;p>The trie has no notion of saturation. A popular prefix concentrates all the traffic on the same replica indefinitely. The docstring assumes the simplified model and declares it: &amp;ldquo;we assume there is no prefix cache eviction&amp;rdquo;.&lt;/p>
&lt;p>It has no pruning either. &lt;code>HashTrie&lt;/code> only has insertion and longest prefix lookup; there is no deletion method. When a replica disappears, its URLs stay forever in every node&amp;rsquo;s sets, and filtering happens at query time. It works, and the trie grows monotonically: each distinct prompt adds its length divided by 128 permanent nodes, with no bound, no TTL and no LRU. The router&amp;rsquo;s memory limit in the chart is 1,000 MiB. By contrast, the round-robin router in the same file does bound its caches to 1,024 entries.&lt;/p>
&lt;p>And it dies on every restart: it is in-process memory state, with no snapshot and no initial load.&lt;/p>
&lt;p>&lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> are better and have a different price. They do not index themselves: they ask the LMCache controller, which knows about real tokens. That requires &lt;code>lmcache==0.3.11&lt;/code> and &lt;code>vllm==0.13.0&lt;/code> pinned exactly, and the official router image drags in the whole of vLLM. The limitation to read before planning anything: they only look at &lt;code>request_json.get(&amp;quot;prompt&amp;quot;, &amp;quot;&amp;quot;)&lt;/code>, with an open TODO for chat completions. With &lt;code>/v1/chat/completions&lt;/code>, both tokenise an empty string.&lt;/p>
&lt;p>&lt;code>loadaware&lt;/code> is the only one of the three that breaks affinity on saturation, with the formula &lt;code>score = relative_match - beta * relative_load&lt;/code> and &lt;code>beta = 1.0&lt;/code> by default. The authors&amp;rsquo; own reading: a replica at twice the average load loses the equivalent of a full cache hit. The guide suggests 0.25 to favour cache and 2.0 to favour balancing.&lt;/p>
&lt;p>On published results, the documentation&amp;rsquo;s benchmarking file literally says that the measurement platform is coming soon. The project blog&amp;rsquo;s two posts claim between three and ten times lower response latency, and only one declares a partial configuration: Llama 3.1 70B on four nodes with tensor parallelism 2 over 80 GB A100s, inputs of 9K tokens and outputs of 10, against AIBrix 0.2.0 and a flat deployment. Charts with no labelled points, no absolute figures and no declaration of which routing logic was used in each.&lt;/p>
&lt;h3 id="llm-d">llm-d&lt;/h3>
&lt;p>There has been a reorganisation here that invalidates a good part of the third-party documentation. The Endpoint Picker code left the Gateway API Inference Extension repository and was merged into llm-d&amp;rsquo;s, which has been renamed to llm-d Router. The Kubernetes project keeps the &lt;code>InferencePool&lt;/code> API and a lightweight reference EPP that does nothing about prefixes. If you look for KV-aware routing in the extension&amp;rsquo;s repository, today it is not there.&lt;/p>
&lt;p>The &lt;code>InferencePool&lt;/code> is at &lt;code>v1&lt;/code> and is GA. The other two APIs moved to the &lt;code>llm-d.ai&lt;/code> group at &lt;code>v1alpha2&lt;/code>, and when they coexist with the old ones the EPP prefers the new ones and ignores the old.&lt;/p>
&lt;p>Scoring is a weighted sum. The default weights, when no configuration is passed: queue 2, KV utilisation 2, prefix 3. Any scorer referenced without a weight counts as 1.0. There is no configuration through environment variables: the old &lt;code>ENABLE_*_SCORER&lt;/code> no longer exist, everything goes in an &lt;code>EndpointPickerConfig&lt;/code> in YAML.&lt;/p>
&lt;p>The prefix scorer consumes an attribute published by a producer, and there are two producers.&lt;/p>
&lt;p>The &lt;strong>approximate&lt;/strong> one computes the hashes in the EPP itself without talking to the engine, and keeps a local LRU per pod and block pair that is populated after the decision. It is an approximation by construction. It has a hard floor: a configured block size below 64 tokens is raised to 64 at request time, because the index stores one entry per pod and block and going down to 16 would multiply memory by four.&lt;/p>
&lt;p>The &lt;strong>exact&lt;/strong> one builds the real index from the engine&amp;rsquo;s ZMQ events, with one subscriber per pod that is installed and torn down with the endpoint&amp;rsquo;s registration and deregistration. It has adapters for vLLM and SGLang, deduplicates evictions by reference counting, and the replay buffer that allows the index to be rebuilt after an EPP restart requires vLLM 0.26.0 or above.&lt;/p>
&lt;p>The trap is in the tokeniser. The EPP tokenises through a plugin, and the default backend is &lt;code>estimate&lt;/code>: byte packing with no tokeniser, roughly 4 bytes per token, with identifiers that do not correspond to real engine tokens. The exact scorer needs real tokens, and for that you have to explicitly configure the &lt;code>vllm&lt;/code> backend, which does HTTP against the engine&amp;rsquo;s &lt;code>/v1/completions/render&lt;/code> endpoints. If it is omitted, the auto-created &lt;code>estimate&lt;/code> satisfies the dependency and the system &lt;strong>degrades silently&lt;/strong>.&lt;/p>
&lt;p>On maturity, the file itself declares it: all in-tree plugins are in alpha or beta, and they will be promoted to stable when the project approaches 1.0. An alpha plugin configured without the corresponding flag makes the EPP fail to start.&lt;/p>
&lt;p>And on replicas, the warning is explicit: active-active mode should be avoided with approximate prefix routing, because the EPP replicas do not share state and each one only sees the traffic it has served itself, which significantly degrades the hit rate. The exact index is safe in high availability, and even so the reference guides fix a single replica because the accounting of in-flight requests is local to the process.&lt;/p>
&lt;p>The cost is the best documented in the whole field, and it deserves a table. Simulator with Qwen3 8B, 100 pods, 100K input tokens:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Requests/s&lt;/th>
&lt;th>Cap on tokens to match&lt;/th>
&lt;th>Peak CPU&lt;/th>
&lt;th>Peak memory&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>1.19 cores&lt;/td>
&lt;td>0.26 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5.0&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>3.82 cores&lt;/td>
&lt;td>0.65 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.7&lt;/td>
&lt;td>4,096&lt;/td>
&lt;td>35.17 cores&lt;/td>
&lt;td>2.46 GiB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>98.8&lt;/td>
&lt;td>100,000&lt;/td>
&lt;td>46.50 cores&lt;/td>
&lt;td>3.41 GiB&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With outputs of 10K tokens at 50 requests per second, 32.53 cores and 12.54 GiB. The maintainers&amp;rsquo; sizing rule is half a core to one core per request per second on large agentic loads. And idle consumption scales with the number of pods because of continuous scraping: with 100 pods, about 7.5 cores with no traffic.&lt;/p>
&lt;h3 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h3>
&lt;p>A precise event-driven index, with a global radix tree in each frontend&amp;rsquo;s process. Since all the replicas consume the same event plane, there is no need to synchronise routers with each other for the prefix state.&lt;/p>
&lt;p>The cost function is explicit and weighted. It discounts from the prefill the blocks that overlap in GPU, host and disk with different weights (1.0 for the overlap credit, 0.75 for host, 0.25 for disk), and adds the potential decode blocks and the active requests.&lt;/p>
&lt;p>There is a parameter to look at before deploying: &lt;code>--router-kv-overlap-score-credit-decay&lt;/code> is at &lt;strong>0&lt;/strong> by default, that is, disabled. Its description says what it is for: preventing loaded, cache-rich replicas from winning again and again while the freshly autoscaled ones receive too little traffic. With the default value, a new pod has no cache, therefore never wins the scoring, therefore never generates cache.&lt;/p>
&lt;p>The approximate mode exists (&lt;code>--no-router-kv-events&lt;/code>, with a TTL of 120 seconds) and the documentation says it is not the recommended route in production.&lt;/p>
&lt;p>Dynamo is the most honest project in its measurement methodology: its comparison guide recommends contrasting &lt;code>--router-mode random&lt;/code> against &lt;code>kv&lt;/code> over the public Mooncake trace from FAST'25, with a declared cache ratio of 59 %, and it generates the table locally instead of publishing a number.&lt;/p>
&lt;h3 id="the-rest-of-the-field">The rest of the field&lt;/h3>
&lt;p>&lt;strong>SGLang&lt;/strong> has renamed its router to &lt;code>sgl-model-gateway&lt;/code>, version 0.3.2 within SGLang 0.5.19. Its &lt;code>cache_aware&lt;/code> policy is an approximate radix tree over raw characters, with switching to shortest queue when the system is unbalanced according to two combined thresholds. One detail that can cost an afternoon: the Rust defaults and the Python CLI defaults &lt;strong>do not match&lt;/strong>, and on the maximum tree size they differ by four orders of magnitude (10,000 nodes against 2^26).&lt;/p>
&lt;p>&lt;strong>AIBrix&lt;/strong> 0.7.0 offers a standard mode with a local hash table and a KV event synchronisation mode behind a flag and a build tag, with vLLM 0.7.0 or above.&lt;/p>
&lt;p>&lt;strong>Ray Serve LLM&lt;/strong> has &lt;code>PrefixCacheAffinityRouter&lt;/code> with an explicit alpha API warning, a character-based prefix tree in a decoupled actor, and its imbalance threshold at infinity by default, that is, out of the box it never breaks affinity on load.&lt;/p>
&lt;p>&lt;strong>Envoy AI Gateway&lt;/strong> 1.1.0 implements none of this and delegates to &lt;code>InferencePool&lt;/code> and the EPP. &lt;strong>KServe&lt;/strong> the same, with its &lt;code>LLMInferenceService&lt;/code> at &lt;code>v1alpha1&lt;/code>. &lt;strong>Mooncake Conductor&lt;/strong> is still an unimplemented proposal.&lt;/p>
&lt;h3 id="comparison-table">Comparison table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Project&lt;/th>
&lt;th>Version and date&lt;/th>
&lt;th>Index&lt;/th>
&lt;th>Where it lives&lt;/th>
&lt;th>Kubernetes&lt;/th>
&lt;th>Declared maturity&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>production-stack &lt;code>prefixaware&lt;/code>&lt;/td>
&lt;td>main branch, 09-Sep-2026&lt;/td>
&lt;td>Approximate, character trie, no eviction&lt;/td>
&lt;td>Router process&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>WIP in the README&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>production-stack &lt;code>kvaware&lt;/code>/&lt;code>loadaware&lt;/code>&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact via LMCache controller&lt;/td>
&lt;td>LMCache controller&lt;/td>
&lt;td>Optional&lt;/td>
&lt;td>No chat completions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router approximate&lt;/td>
&lt;td>v0.10.0, 17-Aug-2026&lt;/td>
&lt;td>Approximate, LRU per pod&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta, active-active discouraged&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>llm-d Router exact&lt;/td>
&lt;td>same&lt;/td>
&lt;td>Exact through ZMQ events&lt;/td>
&lt;td>EPP process&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Beta&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA Dynamo&lt;/td>
&lt;td>v1.4.2, 27-Aug-2026&lt;/td>
&lt;td>Exact, event-driven radix tree&lt;/td>
&lt;td>Frontend process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>GA with experimental subsystems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SGLang &lt;code>sgl-model-gateway&lt;/code>&lt;/td>
&lt;td>0.3.2, 03-Sep-2026&lt;/td>
&lt;td>Approximate, character radix&lt;/td>
&lt;td>Gateway process&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Default policy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AIBrix&lt;/td>
&lt;td>v0.7.0, 16-Jun-2026&lt;/td>
&lt;td>Both&lt;/td>
&lt;td>Gateway plugin&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Synchronisation behind a flag&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ray Serve LLM&lt;/td>
&lt;td>Ray 2.58&lt;/td>
&lt;td>Approximate, characters&lt;/td>
&lt;td>Ray actor&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Declared alpha&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy AI Gateway&lt;/td>
&lt;td>v1.1.0, 21-Aug-2026&lt;/td>
&lt;td>None of its own&lt;/td>
&lt;td>Delegates to EPP&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Stable 1.x API&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-much-it-really-improves">How much it really improves&lt;/h2>
&lt;p>Here two types of source have to be separated.&lt;/p>
&lt;p>&lt;strong>The projects&amp;rsquo; own numbers.&lt;/strong> Red Hat published the llm-d case in May: Qwen3 32B, eight vLLM pods over 16 H100s with tensor parallelism 2, synthetic shared-prefix load with 150 groups of five prompts, 6,000 tokens of system prompt, 1,200 of question and 1,000 of output. Result: up to 109 % more throughput and up to 99 % less TTFT, around 200 concurrent users within SLO against around 20. The repository&amp;rsquo;s result files give more detail: peak throughput from 6,986 to 14,892 output tokens per second, p90 TTFT from 135.5 seconds to 0.26.&lt;/p>
&lt;p>That same file publishes the regression, and that is why it is worth reading: p50 inter-token latency rises by 22.4 % with vLLM and by 45.4 % with SGLang. The trade-off is declared: routing by affinity concentrates more concurrent work on the pods with a warm cache.&lt;/p>
&lt;p>It is the best possible case. A synthetic shared prefix and a reference that is a flat Kubernetes &lt;code>Service&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The independent measurements.&lt;/strong> There are three and they say different things.&lt;/p>
&lt;p>&lt;em>CacheRoute&lt;/em>, from Meta, August 2026, is the most complete. Llama 3.3 70B in fp8, 30 targets with tensor parallelism 2 over 60 H100s, a semi-synthetic trace from production telemetry with 128,824 business keys, and all the alternatives reimplemented in the same harness instead of comparing against a dumb balancer. It obtains 176 requests per second with a p99 SLO of 3.5 seconds against 76 for the best competitor, and a hit rate of 93.2 % against 72.0 %.&lt;/p>
&lt;p>And it publishes two negative findings that nobody else publishes. There are load regimes where affinity &lt;strong>reduces&lt;/strong> capacity to between half and two thirds of the reference, to the point that the authors make a mirrored-traffic trial mandatory before deploying. And growing the fleet can cool a prefix even though total cache capacity grows, because the time between revisits stretches beyond the eviction window. Scaling horizontally can make the hit rate worse.&lt;/p>
&lt;p>&lt;em>GORGO&lt;/em>, June 2026, measures on a multi-region deployment with a dataset where intra-user prefix reuse is 89.4 % and the average prompt is almost 18,000 tokens. An improvement of 6.9 to 15.5 % in p95 TTFT. With extremely high reuse, the improvement is single-digit, because network latency and queuing dominate.&lt;/p>
&lt;p>&lt;em>LAAR&lt;/em>, from IBM Research Tokyo, April 2026, is the uncomfortable counterpoint: in its comparison, session affinity routing was the &lt;strong>worst&lt;/strong> of the baselines, and at 64K contexts a load-based routing gives lower absolute latency at the cost of fewer hits.&lt;/p>
&lt;p>The joint reading fits in three conditions. The big improvements require high, concentrated prefix reuse, long prompts, and a weak reference. Take away any of the three and the numbers deflate.&lt;/p>
&lt;h2 id="the-five-failure-modes">The five failure modes&lt;/h2>
&lt;p>&lt;strong>Hot spot from affinity.&lt;/strong> The busiest bucket inherits the load&amp;rsquo;s skew, and tail latency starts following the hottest target instead of the fleet average. Mitigations exist in the four main products and in three of the four they ship &lt;strong>switched off&lt;/strong>: Dynamo&amp;rsquo;s credit decay at 0, Ray&amp;rsquo;s imbalance threshold at infinity, and production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> with none at all.&lt;/p>
&lt;p>&lt;strong>Starvation of the freshly scaled pod.&lt;/strong> A new pod has no cache, so it never wins the scoring, so it never warms up. Dynamo documents it as the reason for the decay parameter. I found no primary documentation from any of the projects about the specific interaction with KEDA.&lt;/p>
&lt;p>&lt;strong>Cost of the index.&lt;/strong> The llm-d numbers in the previous table are the best public reference that exists. Raising the cap on tokens to match from 16,384 to 400,000 can more than double the EPP&amp;rsquo;s CPU with little traffic.&lt;/p>
&lt;p>&lt;strong>Traffic with unique prefixes.&lt;/strong> Each project degrades to something different. SGLang routes to the smallest tree, which is not the same as the least loaded. Ray falls back to power of two choices. Dynamo has an explicit preset, &lt;code>--load-aware&lt;/code>, to say &amp;ldquo;I want the load model without reuse&amp;rdquo;. And production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> keeps inserting into the trie, that is, keeps paying the cost while receiving nothing.&lt;/p>
&lt;p>&lt;strong>Sibling bursts.&lt;/strong> Parallel sampling, best of N, or an agent that opens five branches at once. With event-driven routing, no engine has yet emitted the block stored event, all the siblings score zero overlap, and the prefix is precomputed on every replica. Dynamo has a prediction TTL for this; the others do not.&lt;/p>
&lt;h2 id="the-architecture-that-comes-out-of-all-of-the-above">The architecture that comes out of all of the above&lt;/h2>
&lt;p>Two layers, with the responsibilities separated.&lt;/p>
&lt;p>On top, LiteLLM, with what it does do well: non-forgeable identity through the virtual key, budgets, spend logging, concurrency limits, fallback between providers and API unification. For the gateway, each model group has &lt;strong>a single destination&lt;/strong>: the URL of the router below.&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="nt">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://kv-router.inferencia.svc:8000/v1&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">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_KEY&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">router_settings&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">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&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">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">optional_pre_call_checks&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;session_affinity&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 class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&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">general_settings&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">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&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">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&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">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Below, the KV-aware router, inside the engine&amp;rsquo;s domain, talking to the vLLM replicas and consuming their events.&lt;/p>
&lt;p>Four consequences of this separation that have to be accepted before building it.&lt;/p>
&lt;p>Per-replica visibility in LiteLLM is lost. For the gateway there is a single deployment, so its cooldowns, its per-deployment counters and its strategies stop meaning anything. Observability of which replica served what goes downstairs.&lt;/p>
&lt;p>Retries stack up. The client SDK retries, LiteLLM retries, and the router below may have its own failover. In &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">the previous article&lt;/a> 45 calls per turn came out with three layers; with four it is worse. Lowering &lt;code>num_retries&lt;/code> in the gateway to 1, or to 0, is the sensible decision when there is a router below that already retries.&lt;/p>
&lt;p>LiteLLM&amp;rsquo;s session affinity stops making sense for the KV, because it no longer chooses a replica. It is kept if it is used for something else, such as pinning a conversation to a specific provider.&lt;/p>
&lt;p>And the choice of the router below conditions the rest of the cluster far more than the gateway&amp;rsquo;s does. llm-d requires Kubernetes 1.32 or above, Gateway API, the inference extension and a conformant Gateway. Dynamo does not require Kubernetes. production-stack has a Helm chart and works outside it. That decision is the expensive one, not the gateway&amp;rsquo;s.&lt;/p>
&lt;h2 id="when-not-to-build-this">When not to build this&lt;/h2>
&lt;p>Three cases where the correct answer is not to do it, at least not yet.&lt;/p>
&lt;p>&lt;strong>A single replica per model.&lt;/strong> With one replica, the engine&amp;rsquo;s prefix cache already does all the work and there is nothing to route. That is the case for a good part of the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">four H100&lt;/a> deployments with one large model and tensor parallelism.&lt;/p>
&lt;p>&lt;strong>Low or scattered prefix reuse.&lt;/strong> Before building anything, the measurement is &lt;code>rate(vllm:prefix_cache_hits_total[5m]) / rate(vllm:prefix_cache_queries_total[5m])&lt;/code> per replica, with a mental correction for the bias from reprefills after eviction. If it is already high with blind distribution, reuse is concentrated inside each conversation, and there LiteLLM&amp;rsquo;s session affinity costs one line of configuration and resolves the case.&lt;/p>
&lt;p>&lt;strong>Traffic where the network dominates.&lt;/strong> The GORGO result is the warning: with enormous prompts and a distributed deployment, the improvement is eaten up by queuing and network latency.&lt;/p>
&lt;p>The case where it does pay off is specific: several replicas of the same model, a large system prompt shared between different users, or a RAG with a corpus of documents that repeat across requests. There the prefill saved is real and it is most of the work.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ul>
&lt;li>Measure the current hit rate per replica with the two vLLM counters before touching anything, and check whether the engine is evicting with &lt;code>vllm:num_preemptions_total&lt;/code>.&lt;/li>
&lt;li>Check that prefix caching is switched on. It is no longer &lt;code>--enable-prefix-caching&lt;/code> that switches it on: the default value is derived from the model and the log of the decision is at DEBUG level. What is actionable today is &lt;code>--no-enable-prefix-caching&lt;/code> to switch it off.&lt;/li>
&lt;li>Audit the prompts before routing: a timestamp or a session identifier at the start of the system prompt breaks the match from block zero and no routing layer fixes it.&lt;/li>
&lt;li>If the KV event stream is enabled, size the bandwidth by tokens per second and watch discarding at the high water mark. With data parallelism, subscribe to one port per rank.&lt;/li>
&lt;li>If an exact router is chosen, verify the vLLM version against the one the replay buffer requires.&lt;/li>
&lt;li>If llm-d is chosen, explicitly configure the &lt;code>vllm&lt;/code> tokenisation backend. The default &lt;code>estimate&lt;/code> does not fail, it degrades.&lt;/li>
&lt;li>If production-stack is chosen with &lt;code>kvaware&lt;/code> or &lt;code>loadaware&lt;/code>, check first whether the traffic goes through &lt;code>/v1/chat/completions&lt;/code>, because today it does not work there.&lt;/li>
&lt;li>Review the load-based affinity-breaking parameter, because in three of the four main projects it ships disabled.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> in LiteLLM when there is a router below that already does failover.&lt;/li>
&lt;li>Trial with mirrored traffic before changing production. It is the explicit recommendation of the only independent measurement that went looking for bad regimes, and found them.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LiteLLM has no prefix routing in any version.&lt;/strong> Neither the open one nor the paid one. I got it wrong in June.&lt;/li>
&lt;li>&lt;strong>&lt;code>prompt_caching&lt;/code> does nothing if the client does not send &lt;code>cache_control&lt;/code>&lt;/strong>, and with the default automatic injection it fails on every turn because it marks the last message.&lt;/li>
&lt;li>&lt;strong>That pin&amp;rsquo;s TTL is 300 hardcoded seconds&lt;/strong>, incompatible with the one-hour caches that LiteLLM itself knows how to request.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>prefixaware&lt;/code> indexes characters, not tokens&lt;/strong>, in non-configurable chunks of 128, and its trie is never purged and models no eviction.&lt;/li>
&lt;li>&lt;strong>production-stack&amp;rsquo;s &lt;code>kvaware&lt;/code> and &lt;code>loadaware&lt;/code> do not support chat completions.&lt;/strong> They tokenise an empty string.&lt;/li>
&lt;li>&lt;strong>The default tokeniser in llm-d&amp;rsquo;s EPP is a byte estimate&lt;/strong>, and with it the exact scorer degrades silently.&lt;/li>
&lt;li>&lt;strong>llm-d&amp;rsquo;s active-active mode is discouraged with approximate prefixes&lt;/strong>, and the reference guides fix one replica even with the exact one.&lt;/li>
&lt;li>&lt;strong>Dynamo&amp;rsquo;s overlap credit decay ships at zero&lt;/strong>, which can leave a freshly autoscaled replica with no traffic.&lt;/li>
&lt;li>&lt;strong>The SGLang router&amp;rsquo;s default values differ between the Rust and the Python CLI&lt;/strong>, by as much as four orders of magnitude.&lt;/li>
&lt;li>&lt;strong>&lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> no longer exists&lt;/strong> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> is now called &lt;code>vllm:kv_cache_usage_perc&lt;/code>. Old panels and autoscalers are measuring nothing.&lt;/li>
&lt;li>&lt;strong>The hit rate counts the reprefill after eviction as a hit.&lt;/strong> A high number can be reuse or it can be thrashing.&lt;/li>
&lt;li>&lt;strong>KV events do not publish the hits by default&lt;/strong>, only the additions, unless full mode is requested request by request.&lt;/li>
&lt;li>&lt;strong>The hashes travel truncated to 64 bits&lt;/strong> unless it is disabled with an environment variable.&lt;/li>
&lt;li>&lt;strong>The default block size is 16, and the attention backend can change it&lt;/strong> without anyone asking.&lt;/li>
&lt;li>&lt;strong>The Endpoint Picker left the Gateway API Inference Extension repository.&lt;/strong> Looking for it there today leads nowhere.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>June&amp;rsquo;s error has a reading that goes beyond the erratum. It came from reading a product&amp;rsquo;s documentation and not its code, and from the fact that LiteLLM&amp;rsquo;s documentation uses the word &amp;ldquo;caching&amp;rdquo; in a pre-call check that does something else. The distance between what a name suggests and what a function does is, at this point in the track, the most repeated pattern of the whole series.&lt;/p>
&lt;p>The architectural conclusion is that KV-aware routing is not a gateway function. It is not one today in LiteLLM and probably should not be: it requires keeping an index synchronised with each engine&amp;rsquo;s memory, replicating its hash function, consuming an event stream and sizing CPU per request per second. That belongs to the engine&amp;rsquo;s domain, not to that of identity and budget.&lt;/p>
&lt;p>What remains is a two-part decision. The first is whether the traffic deserves it, and that is answered with two Prometheus counters and without installing anything. The second is which of the three routers fits the cluster you already have, and there the deciding variable is not the hit rate but whether you are willing to bring in Gateway API and the inference extension, or to pin vLLM 0.13.0, or to operate a Dynamo frontend.&lt;/p>
&lt;p>And the honesty the material demands: the only independent measurement that specifically went looking for regimes where this makes things worse found them, and they are not marginal. Half to two thirds of reference capacity is a serious regression. That no project publishes that side of the curve does not mean it does not exist in theirs.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a>, the session affinity and the pipeline order taken as read here, and the rest of what the gateway can do with two classes of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the June article this one corrects, and the router&amp;rsquo;s other four functions that remain valid.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache: hit rate engineering&lt;/a>, the template audit to do before even considering routing anything.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: fundamentals&lt;/a>, what exactly it is that you are trying not to recompute.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/">Long context and KV offloading&lt;/a>, LMCache and the memory hierarchy that &lt;code>kvaware&lt;/code> and Dynamo&amp;rsquo;s per-tier weights depend on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the specific work saved when routing gets it right.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop where the KV events are published, once per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">OTel instrumentation in vLLM&lt;/a>, the engine&amp;rsquo;s metrics, including the ones that have changed name.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard&lt;/a>, why the gateway is chosen on licence and fit before features, which is exactly what this article illustrates again.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, the replica deployment that everything above operates on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, why without this article&amp;rsquo;s session affinity the capacity calculation describes a different system, with the prefix hit rate measured in production.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, comprobaciones previas, afinidad): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/router.py&lt;/code>, &lt;code>litellm/router_utils/prompt_caching_cache.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/litellm_pre_call_utils.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/kv_cache_utils.py&lt;/code>, &lt;code>vllm/v1/core/block_pool.py&lt;/code>, &lt;code>vllm/v1/core/kv_cache_manager.py&lt;/code>, &lt;code>vllm/distributed/kv_events.py&lt;/code>, &lt;code>vllm/config/kv_events.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>, &lt;code>vllm/v1/metrics/stats.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>KV offloading usage&lt;/em> (niveles CPU, filesystem y objeto; compartición entre procesos): &lt;a href="https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html">https://docs.vllm.ai/en/latest/features/kv_offloading_usage.html&lt;/a>.&lt;/li>
&lt;li>vLLM, notas de la versión 0.29.0 (semilla determinista, seguimiento de propiedad en eventos, flags de admisión): &lt;a href="https://github.com/vllm-project/vllm/releases/tag/v0.29.0">https://github.com/vllm-project/vllm/releases/tag/v0.29.0&lt;/a>.&lt;/li>
&lt;li>vLLM, RFC 16669, publicación de bloques KV y métricas: &lt;a href="https://github.com/vllm-project/vllm/issues/16669">https://github.com/vllm-project/vllm/issues/16669&lt;/a>.&lt;/li>
&lt;li>vLLM production-stack, código: &lt;code>src/vllm_router/routers/routing_logic.py&lt;/code>, &lt;code>src/vllm_router/prefix/hashtrie.py&lt;/code>, &lt;code>src/vllm_router/parsers/parser.py&lt;/code>: &lt;a href="https://github.com/vllm-project/production-stack">https://github.com/vllm-project/production-stack&lt;/a>.&lt;/li>
&lt;li>LMCache Lab, resultados de production-stack frente a AIBrix (parte interesada): &lt;a href="https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/">https://blog.lmcache.ai/en/2025/03/06/open-source-llm-inference-cluster-performing-10x-faster-than-sota-oss-solution/&lt;/a>.&lt;/li>
&lt;li>llm-d Router, código y documentación de operaciones (pesos por defecto, dimensionamiento del EPP, aviso de activo-activo): &lt;a href="https://github.com/llm-d/llm-d-router">https://github.com/llm-d/llm-d-router&lt;/a>.&lt;/li>
&lt;li>llm-d, guía de enrutado preciso por prefijo y resultados publicados (parte interesada): &lt;a href="https://github.com/llm-d/llm-d">https://github.com/llm-d/llm-d&lt;/a>.&lt;/li>
&lt;li>llm-d Router, incidencia 1290, estado de prefijo no compartido entre réplicas del EPP: &lt;a href="https://github.com/llm-d/llm-d-router/issues/1290">https://github.com/llm-d/llm-d-router/issues/1290&lt;/a>.&lt;/li>
&lt;li>Red Hat, &lt;em>Same 16 GPUs, twice the users&lt;/em> (parte interesada): &lt;a href="https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters">https://www.redhat.com/en/blog/same-16-gpus-twice-users-inference-aware-routing-llm-clusters&lt;/a>.&lt;/li>
&lt;li>Kubernetes SIG Network, Gateway API Inference Extension (traslado del EPP y del BBR): &lt;a href="https://github.com/kubernetes-sigs/gateway-api-inference-extension">https://github.com/kubernetes-sigs/gateway-api-inference-extension&lt;/a>.&lt;/li>
&lt;li>NVIDIA Dynamo, diseño y ajuste del router KV: &lt;a href="https://github.com/ai-dynamo/dynamo">https://github.com/ai-dynamo/dynamo&lt;/a>.&lt;/li>
&lt;li>SGLang, &lt;code>sgl-model-gateway&lt;/code>: &lt;a href="https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway">https://github.com/sgl-project/sglang/tree/main/sgl-model-gateway&lt;/a>.&lt;/li>
&lt;li>AIBrix, enrutado por caché de prefijo: &lt;a href="https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md">https://github.com/vllm-project/aibrix/blob/main/pkg/plugins/gateway/algorithms/prefix_cache_readme.md&lt;/a>.&lt;/li>
&lt;li>Ray Serve LLM, enrutado consciente de prefijo (API en alfa): &lt;a href="https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html">https://docs.ray.io/en/latest/serve/llm/user-guides/prefix-aware-routing.html&lt;/a>.&lt;/li>
&lt;li>Huang Cheng (Meta), &lt;em>CacheRoute&lt;/em>, agosto de 2026: &lt;a href="https://arxiv.org/html/2608.19677">https://arxiv.org/html/2608.19677&lt;/a>.&lt;/li>
&lt;li>Yuan et al., &lt;em>DualMap&lt;/em>, febrero de 2026: &lt;a href="https://arxiv.org/abs/2602.06502">https://arxiv.org/abs/2602.06502&lt;/a>.&lt;/li>
&lt;li>Ricci Toniolo et al., &lt;em>GORGO&lt;/em>, junio de 2026: &lt;a href="https://arxiv.org/html/2602.11688">https://arxiv.org/html/2602.11688&lt;/a>.&lt;/li>
&lt;li>Yoshimura, Chiba y van de Beek, &lt;em>Accuracy Is Speed&lt;/em> (EuroMLSys &amp;lsquo;26), abril de 2026: &lt;a href="https://arxiv.org/html/2604.15732v1">https://arxiv.org/html/2604.15732v1&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Humans and agents on the same gateway: why `priority` does not prioritise, the 429 that brings down the whole pool, and the two ways to separate traffic</title><link>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</link><pubDate>Thu, 10 Sep 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/</guid><description>&lt;blockquote>
&lt;p>Fourth article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with Langfuse&lt;/a> covered observability, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the proxy&amp;rsquo;s day 2&lt;/a> covered availability and &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys&lt;/a> covered governance. Here comes the new tenant: agents, which speak the same protocol as humans and behave in a different way. Everything that follows is verified against LiteLLM 1.100.1 and vLLM 0.29.0, both from the first ten days of September 2026.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The day a team connects an agent to the corporate gateway, the platform changes regime without anyone touching a YAML. These are the seven conclusions.&lt;/p>
&lt;p>&lt;strong>LiteLLM&amp;rsquo;s &lt;code>priority&lt;/code> parameter prioritises nothing under normal conditions.&lt;/strong> It is still marked as beta and its queue is only consulted when &lt;strong>all&lt;/strong> the deployments in the group are in cooldown. With free capacity, the only thing it produces is a response header. On top of that, the success branch does not pop the item off the queue, so the list grows indefinitely; and on &lt;code>/chat/completions&lt;/code> the value &lt;code>0&lt;/code>, which is the one used by all the documentation examples, is falsy in Python and disables queuing.&lt;/p>
&lt;p>&lt;strong>Separating traffic is done with two pools, not with priorities.&lt;/strong> The non-forgeable way is &lt;code>model_group_alias&lt;/code> at key or team level: the client always asks for &lt;code>qwen-30b&lt;/code> and the credential decides whether that resolves to &lt;code>qwen-30b-interactivo&lt;/code> or to &lt;code>qwen-30b-agentes&lt;/code>. The way that does not touch clients is &lt;code>tag_regex&lt;/code> against the &lt;code>User-Agent&lt;/code>, with a pattern like &lt;code>^User-Agent: claude-code\/&lt;/code>, which serves to classify load and not to control access, because that header is set by the client.&lt;/p>
&lt;p>&lt;strong>The useful admission control arrives in the 1.101 branch&lt;/strong>, with &lt;code>max_in_flight_requests_per_worker&lt;/code> and a per-process queue that returns 503 with &lt;code>retry-after&lt;/code>. It rejects before authenticating, so what it discards does not appear attributed to any key.&lt;/p>
&lt;p>&lt;strong>&lt;code>max_parallel_requests&lt;/code> is only applied at virtual key level.&lt;/strong> The team, user, end customer and organisation endpoints accept the field and store it in the database, and nobody reads it. &lt;code>global_max_parallel_requests&lt;/code> is a no-op unless the old limiter is reactivated, and &lt;code>general_settings.max_parallel_requests&lt;/code> is read by no code path.&lt;/p>
&lt;p>&lt;strong>vLLM with &lt;code>--scheduling-policy priority&lt;/code> does not push ahead anyone who is already generating.&lt;/strong> The only thing that changes is who it evicts when KV cache runs short. If the agents have filled &lt;code>max_num_seqs&lt;/code>, a chat with the highest priority waits just the same. And the two knobs that in 2025 allowed short prompts to get ahead of long ones, &lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code>, disappeared from the code in 0.27.&lt;/p>
&lt;p>&lt;strong>The retry product reaches 45 calls per agent turn.&lt;/strong> Three from the client SDK times three from the router times up to five fallback groups. The proxy sets &lt;code>max_retries=0&lt;/code> on the SDK it uses itself, but it cannot touch the client&amp;rsquo;s. The timeout is per attempt, not per request, and LiteLLM&amp;rsquo;s &lt;code>Timeout&lt;/code> comes out as a 408, which is retryable.&lt;/p>
&lt;p>&lt;strong>A single 429 takes a deployment out of the pool for five seconds&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. With an agentic burst against a fleet of four replicas, the whole pool enters cooldown and the gateway returns a 429 of its own to everybody, humans included.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-with-two-classes-of-traffic">You are here: the gateway layer with two classes of traffic&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> this still lives in the gateway layer, with one difference compared to the previous three articles. There the variable was load: more requests, more teams, more budget to share out. Here the variable is the shape of the load.&lt;/p>
&lt;p>A gateway sized for three hundred engineers using a chat works. The same gateway, with thirty of those engineers running coding agents, serves a fraction of the human traffic with the previous latency and the rest degrades. The GPUs stay just as busy, the utilisation metrics stay good, and the support team gets complaints from people waiting nine seconds for a paragraph.&lt;/p>
&lt;h2 id="the-analogy-the-registry-desk-and-the-courier-with-two-hundred-files">The analogy: the registry desk and the courier with two hundred files&lt;/h2>
&lt;p>A registry office serves people in order of arrival. Citizens come in one at a time, present a piece of paper, and leave. The system is fair and the queue moves.&lt;/p>
&lt;p>One Tuesday a courier turns up with a trolley of two hundred files. He joins the queue like anyone else, because rules are rules, and when his turn comes he occupies the desk for forty minutes. Behind him are twelve people with one piece of paper each. Nobody has broken any rule. The courier has the same right as everyone else and uses the same procedure. What fails is that the office was designed assuming a certain size of transaction and another one three orders of magnitude bigger has turned up.&lt;/p>
&lt;p>The real solutions for an office are three, and all three have an exact equivalent in the platform. Open a desk for bulk deliveries, which is separating pools. Put up a capacity sign and send people back later, which is admission control with its 503. And say that files are handed in during the afternoon, which is the part that on an inference platform can almost never be applied, because the agent does not negotiate schedules.&lt;/p>
&lt;p>What does not work in the office is giving the citizen a priority number when the courier is already at the desk. That number does not interrupt the transaction in progress. It is, literally, what priority does in the two layers of the platform, and it is worth keeping in mind while reading the next section.&lt;/p>
&lt;h2 id="the-two-kinds-of-traffic-measured">The two kinds of traffic, measured&lt;/h2>
&lt;p>Before configuring anything, you have to be able to state the difference in numbers, because every later adjustment comes out of it.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Trait&lt;/th>
&lt;th>Human chat&lt;/th>
&lt;th>Agentic loop&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Input per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>tens of thousands, and it grows with each turn&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Output per request&lt;/td>
&lt;td>hundreds of tokens&lt;/td>
&lt;td>from two tokens (a tool call) to several thousand&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Concurrency per user&lt;/td>
&lt;td>1&lt;/td>
&lt;td>3 to 20 requests in flight&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>The metric that hurts&lt;/td>
&lt;td>time to first token&lt;/td>
&lt;td>total turn throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tolerance for waiting&lt;/td>
&lt;td>seconds&lt;/td>
&lt;td>minutes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cancelled requests&lt;/td>
&lt;td>rare&lt;/td>
&lt;td>routine, by the agent&amp;rsquo;s own decision&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retries&lt;/td>
&lt;td>the browser&amp;rsquo;s&lt;/td>
&lt;td>the SDK&amp;rsquo;s, plus the loop&amp;rsquo;s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Witness when it fails&lt;/td>
&lt;td>the user&lt;/td>
&lt;td>nobody&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two rows deserve comment because they contradict intuition.&lt;/p>
&lt;p>The first is the metric row. An agent does not perceive time to first token, so on a shared engine it is the natural candidate to give up that metric. The operational conclusion is that the agent pool can be configured for aggressive throughput, with large batches and deep queues, while the interactive pool is configured for the opposite.&lt;/p>
&lt;p>The second is the cancellations row. An agent that launches three speculative continuations and discards two generates two aborted streams per turn. At the gateway, that touches spend accounting, the release of concurrency counters and the open connection to the engine. LiteLLM 1.100 handles it reasonably well, and it did not six months ago.&lt;/p>
&lt;h2 id="what-does-not-work-priority-in-litellm">What does not work: &lt;code>priority&lt;/code> in LiteLLM&lt;/h2>
&lt;p>The first reaction of any operator is to look for a priority field. LiteLLM has one, documents it on its &lt;em>Request Prioritization&lt;/em> page, and that page carries the beta label and the sentence saying it is for testing. The label is correct and has to be taken literally, because behind it there are four things.&lt;/p>
&lt;p>The queue is only consulted when the whole group is in cooldown. The scheduler&amp;rsquo;s &lt;code>poll()&lt;/code> method returns true if there is any healthy deployment, without touching the queue. With available capacity, which is the normal situation, the request goes straight through and priority orders nothing. The only thing that changes is that the response carries the &lt;code>x-litellm-request-prioritization-used&lt;/code> header.&lt;/p>
&lt;p>The queue does not empty. In that success branch there is no &lt;code>heappop&lt;/code> and no call to the cleanup, which only happens in the expiry branch. The list under the key &lt;code>scheduler:queue:{model}&lt;/code> grows without bound, is serialised whole into Redis on every insertion, and there is no maximum size and no discard policy. When the group does enter cooldown, the head of the heap is occupied by fossil entries from requests that finished hours ago, and new ones compare their identifier against that head, never match, and wait until the timeout runs out.&lt;/p>
&lt;p>On &lt;code>/chat/completions&lt;/code>, the highest priority disables the feature. The code reads the value with a &lt;code>kwargs.get(&amp;quot;priority&amp;quot;) or self.default_priority&lt;/code>, and in Python &lt;code>0 or None&lt;/code> is &lt;code>None&lt;/code>. Since in this scheduler the lowest number is the highest priority, &lt;code>priority: 0&lt;/code>, which is the value in the documentation&amp;rsquo;s own examples, goes down the path without queuing. On &lt;code>/completions&lt;/code> the check is done with &lt;code>is not None&lt;/code> and it works; the two routes do not behave the same.&lt;/p>
&lt;p>And &lt;code>default_priority&lt;/code> in &lt;code>router_settings&lt;/code> breaks the request. If the client does not send a priority but the global setting is in place, the value is truthy, the scheduling branch is entered and a function is invoked that requires the positional argument nobody has injected. The setting has no associated test and does not appear in the &lt;code>router_settings&lt;/code> reference.&lt;/p>
&lt;p>There is also a &lt;code>/queue/chat/completions&lt;/code>, marked as experimental and hidden from the OpenAPI schema, which reads &lt;code>data[&amp;quot;priority&amp;quot;]&lt;/code> with no default value: a request without that field raises an exception that comes out as a 400 authentication error, with a message that has no relation to the cause.&lt;/p>
&lt;p>There is a priority feature that is solid, and it is a different thing. The &lt;strong>v3 dynamic limiter&lt;/strong> shares out token and request per minute quota between classes with weights, through &lt;code>litellm.priority_reservation&lt;/code> with values like &lt;code>{&amp;quot;premium&amp;quot;: 0.75, &amp;quot;standard&amp;quot;: 0.25}&lt;/code>. It shares out capacity, it does not order a queue: below the saturation threshold it lends free capacity and above it applies the weights. It never makes anyone wait, either it passes or it returns a 429. It requires Postgres and an enterprise licence, and it is still marked as beta.&lt;/p>
&lt;h2 id="what-does-work-two-pools">What does work: two pools&lt;/h2>
&lt;p>Effective separation is done earlier, by deciding which set of replicas each class of traffic goes to. LiteLLM offers two mechanisms with very different security properties.&lt;/p>
&lt;h3 id="by-credential-model_group_alias-at-key-or-team-level">By credential: &lt;code>model_group_alias&lt;/code> at key or team level&lt;/h3>
&lt;p>Router settings are resolved in the order key, team, global. Among those that accept that override is &lt;code>model_group_alias&lt;/code>, and there lies the piece:&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="nt">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&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">model_info&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">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&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">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&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">model_info&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">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&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">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And on the agent team&amp;rsquo;s key:&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">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&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> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&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> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;plataforma-agentes&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;qwen-30b&amp;#34;],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;router_settings&amp;#34;: {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;model_group_alias&amp;#34;: {&amp;#34;qwen-30b&amp;#34;: &amp;#34;qwen-30b-agentes&amp;#34;}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client asks for &lt;code>qwen-30b&lt;/code> and knows nothing about any of it. The resolution is tied to the credential, so the caller cannot get around it by changing a header. The same list allows &lt;code>routing_strategy&lt;/code>, &lt;code>fallbacks&lt;/code>, &lt;code>context_window_fallbacks&lt;/code>, &lt;code>retry_policy&lt;/code>, &lt;code>cooldown_time&lt;/code> and &lt;code>allowed_fails&lt;/code> to be set per key or team, which gives different reliability policies for each class of traffic on the same proxy instance.&lt;/p>
&lt;p>It should not be confused with &lt;strong>access groups&lt;/strong> (&lt;code>model_info.access_groups&lt;/code>), which are permissions and not routing: they decide whether a key can invoke a model name, not which replica the request goes to.&lt;/p>
&lt;h3 id="by-user-agent-tag_regex">By &lt;code>User-Agent&lt;/code>: &lt;code>tag_regex&lt;/code>&lt;/h3>
&lt;p>When separate credentials cannot be issued, because the agentic client uses the same personal key as that person&amp;rsquo;s chat, LiteLLM classifies by regular expression against the request headers:&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="nt">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&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">tag_regex&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;^User-Agent: claude-code\\/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;^User-Agent: .*codex&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 class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01}&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&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">tags&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;default&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 class="nt">model_info&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="nt">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01}&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">router_settings&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">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">tag_filtering_match_any&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The proxy composes strings of the form &lt;code>User-Agent: &amp;lt;value&amp;gt;&lt;/code> and tests them with &lt;code>re.search&lt;/code>. Exact tag matching takes precedence over the regex. The documentation itself carries a warning that has to be respected: the &lt;code>User-Agent&lt;/code> is written by the client, so this is traffic classification and not a security boundary. As load sharing it is enough, because an agent that lies about its identity to sneak into the interactive pool is a governance problem, not a routing one.&lt;/p>
&lt;p>The behaviour when no deployment carries the requested tag has a nuance that comes as a surprise in production. If the tag is unknown to the group, the request falls through to the pool marked with &lt;code>tags: [&amp;quot;default&amp;quot;]&lt;/code>. If the tag exists in the group but no healthy deployment carries it at that moment, the request &lt;strong>fails&lt;/strong> with the tag configuration error, even though a default pool is available. To get deterministic fallthrough to the default pool you have to set &lt;code>model_info.allow_fail_open: true&lt;/code>.&lt;/p>
&lt;h3 id="a-different-strategy-per-pool-with-routing_groups">A different strategy per pool with &lt;code>routing_groups&lt;/code>&lt;/h3>
&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="nt">router_settings&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">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&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">routing_groups&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">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">interactivo&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">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-interactivo]&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">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&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">group_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&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">models&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen-30b-agentes]&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">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The recommendation to leave &lt;code>simple-shuffle&lt;/code> on both is not laziness, and sustaining it requires looking at what the alternatives do on a homogeneous vLLM fleet.&lt;/p>
&lt;p>&lt;code>least-busy&lt;/code> keeps a complete dictionary under a single key and updates it with non-atomic reads and writes, without using &lt;code>INCR&lt;/code>, so with several proxy replicas increments are lost. It has no TTL, so a request that never completes leaves the counter raised forever and that replica is permanently excluded. Counters can go negative. And when the minimum corresponds to an identifier that is no longer in the healthy list, it falls back to &lt;code>random.choice&lt;/code> without warning. The pattern of requests that do not complete is exactly that of an agent that cancels.&lt;/p>
&lt;p>&lt;code>latency-based-routing&lt;/code> measures seconds per output token, not wall-clock latency. A chat turn of twenty tokens looks extremely slow by that metric and an agent generation of two thousand tokens looks extremely fast, so mixing both kinds of traffic in the same group poisons the signal. It also seeds new deployments with zero latency, and with the buffer defaulting to zero, a freshly started replica takes all the traffic until its first success callback.&lt;/p>
&lt;p>&lt;code>usage-based-routing-v2&lt;/code> uses minute windows that reset all at once, and it accounts for tokens when the request completes, so long generations in flight are invisible to the router. With long outputs, it systematically underestimates the real load.&lt;/p>
&lt;p>&lt;code>cost-based-routing&lt;/code> sorts by the sum of the input and output unit prices and takes the first, with no random tie-break. With replicas of the same model at the same price, all the traffic always goes to the same place. And a model that is not in the price map gets a default cost of 5.0 per token, a figure designed to deprioritise it.&lt;/p>
&lt;h2 id="admission-control-the-new-piece-in-the-1101-branch">Admission control: the new piece in the 1.101 branch&lt;/h2>
&lt;p>When the agent pool saturates, what you want is to reject fast and with a code the client understands, not to accumulate requests inside the proxy until memory runs out. That arrives with per-worker admission control, added in the 1.101 branch, in release candidate as this is written.&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="nt">general_settings&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">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&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">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&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">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is a semaphore with a per-uvicorn-process queue, without Redis. When the queue fills or the wait expires it returns &lt;strong>503&lt;/strong> with &lt;code>retry-after: 1&lt;/code> and a body with &lt;code>&amp;quot;type&amp;quot;: &amp;quot;overloaded_error&amp;quot;&lt;/code>. It is observed through &lt;code>/health/backlog&lt;/code>, which exposes requests in flight, admitted, queued and rejected, and through the Prometheus &lt;code>litellm_admission_*&lt;/code> metrics, whose rejection counter distinguishes &lt;code>queue_full&lt;/code> from &lt;code>queue_timeout&lt;/code>.&lt;/p>
&lt;p>Two properties have to go into the runbook. The middleware &lt;strong>rejects before authenticating&lt;/strong>, so what is discarded is not attributed to any key in the spend records, and the real number of requests rejected per team cannot be reconstructed from the spend table. And the limits are read on the first request: changing them requires a restart.&lt;/p>
&lt;h2 id="concurrency-per-key-and-the-three-settings-that-do-nothing">Concurrency per key, and the three settings that do nothing&lt;/h2>
&lt;p>&lt;code>max_parallel_requests&lt;/code> is the right brake for a specific agent, and its configuration surface is full of dead ends.&lt;/p>
&lt;p>The only level where it is applied is the &lt;strong>virtual key&lt;/strong>. The v3 limiter, active by default since 1.94, has descriptors for user, team, team member, end customer, organisation, model per key, tag and agent, and all of them carry only request and token counters per window. The only reference to &lt;code>max_parallel_requests&lt;/code> is the one in the key descriptor. The old limiter says so in a comment: supporting it for model, user and team is pending.&lt;/p>
&lt;p>At the same time, &lt;code>/team/new&lt;/code>, &lt;code>/user/new&lt;/code>, &lt;code>/customer/new&lt;/code> and the organisation endpoints accept the field, validate it and write it to Postgres, and the documentation shows it in their examples. Nobody reads it afterwards. A team with &lt;code>max_parallel_requests: 20&lt;/code> in its row has no concurrency limit at all.&lt;/p>
&lt;p>&lt;code>global_max_parallel_requests&lt;/code> is injected into the request metadata and is only consulted by the old limiter, which is disabled unless &lt;code>LEGACY_MULTI_INSTANCE_RATE_LIMITING=true&lt;/code> is set. The server settings documentation still describes it as a global limit coordinated through Redis. And &lt;code>general_settings.max_parallel_requests&lt;/code> does not appear in any execution path: its only two appearances are the copy from the database and the type schema of the interface.&lt;/p>
&lt;p>What does work, in detail:&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">curl -X POST &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$PROXY&lt;/span>&lt;span class="s2">/key/generate&amp;#34;&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$MASTER_KEY&lt;/span>&lt;span class="s2">&amp;#34;&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> -d &lt;span class="s1">&amp;#39;{&amp;#34;team_id&amp;#34;:&amp;#34;plataforma-agentes&amp;#34;,&amp;#34;max_parallel_requests&amp;#34;:8,&amp;#34;rpm_limit&amp;#34;:600}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Counting is done with a sorted set in Redis scored by Redis&amp;rsquo;s own clock, with Lua scripts to acquire and release. Each slot expires after &lt;strong>3,600 seconds&lt;/strong>, which is at once the maximum request duration the meter can track and the time a leaked slot takes to heal by itself. Release is hooked to success, to failure and to client disconnection during streaming, which was the classic leak with clients that cancel.&lt;/p>
&lt;p>One live leak remains and it affects exactly the case in this article: &lt;strong>every tool call through the MCP gateway acquires a slot and does not release it&lt;/strong>. The MCP route invokes the pre-call hook, which acquires, and none of the release functions is called from that module. With an agent that makes twenty tool calls per turn and a key with eight slots, the 429 arrives on the first turn and lasts an hour.&lt;/p>
&lt;p>The order of evaluation matters for interpreting 429s. First the window counters, requests and tokens per minute, are checked, and only if they pass is the concurrency slot acquired. The code comment explains why: the other way round, every rate limit rejection would leave an orphan slot. In practice, with a low &lt;code>rpm&lt;/code> you always see the &lt;code>requests&lt;/code> type 429, and the &lt;code>max_parallel_requests&lt;/code> one only appears when the relationship between concurrency and duration makes it binding first. The message distinguishes the two cases with the &lt;code>Limit type:&lt;/code> field and the &lt;code>rate_limit_type&lt;/code> header.&lt;/p>
&lt;p>A minor detail with consequences on the client: the response to a concurrency rejection carries &lt;code>retry-after&lt;/code> and &lt;code>reset_at&lt;/code> computed from the window size, sixty seconds by default, when a concurrency meter has no window. A well-behaved client that respects that header will wait a minute when it might have had a free slot in two seconds.&lt;/p>
&lt;h2 id="session-affinity-the-kv-cache-that-gets-thrown-away">Session affinity: the KV cache that gets thrown away&lt;/h2>
&lt;p>An agent turn resends the whole conversation context, system prompt and tools included. If that request lands on a different replica from the previous turn&amp;rsquo;s, vLLM&amp;rsquo;s prefix cache does not have the prefix and the prefill is recomputed in full. With twenty turns and four replicas balanced blindly, the long prefill is paid for fifteen times over.&lt;/p>
&lt;p>LiteLLM has two mechanisms for this and they are not equally good.&lt;/p>
&lt;p>The &lt;code>prompt_caching&lt;/code> check computes a SHA-256 of the cacheable prefix, defined as everything up to the last block with &lt;code>cache_control&lt;/code>, and pins the deployment with that hash. It has two problems. The TTL is hardcoded to 300 seconds in two places in the code, with no way to configure it, something which is reported and open; with one-hour caches, affinity is lost after five minutes of inactivity. And the key is an exact hash, so a client that moves the cut point forward on every turn, which is exactly what agents do to extend the cached prefix, generates a new key every time.&lt;/p>
&lt;p>The &lt;code>session_affinity&lt;/code> check is the one to use:&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="nt">router_settings&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">optional_pre_call_checks&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;session_affinity&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 class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It pins the deployment with an atomic Lua script in Redis, with a configurable TTL that defaults to one hour and that &lt;strong>is refreshed on every request&lt;/strong>, so it bounds the idle time between turns and not the duration of the conversation. If the pinned deployment enters cooldown, the request goes out through the normal strategy and keeps the pin so it can come back when it heals.&lt;/p>
&lt;p>The session identifier comes from a precedence chain that starts with &lt;code>x-litellm-trace-id&lt;/code>, continues with &lt;code>x-litellm-session-id&lt;/code> and then accepts &lt;strong>any header of the form &lt;code>x-&amp;lt;something&amp;gt;-session-id&lt;/code>&lt;/strong> whose value looks like an identifier. That includes the session header Claude Code already sends, with nothing configured on the client. Whatever matches populates both the session identifier and the trace identifier, which links this to the correlation from &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>One dangerous interaction when combining it with tags: affinity is evaluated &lt;strong>before&lt;/strong> tag-based routing. If the pin reduces the list to a deployment that does not satisfy the request&amp;rsquo;s tag, the request fails instead of falling through to the default pool, unless &lt;code>allow_fail_open&lt;/code> has been enabled. Retagging a pool with live sessions breaks them.&lt;/p>
&lt;p>And a clarification that saves a pointless search: &lt;strong>LiteLLM does not do prefix routing&lt;/strong>. There is no prefix tree and nothing that directs the request to the vLLM replica that already has that prefix warm. What there is, is affinity by session or by key. For the other thing you need a specific router underneath the gateway, and that architecture has its own article pending.&lt;/p>
&lt;h2 id="the-engine-what-vllm-can-and-cannot-do">The engine: what vLLM can and cannot do&lt;/h2>
&lt;p>Everything above shares out requests between engines. Inside one engine, the question is what happens when a twenty-token request and a sixty-thousand-token one live side by side.&lt;/p>
&lt;p>&lt;strong>Priority exists and does less than it looks like.&lt;/strong> &lt;code>--scheduling-policy priority&lt;/code> turns the waiting queue into a heap ordered by priority, arrival time and identifier, with the lowest number first. The request can carry the &lt;code>priority&lt;/code> field in the body, and there is also the &lt;code>X-Vllm-Priority&lt;/code> header, which takes precedence over the body and is not documented anywhere.&lt;/p>
&lt;p>What priority does not do is push ahead of whoever is already generating. In the running queue loop, when KV cache runs short, the policy only changes &lt;strong>who is chosen as the victim&lt;/strong> of eviction: with &lt;code>priority&lt;/code> the one with the worst priority and arrival time is evicted, and with &lt;code>fcfs&lt;/code> the last in the queue. In the waiting queue loop, if there are no blocks available the iteration is cut short, without evicting anyone. And the &lt;code>max_num_seqs&lt;/code> cap is a hard cut. If the agents have filled that cap, a chat with maximum priority waits just the same. There has been an open issue since April and an unmerged pull request about this.&lt;/p>
&lt;p>That nuance about the victim is not negligible, because with &lt;code>fcfs&lt;/code> the victims are the most recently admitted, that is, the chats that have just come in. Switching the priority policy on already improves the situation even if it pushes nobody ahead.&lt;/p>
&lt;p>&lt;strong>Eviction is always by recomputation.&lt;/strong> The swap-to-host-memory mode and the enumeration that controlled it disappeared from the code; all that is left is freeing blocks and returning the request to the queue with the computed token counter at zero. An evicted request loses all its prefill, and the prefix cache recovers part of it, but the perceived time to first token restarts.&lt;/p>
&lt;p>Two admission settings help and do not appear in the narrative documentation. &lt;code>watermark&lt;/code> reserves a fraction of free KV blocks on admission, with the declared purpose of avoiding repeated eviction, and it ships disabled. And &lt;code>scheduler_reserve_full_isl&lt;/code>, active by default, checks that the full input length fits in the cache before admitting, instead of looking only at the first chunk.&lt;/p>
&lt;p>&lt;strong>Chunked prefill is active by default and prioritises decode.&lt;/strong> The scheduler walks the whole running request loop before touching the waiting queue, so a long agent prefill does not freeze a chat&amp;rsquo;s generation in progress. That protects the inter-token latency of what has already been admitted, and it does not protect the time to first token of what is waiting.&lt;/p>
&lt;p>Here there is a breaking change that forces a review of the manifests. &lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> were removed in 0.27.&lt;/strong> They were precisely the mechanism that allowed short prompts to get ahead of long ones. A deployment with those flags in its &lt;code>args&lt;/code> does not start. &lt;code>long_prefill_token_threshold&lt;/code> still exists, and its meaning changed: today it is a per-request chunk size cap, not a classification of long requests.&lt;/p>
&lt;p>The partial replacement arrives in 0.29 with &lt;code>max_num_queued_tokens&lt;/code> and &lt;code>max_num_queued_reqs&lt;/code>, admission valves that return &lt;strong>503&lt;/strong> when the pending prefill work exceeds the target. The code itself frames it as a quality of service mechanism for time to first token, with the formula of setting it to the latency target multiplied by prefill throughput. They are blind to the class of traffic: they reject a chat just the same as an agent.&lt;/p>
&lt;p>And the default value of &lt;code>--max-num-batched-tokens&lt;/code> depends on the hardware and on the start-up mode. With &lt;code>vllm serve&lt;/code> on H100 or H200 it is 8,192, on cards of 160 GiB or more it is 16,384, and on the rest 2,048. The 2,048 that appears in the scheduler configuration only applies to tests.&lt;/p>
&lt;p>The metrics for diagnosing coexistence, with the exact names from 0.29:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>queue depth, the autoscaling signal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting_by_reason&lt;/code>&lt;/td>
&lt;td>separates waiting for capacity from waiting in a blocked state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_preemptions_total&lt;/code>&lt;/td>
&lt;td>evictions, the signal that the two classes are treading on each other&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:kv_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>cache pressure, the cause of the evictions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:request_queue_time_seconds&lt;/code>&lt;/td>
&lt;td>what a chat waits with the agents inside&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:prefix_cache_hits_total&lt;/code> and &lt;code>_queries_total&lt;/code>&lt;/td>
&lt;td>measured in tokens, not in requests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A warning for anyone with old alerts. The log warning about evicted sequences that appears in the optimisation documentation belongs to the previous version of the engine and &lt;strong>no longer exists in the code&lt;/strong>. Preemptions are only visible as a counter. An alert based on searching for that string in the logs has not fired for months, and not because there are no evictions.&lt;/p>
&lt;p>On isolation between classes inside one engine, the short answer is that it does not exist. There are two queues and neither is per class of traffic. The conclusion, which is the one that holds up this article&amp;rsquo;s architecture: effective isolation means &lt;strong>separate fleets&lt;/strong>, and on top of them the gateway deciding who goes to which.&lt;/p>
&lt;h2 id="retries-the-product-nobody-calculates">Retries: the product nobody calculates&lt;/h2>
&lt;p>A human who gets an error sees it and decides. An agentic loop retries, and beneath it there are two more layers that also retry.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Default&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Client SDK&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM router&lt;/td>
&lt;td>2 retries, 3 attempts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fallbacks&lt;/td>
&lt;td>up to 5 groups&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The product is about &lt;strong>45 calls to the engine for one agent turn&lt;/strong>, before counting the loop&amp;rsquo;s own retry. LiteLLM does what it can: when the call comes from the router, it forces &lt;code>max_retries=0&lt;/code> on the SDK it itself uses against the engine. What it cannot touch is the client&amp;rsquo;s SDK. The only layer that breaks the product is that one, and it is broken by setting &lt;code>max_retries=0&lt;/code> on the client and delegating the retry to the gateway.&lt;/p>
&lt;p>The retryable errors are 408, 409, 429 and anything 500 or above. And there lies the problem with long reasoning: &lt;code>litellm.Timeout&lt;/code> is a subclass of OpenAI&amp;rsquo;s timeout exception and comes out with &lt;strong>code 408&lt;/strong>, which is retryable, so the most expensive request of all, the one that expired after ten minutes of generating, is launched again in full. Twice by the router, and as many times again by the client.&lt;/p>
&lt;p>On timeouts you have to be precise because the code comment is: the configured timeout is &lt;strong>per attempt, not per request&lt;/strong>. A &lt;code>request_timeout: 600&lt;/code> with two retries and two fallback groups gives a worst case of almost two hours before the client receives anything. The default value is still 6,000 seconds, as was already covered in &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a>.&lt;/p>
&lt;p>The retry storm has two specific causes and both are default settings.&lt;/p>
&lt;p>The first: &lt;strong>a single 429 takes the deployment out of the pool&lt;/strong>, without going through the &lt;code>allowed_fails&lt;/code> threshold. The cooldown logic has an explicit branch which, on a 429 in a group with more than one deployment, returns immediate cooldown. With an agentic burst against four replicas, all four leave the pool at once and the gateway answers everybody with its own 429 of &amp;ldquo;no deployments available&amp;rdquo;. The default cooldown is five seconds, which in a sustained burst gets renewed.&lt;/p>
&lt;p>The second: &lt;strong>backoff is zero while the pool looks healthy&lt;/strong>. The function that computes the wait returns 0 if the healthy deployment list is not empty. Exponential backoff with jitter, capped at eight seconds, only kicks in when there is nothing healthy left. Under partial degradation, the proxy hammers away without waiting and only slows down once everything has already fallen over.&lt;/p>
&lt;p>On top of that, the engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> header is only respected if its value is between 1 and 60 seconds. A longer limit window is ignored and replaced by the proxy&amp;rsquo;s own backoff.&lt;/p>
&lt;p>The settings that cut the problem off:&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="nt">router_settings&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">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># real floor; the default 0 is hammering&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">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&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">allowed_fails_policy&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">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops a 429 taking the deployment out&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">retry_policy&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">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># do not relaunch the expensive request&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">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">litellm_settings&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">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&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">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That last setting deserves an explanation, because it is a silent way of breaking an agent. With &lt;code>drop_params: true&lt;/code>, LiteLLM removes from the body any parameter the destination provider does not support, and in that branch it writes no warning at all. The candidates for disappearing are &lt;code>tools&lt;/code>, &lt;code>tool_choice&lt;/code>, &lt;code>response_format&lt;/code> and &lt;code>parallel_tool_calls&lt;/code>. An agent that has &lt;code>tools&lt;/code> taken away receives free text where it expected a function call, and interprets it as the model having decided not to use tools.&lt;/p>
&lt;p>Worse still is prompt-based emulation. For a non-OpenAI-compatible provider that does not support tools, LiteLLM does not fail: it turns the tools into text inside the prompt, forces JSON format, discards &lt;code>tool_choice&lt;/code>, and on the way switches on a &lt;strong>process-global variable&lt;/strong> that makes any other tool-less provider in the same process go to emulation instead of erroring.&lt;/p>
&lt;p>With that in mind, the warning about fallbacks is clear: &lt;strong>LiteLLM does not check capabilities before falling back&lt;/strong>. The function that knows whether a model supports function calling exists and is only used in an informational endpoint. The pre-call checks filter by context window, request limit and region, and nothing else. A fallback list that goes from a model with tools to one without them degrades the agent silently. There are guards for provider-scoped resources, files and batches, so the absence of the capability guard is an omission and not a design oversight.&lt;/p>
&lt;h2 id="a-months-budget-in-twenty-minutes">A month&amp;rsquo;s budget in twenty minutes&lt;/h2>
&lt;p>A runaway agent consumes in minutes what a team consumes in weeks, and the operational question is how long the gateway takes to find out.&lt;/p>
&lt;p>The answer has changed, and the old mental model, the one where spend consolidates every minute and until then there is no brake, is no longer correct. Since 1.84 there are two new pieces. An &lt;strong>optimistic reservation before the call&lt;/strong>, which estimates the maximum cost of the request and reserves it atomically in the counter before heading out to the engine, reconciling it with the real cost on completion. And a counter increment after the call that is explicitly awaited, with the comment that this way the counter is up to date before the next request goes through authentication. Budget checks read that counter, with Redis first and seeding from the database when cold.&lt;/p>
&lt;p>Batch writing to Postgres still exists, every 10 to 15 seconds, and it affects persistence and what is shown in the interface, not the cut-off. The production documentation&amp;rsquo;s recommendation to raise that interval to 60 does not relax the control, and it does delay by a minute what &lt;code>/key/info&lt;/code> shows, something which frequently generates operational panic. And the budget rescheduler&amp;rsquo;s ten minutes do not consolidate spend: they fire the job that resets expired budgets.&lt;/p>
&lt;p>The overspend window then comes down to the cost of the requests in flight, provided there is Redis. &lt;strong>Without Redis, the counters are per pod&lt;/strong>, and with N replicas you can spend up to N times the budget. That is the configuration failure that matters and it is the same Redis that &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">the day 2 article&lt;/a> declared non-optional for other reasons.&lt;/p>
&lt;p>Two settings that do not appear in the public documentation, only in the data model descriptions, and that decide behaviour at the worst moment:&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="nt">general_settings&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">fail_closed_budget_enforcement: true # false by default&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">if the counter fails, it lets things through&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="c"># disable_budget_reservation: do not set it to true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the default value, if the counter backend does not respond, the proxy &lt;strong>fails open&lt;/strong> and carries on serving. For an agent in a loop that is exactly the opposite of what you want.&lt;/p>
&lt;p>Time for the honest warning: this route is still unstable. There are open issues in both directions, one where the key and user budget limit stops being applied while spend is recorded, and another where a 429 is returned for stale spend in the reservation counters while the key query shows spend below the limit. Project budgets are not on the atomic reservation route and concurrency overshoots them. The practical conclusion is not to trust the hard cut-off to the budget alone.&lt;/p>
&lt;p>On the manual cut-off, there is an asymmetry to know about before you need it. &lt;code>/key/block&lt;/code> invalidates the local cache, the Redis one, and notifies all workers by publish, so with Redis the effect is almost immediate. &lt;code>/customer/block&lt;/code> does the same. &lt;strong>&lt;code>/team/block&lt;/code> only writes to the database&lt;/strong>, without invalidating or notifying, so it takes however long the management object cache TTL takes, sixty seconds by default. To cut a team off dead you have to block its keys one by one, or lower &lt;code>user_api_key_cache_ttl&lt;/code>. And &lt;code>/user/block&lt;/code> no longer exists, even though the docstring of the function that replaced it still shows that route in its example.&lt;/p>
&lt;p>On alerts, two numbers. The default thresholds warn at &lt;strong>85 % and 95 %&lt;/strong> of the budget. And deduplication of those alerts is &lt;strong>24 hours&lt;/strong> per event and identifier, so crossing 85 % generates one warning and then silence for a day. For agentic traffic, where 85 % and 100 % can be twenty minutes apart, the second warning arrives when it is no longer any use.&lt;/p>
&lt;h2 id="attribution-who-the-user-is-when-the-caller-is-an-agent">Attribution: who the user is when the caller is an agent&lt;/h2>
&lt;p>With agents, the question of who the spend is charged to has two answers and both are needed: the agent that executes and the human on whose behalf it acts.&lt;/p>
&lt;p>The chain that extracts the end customer identifier tries the standard headers first, then the configurable header mapping, and only then the &lt;code>user&lt;/code> field of the body, the &lt;code>litellm_metadata.user&lt;/code> of the Anthropic format, the &lt;code>metadata.user_id&lt;/code> and the &lt;code>safety_identifier&lt;/code>. The last four are body fields and are therefore written by the client. The code itself warns about it: for untrusted callers the identifier has to be set from headers or from a middleware on the server, to avoid impersonation.&lt;/p>
&lt;p>Out of that comes the correct pattern, which is the inverse of the one people put together out of inertia: &lt;strong>the virtual key identifies the agent&lt;/strong> and is the non-forgeable part, and the header or the &lt;code>user&lt;/code> field identify the human and serve for attribution, not for control. An agent that lies about the human behind it is still bounded by the budget and the limits of its key.&lt;/p>
&lt;p>The spend table stores &lt;code>user&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>request_tags&lt;/code>, &lt;code>session_id&lt;/code> and &lt;code>agent_id&lt;/code>, with daily aggregation per agent, so the detail is available once it is sent. The &lt;code>x-litellm-agent-id&lt;/code> header is what populates that last field.&lt;/p>
&lt;p>A note about the MCP gateway, which on a platform with agents ends up being the other entry door. Tool calls &lt;strong>do appear in the spend records&lt;/strong>, with call types of their own and a metadata block that stores tool name, arguments, result and server. What is missing is token counting: the cost is &lt;strong>0.0 by default&lt;/strong> and is only populated if the administrator configures a price per query, per server or per tool. It is the same pattern of silent zero cost that already came up with unpriced models in &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the first article in the track&lt;/a>.&lt;/p>
&lt;p>And the permission setting to review on day one: access control for MCP servers is &lt;strong>open by default&lt;/strong>. If no level of the hierarchy defines a list, the request reaches every configured server. &lt;code>general_settings.require_key_mcp_access_defined: true&lt;/code> inverts that inheritance, and it is not the default value.&lt;/p>
&lt;h2 id="a-reference-configyaml">A reference &lt;code>config.yaml&lt;/code>&lt;/h2>
&lt;p>All of the above, together, on a fleet split into two pools:&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="nt">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-interactivo&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-chat.inferencia.svc:8000/v1&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">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this, silent zero cost&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">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&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">model_info&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">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat-01&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">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># without this there is no context filter&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">qwen-30b-agentes&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/Qwen3-30B&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-agentes.inferencia.svc:8000/v1&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">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000018&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">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000072&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">tag_regex&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;^User-Agent: claude-code\\/&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 class="nt">model_info&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">id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes-01&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">max_input_tokens&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">131072&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">allow_fail_open&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">router_settings&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">routing_strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">simple-shuffle&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">enable_pre_call_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">enable_tag_filtering&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">optional_pre_call_checks&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;session_affinity&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 class="nt">deployment_affinity_ttl_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3600&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">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">retry_after&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&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">cooldown_time&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&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">allowed_fails_policy&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">RateLimitErrorAllowedFails&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&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">retry_policy&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">TimeoutErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">AuthenticationErrorRetries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">general_settings&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">fail_closed_budget_enforcement&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">require_key_mcp_access_defined&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">user_api_key_cache_ttl&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&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">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&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="c"># 1.101 branch onwards:&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">max_in_flight_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&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">max_queued_requests_per_worker&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">64&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">admission_queue_timeout_seconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">litellm_settings&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">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">900&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># per attempt&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">drop_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Redis, not optional&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the start-up of the two engines, with the deliberate asymmetry:&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"># interactive pool: protect the first token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --scheduling-policy priority &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-model-len &lt;span class="m">32768&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">4096&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"># agent pool: throughput and deep queues&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-30B &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">256&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-model-len &lt;span class="m">131072&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;/code>&lt;/pre>&lt;/div>&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>Issue separate keys for agents, with &lt;code>model_group_alias&lt;/code> at key or team level. It is the only separation the client cannot evade.&lt;/li>
&lt;li>Add &lt;code>tag_regex&lt;/code> over the &lt;code>User-Agent&lt;/code> as a safety net for agents that use personal credentials, with a &lt;code>default&lt;/code> pool and &lt;code>allow_fail_open: true&lt;/code>.&lt;/li>
&lt;li>Leave &lt;code>simple-shuffle&lt;/code> on both pools. The other four strategies have verifiable defects on homogeneous replicas.&lt;/li>
&lt;li>Enable &lt;code>session_affinity&lt;/code> with a one-hour TTL. Do not use &lt;code>prompt_caching&lt;/code> for this while its TTL stays hardcoded.&lt;/li>
&lt;li>Declare &lt;code>max_input_tokens&lt;/code> on every deployment. Without that value, the context window filter is not applied and it fails silently for models that are not in the price map.&lt;/li>
&lt;li>Declare per-token prices on every self-hosted model, or the agents&amp;rsquo; spend will be zero and their budget will never run out.&lt;/li>
&lt;li>&lt;code>max_parallel_requests&lt;/code> on the agent&amp;rsquo;s key, never on its team. On the team it is stored and not applied.&lt;/li>
&lt;li>Lower &lt;code>num_retries&lt;/code> to 1, put a floor on &lt;code>retry_after&lt;/code>, raise &lt;code>cooldown_time&lt;/code> and disable retry on timeout. On the client, &lt;code>max_retries=0&lt;/code>.&lt;/li>
&lt;li>&lt;code>fail_closed_budget_enforcement: true&lt;/code> and mandatory Redis. Without Redis, N replicas are N times the budget.&lt;/li>
&lt;li>Start the interactive engine with &lt;code>--scheduling-policy priority&lt;/code> even though it pushes nobody ahead: it changes who it evicts.&lt;/li>
&lt;li>Alert on &lt;code>vllm:num_preemptions_total&lt;/code> and &lt;code>vllm:num_requests_waiting_by_reason&lt;/code>, not on log strings that are no longer emitted.&lt;/li>
&lt;li>Prepare the cut-off: block by key, never by team, and verify the real propagation time in your own deployment.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;code>priority: 0&lt;/code> on &lt;code>/chat/completions&lt;/code> disables prioritisation.&lt;/strong> It is the value in the documentation examples and it is falsy in Python.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_parallel_requests&lt;/code> on a team is stored and not applied.&lt;/strong> The field exists in the API, in the database and in the documentation; the limiter only reads it from the key descriptor.&lt;/li>
&lt;li>&lt;strong>&lt;code>global_max_parallel_requests&lt;/code> is a no-op&lt;/strong> with the default limiter, and the server settings documentation still describes it as active.&lt;/li>
&lt;li>&lt;strong>Every MCP call leaks a concurrency slot&lt;/strong>, which only heals when its hour expires. With agents calling tools in a loop, the 429 appears early and lasts a long time.&lt;/li>
&lt;li>&lt;strong>The TTL of prompt cache affinity is 300 hardcoded seconds.&lt;/strong> For agent conversations, session affinity is the right option.&lt;/li>
&lt;li>&lt;strong>Affinity is evaluated before tag-based routing.&lt;/strong> A pin to a deployment that stops satisfying the tag makes the request fail instead of falling through to the default pool.&lt;/li>
&lt;li>&lt;strong>LiteLLM&amp;rsquo;s timeout is per attempt.&lt;/strong> The worst case is the timeout multiplied by attempts and by fallback groups.&lt;/li>
&lt;li>&lt;strong>A 429 takes the deployment out of the pool without going through &lt;code>allowed_fails&lt;/code>&lt;/strong>, and backoff is zero while anything healthy remains.&lt;/li>
&lt;li>&lt;strong>The engine&amp;rsquo;s &lt;code>Retry-After&lt;/code> is ignored if it asks for more than 60 seconds.&lt;/strong>&lt;/li>
&lt;li>&lt;strong>&lt;code>drop_params: true&lt;/code> removes &lt;code>tools&lt;/code> and &lt;code>response_format&lt;/code> without leaving a warning&lt;/strong>, and the fallback does not check whether the destination model supports function calling.&lt;/li>
&lt;li>&lt;strong>&lt;code>/team/block&lt;/code> does not invalidate the cache.&lt;/strong> It takes the full TTL; &lt;code>/key/block&lt;/code> does propagate by publish in Redis.&lt;/li>
&lt;li>&lt;strong>Access to MCP servers is open by default&lt;/strong> if no level defines a list.&lt;/li>
&lt;li>&lt;strong>&lt;code>max_num_partial_prefills&lt;/code> and &lt;code>max_long_partial_prefills&lt;/code> no longer exist in vLLM.&lt;/strong> A manifest with those flags does not start from 0.27 onwards.&lt;/li>
&lt;li>&lt;strong>vLLM&amp;rsquo;s priority does not push ahead of running requests&lt;/strong>, and with &lt;code>max_num_seqs&lt;/code> full it pushes nobody ahead.&lt;/li>
&lt;li>&lt;strong>The eviction log warning that appears in the vLLM documentation is no longer emitted.&lt;/strong> Only the Prometheus counter is left.&lt;/li>
&lt;/ul>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The coexistence of humans and agents on the same platform is not solved with a priority field, and that is the conclusion that takes the most effort to accept because the field exists and looks made for this. Neither the gateway nor the engine has a usable notion of class of service today: one orders a queue that is almost never consulted, the other picks who to evict. What they do have is the ability to direct each request to a different place, and that is where the win is.&lt;/p>
&lt;p>The architecture that comes out of all of the above fits in one sentence. Two vLLM fleets with opposite parameters, a credential that decides which one each request goes to, session affinity so as not to throw away the prefill work, concurrency limits on the agent&amp;rsquo;s key and not on its team, retries trimmed back on the three layers where they multiply, and budgets that fail closed.&lt;/p>
&lt;p>The rest is measurement. If &lt;code>vllm:num_preemptions_total&lt;/code> rises in the interactive pool, the two classes are treading on each other despite the separation. If chat queue time grows while total throughput holds, the partition is badly sized. And if the agents&amp;rsquo; spend comes out as zero, the problem is not coexistence but undeclared prices, which is where this track started.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a>, the full hierarchy and the v3 limiter that this article&amp;rsquo;s limits rest on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a>, the Redis and the Postgres taken for granted here, and retries seen from the gateway side.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a>, the trace correlation that this article&amp;rsquo;s session identifier feeds, and the zero cost of unpriced models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, where the figures that go into the budgets defended here come from.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents&lt;/a>, the other side of the problem, the agent as a security risk rather than a class of traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">The second cost vector of AI agents&lt;/a>, what a loop that fails halfway costs, and why durable execution changes that sum.&lt;/li>
&lt;li>&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 continuation of this article&amp;rsquo;s session affinity, and the correction of what the gateway can do with the KV cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">LiteLLM&amp;rsquo;s MCP gateway&lt;/a>, the agents&amp;rsquo; other entry door, including the correction about the default access this article took to be open.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>, the router&amp;rsquo;s four functions and the prefix-aware routing LiteLLM does not do.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a>, the work that is recomputed in full when a request is evicted.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a>, the loop the priority policy and chunked prefill operate on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a>, the identity of the other entry door, the one agents use to call tools.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a>, the question before this one: how big the fleet and the gateway have to be, with the agentic load measurements published in 2026.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a>, the identity of the agent and of the human seen from outside the proxy.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>[BETA] Request Prioritization&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/scheduler">https://docs.litellm.ai/docs/scheduler&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router - Load Balancing&lt;/em> (estrategias, afinidad de sesión, comprobaciones previas): &lt;a href="https://docs.litellm.ai/docs/routing">https://docs.litellm.ai/docs/routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Tag Based Routing&lt;/em> (incluye &lt;code>tag_regex&lt;/code> y el ejemplo con clientes agénticos): &lt;a href="https://docs.litellm.ai/docs/proxy/tag_routing">https://docs.litellm.ai/docs/proxy/tag_routing&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Router Settings for Keys and Teams&lt;/em> (resolución clave, equipo, global): &lt;a href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings">https://docs.litellm.ai/docs/proxy/keys_teams_router_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Server Tuning&lt;/em> (control de admisión por worker): &lt;a href="https://docs.litellm.ai/docs/proxy/server_tuning">https://docs.litellm.ai/docs/proxy/server_tuning&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Dynamic TPM/RPM Allocation&lt;/em> (reserva de prioridad, beta y enterprise): &lt;a href="https://docs.litellm.ai/docs/proxy/dynamic_rate_limit">https://docs.litellm.ai/docs/proxy/dynamic_rate_limit&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Budgets, Rate Limits&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/users">https://docs.litellm.ai/docs/proxy/users&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production Best Practices&lt;/em>: &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, ventana de contexto, enfriamientos): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, código: &lt;code>litellm/scheduler.py&lt;/code>, &lt;code>litellm/router_strategy/tag_based_routing.py&lt;/code>, &lt;code>litellm/router_utils/pre_call_checks/deployment_affinity_check.py&lt;/code>, &lt;code>litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/code>, &lt;code>litellm/proxy/middleware/admission_control_middleware.py&lt;/code>, &lt;code>litellm/router_utils/cooldown_handlers.py&lt;/code>: &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 28427, TTL de la afinidad por caché de prompts fijado a cinco minutos: &lt;a href="https://github.com/BerriAI/litellm/issues/28427">https://github.com/BerriAI/litellm/issues/28427&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 34534, fuga de slot de concurrencia en cada llamada MCP: &lt;a href="https://github.com/BerriAI/litellm/issues/34534">https://github.com/BerriAI/litellm/issues/34534&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 26672, presupuesto de clave y usuario no aplicado: &lt;a href="https://github.com/BerriAI/litellm/issues/26672">https://github.com/BerriAI/litellm/issues/26672&lt;/a>.&lt;/li>
&lt;li>LiteLLM, incidencia 872, comprobación previa de soporte de llamada a funciones, cerrada sin implementar: &lt;a href="https://github.com/BerriAI/litellm/issues/872">https://github.com/BerriAI/litellm/issues/872&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Optimization and Tuning&lt;/em> (prefill troceado, prioridad de decodificación): &lt;a href="https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html">https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Metrics design&lt;/em> (nombres, convención &lt;code>_total&lt;/code>, autoescalado como problema abierto): &lt;a href="https://docs.vllm.ai/en/v0.29.0/design/metrics.html">https://docs.vllm.ai/en/v0.29.0/design/metrics.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Data Parallel Deployment&lt;/em> (caché KV independiente por réplica): &lt;a href="https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html">https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html&lt;/a>.&lt;/li>
&lt;li>vLLM, código: &lt;code>vllm/v1/core/sched/scheduler.py&lt;/code>, &lt;code>vllm/v1/core/sched/request_queue.py&lt;/code>, &lt;code>vllm/config/scheduler.py&lt;/code>, &lt;code>vllm/engine/arg_utils.py&lt;/code>, &lt;code>vllm/v1/metrics/loggers.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm">https://github.com/vllm-project/vllm&lt;/a>.&lt;/li>
&lt;li>vLLM, incidencia 40004, la prioridad no desaloja peticiones en ejecución: &lt;a href="https://github.com/vllm-project/vllm/issues/40004">https://github.com/vllm-project/vllm/issues/40004&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Virtual keys, budgets and limits in LiteLLM: the layer that decides who gets the GPU, and the four things the documentation gets wrong</title><link>https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/</link><pubDate>Tue, 08 Sep 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/</guid><description>&lt;blockquote>
&lt;p>Third article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">pair with Langfuse&lt;/a> covered observability, and &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2 of the proxy&lt;/a> covered availability. Here it is governance: who can call, which model, how much and on what budget. The economic model behind those figures is in &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy with LiteLLM&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>On a platform running on your own GPUs there is no provider invoice to put consumption in order. Capacity is fixed, known and shared, so someone has to impose the allocation, and that someone is the gateway.&lt;/p>
&lt;p>&lt;strong>The hierarchy has six levels&lt;/strong>: organisation, team, team member, internal user, key and end customer, plus the project and tag scopes. Each level&amp;rsquo;s budgets are evaluated &lt;strong>independently and all of them have to pass&lt;/strong>. They neither add up nor cancel each other out, with one exception.&lt;/p>
&lt;p>&lt;strong>The exception is in the code and it is the most important governance trap in the system.&lt;/strong> A key that belongs to a team suppresses the check of its owner&amp;rsquo;s personal budget, unless &lt;code>apply_user_budget_to_team_keys&lt;/code> is enabled. A user with a hundred-euro cap who works with their team&amp;rsquo;s key spends whatever the team allows.&lt;/p>
&lt;p>&lt;strong>Rotating a key preserves its spend.&lt;/strong> &lt;code>/key/regenerate&lt;/code> does an UPDATE on the same row changing the hash, so &lt;code>spend&lt;/code>, &lt;code>max_budget&lt;/code> and the budget window remain intact. It accepts a grace period, stored in a table of its own, and if the period&amp;rsquo;s format is wrong it is ignored without blocking anything.&lt;/p>
&lt;p>&lt;strong>The v3 limiter is already the default&lt;/strong> in 1.100.0, and you go back to the previous one with an environment variable. Its window is sliding, sixty seconds, anchored to the first request, and the counters are incremented with Lua scripts inside Redis using Redis&amp;rsquo;s own timestamp to close the race between replicas.&lt;/p>
&lt;p>And four points where the official documentation does not match the code, verified against tag v1.100.0: &lt;strong>an exhausted budget returns 429&lt;/strong>, not 400; &lt;strong>the audited actions are six&lt;/strong>, not three; &lt;code>/key/info&lt;/code> and &lt;code>/key/list&lt;/code> are &lt;strong>GET&lt;/strong>, not POST; and the v3 limiter carries a docstring saying it is not production-ready while it is the one loaded by default.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-as-an-administrative-boundary">You are here: the gateway as an administrative boundary&lt;/h2>
&lt;p>Without this layer, an in-house inference platform has a single mode of operation: whoever knows the URL consumes. It works while there is one team. It stops working the day there are three, or the day someone connects an agent that fires twenty calls per interaction and wrecks everyone else&amp;rsquo;s time to first token.&lt;/p>
&lt;p>The two questions this layer answers are different and have to be kept apart. The &lt;strong>capacity&lt;/strong> one is instantaneous: how many requests and how many tokens per minute each consumer can push, so the fleet does not saturate and latency stays within the service agreement. The &lt;strong>cost&lt;/strong> one is cumulative: how much each consumer has spent this month against its budget. They are configured in the same place, applied at different moments and fail in different ways.&lt;/p>
&lt;h2 id="the-analogy-the-buildings-key-ring">The analogy: the building&amp;rsquo;s key ring&lt;/h2>
&lt;p>An office building hands out keys at three levels. The master opens everything and the building manager has it. The floor keys open a whole floor and each tenant company has one. And the office keys open one office.&lt;/p>
&lt;p>Three properties of that system are the ones that matter here. A key is replaced without changing the lock of the whole building, and the tenant keeps their parking space and their electricity meter: that is rotation preserving the spend. A lost key is cancelled from the central panel without collecting anything physical: that is blocking from the interface. And the record of who took out each key and when lives in a separate book, which is what you show an inspector: that is the audit table.&lt;/p>
&lt;p>The analogy breaks at one point, and that point is where this system&amp;rsquo;s governance flaw lives. In the building, having a floor key does not cancel the office&amp;rsquo;s consumption limit. In LiteLLM, it does.&lt;/p>
&lt;h2 id="the-hierarchy-and-the-line-that-breaks-it">The hierarchy, and the line that breaks it&lt;/h2>
&lt;p>The levels and their management endpoints:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>Endpoints&lt;/th>
&lt;th>Budget&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Organisation&lt;/td>
&lt;td>&lt;code>/organization/new&lt;/code>, &lt;code>/organization/member_add&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Team&lt;/td>
&lt;td>&lt;code>/team/new&lt;/code>, &lt;code>/team/member_add&lt;/code>, &lt;code>/team/block&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Team member&lt;/td>
&lt;td>&lt;code>/team/member_add&lt;/code> with &lt;code>max_budget_in_team&lt;/code>&lt;/td>
&lt;td>Its own row in the budgets table&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal user&lt;/td>
&lt;td>&lt;code>/user/new&lt;/code>, &lt;code>/user/update&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Key&lt;/td>
&lt;td>&lt;code>/key/generate&lt;/code>, &lt;code>/key/update&lt;/code>, …&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>, &lt;code>soft_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>End customer&lt;/td>
&lt;td>&lt;code>/customer/new&lt;/code>, and the parallel &lt;code>/end_user/*&lt;/code> set&lt;/td>
&lt;td>&lt;code>max_budget&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The default behaviour is the right one for a multi-tenant platform: each scope carries its own counter and the check of each one is independent of the rest. A request passes if all of them pass.&lt;/p>
&lt;p>And then there is this condition, which lives in the authorisation checks:&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="n">is_team_key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">team_object&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">team_object&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">team_id&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">None&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">is_team_key&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">general_settings&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;apply_user_budget_to_team_keys&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="kc">True&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Translated: when the key belongs to a team, the check of the budget of the user who owns that key &lt;strong>returns without evaluating anything&lt;/strong>. The personal cap exists in the database, is visible in the interface, and is not applied.&lt;/p>
&lt;p>For a deployment where personal budgets are decorative, it makes no difference. For one where the per-person cap is the control that was shown to someone as a guarantee that a user cannot overrun the platform, it is a hole. The line that closes it is one:&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="nt">general_settings&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">apply_user_budget_to_team_keys&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="virtual-keys-the-complete-life-cycle">Virtual keys: the complete life cycle&lt;/h2>
&lt;p>The endpoints all exist and some of them are not where you expect:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Route&lt;/th>
&lt;th>Method&lt;/th>
&lt;th>What for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/key/generate&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Create&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/update&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Modify&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/info&lt;/code>&lt;/td>
&lt;td>&lt;strong>GET&lt;/strong>&lt;/td>
&lt;td>Query one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/list&lt;/code>&lt;/td>
&lt;td>&lt;strong>GET&lt;/strong>&lt;/td>
&lt;td>List&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/delete&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Delete&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/regenerate&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Rotate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/block&lt;/code>, &lt;code>/key/unblock&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Suspend and reactivate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/key/health&lt;/code>&lt;/td>
&lt;td>POST&lt;/td>
&lt;td>Check that key&amp;rsquo;s callbacks&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The two marked in bold are GET, and several examples in the documentation suggest otherwise. &lt;code>/key/health&lt;/code> is not what its name suggests either: it does not validate the key or its budget, it checks the logging callbacks associated with it.&lt;/p>
&lt;p>There are many creation parameters, and these are the ones that govern:&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">curl -X POST &lt;span class="s1">&amp;#39;https://gateway.interno/key/generate&amp;#39;&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> -H &lt;span class="s1">&amp;#39;Authorization: Bearer sk-...&amp;#39;&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> -d &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;key_alias&amp;#34;: &amp;#34;equipo-datos-notebooks&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;team_id&amp;#34;: &amp;#34;t-datos&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;models&amp;#34;: [&amp;#34;llama-70b&amp;#34;, &amp;#34;qwen-30b&amp;#34;],
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_budget&amp;#34;: 250,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;budget_duration&amp;#34;: &amp;#34;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tpm_limit&amp;#34;: 400000,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;rpm_limit&amp;#34;: 600,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;max_parallel_requests&amp;#34;: 8,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;duration&amp;#34;: &amp;#34;90d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;tags&amp;#34;: [&amp;#34;produccion&amp;#34;, &amp;#34;notebooks&amp;#34;]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> }&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Alongside those there are controls that are rarely used and solve specific problems: &lt;code>model_max_budget&lt;/code> to set a per-model cap within the same key, &lt;code>model_tpm_limit&lt;/code> and &lt;code>model_rpm_limit&lt;/code> for the same thing with capacity, &lt;code>enforced_params&lt;/code> to require certain keys to always arrive with given fields, &lt;code>allowed_routes&lt;/code> so an application key cannot touch the management endpoints, and &lt;code>blocked&lt;/code> to be born suspended.&lt;/p>
&lt;h3 id="how-they-are-stored">How they are stored&lt;/h3>
&lt;p>A key&amp;rsquo;s hash is a single-pass hexadecimal SHA-256, with no salt and no derivation function:&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="n">hashed_token&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">token&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>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The row lives in &lt;code>LiteLLM_VerificationToken&lt;/code>, with the hash as primary key, and it contains the accumulated spend, the limits, the expiry, the allowed models and the rest of the configuration.&lt;/p>
&lt;p>Two consequences to be clear about before an audit. The first is that this hash is not designed to resist a dictionary attack against the database, and the code itself acknowledges it in a comment about encryption key derivation, where it admits that a single-pass unsalted SHA-256 is not a derivation function and that moving to HKDF would be more defensible in an audit. The real mitigation is that virtual keys are generated with enough entropy, not the strength of the hash. The second is that &lt;code>LITELLM_SALT_KEY&lt;/code> &lt;strong>plays no part here&lt;/strong>: that variable encrypts the stored provider credentials, not the virtual keys.&lt;/p>
&lt;p>There is a trap around that variable that takes down a whole deployment. If it is not defined, it &lt;strong>falls back to the master key&lt;/strong>. Rotating &lt;code>LITELLM_MASTER_KEY&lt;/code> without having first set your own &lt;code>LITELLM_SALT_KEY&lt;/code> leaves every stored provider credential unreadable, and the failure does not block: it logs an error and returns null, so the symptom shows up as models that stop authenticating with no explanation. Setting &lt;code>LITELLM_SALT_KEY&lt;/code> on day one, and never touching it, is one of the things that saves the most grief.&lt;/p>
&lt;h3 id="rotation">Rotation&lt;/h3>
&lt;p>&lt;code>/key/regenerate&lt;/code> does not create a new key and delete the old one. It updates the same row changing the hash, so &lt;strong>the accumulated spend, the maximum budget and the reset window are preserved&lt;/strong>. For a monthly chargeback this is exactly what you want: rotating mid-month does not zero the counter.&lt;/p>
&lt;p>It also accepts a grace period, so the old key remains valid while consumers update:&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">curl -X POST &lt;span class="s1">&amp;#39;https://gateway.interno/key/sk-vieja/regenerate&amp;#39;&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> -H &lt;span class="s1">&amp;#39;Authorization: Bearer sk-...&amp;#39;&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> -d &lt;span class="s1">&amp;#39;{&amp;#34;grace_period&amp;#34;: &amp;#34;24h&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The old hash is stored in a separate table with its revocation date. Without that parameter, revocation is immediate, and the default value can be set with &lt;code>LITELLM_KEY_ROTATION_GRACE_PERIOD&lt;/code>.&lt;/p>
&lt;p>A detail with teeth: if the period&amp;rsquo;s format is wrong, the system logs a warning and &lt;strong>continues with no grace period&lt;/strong>. The rotation completes, the old key dies on the spot, and all that is left is a log line. In an automated rotation procedure, that combination of &amp;ldquo;carry on&amp;rdquo; and &amp;ldquo;warn in the log&amp;rdquo; is what produces the outage at three in the morning.&lt;/p>
&lt;h2 id="what-exactly-happens-when-a-budget-runs-out">What exactly happens when a budget runs out&lt;/h2>
&lt;p>The code raises &lt;code>BudgetExceededError&lt;/code> with &lt;strong>status 429&lt;/strong>. The documentation says 400 in one place, and there the code wins. Its being a 429 has a practical implication: OpenAI clients retry automatically on a 429, so an exhausted budget turns into a retry storm against a gateway that is going to keep rejecting. Internal clients should distinguish the reason, which travels in the message.&lt;/p>
&lt;p>The messages are templates and state the scope, the spend and the cap:&lt;/p>
&lt;pre tabindex="0">&lt;code>Budget has been exceeded! Key=&amp;lt;clave&amp;gt; Current cost: &amp;lt;gasto&amp;gt;, Max budget: &amp;lt;tope&amp;gt;
Budget has been exceeded! Team=&amp;lt;equipo&amp;gt; Current cost: &amp;lt;gasto&amp;gt;, Max budget: &amp;lt;tope&amp;gt;
ExceededBudget: User=&amp;lt;usuario&amp;gt; over budget. Spend=&amp;lt;gasto&amp;gt;, Budget=&amp;lt;tope&amp;gt;
ExceededBudget: End User=&amp;lt;cliente&amp;gt; over budget. Spend=&amp;lt;gasto&amp;gt;, Budget=&amp;lt;tope&amp;gt;
Budget has been exceeded! Organization=&amp;lt;org&amp;gt; ...
Budget has been exceeded! Tag=&amp;lt;etiqueta&amp;gt; ...
&lt;/code>&lt;/pre>&lt;p>Two clarifications about the comparison. It is &lt;code>spend &amp;gt;= cap&lt;/code>, with the equals sign included, so landing exactly on the cap already blocks. And the soft budget, &lt;code>soft_budget&lt;/code>, &lt;strong>blocks nothing&lt;/strong>: it only writes a log line when it is crossed. It serves to warn, not to contain, and building an alert on top of it is the right way to use it.&lt;/p>
&lt;p>The rest of the rejections, with their codes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Situation&lt;/th>
&lt;th>Code&lt;/th>
&lt;th>Behaviour&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Budget exhausted&lt;/td>
&lt;td>429&lt;/td>
&lt;td>Message with scope, spend and cap&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Expired key&lt;/td>
&lt;td>401&lt;/td>
&lt;td>It also deletes the cache entry, so the expiry takes effect across the whole fleet without waiting for the TTL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model not allowed&lt;/td>
&lt;td>403&lt;/td>
&lt;td>Different error type depending on the scope: key, team, user, organisation or project&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Blocked key&lt;/td>
&lt;td>Generic exception&lt;/td>
&lt;td>&lt;code>Key is blocked. Update via /key/unblock if you're an admin.&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row is an inconsistency in the project worth knowing about: blocking raises an exception with no explicit type or code, while expiry and model access do carry them. Any alert that discriminates by error type will not see blocks the way it sees the rest.&lt;/p>
&lt;p>A useful note about allowed models: the check accepts both the alias and the underlying model, so a key authorised for &lt;code>llama-70b&lt;/code> can call it by its alias or by its real name with no additional configuration.&lt;/p>
&lt;h2 id="capacity-limits-the-v3-limiter">Capacity limits: the v3 limiter&lt;/h2>
&lt;p>This is the most significant silent change of the year in this layer. In 1.100.0, the limiter loaded by default is the third version. Going back to the previous one has to be asked for explicitly:&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="nv">LEGACY_MULTI_INSTANCE_RATE_LIMITING&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There is no flag to turn it on, only to turn it off. And it carries a contradiction worth knowing before trusting it: the file itself still opens with a comment saying it is under development and not production-ready, while it is the one running. The design is avowedly inspired by Envoy&amp;rsquo;s.&lt;/p>
&lt;p>&lt;strong>The window is sliding and lasts sixty seconds&lt;/strong>, configurable with &lt;code>LITELLM_RATE_LIMIT_WINDOW_SIZE&lt;/code>. It is not an exact rolling log nor a clock-aligned window: it anchors to the first request and resets once the window size has elapsed from that moment. For one-minute limits, the difference from a calendar window shows up at the edges and rarely matters.&lt;/p>
&lt;p>What does matter is Redis. The counters are incremented with Lua scripts executed inside Redis, using Redis&amp;rsquo;s own timestamp and not the pod&amp;rsquo;s, precisely so window resets are deterministic across replicas and to close the race between checking and counting. Without Redis, &lt;strong>each pod counts on its own and the effective limit is multiplied by the number of pods&lt;/strong>. A limit of 600 requests per minute with five replicas is a limit of 3,000.&lt;/p>
&lt;p>There is an inherent imprecision in the token limits that Redis does not solve. The number of output tokens is not known until the response finishes, so the limiter counts with an estimate before the call and reconciles afterwards. The possible excess is, per in-flight request, the difference between the estimate and the real figure. With high concurrency and long responses, that allows the cap to be exceeded within a window. To protect the fleet, the concurrent request limit (&lt;code>max_parallel_requests&lt;/code>) is a more direct instrument than tokens per minute.&lt;/p>
&lt;h3 id="what-the-client-sees">What the client sees&lt;/h3>
&lt;p>When limited, a 429 with the message giving the scope, the current limit, what is left and when it resets. And three headers: &lt;code>retry-after&lt;/code> with the window size, that is &lt;code>60&lt;/code> by default, plus &lt;code>rate_limit_type&lt;/code> and &lt;code>reset_at&lt;/code>.&lt;/p>
&lt;p>On the success path, the informational headers use a nested format of their own:&lt;/p>
&lt;pre tabindex="0">&lt;code>x-ratelimit-api_key-remaining-requests
x-ratelimit-api_key-limit-tokens
x-ratelimit-team-remaining-requests
&lt;/code>&lt;/pre>&lt;p>&lt;strong>They are not the standard names&lt;/strong> &lt;code>x-ratelimit-limit-requests&lt;/code> that OpenAI and compatibles emit, so a client expecting the usual format will find nothing. There is also an open issue about these headers being lost on streaming responses, which is most of an assistant&amp;rsquo;s traffic.&lt;/p>
&lt;h2 id="identity-beyond-the-static-key">Identity: beyond the static key&lt;/h2>
&lt;p>Handing out keys by hand works with three teams and stops working with thirty. The two routes for connecting the gateway to corporate identity:&lt;/p>
&lt;p>&lt;strong>JWT.&lt;/strong> It is enabled with &lt;code>enable_jwt_auth&lt;/code> and configured with the &lt;code>litellm_jwtauth&lt;/code> block, which maps token fields to LiteLLM entities: the team comes from &lt;code>team_id_jwt_field&lt;/code>, the user from &lt;code>user_id_jwt_field&lt;/code>, the organisation from &lt;code>org_id_jwt_field&lt;/code>. It supports dot notation for nested claims, automatic creation of users and teams with &lt;code>user_id_upsert&lt;/code> and &lt;code>team_id_upsert&lt;/code>, role-based access control with &lt;code>enforce_rbac&lt;/code>, per-team model restriction with &lt;code>enforce_team_based_model_access&lt;/code>, and several issuers at once. The issuer&amp;rsquo;s public key is cached with a TTL of 600 seconds by default.&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="nt">general_settings&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">enable_jwt_auth&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">litellm_jwtauth&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">team_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;groups&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">user_id_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sub&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">user_email_jwt_field&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;email&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">enforce_rbac&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">enforce_team_based_model_access&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">team_id_upsert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>OIDC for the admin interface.&lt;/strong> With Keycloak you use the generic provider, and the variables are &lt;code>PROXY_BASE_URL&lt;/code>, &lt;code>GENERIC_CLIENT_ID&lt;/code>, &lt;code>GENERIC_CLIENT_SECRET&lt;/code>, &lt;code>GENERIC_AUTHORIZATION_ENDPOINT&lt;/code>, &lt;code>GENERIC_TOKEN_ENDPOINT&lt;/code>, &lt;code>GENERIC_USERINFO_ENDPOINT&lt;/code>, plus a set of attributes to map the identifier, the email, the name and the role, and &lt;code>GENERIC_ROLE_MAPPINGS_*&lt;/code> to translate Keycloak groups into LiteLLM roles. Login is rate-limited and sessions have a TTL of their own.&lt;/p>
&lt;p>For automatic provisioning there is SCIM in the enterprise version, with base &lt;code>/scim/v2&lt;/code>, and deprovisioning a user blocks their keys and takes them out of the authentication cache. That part is not in the open tree, so it cannot be verified against the code.&lt;/p>
&lt;p>This fits with the identity work already covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets of the sovereign stack&lt;/a> and in the article on &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">MCP authentication with Keycloak&lt;/a>, which uses the same issuer.&lt;/p>
&lt;h2 id="audit-what-gets-recorded">Audit: what gets recorded&lt;/h2>
&lt;p>The table is &lt;code>LiteLLM_AuditLog&lt;/code> and its columns are what you would expect from a change log:&lt;/p>
&lt;pre tabindex="0">&lt;code>id, updated_at, changed_by, changed_by_api_key,
action, table_name, object_id, before_value, updated_values
&lt;/code>&lt;/pre>&lt;p>It stores the previous value and the new one, and attributes the change to an actor and to the key it was made with.&lt;/p>
&lt;p>Three clarifications the documentation does not capture well.&lt;/p>
&lt;p>&lt;strong>The actions are six, in the past tense&lt;/strong>: &lt;code>created&lt;/code>, &lt;code>updated&lt;/code>, &lt;code>deleted&lt;/code>, &lt;code>blocked&lt;/code>, &lt;code>unblocked&lt;/code> and &lt;code>rotated&lt;/code>. The documentation mentions three. That blocking, unblocking and rotation are actions in their own right is exactly what is needed to reconstruct a credential&amp;rsquo;s life cycle for an auditor.&lt;/p>
&lt;p>&lt;strong>The scope is wider than advertised.&lt;/strong> Besides teams, users, keys and models, changes to the proxy configuration and to the SSO configuration are audited. A change of identity issuer is recorded.&lt;/p>
&lt;p>&lt;strong>The default depends on the licence.&lt;/strong> The resolution order is &lt;code>litellm_settings.store_audit_logs&lt;/code>, then the variable &lt;code>LITELLM_STORE_AUDIT_LOGS&lt;/code>, and if neither is set, it stays &lt;strong>enabled in enterprise and disabled in the open version&lt;/strong>. An OSS deployment that assumes there is an audit trail does not have one:&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="nt">litellm_settings&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">store_audit_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two more pieces for a serious chain of evidence. The actor attribution can be passed with the &lt;code>LiteLLM-Changed-By&lt;/code> header, and its use is restricted by the key&amp;rsquo;s or the team&amp;rsquo;s configuration, so any caller cannot forge it. And the events can be shipped out with &lt;code>audit_log_callbacks&lt;/code> and its S3 parameters, which is what allows depositing them in an immutable store with object lock.&lt;/p>
&lt;p>That last part is what turns the record into evidence. The Postgres table is mutable by anyone with database access, and as of September 2026 it has no indexes, so querying it by date range over a long history will not be comfortable either.&lt;/p>
&lt;h2 id="what-this-demonstrates-to-an-auditor-and-what-it-does-not">What this demonstrates to an auditor, and what it does not&lt;/h2>
&lt;p>This is the part that decides whether the setup is good enough for a regulated customer. With everything above configured, the platform sustains without difficulty:&lt;/p>
&lt;p>&lt;strong>Access control via corporate identity&lt;/strong>, with onboarding and offboarding in the identity provider and not in the gateway, and with the role determining which models are reachable. &lt;strong>Segregation between tenants&lt;/strong>, with independent counters per organisation, team and person. &lt;strong>Traceability of administrative changes&lt;/strong>, with actor, time, previous value and new value, exportable to an immutable store. And &lt;strong>demonstrable consumption limits&lt;/strong>, with the evidence of the rejection in the spend log.&lt;/p>
&lt;p>What it does not cover, and has to be solved elsewhere:&lt;/p>
&lt;p>&lt;strong>The record of the interactions&lt;/strong> is not this. The audit table records configuration changes, not model calls. The calls are in the spend log and in the traces, and traces are best-effort, which is the discussion in the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">first article in the series&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The integrity of the record&lt;/strong> is not something Postgres provides. Without export to immutable storage, the evidence is only as strong as the database permissions.&lt;/p>
&lt;p>&lt;strong>The content of the prompts&lt;/strong> is a separate problem, with its own personal-data treatment and its own decision about whether it is stored, masked or not logged at all.&lt;/p>
&lt;p>And a warning about the control that tends to be shown first: while &lt;code>apply_user_budget_to_team_keys&lt;/code> is not enabled, the per-person cap is not being applied to team keys. Showing that field in the interface as proof of a control that is not executed is the kind of finding a competent auditor discovers. The complete control mapping is in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS, ISO 42001 and the EU AI Act&lt;/a>.&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;ol>
&lt;li>&lt;code>apply_user_budget_to_team_keys: true&lt;/code> if personal budgets have to be enforced.&lt;/li>
&lt;li>&lt;code>LITELLM_SALT_KEY&lt;/code> set on day one, different from the master key and never rotated lightly.&lt;/li>
&lt;li>&lt;code>store_audit_logs: true&lt;/code> explicitly, without trusting the default.&lt;/li>
&lt;li>&lt;code>audit_log_callbacks&lt;/code> towards an immutable store if an audit has to be sustained.&lt;/li>
&lt;li>Redis mandatory if there is more than one replica, or the limits get multiplied by the number of pods.&lt;/li>
&lt;li>&lt;code>max_parallel_requests&lt;/code> per key, on top of the per-minute limits, to protect the fleet from a runaway agent.&lt;/li>
&lt;li>&lt;code>allowed_routes&lt;/code> on application keys, so they cannot reach the management endpoints.&lt;/li>
&lt;li>&lt;code>soft_budget&lt;/code> with an alert on top, understanding that it does not block.&lt;/li>
&lt;li>Rotation with a verified &lt;code>grace_period&lt;/code>, because an invalid format revokes on the spot.&lt;/li>
&lt;li>JWT against Keycloak as soon as there are more than a handful of teams, with &lt;code>enforce_rbac&lt;/code> and &lt;code>enforce_team_based_model_access&lt;/code>.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>A user&amp;rsquo;s cap is not enforced.&lt;/strong> They are using a team key and &lt;code>apply_user_budget_to_team_keys&lt;/code> is missing.&lt;/p>
&lt;p>&lt;strong>Clients retry in a loop against an exhausted budget.&lt;/strong> The code is 429 and the OpenAI libraries retry 429s automatically.&lt;/p>
&lt;p>&lt;strong>The soft budget has stopped nothing.&lt;/strong> It does not block, it only logs.&lt;/p>
&lt;p>&lt;strong>The per-minute limits are enforced at double or triple.&lt;/strong> There is no Redis, and each replica keeps its own count.&lt;/p>
&lt;p>&lt;strong>The client does not see the limit headers.&lt;/strong> The names are nested and proprietary, and in streaming there is an open issue where they get lost.&lt;/p>
&lt;p>&lt;strong>There is no audit log.&lt;/strong> It is an OSS deployment and the default there is disabled.&lt;/p>
&lt;p>&lt;strong>The models stop authenticating after rotating the master key.&lt;/strong> There was no fixed &lt;code>LITELLM_SALT_KEY&lt;/code>, and the encrypted provider credentials can no longer be decrypted.&lt;/p>
&lt;p>&lt;strong>The rotation cut the service on the spot.&lt;/strong> The &lt;code>grace_period&lt;/code> carried a format the system did not understand and it carried on without it.&lt;/p>
&lt;p>&lt;strong>Blocking a key does not fire the alert.&lt;/strong> It is raised as a generic exception, without the type that expiry and model access do carry.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The keys and budgets layer is the one that turns a shared URL into a platform with tenants. It is well solved in LiteLLM and has more depth than the documentation suggests, with per-model budgets inside a key, multiple budget windows, per-tag limits and rotation that preserves the history.&lt;/p>
&lt;p>Out of the whole article, two things take the attention. The &lt;code>apply_user_budget_to_team_keys&lt;/code> line, because it is the difference between having a control and believing you have one. And setting &lt;code>LITELLM_SALT_KEY&lt;/code>, because its default turns a routine rotation of the master key into an outage that is hard to diagnose. Both are one line of configuration and both are discovered late.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">Humans and agents on the same gateway&lt;/a> — what happens to these limits when the caller is an agentic loop, and why &lt;code>max_parallel_requests&lt;/code> is only applied on the key.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — where the traces of all this end up, and why the audit record cannot lean on them.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the Redis taken as given here, and the Postgres arithmetic that sustains the spend table.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — where the figures that go into &lt;code>max_budget&lt;/code> come from, and the difference between showback and chargeback.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">From GPU-hour to cost per token and per request&lt;/a> — the conversion that gives meaning to a budget in euros on your own GPU.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — the complete mapping, of which this article covers the access control and administrative traceability part.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign stack&lt;/a> — the management of &lt;code>LITELLM_SALT_KEY&lt;/code> and of the rest of the deployment&amp;rsquo;s secrets.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">When MCP grows: authentication with Keycloak&lt;/a> — the same identity issuer, applied to the platform&amp;rsquo;s other front door.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a> — the alternative to these keys, and why a long-lived key decouples identity from the provider.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Virtual Keys&lt;/em> (parámetros de creación, ciclo de vida, rotación): &lt;a href="https://docs.litellm.ai/docs/proxy/virtual_keys">https://docs.litellm.ai/docs/proxy/virtual_keys&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Budgets, Rate Limits&lt;/em> (jerarquía, presupuestos por nivel, clientes finales): &lt;a href="https://docs.litellm.ai/docs/proxy/users">https://docs.litellm.ai/docs/proxy/users&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>JWT Token Auth&lt;/em> (bloque &lt;code>litellm_jwtauth&lt;/code> y todos sus campos): &lt;a href="https://docs.litellm.ai/docs/proxy/token_auth">https://docs.litellm.ai/docs/proxy/token_auth&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Audit Logs&lt;/em> y administración múltiple (la cabecera &lt;code>LiteLLM-Changed-By&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/multiple_admins">https://docs.litellm.ai/docs/proxy/multiple_admins&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>SCIM&lt;/em> (aprovisionamiento automático, versión enterprise): &lt;a href="https://docs.litellm.ai/docs/tutorials/scim_litellm">https://docs.litellm.ai/docs/tutorials/scim_litellm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/auth/auth_checks.py&lt;/code> en la etiqueta v1.100.0 (la condición de &lt;code>apply_user_budget_to_team_keys&lt;/code> y los mensajes de presupuesto agotado): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/auth/auth_checks.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/auth/auth_checks.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/management_endpoints/key_management_endpoints.py&lt;/code> en v1.100.0 (rutas, rotación sobre la misma fila, periodo de gracia): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/management_endpoints/key_management_endpoints.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/management_endpoints/key_management_endpoints.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/code> y &lt;code>litellm/proxy/hooks/__init__.py&lt;/code> en v1.100.0 (el limitador v3 como valor por defecto, la ventana, los scripts Lua): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/hooks/parallel_request_limiter_v3.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/hooks/parallel_request_limiter_v3.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/exceptions.py&lt;/code> en v1.100.0 (&lt;code>BudgetExceededError&lt;/code> con código 429): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/exceptions.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/exceptions.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/proxy/common_utils/encrypt_decrypt_utils.py&lt;/code> en v1.100.0 (&lt;code>LITELLM_SALT_KEY&lt;/code> cayendo en la clave maestra, y el comentario sobre la derivación de claves): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/common_utils/encrypt_decrypt_utils.py">https://github.com/BerriAI/litellm/blob/v1.100.0/litellm/proxy/common_utils/encrypt_decrypt_utils.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>schema.prisma&lt;/code> en v1.100.0 (&lt;code>LiteLLM_VerificationToken&lt;/code>, &lt;code>LiteLLM_AuditLog&lt;/code>, la tabla de claves deprecadas): &lt;a href="https://github.com/BerriAI/litellm/blob/v1.100.0/schema.prisma">https://github.com/BerriAI/litellm/blob/v1.100.0/schema.prisma&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #27748 — cabeceras &lt;code>x-ratelimit-*&lt;/code> perdidas en respuestas en streaming con el limitador v3: &lt;a href="https://github.com/BerriAI/litellm/issues/27748">https://github.com/BerriAI/litellm/issues/27748&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM on day 2: one worker per pod, six thousand seconds of timeout and the other five things to change before opening the traffic</title><link>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</link><pubDate>Tue, 08 Sep 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-day-2-high-availability/</guid><description>&lt;blockquote>
&lt;p>Second article in the operational track of the control layer. The &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">operational pair with Langfuse&lt;/a> covered the seam between the gateway and observability; here everything stays inside the gateway. The prior decisions, which gateway and why, are in &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LiteLLM Proxy starts from a twenty-line &lt;code>config.yaml&lt;/code> and serves traffic that same afternoon. The operational work consists of correcting seven defaults designed for a scenario other than an in-house inference factory.&lt;/p>
&lt;p>&lt;strong>One uvicorn worker per pod.&lt;/strong> The project&amp;rsquo;s production guide asks for &lt;code>--num_workers 1&lt;/code> on Kubernetes and horizontal scaling, with &lt;strong>1 vCPU and 4 GiB per worker set at the same time as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code>&lt;/strong>. The 4 GiB are a floor, not a target: the Prisma query engine marks a resident memory high-water mark that grows up to the largest statement it has executed and that glibc does not return to the system.&lt;/p>
&lt;p>&lt;strong>Autoscale on CPU, never on memory.&lt;/strong> A direct consequence of the above. Memory goes up and never down, so an HPA on memory scales and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The Postgres connection arithmetic.&lt;/strong> &lt;code>database_connection_pool_limit&lt;/code> is 10, and the number of connections is instances times workers times the pool. The Helm charts ship &lt;code>maxReplicas: 100&lt;/code>, on the order of a thousand connections, far above what a stock Postgres accepts.&lt;/p>
&lt;p>&lt;strong>Redis stops being optional&lt;/strong> from around a thousand requests per second or ten instances. Without it, each instance applies its limits on its own, caches are not shared, and spend updates against the same rows produce locks and exhaust the connections with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Three health endpoints and only two are usable as probes.&lt;/strong> &lt;code>/health/liveliness&lt;/code> and &lt;code>/health/readiness&lt;/code> require no authentication and answer fast. Bare &lt;code>/health&lt;/code> demands a key and fires a real request against every model in the catalogue, with its cost in tokens.&lt;/p>
&lt;p>&lt;strong>The resilience numbers are 2, 3 and 5&lt;/strong>: two retries, three allowed failures and five seconds of cooldown, the last one despite the function&amp;rsquo;s own documentation saying the default is 1.&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against an engine that does not answer.&lt;/p>
&lt;p>And a warning about the performance figures: the project publishes 8 ms of p95 overhead on one benchmark and 257.7 ms of p99 on another. Both are theirs, and the difference is in the shape of the load.&lt;/p>
&lt;h2 id="you-are-here-the-gateway-layer-the-day-after">You are here: the gateway layer, the day after&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, this article lives entirely in the gateway layer. The difference with the selection article is the one that separates buying from maintaining: there the question was which piece to put in front of the fleet, and here it is what happens when that piece has been serving for six months, the team has tripled the traffic and someone has added an agent that fires twenty calls per interaction.&lt;/p>
&lt;p>The gateway has an uncomfortable property worth keeping in mind: it sits on the critical path of every request and contributes not a single token. All it does is coordination, and any coordination that fails turns into an outage of the whole inference service, even with the GPUs perfectly healthy.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-and-its-notebook">The analogy: the switchboard and its notebook&lt;/h2>
&lt;p>A hospital switchboard does three jobs at once. It puts calls through, which is the urgent part. It writes down in a notebook who called, to which extension and for how long, which is what sustains the billing. And it consults a list of extensions to know where to put each call through.&lt;/p>
&lt;p>The three jobs compete. If the operator stops to write each line in the notebook before putting the next call through, the waiting queue grows. If there are three operators and each keeps her own notebook, the totals do not add up. And if the list of extensions is consulted in a filing cabinet on another floor, every call costs the trip.&lt;/p>
&lt;p>The three tensions have an exact equivalent in LiteLLM. The notebook is the &lt;code>SpendLogs&lt;/code> in Postgres, which is why the transaction buffer in Redis exists. The three operators with three notebooks are the pod replicas applying limits independently. And the filing cabinet on another floor is usage-based routing, which adds a Redis lookup inside the request path.&lt;/p>
&lt;h2 id="the-process-model-one-worker-per-pod">The process model: one worker per pod&lt;/h2>
&lt;p>The production documentation is explicit: on Kubernetes, &lt;code>--num_workers 1&lt;/code> and scale with replicas. On a virtual machine with no orchestrator, &lt;code>NUM_WORKERS&lt;/code> equal to the number of vCPUs.&lt;/p>
&lt;p>The reason is not ideological. An additional worker inside the same pod shares the container&amp;rsquo;s memory limit with the others, multiplies the database connections by the same factor, and registers a copy of every periodic job. With separate replicas, each one has its own resource budget, its own connection quota and a place where the scheduler can put it.&lt;/p>
&lt;p>The recommended sizing is &lt;strong>1 vCPU and 4 GiB per worker&lt;/strong>, and the part that gets overlooked is that these go as &lt;code>requests&lt;/code> and as &lt;code>limits&lt;/code> at the same time. Setting less than 4 GiB produces an OOM restart loop that shows up late, once some large request has grown the query engine&amp;rsquo;s footprint.&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="nt">resources&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">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &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">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">, memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That footprint needs to be described precisely, because it determines the autoscaling policy. The Prisma query engine keeps a resident memory that behaves as a high-water mark: it grows up to the largest statement the process has executed and does not come back down, because glibc does not return that freed memory to the system. A pod that served a spike yesterday still shows the footprint of that spike today.&lt;/p>
&lt;p>Two rules come out of this. The first: &lt;strong>autoscale on CPU and never on memory&lt;/strong>. An HPA on memory sees a metric that only goes up, scales at the peak and never scales back. The recommended target is &lt;code>targetCPUUtilizationPercentage: 60&lt;/code>, lower than the 80 the charts ship by default, because the startup probe allows up to 300 seconds before passing the first readiness check and there needs to be headroom for the new pod to be ready before the saturated one goes down.&lt;/p>
&lt;p>The second: bound the process lifetime. &lt;code>--max_requests_before_restart 10000&lt;/code> recycles the worker before the high-water mark matters.&lt;/p>
&lt;h2 id="postgres-the-arithmetic-that-blows-up-when-you-scale">Postgres: the arithmetic that blows up when you scale&lt;/h2>
&lt;p>&lt;code>database_connection_pool_limit&lt;/code> is &lt;strong>10&lt;/strong> by default. The total number of connections the deployment opens is replicas times workers times pool, and it has to be compared against the Postgres &lt;code>max_connections&lt;/code>.&lt;/p>
&lt;p>The formula the documentation gives is the inverse, and it is the one to apply when sizing:&lt;/p>
&lt;pre tabindex="0">&lt;code>database_connection_pool_limit = MAX_DB_CONNECTIONS / (instances × workers)
&lt;/code>&lt;/pre>&lt;p>The detail that hurts in production is in the Helm charts, which ship &lt;code>autoscaling.maxReplicas&lt;/code> and &lt;code>keda.maxReplicas&lt;/code> at &lt;strong>100&lt;/strong>. With the default pool, that is on the order of a thousand connections at full scale-out, far above what an untouched Postgres accepts. And the moment the HPA reaches those replicas is exactly the moment of highest load, so connection exhaustion arrives when it can least be afforded.&lt;/p>
&lt;p>There are two more valves on the same path. Provider errors are written to the database by default, and under a sustained engine failure that inflates the spend table without contributing anything that is not already in the metrics. They are switched off like this:&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="nt">general_settings&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">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stops writing provider errors&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">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># batches the spend writes&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>disable_spend_logs: true&lt;/code> is the radical version, which removes the per-request detail from the interface and leaves the cost in Prometheus and in the tracing backend. Before going that far you have to decide whether that table is the system&amp;rsquo;s audit record, which is the discussion in the &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">previous article&lt;/a>: if it is, it cannot be switched off, and the way out is the Redis buffer.&lt;/p>
&lt;h2 id="redis-when-it-stops-being-optional">Redis: when it stops being optional&lt;/h2>
&lt;p>Without Redis, LiteLLM works. Each instance keeps its own in-memory cache and applies its own counters. The consequences are three, and all of them show up as you grow.&lt;/p>
&lt;p>Requests-per-minute limits are applied per instance, so a limit of 100 with five replicas is in practice a limit of 500. Cache hits are local, so the same repeated question hits once in five. And there is no leader election, so periodic jobs run in every process at once.&lt;/p>
&lt;p>The threshold the documentation gives for enabling the transaction buffer is &lt;strong>a thousand requests per second or ten instances&lt;/strong>. Below that, each instance updates the key, user and team rows directly; above it, they all write to the same rows, locks appear and Postgres starts rejecting with &lt;code>FATAL: sorry, too many clients already&lt;/code>.&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="nt">general_settings&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">use_redis_transaction_buffer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The metrics to watch when that is enabled are &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code>, &lt;code>litellm_redis_spend_update_queue_size&lt;/code> and &lt;code>litellm_pod_lock_manager_size&lt;/code>. If the first grows without the second coming down, the flush to Postgres is not keeping up.&lt;/p>
&lt;p>A note about the routing strategy, which interacts with this. &lt;code>simple-shuffle&lt;/code> consults nothing external. Usage-based routing does, and it adds a round trip to Redis inside the request path. On a homogeneous vLLM fleet behind the same model, the improvement it brings rarely pays for that latency; on a heterogeneous fleet, the conversation is different and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the router article&lt;/a> covered it.&lt;/p>
&lt;h2 id="background-jobs-register-per-worker">Background jobs register per worker&lt;/h2>
&lt;p>This detail appears in no getting-started guide and produces considerable bewilderment when it is discovered. The proxy&amp;rsquo;s periodic jobs register &lt;strong>per uvicorn worker, not per pod&lt;/strong>. With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every job, all of them running with no coordination if there is no Redis for leader election.&lt;/p>
&lt;p>The variable that separates them is &lt;code>LITELLM_JOB_ROLE&lt;/code>: value &lt;code>serving&lt;/code> on the pods that serve traffic and a separate replica with value &lt;code>worker&lt;/code> for the jobs. With the one-worker-per-pod recommendation the problem shrinks, but it does not go away while there are several replicas.&lt;/p>
&lt;h2 id="probes-three-endpoints-and-only-two-are-usable">Probes: three endpoints and only two are usable&lt;/h2>
&lt;p>The three endpoints exist and do different things:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>Authentication&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>What it is for&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>/health/liveliness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Returns &lt;code>I'm alive!&lt;/code>, or 503 during shutdown&lt;/td>
&lt;td>&lt;code>livenessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health/readiness&lt;/code>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>State of the process and the database. &lt;strong>503 if the configured Postgres does not answer&lt;/strong>&lt;/td>
&lt;td>&lt;code>readinessProbe&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/health&lt;/code>&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Fires a real request against &lt;strong>every model&lt;/strong> in the catalogue&lt;/td>
&lt;td>Manual diagnosis, never a probe&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The last row deserves insisting on. &lt;code>/health&lt;/code> spends tokens on every call, one per configured model. Set as a probe, with the Kubernetes default interval and a fleet of eight models, that is thousands of inference requests a day that serve nobody.&lt;/p>
&lt;p>There are more endpoints useful for diagnosis: &lt;code>/health/readiness/details&lt;/code>, &lt;code>/health/services?service=langfuse&lt;/code> to check one specific callback, &lt;code>/health/history&lt;/code> and &lt;code>/health/latest&lt;/code> for the history, and &lt;code>/health/backlog&lt;/code>, which returns the in-flight requests and shows up in the latency section. &lt;code>/health/drain&lt;/code> exists but returns 404 unless it is enabled with &lt;code>enable_drain_endpoint: true&lt;/code>, and it is protected with &lt;code>X-Drain-Token&lt;/code>.&lt;/p>
&lt;p>The periodic model checks are configured separately and not in the probe:&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="nt">general_settings&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">background_health_checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">health_check_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">300&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># seconds&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">background_health_check_model_groups&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;llama-70b&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&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>And one setting with consequences in deployments with no Internet egress: &lt;code>allow_requests_on_db_unavailable: true&lt;/code> allows serving traffic with the database down, but leaves &lt;code>/health/readiness&lt;/code> always returning 200. With that set, the readiness probe stops detecting the very failure it was designed to detect, and budget and model errors keep blocking just the same.&lt;/p>
&lt;h2 id="retries-cooldowns-and-fallbacks-the-real-numbers">Retries, cooldowns and fallbacks: the real numbers&lt;/h2>
&lt;p>The defaults, read from the project&amp;rsquo;s code and not from the documentation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Constant&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>What it governs&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>DEFAULT_MAX_RETRIES&lt;/code>&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Retries before moving on to fallbacks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_ALLOWED_FAILS&lt;/code>&lt;/td>
&lt;td>3&lt;/td>
&lt;td>Failures before cooling down a deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_COOLDOWN_TIME_SECONDS&lt;/code>&lt;/td>
&lt;td>5&lt;/td>
&lt;td>Seconds a deployment stays out&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>SINGLE_DEPLOYMENT_TRAFFIC_FAILURE_THRESHOLD&lt;/code>&lt;/td>
&lt;td>1000&lt;/td>
&lt;td>Minimum requests before applying the cooldown logic with a single deployment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DEFAULT_REQUEST_TIMEOUT_SECONDS&lt;/code>&lt;/td>
&lt;td>6000.0&lt;/td>
&lt;td>Request timeout&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Besides the failure counter, there is a proportional criterion: a deployment enters cooldown if 50% of its requests fail in any given minute.&lt;/p>
&lt;p>On the cooldown there is a discrepancy worth knowing about before debugging blind. The docstring of the router function says &lt;code>cooldown_time&lt;/code> defaults to 1, and the code uses the constant of 5. The code wins.&lt;/p>
&lt;p>The fallbacks are three separate lists and are applied &lt;strong>after&lt;/strong> the retries are exhausted:&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="nt">router_settings&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">num_retries&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&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="m">120&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">fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;qwen-30b&amp;#34;&lt;/span>&lt;span class="p">]&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">context_window_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-128k&amp;#34;&lt;/span>&lt;span class="p">]&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">content_policy_fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">&amp;#34;llama-70b&amp;#34;: &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;llama-70b-sin-guardrail&amp;#34;&lt;/span>&lt;span class="p">]&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>A note on the behaviour when everything is in cooldown: if no deployment is left available in the group, LiteLLM falls back onto a specific &lt;code>model_info.id&lt;/code>, skipping the cooldown check. It is a deliberate degradation, serve something rather than return 503, and it is worth knowing it exists because it masks the real state of the fleet.&lt;/p>
&lt;p>And a capability lost along the way: since version 1.85.0, the &lt;code>mock_testing_fallbacks&lt;/code> parameter and its two siblings are stripped from requests arriving through the proxy and have no effect. Fallbacks can no longer be tested against the proxy with a doctored request; it has to be done against &lt;code>litellm.Router&lt;/code> directly, in a test. Anyone who had a smoke test built on that has it broken without warning.&lt;/p>
&lt;h3 id="the-six-thousand-second-timeout">The six thousand second timeout&lt;/h3>
&lt;p>It deserves its own section because it is the setting with the worst damage-to-effort ratio in the whole system. &lt;code>request_timeout&lt;/code> is 6,000 seconds. A hundred minutes. An inference engine that stops answering without closing the connection holds a worker for that long, and with one worker per pod that is an entire pod out of service per trapped request.&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="nt">general_settings&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">request_timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ten minutes is still generous for a long generation, and it bounds the damage.&lt;/p>
&lt;h2 id="overhead-the-projects-two-figures">Overhead: the project&amp;rsquo;s two figures&lt;/h2>
&lt;p>LiteLLM publishes two measurements of its own overhead that differ by a factor of thirty, and both are legitimate because they measure different loads.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of &lt;strong>median 2 ms, p95 8 ms and p99 13 ms&lt;/strong> at 1,170 requests per second. With two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures correspond to around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, from July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> and 329.5 MB of peak memory, with no callbacks, no spend tracking and no persistence. The Rust variant, in beta, gives 0.7 ms.&lt;/p>
&lt;p>The reading for an in-house platform: the first figure is the best case with human clients, and the second is the order of magnitude to plan for agentic load, which arrives in a closed loop against fast responses. There is one reported case that resembles this scenario closely: an in-house OpenAI-compatible engine served around 16 requests per second directly and around 9 through LiteLLM, with the degradation growing with concurrency, on a 4 vCPU, 8 GB pod.&lt;/p>
&lt;h3 id="the-latency-gap-the-metrics-cannot-see">The latency gap the metrics cannot see&lt;/h3>
&lt;p>LiteLLM&amp;rsquo;s timers start when its handler starts. The time the request spends queued in the uvicorn event loop, before getting there, appears in none of its metrics. The example in its own troubleshooting guide is a case in which LiteLLM records 10 seconds and the user experiences 20.&lt;/p>
&lt;p>It is detected by comparing two sources: &lt;code>GET /health/backlog&lt;/code> or the &lt;code>litellm_in_flight_requests&lt;/code> gauge against the response time measured by the load balancer in front. If they diverge, the gap is in the input queue and the answer is more replicas, not more tuning.&lt;/p>
&lt;p>Two headers are always on and serve to watch it without instrumenting anything: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>. For the second, the documentation sets the suspicion threshold at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>And the first cause of latency that guide mentions is none of the above: &lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously, and with payloads over 2 MB that alone can cost between 2 and 5 seconds per request. A log level set to debug a problem and forgotten there produces exactly the problem it was meant to debug.&lt;/p>
&lt;h2 id="the-failure-mode-to-put-in-the-runbook">The failure mode to put in the runbook&lt;/h2>
&lt;p>There is an open issue describing a cascade with a characteristic shape. Under sustained 429s from the engine upstream, the pods stopped answering readiness probes during startup, Kubernetes killed them on failed probes, and the restart put more pressure on an upstream that was already saturated. Peaks of 500 requests per second, average of 60, between two and five replicas with 1.3 CPU and 4 GB.&lt;/p>
&lt;p>The shape of the failure is what matters more than the specific case: &lt;strong>the engine&amp;rsquo;s saturation turns into a restart loop of the gateway&lt;/strong>, and from the outside it looks like the problem is the gateway. The defences are the ones already in this article, applied together: &lt;code>startupProbe&lt;/code> with a wide margin so readiness does not compete with startup, &lt;code>request_timeout&lt;/code> bounded so trapped requests get released, cooldowns that take the deployment returning 429 out of rotation, and CPU autoscaling at 60% so the capacity exists before it is needed. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="upgrading-without-cutting-traffic">Upgrading without cutting traffic&lt;/h2>
&lt;p>The deployment is a normal &lt;code>Deployment&lt;/code> with &lt;code>RollingUpdate&lt;/code>, &lt;code>maxUnavailable: 0&lt;/code> and &lt;code>maxSurge: 1&lt;/code>, and there is no further mystery to it except on one point: &lt;strong>schema migrations&lt;/strong>. LiteLLM uses Prisma and applies migrations at startup. With several replicas coming up at once after a version change, the migration must be run by a single process, which is another reason for the dedicated replica with &lt;code>LITELLM_JOB_ROLE: worker&lt;/code>.&lt;/p>
&lt;p>Before a version bump, two checks that pay for themselves: read the release notes looking for changes in the metadata emitted to the callbacks, because a change like that breaks saved dashboards without touching the service, and verify that the new configuration starts with &lt;code>--detailed_debug&lt;/code> on a separate pod before applying it to the fleet.&lt;/p>
&lt;h2 id="go-live-checklist">Go-live checklist&lt;/h2>
&lt;ol>
&lt;li>&lt;code>--num_workers 1&lt;/code> and scaling by replicas.&lt;/li>
&lt;li>&lt;code>requests&lt;/code> equal to &lt;code>limits&lt;/code>, 1 vCPU and 4 GiB per worker.&lt;/li>
&lt;li>HPA on CPU at 60%. Never on memory.&lt;/li>
&lt;li>&lt;code>database_connection_pool_limit&lt;/code> computed from &lt;code>max_connections&lt;/code>, and the chart&amp;rsquo;s &lt;code>maxReplicas&lt;/code> lowered to something the database can take.&lt;/li>
&lt;li>Redis as soon as there is more than one replica, and &lt;code>use_redis_transaction_buffer&lt;/code> from ten instances or a thousand requests per second.&lt;/li>
&lt;li>&lt;code>livenessProbe&lt;/code> on &lt;code>/health/liveliness&lt;/code>, &lt;code>readinessProbe&lt;/code> on &lt;code>/health/readiness&lt;/code>, and &lt;code>/health&lt;/code> out of the probes.&lt;/li>
&lt;li>&lt;code>startupProbe&lt;/code> with a margin of up to 300 seconds.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code>.&lt;/li>
&lt;li>&lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>, and &lt;code>disable_error_logs: true&lt;/code>.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code>, &lt;code>litellm_in_flight_requests&lt;/code> and the response time of the load balancer in front.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The HPA scales up and never back down.&lt;/strong> It is measuring memory, which in this process only goes up.&lt;/p>
&lt;p>&lt;strong>The pods die in a loop right after deploying.&lt;/strong> Less than 4 GiB of limit. The footprint appears with the first large request, not at startup.&lt;/p>
&lt;p>&lt;strong>Postgres starts rejecting connections at the peak.&lt;/strong> The chart has &lt;code>maxReplicas: 100&lt;/code> and the pool defaults to 10.&lt;/p>
&lt;p>&lt;strong>The per-minute limits are enforced badly.&lt;/strong> There is no Redis, and each replica keeps its own count.&lt;/p>
&lt;p>&lt;strong>The health check consumes tokens.&lt;/strong> Someone set &lt;code>/health&lt;/code> as the readiness probe.&lt;/p>
&lt;p>&lt;strong>The readiness probe returns 200 with the database down.&lt;/strong> &lt;code>allow_requests_on_db_unavailable&lt;/code> is set.&lt;/p>
&lt;p>&lt;strong>A deployment keeps receiving traffic after failing.&lt;/strong> The cooldown is 5 seconds, not minutes, and with a single deployment in the group you have to pass a thousand requests for the logic to apply.&lt;/p>
&lt;p>&lt;strong>The fallback test no longer tests anything.&lt;/strong> The simulation parameters are stripped from requests arriving through the proxy since 1.85.0.&lt;/p>
&lt;p>&lt;strong>The p99 is ten times worse than the published benchmark.&lt;/strong> The benchmark has think time and the real load is agentic.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>None of these settings is complicated and all of them are one line. What unites them is that their defaults describe a small deployment, with one instance, a human client on the other side and a commercial provider taking care of capacity. An in-house inference factory is the opposite on all four axes: several replicas, automated clients, and an engine whose capacity is finite and known.&lt;/p>
&lt;p>Of the ten points in the checklist, three decide almost everything in an on-premise deployment: the process model with its sizing, the database connection arithmetic, and the request timeout. The other seven are the ones that avoid the Saturday phone call.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the other half of day 2: cost per token on in-house models, trace correlation and the telemetry queues this post takes as given.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the routing strategies whose latency is discussed here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard: which OSS gateway to put in front&lt;/a> — the prior decision, with verified licences.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the cascade of 429s and failed probes described here fits.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey, of which this post details the gateway leg.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM and SLO validation under load&lt;/a> — how to measure the real saturation point before setting the limits in this article.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — where the gateway lives in the complete building.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, workers, memoria, pool de conexiones, &lt;code>LITELLM_JOB_ROLE&lt;/code>, buffer de transacciones en Redis, &lt;code>request_timeout&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Health checks&lt;/em> (los tres endpoints, comprobaciones de fondo, &lt;code>enable_drain_endpoint&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/health">https://docs.litellm.ai/docs/proxy/health&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Reliability&lt;/em> (fallbacks, reintentos, deprecación de los parámetros de simulación en la 1.85.0): &lt;a href="https://docs.litellm.ai/docs/proxy/reliability">https://docs.litellm.ai/docs/proxy/reliability&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Load test advanced&lt;/em> (las cifras de sobrecarga por número de instancias y la advertencia de la ley de Little): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/load_test_advanced">https://docs.litellm.ai/docs/load_test_advanced&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (los 257,7 ms de p99 del proxy en Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Latency overhead&lt;/em> (el hueco de la cola de uvicorn, las cabeceras de diagnóstico, el coste de &lt;code>LITELLM_LOG=DEBUG&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>DB info&lt;/em> (&lt;code>disable_spend_logs&lt;/code>, &lt;code>disable_error_logs&lt;/code> y qué se pierde con cada uno): &lt;a href="https://docs.litellm.ai/docs/proxy/db_info">https://docs.litellm.ai/docs/proxy/db_info&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> (los valores por defecto de reintentos, fallos permitidos, enfriamiento y timeout): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/router.py&lt;/code> (la discrepancia entre el docstring de &lt;code>cooldown_time&lt;/code> y la constante que usa el código): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/router.py">https://github.com/BerriAI/litellm/blob/main/litellm/router.py&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #15526 — cascada de 429 sostenidos, sondas de readiness fallidas y bucle de reinicios: &lt;a href="https://github.com/BerriAI/litellm/issues/15526">https://github.com/BerriAI/litellm/issues/15526&lt;/a>.&lt;/li>
&lt;li>LiteLLM, issue #21046 — degradación de throughput contra un motor propio compatible con OpenAI: &lt;a href="https://github.com/BerriAI/litellm/issues/21046">https://github.com/BerriAI/litellm/issues/21046&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>LiteLLM and Langfuse: the operational pair, and the four places where the trace is lost between the gateway and the dashboard</title><link>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</link><pubDate>Mon, 07 Sep 2026 18:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/</guid><description>&lt;blockquote>
&lt;p>This post opens the operational track of the control layer. The previous articles left the pieces chosen and assembled: &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">choosing the OSS gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a> explain why LiteLLM ends up in front of the fleet, and &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a> takes apart the backend that receives the traces. Here we deal with the seam between the two, which is where the work of the next three years lives.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The LiteLLM plus Langfuse pair is configured with a list of callbacks and two API keys, and from there four matters decide whether the dashboard is good for operating or only for showing in a demo.&lt;/p>
&lt;p>&lt;strong>There are three integration routes and they are not equivalent.&lt;/strong> The native &lt;code>langfuse&lt;/code> callback is tied to the Python SDK v2 (&lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in LiteLLM&amp;rsquo;s &lt;code>pyproject.toml&lt;/code>), which writes against the legacy ingestion that Langfuse Cloud retires on &lt;strong>16 November 2026&lt;/strong>. The &lt;code>langfuse_otel&lt;/code> preset exports over OTLP and is the path recommended in the current documentation of both projects. The OTel v2 route, behind the &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag, changes span names, leaves the success status at &lt;code>UNSET&lt;/code> and, above all, its Langfuse mapper does not read the request metadata: &lt;code>session_id&lt;/code>, &lt;code>trace_user_id&lt;/code>, &lt;code>tags&lt;/code> and &lt;code>trace_id&lt;/code> stop arriving.&lt;/p>
&lt;p>&lt;strong>The cost of a self-hosted model comes out as zero unless a price is registered.&lt;/strong> &lt;code>get_model_info()&lt;/code> raises an exception for a model that is not in the pricing map, &lt;code>completion_cost()&lt;/code> propagates it, the logger catches it, stores it in a debugging field and returns &lt;code>None&lt;/code>, and the payload turns it into &lt;code>0.0&lt;/code>. There is no warning at the default log level. A team&amp;rsquo;s budget is never exhausted because its spend is always zero.&lt;/p>
&lt;p>&lt;strong>Client-gateway-engine correlation does not come out of the box.&lt;/strong> LiteLLM does continue the client&amp;rsquo;s trace if a &lt;code>traceparent&lt;/code> arrives, and it also extracts from it the 32-hex trace-id to use as &lt;code>litellm_trace_id&lt;/code>, which makes the Langfuse identifier equal to the W3C one. Upstream the story changes: &lt;code>forward_traceparent_to_llm_provider&lt;/code> is &lt;code>False&lt;/code> by default, the function that applies it returns early if no &lt;code>otel&lt;/code> logger is loaded, and when it does act it forwards the client&amp;rsquo;s original header, so the vLLM span ends up a sibling of LiteLLM&amp;rsquo;s rather than a child. Add to that the fact that vLLM exports OTLP over gRPC by default and Langfuse does not accept gRPC.&lt;/p>
&lt;p>&lt;strong>Between the request and the dashboard there are four bounded queues that drop events.&lt;/strong> LiteLLM&amp;rsquo;s logging thread pool (100 threads, 10,000 pending tasks, dropping once full), the asynchronous &lt;code>LoggingWorker&lt;/code> (50,000, and once full it aggressively flushes half), OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code> (2,048 spans by default) and Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>, which drops rows after exhausting the retries with no dead-letter queue. None of the four applies back pressure on the request, which is exactly what you want, and none of them guarantees delivery, which is exactly what an auditor asks about.&lt;/p>
&lt;p>The rest of the article develops the four points with configuration applicable to a generic 4×H100 cluster with vLLM, the overhead figures the project itself publishes, and the honest limit of what this data sustains before ENS or ISO/IEC 42001.&lt;/p>
&lt;h2 id="you-are-here-the-seam-between-deploy-and-observe">You are here: the seam between DEPLOY and OBSERVE&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, LiteLLM lives in the gateway layer and Langfuse in the observability one. The separation is clean on the diagram and false in operation: the gateway is the only point in the system that sees the complete request before chopping it up and the complete response after reassembling it, so it is also the only reasonable place from which to emit the trace. The inference engine sees tokens and latencies; it does not see who is asking or how much that team has spent this month.&lt;/p>
&lt;p>That turns LiteLLM into a telemetry producer as well as a traffic distributor, and moves onto the gateway a responsibility that was not in the original design: if the callback fails, the request is served all the same and nobody notices that the dashboard has been incomplete for two days.&lt;/p>
&lt;h2 id="the-analogy-the-toll-booth-that-issues-the-ticket">The analogy: the toll booth that issues the ticket&lt;/h2>
&lt;p>A motorway toll booth does two independent jobs. It raises the barrier, which is its visible function, and it issues a ticket with number plate, time, section and amount, which is the function the concession lives off. The two share a booth and do not share a destination: the barrier responds in a hundred milliseconds because there is a car waiting, and the ticket travels to a processing centre over a slower channel that batches thousands of records before sending them.&lt;/p>
&lt;p>The design is well thought out as long as its consequence is understood. If the processing centre goes down, cars keep going through. If the ticket queue fills up, tickets are thrown away, traffic is not stopped. The concession accepts losing records rather than causing a jam, and charges on a statistical sample it knows to be imperfect.&lt;/p>
&lt;p>LiteLLM and Langfuse work the same way. The four queues in the corresponding section are the slow channel, and all of them prefer to drop rather than block. Whoever builds the pair inheriting the complete analogy gets the operation right. Whoever thinks the ticket is an accounting entry ends up explaining to an auditor why traces from a Tuesday afternoon are missing.&lt;/p>
&lt;h2 id="three-routes-and-one-with-an-expiry-date">Three routes, and one with an expiry date&lt;/h2>
&lt;p>As of September 2026 three paths coexist for getting a request from LiteLLM to Langfuse. LiteLLM&amp;rsquo;s documentation already labels the first as legacy.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Route&lt;/th>
&lt;th>How it is enabled&lt;/th>
&lt;th>Transport&lt;/th>
&lt;th>Status&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Native &lt;code>langfuse&lt;/code> callback&lt;/td>
&lt;td>&lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>&lt;/td>
&lt;td>Langfuse Python SDK v2, legacy ingestion&lt;/td>
&lt;td>Maintained for compatibility. Tied to &lt;code>langfuse&amp;lt;3.0&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>langfuse_otel&lt;/code> preset (v1)&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP against &lt;code>/api/public/otel&lt;/code>&lt;/td>
&lt;td>Recommended by both projects&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel v2 route&lt;/td>
&lt;td>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> + &lt;code>LITELLM_OTEL_V2=true&lt;/code>&lt;/td>
&lt;td>OTLP/HTTP, new mapper&lt;/td>
&lt;td>Opt-in. Changes semantics and loses metadata&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The date that orders the decision is &lt;strong>16 November 2026&lt;/strong>. Langfuse&amp;rsquo;s compatibility matrix marks the legacy batch ingestion of the Python SDK v2 as retired on that date for Cloud, at which point trace ingestion through that route stops working. On self-hosted the endpoint survives, but a v4 server in &lt;code>events_only&lt;/code> mode returns 400 for every event type except &lt;code>score-create&lt;/code> and &lt;code>sdk-log&lt;/code>.&lt;/p>
&lt;p>Since LiteLLM&amp;rsquo;s native callback is pinned to &lt;code>langfuse&amp;gt;=2.59.7,&amp;lt;3.0&lt;/code> in its own &lt;code>pyproject.toml&lt;/code>, and its code calls APIs that only exist in SDK v2 (&lt;code>self.Langfuse.trace(...)&lt;/code>, &lt;code>trace.generation(...)&lt;/code>), there is no way to move up an SDK without changing route. Anyone who today has &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code> against Langfuse Cloud has a migration with a date on it. Anyone who has it against a self-hosted Langfuse has one with some slack, because v3 receives security patches until the end of January 2027, but they have one all the same.&lt;/p>
&lt;p>One detail of the OTLP route worth checking in older deployments: the &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is the one that enables real-time ingestion. Without it the traces arrive, but with a delay that Langfuse&amp;rsquo;s documentation puts at around ten minutes (the compatibility page says fifteen, and that discrepancy remains unresolved in their own documents). LiteLLM started sending it in version 1.95.0, of August 2026. A proxy older than that version against a Langfuse v4 produces the classic symptom: the dashboard works, but it is always behind and nobody knows why.&lt;/p>
&lt;h2 id="the-configuration-that-works-today">The configuration that works today&lt;/h2>
&lt;p>For a generic 4×H100 cluster with vLLM behind it and Langfuse self-hosted in the same Kubernetes, the OTLP route in its v1 variant is the one that gives the most functionality with the fewest surprises.&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="nt">model_list&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">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b&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">litellm_params&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">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">hosted_vllm/meta-llama/Llama-3.3-70B-Instruct&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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-llama70b.inference.svc.cluster.local:8000/v1&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">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/VLLM_API_KEY&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="c"># Price derived from the cluster&amp;#39;s €/GPU-hour. Without this, spend = 0.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="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000031&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">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000089&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">litellm_settings&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">callbacks&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;langfuse_otel&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 class="nt">turn_off_message_logging&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># a conscious decision, see the PII section&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">langfuse_default_tags&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;model_group&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;user_api_key_team_alias&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cache_hit&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">general_settings&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">disable_error_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># provider errors inflate SpendLogs&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">proxy_batch_write_at&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The credentials travel through the environment. &lt;code>LANGFUSE_PUBLIC_KEY&lt;/code>, &lt;code>LANGFUSE_SECRET_KEY&lt;/code> and &lt;code>LANGFUSE_HOST&lt;/code> are enough: the preset builds the &lt;code>Authorization: Basic base64(public:secret)&lt;/code> header and adds the ingestion version one, so there is no need to touch &lt;code>OTEL_EXPORTER_OTLP_HEADERS&lt;/code> by hand. The preset&amp;rsquo;s default host points to the US region of Langfuse Cloud, a relevant detail for anyone building a platform with data sovereignty requirements: if the variable is not defined, the telemetry leaves Spain without anyone having decided it.&lt;/p>
&lt;p>For real multi-tenancy, LiteLLM supports per-team Langfuse credentials (&lt;code>POST /team/&amp;lt;id&amp;gt;/callback&lt;/code> with &lt;code>callback_vars&lt;/code>) and per-key ones (&lt;code>metadata.logging[]&lt;/code> when generating the key), and the key&amp;rsquo;s wins over the team&amp;rsquo;s without mixing. There is a limitation that breaks the architecture of anyone who planned to separate tenants across different Langfuse instances: under OTel v2, a per-key or per-team &lt;code>langfuse_host&lt;/code> does not move that tenant to another host, because the exporter fixes the destination at startup and the only thing that varies per tenant are the headers.&lt;/p>
&lt;p>And one operational warning with years of history behind it: &lt;code>MAX_LANGFUSE_INITIALIZED_CLIENTS&lt;/code> is 50 by default because each Langfuse client starts a thread. The comment in LiteLLM&amp;rsquo;s code is explicit about the incident that motivated it, with the CPU at 100% from repeated initialisations. A deployment with per-team credentials and many teams hits that ceiling.&lt;/p>
&lt;h2 id="what-is-lost-when-moving-to-otel-v2">What is lost when moving to OTel v2&lt;/h2>
&lt;p>The &lt;code>LITELLM_OTEL_V2=true&lt;/code> flag brings a step up in quality in the proxy&amp;rsquo;s general instrumentation, and at the same time a concrete regression on the path to Langfuse that the documentation acknowledges without underlining it.&lt;/p>
&lt;p>The Langfuse mapper of the v2 route emits &lt;code>langfuse.observation.type&lt;/code>, &lt;code>langfuse.observation.model.name&lt;/code>, the observation identifier, the model parameters, input and output, &lt;code>usage_details&lt;/code> and &lt;code>cost_details&lt;/code>, plus &lt;code>langfuse.trace.metadata.team_id&lt;/code> and &lt;code>team_alias&lt;/code>. It does not emit &lt;code>session.id&lt;/code>, nor &lt;code>user.id&lt;/code>, nor &lt;code>langfuse.trace.tags&lt;/code>, nor &lt;code>langfuse.trace.id&lt;/code>, nor the generation name, nor &lt;code>parent_observation_id&lt;/code>. LiteLLM&amp;rsquo;s documents put it this way: those attributes are set by the preset from the request and the response, not from metadata supplied by the client.&lt;/p>
&lt;p>The practical consequence is that an application that today groups conversations by sending &lt;code>metadata: {&amp;quot;session_id&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;trace_user_id&amp;quot;: &amp;quot;...&amp;quot;}&lt;/code> in the request body stops grouping them when the flag is enabled, with no error and no warning. The traces keep arriving, loose.&lt;/p>
&lt;p>The other v2 changes are semantic and affect alerts already written. The root span goes from being called &lt;code>Received Proxy Server Request&lt;/code> to being named after the route (&lt;code>POST /v1/chat/completions&lt;/code>), the inference span goes from &lt;code>litellm_request&lt;/code> to &lt;code>{operation} {model}&lt;/code>, the guardrail spans stop hanging off the inference span and hang off the root instead, and the success status stays at &lt;code>UNSET&lt;/code> instead of &lt;code>OK&lt;/code>. Any rule that fires on &lt;code>status == OK&lt;/code> stops firing. The way back is to remove the flag and restart.&lt;/p>
&lt;h2 id="cost-why-the-trace-says-zero">Cost: why the trace says zero&lt;/h2>
&lt;p>This is the part that takes the most time to diagnose and the least to fix.&lt;/p>
&lt;p>LiteLLM computes the cost of a request from a per-model pricing map. For commercial provider models the map comes filled in. For &lt;code>hosted_vllm/whatever&lt;/code>, which is all there is in an in-house inference factory, the map is empty, and the failure path is as follows:&lt;/p>
&lt;ol>
&lt;li>&lt;code>litellm.get_model_info()&lt;/code> raises an exception with the message &lt;code>This model isn't mapped yet&lt;/code>.&lt;/li>
&lt;li>&lt;code>completion_cost()&lt;/code> and &lt;code>response_cost_calculator()&lt;/code> propagate the exception.&lt;/li>
&lt;li>&lt;code>Logging._response_cost_calculator()&lt;/code> catches it, writes a &lt;code>StandardLoggingModelCostFailureDebugInformation&lt;/code> into &lt;code>response_cost_failure_debug_information&lt;/code>, logs it at &lt;strong>debug&lt;/strong> level and returns &lt;code>None&lt;/code>.&lt;/li>
&lt;li>The standard logging payload does &lt;code>llm_response_cost = raw_response_cost or 0.0&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>The system delivers a spend of zero, with no visible error at the default log level, and writes it into the &lt;code>spend&lt;/code> column of &lt;code>LiteLLM_SpendLogs&lt;/code>. The per-key, per-team and per-organisation budgets are evaluated against that zero and are never exhausted. The Langfuse dashboard shows a total cost of zero for everything self-hosted, which is precisely the part you wanted to measure.&lt;/p>
&lt;h3 id="registering-the-price">Registering the price&lt;/h3>
&lt;p>The pricing keys are accepted both in &lt;code>litellm_params&lt;/code> and in &lt;code>model_info&lt;/code>, and those in &lt;code>litellm_params&lt;/code> win when they are in both places. The complete set has 91 fields, but for a deployment with vLLM two are enough, and there are three things to know about them.&lt;/p>
&lt;p>The first is that &lt;strong>the price is per individual token, in USD&lt;/strong>. There are no per-thousand variants: searching for &lt;code>per_1k&lt;/code> or &lt;code>per_1000&lt;/code> in the pricing model returns nothing. A three-orders-of-magnitude error here is easy to make and hard to see, because it produces plausible figures.&lt;/p>
&lt;p>The second is that &lt;code>input_cost_per_request&lt;/code> exists in the schema and &lt;strong>is not applied&lt;/strong> in chat calls: a model registered with that field returns a cost of 0.0. To charge per request it has to be converted into a cost per token.&lt;/p>
&lt;p>The third is that &lt;code>input_cost_per_second&lt;/code> needs the response time and is computed as price times &lt;code>response_time_ms / 1000&lt;/code>, so outside the proxy&amp;rsquo;s request path it silently returns zero.&lt;/p>
&lt;p>The number that goes into those two keys comes from the article on &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy with LiteLLM&lt;/a> and the one on &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">cost per token and per request&lt;/a>: amortisation plus energy divided by tokens served. The example arithmetic, to set the order of magnitude: a cluster whose fully loaded cost is 9.60 USD per hour sustaining 3,000 output tokens per second produces 10.8 million tokens an hour, that is 0.00000089 USD per output token. That is the value that goes into &lt;code>output_cost_per_token&lt;/code>. If the cluster&amp;rsquo;s average utilisation is 30 % and not 100 %, the divisor changes and the price triples, which is the same lever that article develops.&lt;/p>
&lt;p>Before sending traffic, the &lt;code>/spend/calculate&lt;/code> endpoint accepts the same parameters as &lt;code>completion_cost&lt;/code> and returns the computed cost. It is the cheap way to check that the price resolves.&lt;/p>
&lt;h3 id="how-that-number-reaches-langfuse-and-who-wins">How that number reaches Langfuse, and who wins&lt;/h3>
&lt;p>The two integration routes write the cost into different fields. The native callback sends it as &lt;code>usage.total_cost&lt;/code> of the v2 schema, and duplicates it in the trace metadata as &lt;code>litellm_response_cost&lt;/code>. The OTLP preset emits &lt;code>langfuse.observation.cost_details&lt;/code> as a JSON string of the form &lt;code>{&amp;quot;total&amp;quot;: cost}&lt;/code>, and &lt;code>langfuse.observation.usage_details&lt;/code> with input, output and total.&lt;/p>
&lt;p>The rule on the Langfuse side is documented and is the right one for this case: when there is ingested cost and inferred cost, &lt;strong>the ingested one takes priority&lt;/strong>. Langfuse only computes from its own pricing table when the emitter sends no cost, and changes to a model definition apply to new generations, with no retroactive recalculation. A self-hosted model therefore needs no pricing definition in Langfuse: it is enough for LiteLLM to send &lt;code>cost_details&lt;/code>.&lt;/p>
&lt;p>One detail remains that explains a puzzling symptom. Langfuse stores the ingested cost and the computed one in separate columns, &lt;code>totalCost&lt;/code> and &lt;code>calculatedTotalCost&lt;/code>, and the session header totals add up only the first. A mixed deployment where part of the traffic brings cost and part lets Langfuse compute it shows sessions at zero while the individual generations show cost. There is an open issue about it.&lt;/p>
&lt;h3 id="the-tokens-of-a-streaming-response">The tokens of a streaming response&lt;/h3>
&lt;p>Almost all of an assistant&amp;rsquo;s traffic is streamed, and there the OpenAI &lt;code>usage&lt;/code> object only appears if the client asks for &lt;code>stream_options.include_usage&lt;/code>. LiteLLM solves this by injecting the option on its own: on the asynchronous completion routes with &lt;code>stream: true&lt;/code>, if the caller did not set it and all the candidate deployments support it, it adds it and marks &lt;code>_litellm_strip_stream_usage&lt;/code> so the usage block is used in the cost calculation and not returned to the client. The &lt;code>general_settings.always_include_stream_usage&lt;/code> flag forces or disables that behaviour, and is worth setting to &lt;code>True&lt;/code> when capability detection does not recognise the vLLM deployment.&lt;/p>
&lt;p>When the usage is missing anyway, LiteLLM reassembles the response with &lt;code>stream_chunk_builder()&lt;/code> and counts tokens with the tokeniser. It is an approximation, and it produces the second path towards a cost of zero. The two paths to zero are told apart by looking at &lt;code>total_tokens&lt;/code> in &lt;code>LiteLLM_SpendLogs&lt;/code>: zero tokens with a correct price is a usage problem, correct tokens with zero cost is a price problem.&lt;/p>
&lt;h2 id="correlation-from-the-client-to-the-engine">Correlation: from the client to the engine&lt;/h2>
&lt;p>The operational question that justifies the whole setup is being able to take a user complaint, find their request and see where the time went. That requires the trace identifier to survive three hops.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Propagation of the client's traceparent to the LiteLLM gateway and the vLLM engine, with the points where correlation breaks">
&lt;style>
.bx{fill:none;stroke:currentColor;stroke-width:1.3}
.bxd{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}
.t{font:600 13px sans-serif;fill:currentColor}
.h{font:600 12px sans-serif;fill:currentColor}
.s{font:11px sans-serif;fill:currentColor}
.ar{stroke:currentColor;stroke-width:1.2;fill:none;marker-end:url(#ah)}
.ard{stroke:currentColor;stroke-width:1.1;fill:none;stroke-dasharray:5 3;marker-end:url(#ah)}
&lt;/style>
&lt;defs>&lt;marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="430" y="18" text-anchor="middle" class="t">One identifier, three hops and two places where it is lost&lt;/text>
&lt;rect x="20" y="40" width="150" height="60" class="bx" rx="6"/>
&lt;text x="36" y="62" class="h">Client&lt;/text>
&lt;text x="36" y="78" class="s">traceparent: 00-abc…-01&lt;/text>
&lt;text x="36" y="92" class="s">baggage: session.id=…&lt;/text>
&lt;rect x="240" y="40" width="180" height="60" class="bx" rx="6"/>
&lt;text x="256" y="62" class="h">LiteLLM Proxy&lt;/text>
&lt;text x="256" y="78" class="s">litellm_trace_id = abc…&lt;/text>
&lt;text x="256" y="92" class="s">gateway parent span&lt;/text>
&lt;rect x="500" y="40" width="160" height="60" class="bx" rx="6"/>
&lt;text x="516" y="62" class="h">vLLM&lt;/text>
&lt;text x="516" y="78" class="s">gen_ai.latency.*&lt;/text>
&lt;text x="516" y="92" class="s">OTLP gRPC by default&lt;/text>
&lt;rect x="700" y="40" width="140" height="60" class="bxd" rx="6"/>
&lt;text x="716" y="62" class="h">Collector&lt;/text>
&lt;text x="716" y="78" class="s">gRPC to HTTP&lt;/text>
&lt;text x="716" y="92" class="s">renames attributes&lt;/text>
&lt;path d="M170 70 L236 70" class="ar"/>
&lt;text x="203" y="62" text-anchor="middle" class="s">1&lt;/text>
&lt;path d="M420 70 L496 70" class="ard"/>
&lt;text x="458" y="62" text-anchor="middle" class="s">2&lt;/text>
&lt;path d="M660 70 L696 70" class="ar"/>
&lt;rect x="20" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="36" y="160" class="h">1 · Inbound: works&lt;/text>
&lt;text x="36" y="177" class="s">LiteLLM extracts the 32-hex trace-id from the traceparent and uses it&lt;/text>
&lt;rect x="440" y="140" width="400" height="52" class="bxd" rx="6"/>
&lt;text x="456" y="160" class="h">2 · Outbound: off by default&lt;/text>
&lt;text x="456" y="177" class="s">forward_traceparent_to_llm_provider = False&lt;/text>
&lt;rect x="20" y="212" width="820" height="96" class="bx" rx="6"/>
&lt;text x="36" y="232" class="h">The three ways hop 2 fails to happen&lt;/text>
&lt;text x="36" y="252" class="s">a · The function returns early if no otel callback is loaded, even with the flag at True&lt;/text>
&lt;text x="36" y="270" class="s">b · It forwards the client header, not its own span context: vLLM ends up a sibling, not a child&lt;/text>
&lt;text x="36" y="288" class="s">c · If the client sends no traceparent, none is created&lt;/text>
&lt;path d="M220 100 L220 136" class="ar"/>
&lt;path d="M640 100 L640 136" class="ar"/>
&lt;/svg>
&lt;/div>
&lt;h3 id="what-does-work">What does work&lt;/h3>
&lt;p>If the client sends &lt;code>traceparent&lt;/code>, LiteLLM&amp;rsquo;s spans hang off the existing trace. In addition, and independently of the OTel subsystem, &lt;code>litellm_pre_call_utils.py&lt;/code> extracts by regular expression the 32-character hexadecimal trace-id and assigns it to &lt;code>data[&amp;quot;litellm_trace_id&amp;quot;]&lt;/code> as a last resort, and takes the &lt;code>session_id&lt;/code> from the W3C &lt;code>baggage&lt;/code> header. That makes the Langfuse trace identifier equal to the W3C trace identifier, which is the most robust correlation key available: with it, an identifier copied from the application log opens the trace in Langfuse with no translation.&lt;/p>
&lt;p>For the routes that are not OTel v2, moreover, headers prefixed with &lt;code>langfuse_&lt;/code> inject per-request metadata. &lt;code>langfuse_trace_id&lt;/code>, &lt;code>langfuse_trace_user_id&lt;/code> and &lt;code>langfuse_trace_metadata&lt;/code> are documented, but the code strips the prefix and writes the rest into the metadata, so any body key works as a header. Two warnings about this: header values arrive as strings and are not interpreted as JSON, and Nginx drops headers with underscores by default (&lt;code>underscores_in_headers off&lt;/code>), which produces the symptom that the metadata works locally and disappears behind the ingress.&lt;/p>
&lt;h3 id="what-does-not-work-without-effort">What does not work without effort&lt;/h3>
&lt;p>Towards the engine, propagation is off. The flag is &lt;code>litellm.forward_traceparent_to_llm_provider&lt;/code>, it is &lt;code>False&lt;/code>, and its implementation has three behaviours to know about before enabling it:&lt;/p>
&lt;ul>
&lt;li>The function returns immediately if &lt;code>open_telemetry_logger is None&lt;/code>. A deployment with &lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> and without the generic &lt;code>otel&lt;/code> callback propagates nothing, whatever the flag is set to.&lt;/li>
&lt;li>When it does propagate, it copies the client&amp;rsquo;s &lt;code>traceparent&lt;/code> header as is, instead of injecting the context of LiteLLM&amp;rsquo;s active span. The vLLM span ends up hanging off the client&amp;rsquo;s span-id, a sibling of the gateway call&amp;rsquo;s span. The trace is one, the hierarchy is wrong.&lt;/li>
&lt;li>If the client sends no &lt;code>traceparent&lt;/code>, none is created.&lt;/li>
&lt;/ul>
&lt;p>Add to that the fact that LiteLLM&amp;rsquo;s general policy is not to forward client headers to the provider, and that the &lt;code>forward_client_headers_to_llm_api&lt;/code> allowlist covers headers prefixed with &lt;code>x-&lt;/code>, among which &lt;code>traceparent&lt;/code> is not. This is the only route.&lt;/p>
&lt;h3 id="the-vllm-side-and-why-the-collector-stops-being-optional">The vLLM side, and why the Collector stops being optional&lt;/h3>
&lt;p>vLLM enables traces with &lt;code>--otlp-traces-endpoint&lt;/code>, and &lt;code>--collect-detailed-traces&lt;/code> with &lt;code>model&lt;/code>, &lt;code>worker&lt;/code> or &lt;code>all&lt;/code> adds forward and execute times, with a performance impact its own documentation warns about. The attributes it emits are in &lt;code>vllm/tracing/utils.py&lt;/code> and are the most useful part of the setup: &lt;code>gen_ai.latency.time_in_queue&lt;/code>, &lt;code>time_to_first_token&lt;/code>, &lt;code>time_in_scheduler&lt;/code>, &lt;code>time_in_model_prefill&lt;/code>, &lt;code>time_in_model_decode&lt;/code>, &lt;code>time_in_model_forward&lt;/code>, &lt;code>time_in_model_execute&lt;/code>, &lt;code>time_in_model_inference&lt;/code> and &lt;code>e2e&lt;/code>, plus the token counters. That breakdown is what turns a &amp;ldquo;it&amp;rsquo;s slow&amp;rdquo; into &amp;ldquo;the time goes in the queue, not in decode&amp;rdquo;, which is the conversation you want to have during an incident. It fits with what was already covered in &lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a> and with the metrics in &lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM and vLLM&lt;/a>.&lt;/p>
&lt;p>There are three constraints that decide the architecture:&lt;/p>
&lt;p>vLLM exports over gRPC unless &lt;code>OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf&lt;/code> is set, and its gRPC exporter is built with &lt;code>insecure=True&lt;/code> hard-coded. Langfuse, for its part, does not support gRPC. And vLLM emits the old convention names (&lt;code>gen_ai.usage.prompt_tokens&lt;/code>, &lt;code>gen_ai.usage.completion_tokens&lt;/code>), renamed in the specification to &lt;code>input_tokens&lt;/code> and &lt;code>output_tokens&lt;/code>, plus a &lt;code>gen_ai.latency.*&lt;/code> family that was never standard and that the file itself documents as its own attributes until they are normalised.&lt;/p>
&lt;p>An OpenTelemetry Collector in between solves all three at once: it receives gRPC, exports HTTP with the Basic header towards &lt;code>/api/public/otel/v1/traces&lt;/code>, and renames attributes with a &lt;code>transform&lt;/code>. It is also the only place where tail sampling can be done, which is the next point. And add the warning from the llm-d community, which holds for any deployment with Envoy or sidecars in between: &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per engine, because vLLM sends no service name and without it every span arrives as &lt;code>unknown_service&lt;/code>.&lt;/p>
&lt;h2 id="the-four-serial-queues">The four serial queues&lt;/h2>
&lt;p>A request&amp;rsquo;s telemetry crosses four bounded buffers before it is queryable. All four drop once full and none blocks the request.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Queue&lt;/th>
&lt;th>Capacity&lt;/th>
&lt;th>What it does once full&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM logging thread pool&lt;/td>
&lt;td>100 threads, 10,000 pending tasks&lt;/td>
&lt;td>Drops the new ones with a rate-limited warning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Asynchronous &lt;code>LoggingWorker&lt;/code>&lt;/td>
&lt;td>50,000 events, concurrency 100&lt;/td>
&lt;td>Aggressively flushes 50 % of the queue; &lt;code>enqueue()&lt;/code> documents that it never blocks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OTel&amp;rsquo;s &lt;code>BatchSpanProcessor&lt;/code>&lt;/td>
&lt;td>2,048 spans (&lt;code>OTEL_BSP_MAX_QUEUE_SIZE&lt;/code>), batch 512, sent every 5 s&lt;/td>
&lt;td>Drops spans silently&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Langfuse&amp;rsquo;s &lt;code>ClickhouseWriter&lt;/code>&lt;/td>
&lt;td>Bounded retries&lt;/td>
&lt;td>Drops rows after exhausting retries, with no dead-letter queue&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The first is documented in LiteLLM&amp;rsquo;s own code with a sentence that saves half an hour of discussion: logging is best-effort and, once the cap is reached, new entries are dropped with a rate-limited warning instead of being queued forever. The third is a barely visible consequence: LiteLLM builds the &lt;code>BatchSpanProcessor&lt;/code> without adjusting &lt;code>max_queue_size&lt;/code>, so the OTel SDK defaults govern, and no LiteLLM documentation mentions the &lt;code>OTEL_BSP_*&lt;/code> variables that change them. The Langfuse SDK does the same on its own account: it sets batch size and interval, and leaves the queue size at the default.&lt;/p>
&lt;p>On top of this comes a fifth loss point that is not a queue: LiteLLM does not call &lt;code>flush()&lt;/code> per request on the native route, so a &lt;code>SIGKILL&lt;/code> on the pod takes the last batch with it. On Kubernetes, this is an argument for a generous &lt;code>terminationGracePeriodSeconds&lt;/code> and a &lt;code>preStop&lt;/code> that gives some slack.&lt;/p>
&lt;p>None of this is a defect. It is the right design decision for a piece that sits on the critical path of inference. What changes is what can be asserted with the resulting data.&lt;/p>
&lt;h2 id="what-cannot-be-promised-to-the-auditor">What cannot be promised to the auditor&lt;/h2>
&lt;p>A dashboard built on this pipeline answers statistical questions well: what is the p95 of time to first token for the model group, which team consumes the most, what proportion of requests fails, how cost per request evolves. That is what it is designed for, and that is what it is good for.&lt;/p>
&lt;p>It does not answer completeness questions well, which are the ones an ENS auditor or an ISO/IEC 42001 management system auditor asks. Faced with &amp;ldquo;show me that I have a record of every interaction with the AI system during March&amp;rdquo;, the honest answer is that the traces are a best-effort record with four documented points of silent dropping, with no dead-letter queue on the last hop and no durability guarantee on any of them.&lt;/p>
&lt;p>The clean separation is the one already proposed in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">technical controls ENS, 42001 and the AI Act&lt;/a>: the regulatory audit record is a different artefact, with a different write path, durability guarantees and retention of its own. In the pair this article deals with, the &lt;code>LiteLLM_SpendLogs&lt;/code> table in Postgres is a much better candidate than Langfuse for that function, because it is a transactional write against a relational database, and because it already contains &lt;code>request_id&lt;/code>, &lt;code>api_key&lt;/code> in hashed form, &lt;code>team_id&lt;/code>, &lt;code>organization_id&lt;/code>, &lt;code>end_user&lt;/code>, &lt;code>model&lt;/code>, tokens, &lt;code>startTime&lt;/code>, &lt;code>endTime&lt;/code>, &lt;code>status&lt;/code> and &lt;code>requester_ip_address&lt;/code>. Writing the audit event from the same place and in the same transaction as the spend is the sensible route; delegating it to the tracing pipeline is not.&lt;/p>
&lt;p>Here an operational tension appears that has to be resolved deliberately. LiteLLM&amp;rsquo;s performance recommendation for production includes &lt;code>disable_error_logs: true&lt;/code> and, in very loaded deployments, &lt;code>disable_spend_logs: true&lt;/code>, because provider errors inflate the table and concurrent writes against the same rows produce locks and exhaust the Postgres connections. Disabling &lt;code>spend_logs&lt;/code> to gain performance and at the same time leaning on that table for the audit record are incompatible decisions. Above a thousand requests per second or more than ten instances, the way out is &lt;code>use_redis_transaction_buffer: true&lt;/code>, which batches the spend updates in Redis before pushing them down to Postgres, watching the &lt;code>litellm_in_memory_spend_update_queue_size&lt;/code> and &lt;code>litellm_redis_spend_update_queue_size&lt;/code> metrics.&lt;/p>
&lt;h2 id="pii-switching-off-the-content-before-it-leaves">PII: switching off the content before it leaves&lt;/h2>
&lt;p>An LLM trace contains, by definition, the user&amp;rsquo;s prompt. In an on-premise deployment for a regulated customer, that content may be exactly what cannot leave the application&amp;rsquo;s domain, not even towards the observability backend in the same cluster.&lt;/p>
&lt;p>The controls on the proxy side, from most global to most fine-grained:&lt;/p>
&lt;ul>
&lt;li>&lt;code>litellm_settings.turn_off_message_logging: true&lt;/code> cuts messages and responses in every callback, and keeps the metadata and the spend. The redaction replaces the content with the &lt;code>REDACTED_BY_LITELLM&lt;/code> sentinel in messages, &lt;code>choices&lt;/code>, &lt;code>reasoning_content&lt;/code>, streaming deltas, tool call arguments and in the standard logging object.&lt;/li>
&lt;li>The &lt;code>x-litellm-enable-message-redaction: true&lt;/code> header enables redaction per request, and &lt;code>LiteLLM-Disable-Message-Redaction: true&lt;/code> disables it. The second is checked first and wins over everything else, including the global configuration, which makes it a control to block at the ingress if the policy is that content is never logged.&lt;/li>
&lt;li>&lt;code>redact_user_api_key_info: true&lt;/code> removes the hashed token, the &lt;code>user_id&lt;/code> and the &lt;code>team_id&lt;/code> from the log.&lt;/li>
&lt;li>Per request and per callback, &lt;code>metadata: {&amp;quot;mask_input&amp;quot;: true, &amp;quot;mask_output&amp;quot;: true}&lt;/code>.&lt;/li>
&lt;li>The body parameter &lt;code>&amp;quot;no-log&amp;quot;: true&lt;/code> disables logging of that call. It is a body parameter, not a header, and it can be disabled globally with &lt;code>global_disable_no_log_param: true&lt;/code> so a client cannot make itself invisible.&lt;/li>
&lt;/ul>
&lt;p>On the OTel v2 route the policy is inverted towards the safe side: content is off by default and is opened with &lt;code>OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT&lt;/code>, with values &lt;code>no_content&lt;/code>, &lt;code>span_only&lt;/code>, &lt;code>event_only&lt;/code> and &lt;code>span_and_event&lt;/code>. The documentation underlines that the gate is applied centrally and that a user&amp;rsquo;s request cannot force their prompt towards the backend while capture is disabled. It is also the stance the OpenTelemetry specification recommends, whose preferred pattern for production is to store the content in external storage and leave only the reference in the span.&lt;/p>
&lt;p>One specific version to check in the inventory: below LiteLLM 1.98.0, with per-team Langfuse credentials, the team&amp;rsquo;s own credentials ended up inside its traces. That version changed the origin of the metadata emitted to the &lt;code>StandardLoggingPayload&lt;/code> to cut it off. The same change removed a couple of dozen metadata fields (&lt;code>model_group&lt;/code>, &lt;code>deployment&lt;/code>, &lt;code>queue_time_seconds&lt;/code>, &lt;code>attempted_retries&lt;/code> and others), so it breaks saved dashboards and alerts that filter on them. It is an upgrade with work attached, and it has to be done anyway. The same applies to the general hardening of the stack, covered in &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets of the sovereign stack&lt;/a>.&lt;/p>
&lt;h2 id="the-price-in-latency">The price in latency&lt;/h2>
&lt;p>LiteLLM publishes two overhead figures that differ by a factor of thirty, and both are theirs.&lt;/p>
&lt;p>The benchmarks page, with Locust, a thousand users with think time and a fake OpenAI endpoint, gives for four instances an &lt;code>x-litellm-overhead-duration-ms&lt;/code> of median 2 ms, p95 8 ms and p99 13 ms, at 1,170 requests per second. For two instances, median 12 ms, p95 29 ms and p99 43 ms. The page itself warns that those figures hold with around 130 in-flight requests, and that a closed-loop client with no think time keeps a thousand in flight and sees on the order of eight times that latency at the same rate, by Little&amp;rsquo;s law.&lt;/p>
&lt;p>The AIGatewayBench benchmark, of July 2026, measures the Python proxy at &lt;strong>257.7 ms of added p99&lt;/strong> with 329.5 MB of peak memory, against 0.7 ms for the Rust variant in beta. That measurement has no callbacks, no spend tracking and no persistence.&lt;/p>
&lt;p>The operational reading is that the first figure is the best case and the second the order of magnitude to plan for agentic load, which is the one that arrives in a closed loop against fast responses. To watch it there are two always-on headers: &lt;code>x-litellm-overhead-duration-ms&lt;/code> and &lt;code>x-litellm-callback-duration-ms&lt;/code>, and the documentation sets the useful threshold of the second at 100 ms, above which the diagnosis is that the payloads are too large.&lt;/p>
&lt;p>Three production settings that appear in LiteLLM&amp;rsquo;s documentation and that are wrong by default:&lt;/p>
&lt;p>&lt;strong>&lt;code>request_timeout&lt;/code> is 6,000 seconds.&lt;/strong> A hundred minutes holding a connection against a hung upstream. The recommendation is to lower it to 600.&lt;/p>
&lt;p>&lt;strong>&lt;code>LITELLM_LOG=DEBUG&lt;/code> serialises the payload with &lt;code>json.dumps(indent=4)&lt;/code> synchronously&lt;/strong>, and the documentation puts the cost of that serialisation at 2 to 5 seconds with payloads over 2 MB. It is the first cause of latency their troubleshooting guide mentions.&lt;/p>
&lt;p>&lt;strong>Background jobs register per uvicorn worker, not per pod.&lt;/strong> With &lt;code>--num_workers 4&lt;/code> and ten replicas that is forty copies of every periodic job. The &lt;code>LITELLM_JOB_ROLE&lt;/code> variable separates the pods that serve traffic from the replica that runs jobs.&lt;/p>
&lt;p>And one failure mode with an open issue that deserves to be in the runbook: under sustained 429s from the upstream, pods have been reported unable to answer readiness probes during startup, with the ensuing restart loop. It fits with what was covered in &lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">incident response runbooks&lt;/a>.&lt;/p>
&lt;h2 id="start-up-checklist">Start-up checklist&lt;/h2>
&lt;p>Ten checks before signing off the installation of the pair:&lt;/p>
&lt;ol>
&lt;li>&lt;code>callbacks: [&amp;quot;langfuse_otel&amp;quot;]&lt;/code> instead of &lt;code>success_callback: [&amp;quot;langfuse&amp;quot;]&lt;/code>, unless you depend on prompt management, which the OTLP preset does not support.&lt;/li>
&lt;li>&lt;code>LANGFUSE_HOST&lt;/code> defined explicitly. The preset&amp;rsquo;s default value points to the US region.&lt;/li>
&lt;li>LiteLLM at 1.98.0 or higher, because of the team credential leak into the traces, and at 1.95.0 or higher because of the v4 ingestion header.&lt;/li>
&lt;li>A price registered for every self-hosted model, per token and in USD, verified with &lt;code>/spend/calculate&lt;/code> before opening traffic.&lt;/li>
&lt;li>A test request with a known &lt;code>traceparent&lt;/code>, checking that the same identifier appears in Langfuse.&lt;/li>
&lt;li>An OpenTelemetry Collector between vLLM and Langfuse, with &lt;code>OTEL_SERVICE_NAME&lt;/code> per engine and renaming of the old token attributes.&lt;/li>
&lt;li>&lt;code>request_timeout: 600&lt;/code> and &lt;code>LITELLM_LOG&lt;/code> out of &lt;code>DEBUG&lt;/code>.&lt;/li>
&lt;li>Tail sampling in the Collector, with a latency and status code policy, to keep 100 % of what is slow and what failed.&lt;/li>
&lt;li>A written decision about prompt content, and blocking of the &lt;code>LiteLLM-Disable-Message-Redaction&lt;/code> header at the ingress if the policy is not to log.&lt;/li>
&lt;li>A dashboard with &lt;code>x-litellm-overhead-duration-ms&lt;/code>, &lt;code>x-litellm-callback-duration-ms&lt;/code> and Langfuse&amp;rsquo;s ingestion queue depth (&lt;code>langfuse.queue.ingestion.depth&lt;/code>), which is the autoscaling signal for its workers.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>The trace arrives, but ten minutes late.&lt;/strong> The &lt;code>x-langfuse-ingestion-version: 4&lt;/code> header is missing, which LiteLLM sends from 1.95.0. It is not a ClickHouse capacity problem.&lt;/p>
&lt;p>&lt;strong>The metadata works in development and disappears in production.&lt;/strong> Nginx drops headers with underscores by default, and all of this integration&amp;rsquo;s have one.&lt;/p>
&lt;p>&lt;strong>Session cost comes out as zero and generation cost does not.&lt;/strong> Langfuse adds up only the ingested cost in the session totals, not the one it computes itself.&lt;/p>
&lt;p>&lt;strong>Enabling OTel v2 breaks nothing visible and ungroups the conversations.&lt;/strong> The v2 mapper does not read &lt;code>session_id&lt;/code> or &lt;code>trace_user_id&lt;/code> from the request.&lt;/p>
&lt;p>&lt;strong>A team&amp;rsquo;s budget is never exhausted.&lt;/strong> Its spend is zero because the model has no price, and the calculation failure is only logged at debug level.&lt;/p>
&lt;p>&lt;strong>The alerts stop firing after the migration to v2.&lt;/strong> The success status becomes &lt;code>UNSET&lt;/code>; no rule on &lt;code>OK&lt;/code> is satisfied.&lt;/p>
&lt;p>&lt;strong>Forwarding the &lt;code>traceparent&lt;/code> is enabled and it does not reach the engine.&lt;/strong> The generic &lt;code>otel&lt;/code> callback is missing, without which the function that applies it returns before looking at the flag.&lt;/p>
&lt;p>&lt;strong>All the engines&amp;rsquo; spans appear under &lt;code>unknown_service&lt;/code>.&lt;/strong> vLLM emits no service name; &lt;code>OTEL_SERVICE_NAME&lt;/code> has to be set per deployment.&lt;/p>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>The gateway and the tracing backend are the two pieces of the inference stack that take the least time to install and the longest to operate well. The installation fits in a ConfigMap. The operation consists of four decisions no getting-started guide raises: which integration route to take knowing that one expires in November, where the price per token of a model nobody invoices comes from, what to do with the &lt;code>traceparent&lt;/code> at each hop, and what to tell an auditor about data the system drops by design when things get tight.&lt;/p>
&lt;p>The answer to the fourth is the one that orders the other three. This pipeline is operational instrumentation, with statistical precision and best-effort guarantees, and separating it from day one from the regulatory audit record avoids the awkward conversation two years from now. With that separation made, the LiteLLM and Langfuse pair is the pair of pieces that turns a fleet of GPUs into a platform with an owner, a known cost and explainable latency.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: the sorting centre&lt;/a> — the six-service architecture and the ten backend knobs behind the receiving end of this integration. That post opens the box; this one connects the cable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the router&amp;rsquo;s four functions and the minimal LiteLLM Proxy manifest on the generic cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the OSS gateway for LLM inference&lt;/a> — the decision prior to this article, with the small print on licences and what is gated in LiteLLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — where the numbers that go into &lt;code>input_cost_per_token&lt;/code> and &lt;code>output_cost_per_token&lt;/code> come from, and why utilisation is the lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">From GPU-hour to cost per token and per request&lt;/a> — the full arithmetic of the conversion summarised here in one line.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">Instrumenting vLLM with OpenTelemetry&lt;/a> — the other end of the correlation, with the detail of what each &lt;code>gen_ai.latency.*&lt;/code> measures.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the &lt;code>SDK → Collector → backend&lt;/code> pipeline and the two-layer sampling this post takes as given.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — why the regulatory audit record cannot lean on a best-effort pipeline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a> — where the diagnostic headers and the readiness probe failure mode under sustained 429s fit.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the complete journey of a request through every layer, of which this post details two.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — the process model, the probes, the retries and the 6,000-second timeout of the proxy that emits these traces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits&lt;/a> — who can call and with what cap, and the audit table that does work as evidence when the traces do not.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">The gateway does not live alone&lt;/a> — the gateway&amp;rsquo;s other four seams, including the identity that never reaches the trace.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — the new data model at the receiving end, and the 16 November date with its small print.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LiteLLM, &lt;em>Langfuse integration&lt;/em> (rutas de integración, metadata aceptada, cabeceras &lt;code>langfuse_*&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/observability/langfuse_integration">https://docs.litellm.ai/docs/observability/langfuse_integration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>OpenTelemetry v2&lt;/em> y &lt;em>Migración a OTel v2&lt;/em> (preset, captura de contenido, cambios de semántica): &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2">https://docs.litellm.ai/docs/observability/opentelemetry_v2&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration">https://docs.litellm.ai/docs/observability/opentelemetry_v2_migration&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Logging&lt;/em> y &lt;em>Config settings&lt;/em> (redacción, &lt;code>turn_off_message_logging&lt;/code>, variables de entorno): &lt;a href="https://docs.litellm.ai/docs/proxy/logging">https://docs.litellm.ai/docs/proxy/logging&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/config_settings">https://docs.litellm.ai/docs/proxy/config_settings&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Custom pricing&lt;/em> y &lt;em>Spend tracking&lt;/em> (claves de precio, SpendLogs, endpoints de gasto): &lt;a href="https://docs.litellm.ai/docs/proxy/custom_pricing">https://docs.litellm.ai/docs/proxy/custom_pricing&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/proxy/cost_tracking">https://docs.litellm.ai/docs/proxy/cost_tracking&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Production best practices&lt;/em> (topología, &lt;code>request_timeout&lt;/code>, buffer de transacciones en Redis, &lt;code>LITELLM_JOB_ROLE&lt;/code>): &lt;a href="https://docs.litellm.ai/docs/proxy/prod">https://docs.litellm.ai/docs/proxy/prod&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Benchmarks&lt;/em> y &lt;em>Latency overhead&lt;/em> (cifras de sobrecarga y cabeceras de diagnóstico): &lt;a href="https://docs.litellm.ai/docs/benchmarks">https://docs.litellm.ai/docs/benchmarks&lt;/a> · &lt;a href="https://docs.litellm.ai/docs/troubleshoot/latency_overhead">https://docs.litellm.ai/docs/troubleshoot/latency_overhead&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Rust AI Gateway benchmarks&lt;/em> (la cifra de 257,7 ms de p99 del proxy Python): &lt;a href="https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks">https://docs.litellm.ai/blog/rust-ai-gateway-benchmarks&lt;/a>.&lt;/li>
&lt;li>LiteLLM, notas de versión v1.95.0, v1.98.0 y v1.100.0: &lt;a href="https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0">https://docs.litellm.ai/release_notes/v1.95.0/v1-95-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0">https://docs.litellm.ai/release_notes/v1.98.0/v1-98-0&lt;/a> · &lt;a href="https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0">https://docs.litellm.ai/release_notes/v1.100.0/v1-100-0&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>OpenTelemetry&lt;/em> (endpoint, autenticación, tabla de mapeo de atributos, ausencia de gRPC): &lt;a href="https://langfuse.com/integrations/native/opentelemetry">https://langfuse.com/integrations/native/opentelemetry&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Compatibility&lt;/em> y &lt;em>Migrate v3 to v4&lt;/em> (la fecha del 16 de noviembre de 2026 y los modos de escritura): &lt;a href="https://langfuse.com/docs/compatibility">https://langfuse.com/docs/compatibility&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v3-to-v4&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Token and cost tracking&lt;/em> (prioridad del coste ingerido sobre el inferido): &lt;a href="https://langfuse.com/docs/observability/features/token-and-cost-tracking">https://langfuse.com/docs/observability/features/token-and-cost-tracking&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Scaling&lt;/em> y &lt;em>Sampling&lt;/em> (dimensionado, señal de autoescalado, muestreo de cabecera): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a> · &lt;a href="https://langfuse.com/docs/observability/features/sampling">https://langfuse.com/docs/observability/features/sampling&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — descarte de filas en &lt;code>ClickhouseWriter&lt;/code> sin cola de mensajes muertos: &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>. Issue #15109 — coste de sesión a cero: &lt;a href="https://github.com/langfuse/langfuse/issues/15109">https://github.com/langfuse/langfuse/issues/15109&lt;/a>. Issue #5173 — Nginx y cabeceras con guión bajo: &lt;a href="https://github.com/langfuse/langfuse/issues/5173">https://github.com/langfuse/langfuse/issues/5173&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;code>vllm/tracing/utils.py&lt;/code> y &lt;code>vllm/tracing/otel.py&lt;/code> en la etiqueta v0.28.0 (atributos emitidos, protocolo por defecto, &lt;code>insecure=True&lt;/code>): &lt;a href="https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py">https://github.com/vllm-project/vllm/blob/v0.28.0/vllm/tracing/utils.py&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>vLLM integration&lt;/em> (la advertencia de que vLLM solo exporta contadores y latencias): &lt;a href="https://langfuse.com/integrations/model-providers/vllm">https://langfuse.com/integrations/model-providers/vllm&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;code>litellm/constants.py&lt;/code> y &lt;code>litellm/litellm_core_utils/logging_worker.py&lt;/code> (capacidades de las colas y política de descarte): &lt;a href="https://github.com/BerriAI/litellm/blob/main/litellm/constants.py">https://github.com/BerriAI/litellm/blob/main/litellm/constants.py&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> y el repositorio &lt;code>semantic-conventions-genai&lt;/code> (estado de desarrollo, renombrados, captura de contenido): &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai">https://github.com/open-telemetry/semantic-conventions-genai&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry Collector, &lt;code>tailsamplingprocessor&lt;/code> (políticas de muestreo por cola y la restricción de instancia única por traza): &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md">https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/tailsamplingprocessor/README.md&lt;/a>.&lt;/li>
&lt;li>llm-d, &lt;em>End-to-end and fine-grained tracing in llm-d&lt;/em> (&lt;code>OTEL_SERVICE_NAME&lt;/code> y los spans huérfanos tras Envoy): &lt;a href="https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d">https://llm-d.ai/blog/end-to-end-and-fine-grained-tracing-in-llm-d&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Full TCO of an on-premise GPU cluster: from capex to the all-in €/GPU-hour and the break-even against cloud</title><link>https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/</link><pubDate>Tue, 16 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>N €&lt;/strong> or &lt;strong>N USD&lt;/strong> (source denominated in dollars); decimal point; comma as thousands separator. The dollar sign is not used (it is the formula delimiter). Data centred on Europe/Spain. Generic example hardware: a cluster of N nodes, each with 4×H100 SXM5 80 GB.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A 4×H100 SXM5 node costs between &lt;strong>150,000 USD and 200,000 USD&lt;/strong> in total capex (GPUs + server + network + storage + prorated rack). Amortised over 3 years with European opex (energy at ~0.116 €/kWh industrial, PUE 1.54 average or 1.2 with liquid, 0.3 FTE of staff), the &lt;strong>all-in&lt;/strong> cost ranges from &lt;strong>3.10 USD/GPU-hour&lt;/strong> (100 % utilisation) to &lt;strong>6.20 USD/GPU-hour&lt;/strong> (50 % utilisation). The break-even against AWS p5 on-demand (~6.88 USD/GPU-hour) is crossed at around &lt;strong>70 % utilisation&lt;/strong>; against a neocloud 3-year reserved (~1.49–2.10 USD/GPU-hour), on-prem never closes the gap in that scenario. Utilisation is the variable that decides the cost axis, not the price of the hardware.&lt;/p>
&lt;hr>
&lt;h2 id="the-model-declared-assumptions">The model: declared assumptions&lt;/h2>
&lt;p>Every calculation below starts from these assumptions. Changing any of them moves the conclusion; the sensitivity section quantifies by how much.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Parameter&lt;/th>
&lt;th>Base value&lt;/th>
&lt;th>Sensitivity range&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Node&lt;/td>
&lt;td>4×H100 SXM5 80 GB (HGX baseboard)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cluster&lt;/td>
&lt;td>N nodes (per-node model; scales linearly)&lt;/td>
&lt;td>1–32 nodes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Capex amortisation&lt;/td>
&lt;td>3 years (straight line)&lt;/td>
&lt;td>3–5 years&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU utilisation&lt;/td>
&lt;td>70 %&lt;/td>
&lt;td>30 %–100 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy price&lt;/td>
&lt;td>0.116 €/kWh (industrial Spain, Sept. 2025)&lt;/td>
&lt;td>0.06–0.20 €/kWh&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PUE&lt;/td>
&lt;td>1.54 (global average, Uptime Institute 2025)&lt;/td>
&lt;td>1.15–1.80&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Exchange rate&lt;/td>
&lt;td>1 USD = 0.93 € (reference Jun. 2026)&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>Energy source: &lt;a href="https://www.globalpetrolprices.com/Spain/electricity_prices/">GlobalPetrolPrices · Spain Business Electricity, Sept. 2025&lt;/a>. PUE source: &lt;a href="https://datacenter.uptimeinstitute.com/rs/711-RIA-145/images/2025.Annual.Survey.Report.pdf">Uptime Institute Global Data Center Survey 2025&lt;/a> — the global average PUE has been stuck at 1.54 for the sixth consecutive year; hyperscalers 1.10–1.15; colocation/enterprise 1.58–1.80; facilities less than 5 years old, 1.48. PUE 1.2 is achievable with direct-to-chip liquid cooling.&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;h2 id="capex-breakdown-per-4h100-sxm5-node">Capex breakdown per 4×H100 SXM5 node&lt;/h2>
&lt;h3 id="gpus">GPUs&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Unit price (USD)&lt;/th>
&lt;th>Quantity&lt;/th>
&lt;th>Subtotal (USD)&lt;/th>
&lt;th>Source and date&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>H100 SXM5 80 GB (card)&lt;/td>
&lt;td>30,000–40,000&lt;/td>
&lt;td>4&lt;/td>
&lt;td>120,000–160,000&lt;/td>
&lt;td>&lt;a href="https://www.gmicloud.ai/en/blog/nvidia-h100-gpu-pricing-2026-rent-vs-buy-cost-analysis">GMI Cloud, Apr. 2026&lt;/a> · &lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Introl, Apr. 2026&lt;/a>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The range reflects market variability and volume discounts (5–15 % for orders above 50 units). H100 SXM5 cards require NVIDIA&amp;rsquo;s &lt;strong>HGX baseboard&lt;/strong>; they are not sold loose for direct installation in standard servers.&lt;/p>
&lt;h3 id="server--hgx-baseboard">Server / HGX baseboard&lt;/h3>
&lt;p>A complete 4×H100 SXM node uses NVIDIA&amp;rsquo;s &lt;strong>HGX H100 4-GPU baseboard&lt;/strong> plus a compatible host server. Reference models: Supermicro SYS-421GU-TNXR (4U, dual Intel Xeon 4th Gen, HGX H100 4-GPU) and its Dell equivalent.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Estimated cost (USD)&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Server chassis + CPU (2× Xeon) + RAM (512 GB DDR5) + redundant PSU&lt;/td>
&lt;td>18,000–25,000&lt;/td>
&lt;td>Based on a Supermicro SYS-821GE bare without GPUs at ~24,806 USD (&lt;a href="https://www.xicomputer.com/Solutions/Data-Center-Servers/SYS-821GE-TNHR.asp">xicomputer.com&lt;/a>, Jun. 2026); scaled to the 4-GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HGX H100 4-GPU baseboard&lt;/td>
&lt;td>included in the GPU price&lt;/td>
&lt;td>NVIDIA HGX platform; no separate public price&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Inter-GPU NVLink (inside the node)&lt;/td>
&lt;td>included in the baseboard&lt;/td>
&lt;td>4 GPUs connected by NVLink 4.0 on the HGX baseboard&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>Marketing claim (no independent verification): Supermicro announces datacenter energy cost reductions of up to 40 % with liquid cooling in its HGX H100 servers (&lt;a href="https://www.supermicro.com/en/pressreleases/supermicro-launches-industrys-first-nvidia-hgx-h100-8-and-4-gpu-h100-servers-with-liquid-cooling">Supermicro press release&lt;/a>).&lt;/p>
&lt;/blockquote>
&lt;h3 id="infiniband-ndr-network">InfiniBand NDR network&lt;/h3>
&lt;p>For a multi-node cluster with tensor parallelism across nodes, the GPU-to-GPU network is critical. NDR InfiniBand (400 Gb/s per port) is the de facto standard for HGX clusters.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Estimated cost per node (USD)&lt;/th>
&lt;th>Source / Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>NVIDIA Quantum-2 NDR 400G switch (64 ports, prorated across N nodes)&lt;/td>
&lt;td>2,000–4,000&lt;/td>
&lt;td>Switch ~35,000 USD (&lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Introl, Apr. 2026&lt;/a>); at 16 nodes, ~2,200 USD/node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>InfiniBand NDR cables/transceivers (4 ports per node × ~1,000 USD/port)&lt;/td>
&lt;td>4,000&lt;/td>
&lt;td>Estimate based on ~1,000 USD per optical transceiver (&lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Introl, Apr. 2026&lt;/a>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>InfiniBand network (prorated per 4-GPU node)&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~6,000–8,000&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>For inference serving inside a single node (4 GPUs with NVLink), the inter-node network is less critical than for multi-node training. For disaggregated prefill-decode workloads spread across nodes, InfiniBand NDR is necessary.&lt;/p>
&lt;/blockquote>
&lt;h3 id="nvme-storage">NVMe storage&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Estimated cost (USD)&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Local NVMe (4 TB × 2 U.2/E1.S drives, working datasets and checkpoints)&lt;/td>
&lt;td>2,000–4,000&lt;/td>
&lt;td>~500–1,000 USD/TB enterprise NVMe, 2025&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared object storage (NAS/MinIO, prorated per node)&lt;/td>
&lt;td>2,000–5,000&lt;/td>
&lt;td>Varies with the total capacity of the cluster&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total storage per node&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~4,000–9,000&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>Introl models 50 TB per GPU for effective operation in training clusters (&lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Introl, Apr. 2026&lt;/a>); for pure inference the requirement is significantly lower (model weights + logs).&lt;/p>
&lt;/blockquote>
&lt;h3 id="rack-pdu-and-datacenter-connectivity">Rack, PDU and datacenter connectivity&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Estimated cost per node (USD/year)&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Rack colocation (high density, 10–15 kW per node)&lt;/td>
&lt;td>5,000–12,000/year&lt;/td>
&lt;td>&lt;a href="https://encoradvisors.com/data-center-colocation-pricing/">Encoradvisors · Colocation Pricing 2026&lt;/a>: high density 3,000–6,000 USD/month per rack; at 2 nodes per rack, ~1,500–3,000 USD/month per node = 18,000–36,000 USD/year in tier-1; lower in Spain&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rack PDU, electrical cabling (prorated)&lt;/td>
&lt;td>500–1,000 per node (amortised capex)&lt;/td>
&lt;td>Inside the colocation line item or in an own datacenter&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>Colocation in Spain/Europe is structurally cheaper than in US tier-1 markets (New York, Silicon Valley). For an own datacenter, replace it with the cost of your own space plus amortisation of the electrical and cooling infrastructure.&lt;/p>
&lt;/blockquote>
&lt;h3 id="capex-summary-per-4h100-sxm5-node">Capex summary per 4×H100 SXM5 node&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Line item&lt;/th>
&lt;th>Range (USD)&lt;/th>
&lt;th>Midpoint&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPUs (4× H100 SXM5)&lt;/td>
&lt;td>120,000–160,000&lt;/td>
&lt;td>140,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Server chassis + CPU/RAM/PSU&lt;/td>
&lt;td>18,000–25,000&lt;/td>
&lt;td>21,500&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>InfiniBand NDR network (prorated)&lt;/td>
&lt;td>6,000–8,000&lt;/td>
&lt;td>7,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVMe + object storage&lt;/td>
&lt;td>4,000–9,000&lt;/td>
&lt;td>6,500&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PDU/rack/other (capex)&lt;/td>
&lt;td>2,000–5,000&lt;/td>
&lt;td>3,500&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total capex per node&lt;/strong>&lt;/td>
&lt;td>&lt;strong>150,000–207,000&lt;/strong>&lt;/td>
&lt;td>&lt;strong>178,500&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Sources: &lt;a href="https://www.gmicloud.ai/en/blog/nvidia-h100-gpu-pricing-2026-rent-vs-buy-cost-analysis">GMI Cloud (Apr. 2026)&lt;/a>, &lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Introl (Apr. 2026)&lt;/a>, &lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron (Apr. 2026)&lt;/a>, &lt;a href="https://www.xicomputer.com/Solutions/Data-Center-Servers/SYS-821GE-TNHR.asp">xicomputer.com (Jun. 2026)&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="opex-breakdown-per-4h100-sxm5-node-annual">Opex breakdown per 4×H100 SXM5 node (annual)&lt;/h2>
&lt;h3 id="energy">Energy&lt;/h3>
&lt;p>A 4×H100 SXM5 node at full load draws approximately:&lt;/p>
$$P_{\text{node}} = 4 \times 700\,\text{W (TDP H100 SXM5)} + 800\,\text{W (server)} \approx 3.6\,\text{kW (IT)}$$
&lt;p>The total datacenter power includes the cooling overhead, expressed by the &lt;strong>PUE&lt;/strong>:&lt;/p>
$$P_{\text{total}} = P_{\text{IT}} \times \text{PUE}$$
$$\text{annual energy cost} = P_{\text{IT}} \times \text{PUE} \times 8{,}760\,\text{h} \times \text{kWh price}$$
&lt;p>With the base values (PUE 1.54; 0.116 €/kWh):&lt;/p>
$$\text{energy/year} = 3.6\,\text{kW} \times 1.54 \times 8{,}760\,\text{h} \times 0.116\,\text{EUR/kWh} \approx 5{,}475\,\text{EUR}$$
&lt;p>With a Spanish solar PPA (Q3 2025 reference price: ~34 €/MWh = 0.034 €/kWh according to &lt;a href="https://www.pv-tech.org/european-solar-ppa-prices-fall-below-35-mwh-q3-2025/">PV Tech, Oct. 2025&lt;/a>):&lt;/p>
$$\text{energy/year (solar PPA)} = 3.6 \times 1.54 \times 8{,}760 \times 0.034 \approx 1{,}604\,\text{EUR}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Energy scenario&lt;/th>
&lt;th>Price (€/kWh)&lt;/th>
&lt;th>Energy cost/year per 4-GPU node&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Spanish solar PPA (Q3 2025)&lt;/td>
&lt;td>0.034&lt;/td>
&lt;td>~1,604 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Industrial Spain (Sept. 2025)&lt;/td>
&lt;td>0.116&lt;/td>
&lt;td>~5,475 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>European average (industrial tariff)&lt;/td>
&lt;td>0.160&lt;/td>
&lt;td>~7,550 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Worst case (no PPA, high tariff)&lt;/td>
&lt;td>0.200&lt;/td>
&lt;td>~9,437 €&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="staff--operations">Staff / operations&lt;/h3>
&lt;p>Staff cost is the most variable line item with cluster size. For a small cluster (2–8 nodes), the rule of thumb is &lt;strong>0.3–0.5 FTE per cluster&lt;/strong> of GPU infrastructure support (&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron, Apr. 2026&lt;/a>).&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cluster size&lt;/th>
&lt;th>Estimated FTE&lt;/th>
&lt;th>FTE cost (€/year, Western Europe)&lt;/th>
&lt;th>Cost per 4-GPU node (€/year)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>2–4 nodes&lt;/td>
&lt;td>0.3 FTE&lt;/td>
&lt;td>~120,000&lt;/td>
&lt;td>36,000–18,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8–16 nodes&lt;/td>
&lt;td>0.5 FTE&lt;/td>
&lt;td>~120,000&lt;/td>
&lt;td>7,500&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>32+ nodes&lt;/td>
&lt;td>1–2 FTE&lt;/td>
&lt;td>~120,000&lt;/td>
&lt;td>3,750–7,500&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>Indicative salary reference: a GPU infrastructure engineer with CUDA, InfiniBand and Kubernetes knowledge in Western Europe, ~90,000–140,000 €/year fully loaded. Introl&amp;rsquo;s figures (&lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Apr. 2026&lt;/a>) in USD (~275,000 USD/year for the US) reflect the North American market, which is appreciably higher.&lt;/p>
&lt;/blockquote>
&lt;h3 id="maintenance-and-support">Maintenance and support&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Line item&lt;/th>
&lt;th>Annual cost (% of hardware capex)&lt;/th>
&lt;th>Per 4-GPU node (midpoint)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Vendor maintenance / support&lt;/td>
&lt;td>5–10 % of capex&lt;/td>
&lt;td>~7,000–14,000 USD → ~6,500–13,000 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU failure rate (~5 % annual) × replacement cost&lt;/td>
&lt;td>5 % × 4 GPUs × ~35,000 USD = ~7,000 USD expected&lt;/td>
&lt;td>~6,500 € (amortised as a provision)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Minor spares (cables, modules)&lt;/td>
&lt;td>~500–1,000 €&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>Introl quotes GPU failure rates of 2–3 % per year in small clusters; Google Research documented ~9 % annualised in Meta&amp;rsquo;s 16,384-GPU H100 cluster (&lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Introl, Apr. 2026&lt;/a>). 5 % is used here as a conservative intermediate value.&lt;/p>
&lt;/blockquote>
&lt;h3 id="depreciation-for-accounting-purposes">Depreciation (for accounting purposes)&lt;/h3>
&lt;p>Straight-line depreciation turns capex into an annual flow comparable to the cost of committed cloud:&lt;/p>
$$\text{annual depreciation} = \frac{\text{node capex}}{\text{amortisation years}}$$
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Node capex (USD)&lt;/th>
&lt;th>3-year amortisation (USD/year)&lt;/th>
&lt;th>5-year amortisation (USD/year)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>150,000 (minimum)&lt;/td>
&lt;td>50,000&lt;/td>
&lt;td>30,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>178,500 (average)&lt;/td>
&lt;td>59,500&lt;/td>
&lt;td>35,700&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>207,000 (maximum)&lt;/td>
&lt;td>69,000&lt;/td>
&lt;td>41,400&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>H100 hardware depreciates fast: secondary-market analyses put the residual value at 20–40 % of the purchase price after 3 years (&lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">Introl, Apr. 2026&lt;/a>). The arrival of Blackwell GB200/GB300 accelerates the perceived obsolescence.&lt;/p>
&lt;/blockquote>
&lt;h3 id="annual-opex-summary-per-4h100-sxm5-node-base-scenario-8-node-cluster">Annual opex summary per 4×H100 SXM5 node (base scenario, 8-node cluster)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Line item&lt;/th>
&lt;th>Base scenario (€/year)&lt;/th>
&lt;th>Range&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Energy (PUE 1.54; 0.116 €/kWh)&lt;/td>
&lt;td>5,475&lt;/td>
&lt;td>1,604–9,437&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Staff (0.5 FTE × 8 nodes, prorated)&lt;/td>
&lt;td>7,500&lt;/td>
&lt;td>3,750–36,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintenance / support / failures&lt;/td>
&lt;td>9,000&lt;/td>
&lt;td>5,000–15,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rack colocation (Spain, high density)&lt;/td>
&lt;td>6,000&lt;/td>
&lt;td>3,000–15,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total opex per node&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~28,000&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~13,000–75,000&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The extreme range reflects the difference between a well amortised own datacenter with a solar PPA and cheap energy (minimum opex) and tier-1 colocation with market tariffs and senior staff.&lt;/p>
&lt;hr>
&lt;h2 id="deriving-the-all-in-gpu-hour">Deriving the all-in €/GPU-hour&lt;/h2>
&lt;h3 id="formula">Formula&lt;/h3>
$$\text{EUR/GPU-hour all-in} = \frac{\frac{\text{node capex}}{\text{years}} + \text{node annual opex}}{4\,\text{GPUs} \times 8{,}760\,\text{h} \times u}$$
&lt;p>where \(u\) is the average annual utilisation (0 to 1).&lt;/p>
&lt;p>See the cost-per-token identity in &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">cost per token and per request&lt;/a> for the connection with throughput.&lt;/p>
&lt;h3 id="gpu-hour-table-by-utilisation-and-scenario">€/GPU-hour table by utilisation and scenario&lt;/h3>
&lt;p>&lt;strong>Average capex (178,500 USD → ~166,000 €), 3-year amortisation → 55,300 €/year.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation&lt;/th>
&lt;th>Opex/year (base, €)&lt;/th>
&lt;th>Total cost/year (€)&lt;/th>
&lt;th>Useful GPU-hours/year&lt;/th>
&lt;th>&lt;strong>€/GPU-hour&lt;/strong>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>30 %&lt;/td>
&lt;td>28,000&lt;/td>
&lt;td>83,300&lt;/td>
&lt;td>10,512&lt;/td>
&lt;td>&lt;strong>7.93&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>28,000&lt;/td>
&lt;td>83,300&lt;/td>
&lt;td>17,520&lt;/td>
&lt;td>&lt;strong>4.75&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70 %&lt;/td>
&lt;td>28,000&lt;/td>
&lt;td>83,300&lt;/td>
&lt;td>24,528&lt;/td>
&lt;td>&lt;strong>3.39&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>28,000&lt;/td>
&lt;td>83,300&lt;/td>
&lt;td>28,032&lt;/td>
&lt;td>&lt;strong>2.97&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>28,000&lt;/td>
&lt;td>83,300&lt;/td>
&lt;td>35,040&lt;/td>
&lt;td>&lt;strong>2.38&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Low-opex scenario&lt;/strong> (solar PPA, own datacenter, large cluster): opex/year ~13,000 €.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation&lt;/th>
&lt;th>Total cost/year (€)&lt;/th>
&lt;th>&lt;strong>€/GPU-hour&lt;/strong>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>68,300&lt;/td>
&lt;td>&lt;strong>3.90&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70 %&lt;/td>
&lt;td>68,300&lt;/td>
&lt;td>&lt;strong>2.78&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>68,300&lt;/td>
&lt;td>&lt;strong>2.43&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>68,300&lt;/td>
&lt;td>&lt;strong>1.95&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>High-opex scenario&lt;/strong> (market tariff, expensive colocation, small cluster): opex/year ~75,000 €.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation&lt;/th>
&lt;th>Total cost/year (€)&lt;/th>
&lt;th>&lt;strong>€/GPU-hour&lt;/strong>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>130,300&lt;/td>
&lt;td>&lt;strong>7.44&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70 %&lt;/td>
&lt;td>130,300&lt;/td>
&lt;td>&lt;strong>5.31&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>130,300&lt;/td>
&lt;td>&lt;strong>4.65&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>130,300&lt;/td>
&lt;td>&lt;strong>3.72&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="from-gpu-hour-to-1m-tokens">From €/GPU-hour to €/1M tokens&lt;/h2>
&lt;p>The cost-per-token identity connects the hardware cost with the inference cost:&lt;/p>
$$\text{EUR/1M tokens} = \frac{\text{EUR/GPU-hour} \times 10^6}{\text{throughput (tok/s)} \times 3{,}600}$$
&lt;p>For reference throughputs on H100 SXM5 with vLLM (see &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning for on-premise inference&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Typical throughput (tok/s per GPU)&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama-3 70B FP8, high batch&lt;/td>
&lt;td>~2,800&lt;/td>
&lt;td>Series B benchmarks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama-3 8B FP16, medium batch&lt;/td>
&lt;td>~9,000&lt;/td>
&lt;td>Series B benchmarks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8×7B, high batch&lt;/td>
&lt;td>~4,500&lt;/td>
&lt;td>Series B benchmarks&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>€/1M tokens table in the base scenario (€/GPU-hour 3.39 at 70 % utilisation):&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Throughput (tok/s)&lt;/th>
&lt;th>€/1M tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama-3 70B FP8&lt;/td>
&lt;td>2,800&lt;/td>
&lt;td>&lt;strong>~0.336&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama-3 8B FP16&lt;/td>
&lt;td>9,000&lt;/td>
&lt;td>&lt;strong>~0.105&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8×7B&lt;/td>
&lt;td>4,500&lt;/td>
&lt;td>&lt;strong>~0.209&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>At 50 % utilisation (€/GPU-hour 4.75):&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>€/1M tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama-3 70B FP8&lt;/td>
&lt;td>&lt;strong>~0.471&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama-3 8B FP16&lt;/td>
&lt;td>&lt;strong>~0.147&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Occupancy (batching) multiplies the effective throughput and lowers the €/1M tokens without changing the hardware; it is analysed in &lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="break-even-on-prem-vs-cloud">Break-even on-prem vs cloud&lt;/h2>
&lt;h3 id="the-break-even-formula">The break-even formula&lt;/h3>
&lt;p>Break-even happens when the total annual on-prem cost equals the annual cloud cost at the same utilisation:&lt;/p>
$$\text{annual cloud cost} = \text{cloud GPU-hour price} \times 4\,\text{GPUs} \times 8{,}760\,\text{h} \times u$$
$$\text{break-even}: \quad \frac{\text{capex/year} + \text{opex/year}}{4 \times 8{,}760 \times u} = \text{cloud GPU-hour price}$$
&lt;p>Solving for the break-even utilisation:&lt;/p>
$$u^* = \frac{\text{capex/year} + \text{opex/year}}{4 \times 8{,}760 \times \text{cloud GPU-hour price}}$$
&lt;h3 id="break-even-table-by-cloud-mode-and-on-prem-scenario">Break-even table by cloud mode and on-prem scenario&lt;/h3>
&lt;p>&lt;strong>Base on-prem scenario&lt;/strong> (capex/year 55,300 €, opex/year 28,000 €, total 83,300 €/year per 4-GPU node):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cloud reference (price/GPU-hour)&lt;/th>
&lt;th>USD equiv.&lt;/th>
&lt;th>Break-even utilisation \(u^*\)&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Neocloud on-demand (Lambda/Spheron ~2.90 USD)&lt;/td>
&lt;td>2.90 USD (~2.70 €)&lt;/td>
&lt;td>&lt;strong>&amp;gt;100 %&lt;/strong>, on-prem does not compete&lt;/td>
&lt;td>Neocloud on-demand is cheaper even at full utilisation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Neocloud 3-year reserved (CoreWeave ~1.49–2.10 USD)&lt;/td>
&lt;td>~1.80 USD (~1.67 €)&lt;/td>
&lt;td>&lt;strong>&amp;gt;100 %&lt;/strong>, impossible&lt;/td>
&lt;td>Neocloud reserved beats on-prem in every scenario of this model&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS p5 on-demand (6.88 USD/GPU-hour)&lt;/td>
&lt;td>6.88 USD (~6.40 €)&lt;/td>
&lt;td>&lt;strong>~47 %&lt;/strong>&lt;/td>
&lt;td>Above 47 %, the average on-prem beats AWS on-demand&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS p5 3-year reserved (~2.97 USD/GPU-hour)&lt;/td>
&lt;td>2.97 USD (~2.76 €)&lt;/td>
&lt;td>&lt;strong>&amp;gt;100 %&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GCP A3 on-demand (~10.98 USD/GPU-hour)&lt;/td>
&lt;td>10.98 USD (~10.21 €)&lt;/td>
&lt;td>&lt;strong>~29 %&lt;/strong>&lt;/td>
&lt;td>Above 29 %, on-prem beats GCP on-demand&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Azure ND H100 v5 on-demand (~12.29 USD/GPU-hour)&lt;/td>
&lt;td>12.29 USD (~11.43 €)&lt;/td>
&lt;td>&lt;strong>~26 %&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Low-opex scenario&lt;/strong> (total 68,300 €/year):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cloud reference&lt;/th>
&lt;th>Break-even utilisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>AWS p5 on-demand (6.88 USD ≈ 6.40 €)&lt;/td>
&lt;td>&lt;strong>~38 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Neocloud on-demand (2.90 USD ≈ 2.70 €)&lt;/td>
&lt;td>&lt;strong>~91 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Neocloud 3-yr reserved (1.80 USD ≈ 1.67 €)&lt;/td>
&lt;td>&lt;strong>&amp;gt;100 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>High-opex scenario&lt;/strong> (total 130,300 €/year):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Cloud reference&lt;/th>
&lt;th>Break-even utilisation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>AWS p5 on-demand (6.88 USD ≈ 6.40 €)&lt;/td>
&lt;td>&lt;strong>~72 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GCP A3 on-demand (~10.21 €)&lt;/td>
&lt;td>&lt;strong>~45 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Azure on-demand (~11.43 €)&lt;/td>
&lt;td>&lt;strong>~41 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="reading-the-break-even-table">Reading the break-even table&lt;/h3>
&lt;ul>
&lt;li>Against &lt;strong>neoclouds&lt;/strong> (on-demand or reserved), on-prem TCO &lt;strong>does not reach break-even&lt;/strong> in any scenario of the base model. Neocloud reserved beats on-prem even at 100 % utilisation, because its hourly price is below the all-in cost of your own hardware. This is consistent with the analysis in &lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">cloud GPU: on-demand, reserved and spot&lt;/a>.&lt;/li>
&lt;li>Against &lt;strong>on-demand hyperscalers&lt;/strong> (AWS, GCP, Azure), on-prem does have an achievable break-even: around &lt;strong>26–72 %&lt;/strong> utilisation depending on the scenario. At medium-high utilisation (&amp;gt;70 %), on-prem clearly beats AWS/GCP/Azure on-demand.&lt;/li>
&lt;li>The variable that moves the break-even most is &lt;strong>opex&lt;/strong> (staff above all), not hardware capex. A well sized cluster in cheap colocation with PPA energy can lower the threshold by 20 percentage points compared with the high scenario.&lt;/li>
&lt;li>For GDPR data, the break-even against US hyperscalers is skewed: the sovereignty axis rules out US hyperscalers before cost does (see &lt;a href="https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/">sovereign on-premise vs hyperscalers&lt;/a>).&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:760px;margin:1rem auto;">
&lt;svg viewBox="0 0 760 260" role="img" aria-label="Break-even on-prem vs cloud by utilisation: base on-prem crosses AWS on-demand at 47 per cent, GCP at 29 per cent and Azure at 26 per cent utilisation. Neoclouds have no feasible break-even." xmlns="http://www.w3.org/2000/svg">
&lt;style>.ax{fill:none;stroke:currentColor;stroke-width:1}.cv{fill:none;stroke:currentColor;stroke-width:1.8}.dsh{fill:none;stroke:currentColor;stroke-width:1.2;stroke-dasharray:5 3}.ts{font:11px sans-serif;fill:currentColor}.tl{font:600 11.5px sans-serif;fill:currentColor}&lt;/style>
&lt;line class="ax" x1="60" y1="30" x2="60" y2="210"/>
&lt;line class="ax" x1="60" y1="210" x2="730" y2="210"/>
&lt;text x="22" y="125" class="ts" transform="rotate(-90 22 125)">€/GPU-hour&lt;/text>
&lt;text x="350" y="235" class="ts">utilisation (%) →&lt;/text>
&lt;text x="65" y="226" class="ts">0&lt;/text>
&lt;text x="220" y="226" class="ts">30&lt;/text>
&lt;text x="375" y="226" class="ts">60&lt;/text>
&lt;text x="535" y="226" class="ts">90&lt;/text>
&lt;text x="700" y="226" class="ts">100&lt;/text>
&lt;line class="ax" x1="220" y1="207" x2="220" y2="213"/>
&lt;line class="ax" x1="375" y1="207" x2="375" y2="213"/>
&lt;line class="ax" x1="535" y1="207" x2="535" y2="213"/>
&lt;line class="ax" x1="700" y1="207" x2="700" y2="213"/>
&lt;path class="cv" d="M80,40 C150,65 250,110 375,148 C480,178 600,195 710,203"/>
&lt;text x="82" y="36" class="tl">on-prem (fixed capex)&lt;/text>
&lt;line class="cv" x1="60" y1="155" x2="730" y2="155"/>
&lt;text x="632" y="149" class="ts">AWS p5 OD (~6.40 €)&lt;/text>
&lt;line class="cv" x1="60" y1="190" x2="730" y2="190"/>
&lt;text x="620" y="186" class="ts">GCP OD (~10.21 €)&lt;/text>
&lt;line class="cv" x1="60" y1="200" x2="730" y2="200"/>
&lt;text x="620" y="198" class="ts">Azure OD (~11.43 €)&lt;/text>
&lt;line class="cv" x1="60" y1="95" x2="730" y2="95"/>
&lt;text x="632" y="91" class="ts">neocloud OD (~2.70 €)&lt;/text>
&lt;line class="dsh" x1="310" y1="30" x2="310" y2="210"/>
&lt;text x="272" y="48" class="tl">≈47 % (AWS)&lt;/text>
&lt;line class="dsh" x1="187" y1="30" x2="187" y2="210"/>
&lt;text x="148" y="48" class="ts">≈29 % (GCP)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="sensitivity-analysis">Sensitivity analysis&lt;/h2>
&lt;h3 id="tco-vs-utilisation">TCO vs utilisation&lt;/h3>
&lt;p>The all-in cost per GPU-hour varies inversely with utilisation because capex is fixed:&lt;/p>
$$\frac{d(\text{EUR/GPU-hour})}{du} = -\frac{\text{capex/year} + \text{opex/year}}{4 \times 8{,}760 \times u^2} &lt; 0$$
&lt;p>Going from 50 % to 80 % utilisation cuts the €/GPU-hour by \(\frac{4.75 - 2.97}{4.75} \approx 37\,\%\) in the base scenario. That 37 % reduction requires no hardware change, only more efficient scheduling (see &lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a>).&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation&lt;/th>
&lt;th>€/GPU-hour (base scenario)&lt;/th>
&lt;th>Change vs 50 %&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>30 %&lt;/td>
&lt;td>7.93&lt;/td>
&lt;td>+67 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>4.75&lt;/td>
&lt;td>reference&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>70 %&lt;/td>
&lt;td>3.39&lt;/td>
&lt;td>−29 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>2.97&lt;/td>
&lt;td>−37 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>2.38&lt;/td>
&lt;td>−50 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="tco-vs-energy-price">TCO vs energy price&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Energy price (€/kWh)&lt;/th>
&lt;th>Energy opex/year&lt;/th>
&lt;th>€/GPU-hour (70 % util.)&lt;/th>
&lt;th>Change vs base&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0.034 (solar PPA)&lt;/td>
&lt;td>1,604 €&lt;/td>
&lt;td>&lt;strong>3.00&lt;/strong>&lt;/td>
&lt;td>−12 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.116 (industrial ES, base)&lt;/td>
&lt;td>5,475 €&lt;/td>
&lt;td>&lt;strong>3.39&lt;/strong>&lt;/td>
&lt;td>reference&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.160 (European average)&lt;/td>
&lt;td>7,550 €&lt;/td>
&lt;td>&lt;strong>3.54&lt;/strong>&lt;/td>
&lt;td>+4 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.200 (high tariff)&lt;/td>
&lt;td>9,437 €&lt;/td>
&lt;td>&lt;strong>3.67&lt;/strong>&lt;/td>
&lt;td>+8 %&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Energy has a &lt;strong>moderate&lt;/strong> impact on total TCO (8–12 % variation between the extremes), because hardware capex dominates. Over a very long amortisation (5 years) with a solar PPA, however, energy drops from 6 % to 1 % of total TCO and the differential is amplified. The price of energy matters more for the carbon footprint (CSRD) than for TCO when capex is dominant.&lt;/p>
&lt;h3 id="tco-vs-pue">TCO vs PUE&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>PUE&lt;/th>
&lt;th>Cooling overhead&lt;/th>
&lt;th>Energy/year (0.116 €/kWh)&lt;/th>
&lt;th>€/GPU-hour (70 % util.)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1.15 (liquid cooling, new facilities)&lt;/td>
&lt;td>+15 %&lt;/td>
&lt;td>2,166 €&lt;/td>
&lt;td>&lt;strong>3.21&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.20 (liquid, modern datacenter)&lt;/td>
&lt;td>+20 %&lt;/td>
&lt;td>2,259 €&lt;/td>
&lt;td>&lt;strong>3.23&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.48 (facilities &amp;lt;5 years, Uptime 2025)&lt;/td>
&lt;td>+48 %&lt;/td>
&lt;td>3,490 €&lt;/td>
&lt;td>&lt;strong>3.33&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.54 (global average, Uptime 2025)&lt;/td>
&lt;td>+54 %&lt;/td>
&lt;td>3,627 €&lt;/td>
&lt;td>&lt;strong>3.39&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.80 (legacy colocation)&lt;/td>
&lt;td>+80 %&lt;/td>
&lt;td>4,260 €&lt;/td>
&lt;td>&lt;strong>3.47&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The difference between PUE 1.15 (liquid) and 1.80 (legacy) is barely &lt;strong>~8 %&lt;/strong> of the €/GPU-hour at 70 % utilisation, because energy is only a fraction of TCO. PUE matters far more for the &lt;strong>absolute energy cost&lt;/strong> and for &lt;strong>CSRD reporting&lt;/strong> than for total TCO when hardware is the dominant component.&lt;/p>
&lt;h3 id="tco-vs-amortisation-years">TCO vs amortisation years&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Amortisation&lt;/th>
&lt;th>Capex/year (average node, USD)&lt;/th>
&lt;th>€/GPU-hour (70 % util., base opex scenario)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>3 years&lt;/td>
&lt;td>59,500 USD (~55,300 €)&lt;/td>
&lt;td>&lt;strong>3.39&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4 years&lt;/td>
&lt;td>44,625 USD (~41,500 €)&lt;/td>
&lt;td>&lt;strong>2.99&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5 years&lt;/td>
&lt;td>35,700 USD (~33,200 €)&lt;/td>
&lt;td>&lt;strong>2.72&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Stretching the amortisation from 3 to 5 years lowers the €/GPU-hour by ~&lt;strong>20 %&lt;/strong>, assuming the hardware remains competitive and the resale market supports the residual value. With the refresh cycle accelerated by Blackwell GB200/GB300, a 5-year amortisation carries a higher risk of technological obsolescence.&lt;/p>
&lt;h3 id="sensitivity-heat-map-gpu-hour-at-70--utilisation-base-scenario">Sensitivity heat map (€/GPU-hour at 70 % utilisation, base scenario)&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>PUE 1.15&lt;/th>
&lt;th>PUE 1.54&lt;/th>
&lt;th>PUE 1.80&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>3-yr amort., solar PPA (0.034 €)&lt;/strong>&lt;/td>
&lt;td>2.72&lt;/td>
&lt;td>2.74&lt;/td>
&lt;td>2.76&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>3-yr amort., industrial (0.116 €)&lt;/strong>&lt;/td>
&lt;td>3.21&lt;/td>
&lt;td>3.39&lt;/td>
&lt;td>3.47&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>5-yr amort., industrial (0.116 €)&lt;/strong>&lt;/td>
&lt;td>2.54&lt;/td>
&lt;td>2.72&lt;/td>
&lt;td>2.80&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>3-yr amort., high tariff (0.200 €)&lt;/strong>&lt;/td>
&lt;td>3.44&lt;/td>
&lt;td>3.67&lt;/td>
&lt;td>3.78&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="decision-table-costcontrolsovereignty-pareto">Decision table: cost/control/sovereignty Pareto&lt;/h2>
&lt;p>The table below crosses the four dimensions with no implicit hierarchy; the ordinal reading depends on each organisation&amp;rsquo;s constraints.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>€/GPU-hour&lt;/th>
&lt;th>Initial capex&lt;/th>
&lt;th>Full stack control&lt;/th>
&lt;th>EU sovereignty&lt;/th>
&lt;th>Elasticity&lt;/th>
&lt;th>Operational risk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>On-prem (util. &amp;gt;70 %, low opex)&lt;/strong>&lt;/td>
&lt;td>2.40–3.00&lt;/td>
&lt;td>high (150–207k USD/node)&lt;/td>
&lt;td>total&lt;/td>
&lt;td>total&lt;/td>
&lt;td>none&lt;/td>
&lt;td>hardware failure, idle&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>On-prem (util. &amp;lt;50 %, base opex)&lt;/strong>&lt;/td>
&lt;td>4.75–7.93&lt;/td>
&lt;td>high&lt;/td>
&lt;td>total&lt;/td>
&lt;td>total&lt;/td>
&lt;td>none&lt;/td>
&lt;td>capex with no return&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Neocloud 3-year reserved (CoreWeave, Lambda)&lt;/strong>&lt;/td>
&lt;td>1.49–2.10 USD&lt;/td>
&lt;td>none&lt;/td>
&lt;td>partial (API)&lt;/td>
&lt;td>depends on the provider&lt;/td>
&lt;td>rigid contract&lt;/td>
&lt;td>minimal interruption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Neocloud on-demand (Lambda, Spheron)&lt;/strong>&lt;/td>
&lt;td>2.49–3.44 USD&lt;/td>
&lt;td>none&lt;/td>
&lt;td>partial&lt;/td>
&lt;td>depends&lt;/td>
&lt;td>total&lt;/td>
&lt;td>no interruption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AWS p5 on-demand&lt;/strong>&lt;/td>
&lt;td>6.88 USD&lt;/td>
&lt;td>none&lt;/td>
&lt;td>minimal&lt;/td>
&lt;td>NO (CLOUD Act)&lt;/td>
&lt;td>total&lt;/td>
&lt;td>no interruption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>AWS p5 3-year reserved&lt;/strong>&lt;/td>
&lt;td>~2.97 USD&lt;/td>
&lt;td>financial commitment&lt;/td>
&lt;td>minimal&lt;/td>
&lt;td>NO (CLOUD Act)&lt;/td>
&lt;td>rigid&lt;/td>
&lt;td>no interruption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sovereign EU cloud (Scaleway, Nebius EU)&lt;/strong>&lt;/td>
&lt;td>2.15–3.85 USD&lt;/td>
&lt;td>none&lt;/td>
&lt;td>partial&lt;/td>
&lt;td>yes (EU)&lt;/td>
&lt;td>total&lt;/td>
&lt;td>no interruption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hybrid on-prem base + EU cloud peak&lt;/strong>&lt;/td>
&lt;td>2.00–3.50 (weighted)&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>high&lt;/td>
&lt;td>yes (EU)&lt;/td>
&lt;td>elastic peak&lt;/td>
&lt;td>operational complexity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&amp;ldquo;EU sovereignty&amp;rdquo; column: US hyperscalers (AWS, GCP, Azure) are subject to the US CLOUD Act regardless of the datacenter region. Nebius has a Dutch legal entity; CoreWeave is a US company. See the full analysis in &lt;a href="https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/">sovereign on-premise vs hyperscalers&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;blockquote>
&lt;p>&amp;ldquo;Full stack control&amp;rdquo; column: on-prem lets you choose the driver version, the kernel, the NCCL configuration, MIG partitioning, and any system parameter. Cloud options offer control at container/pod level, with the hypervisor and firmware opaque.&lt;/p>
&lt;/blockquote>
&lt;p>The cost/sovereignty Pareto frontier for GDPR data excludes US hyperscalers, leaving: &lt;strong>on-prem&lt;/strong>, &lt;strong>sovereign EU cloud&lt;/strong>, and the &lt;strong>hybrid&lt;/strong>. Among those three, the deciding variable is sustained utilisation and the predictability of traffic (see &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning for on-premise LLM inference&lt;/a>).&lt;/p>
&lt;hr>
&lt;h2 id="integration-with-the-series-finops-model">Integration with the series FinOps model&lt;/h2>
&lt;p>The all-in €/GPU-hour of on-prem is the number that feeds the cost allocation pipeline of the series:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Cost-per-token identity&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">cost per token and per request&lt;/a>): engine throughput × €/GPU-hour → €/1M tokens.&lt;/li>
&lt;li>&lt;strong>Chargeback and showback&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/">chargeback and showback in GPU multitenancy&lt;/a>): the all-in €/GPU-hour is the internal price charged to each tenant of the multi-tenant cluster.&lt;/li>
&lt;li>&lt;strong>Utilisation as a lever&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as FinOps&lt;/a>): raising utilisation from 50 % to 80 % cuts the €/GPU-hour by 37 % without changing the hardware, the highest ROI in on-prem FinOps.&lt;/li>
&lt;li>&lt;strong>Capacity planning&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning for on-premise LLM inference&lt;/a>): the number of nodes to buy depends on the percentile of base load you want to cover in iron.&lt;/li>
&lt;li>&lt;strong>Cloud comparison&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">cloud GPU: on-demand, reserved and spot&lt;/a>): the all-in €/GPU-hour goes head to head with the cloud price in table A7 to compute the break-even.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Spheron · LLM Inference On-Premise vs GPU Cloud: 2026 Cost and Break-Even Analysis (abr. 2026) — &lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/&lt;/a>&lt;/li>
&lt;li>Introl · GPU Infrastructure TCO Model: 5-Year Cost Analysis for Enterprise AI (abr. 2026) — &lt;a href="https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model">https://introl.com/blog/gpu-infrastructure-tco-5-year-cost-model&lt;/a>&lt;/li>
&lt;li>GMI Cloud · NVIDIA H100 GPU Pricing: 2026 Rent vs. Buy Cost Analysis (abr. 2026) — &lt;a href="https://www.gmicloud.ai/en/blog/nvidia-h100-gpu-pricing-2026-rent-vs-buy-cost-analysis">https://www.gmicloud.ai/en/blog/nvidia-h100-gpu-pricing-2026-rent-vs-buy-cost-analysis&lt;/a>&lt;/li>
&lt;li>Uptime Institute · Global Data Center Survey 2025 (PDF oficial) — &lt;a href="https://datacenter.uptimeinstitute.com/rs/711-RIA-145/images/2025.Annual.Survey.Report.pdf">https://datacenter.uptimeinstitute.com/rs/711-RIA-145/images/2025.Annual.Survey.Report.pdf&lt;/a>&lt;/li>
&lt;li>Uptime Institute · Global Data Center PUE Stalls at 1.54 (comunicado, oct. 2025) — &lt;a href="https://mgrid.org/2025/10/01/uptime-institute-data-center-pue-stagnation-2025-liquid-cooling/">https://mgrid.org/2025/10/01/uptime-institute-data-center-pue-stagnation-2025-liquid-cooling/&lt;/a>&lt;/li>
&lt;li>GlobalPetrolPrices · Spain Business Electricity Price (sept. 2025) — &lt;a href="https://www.globalpetrolprices.com/Spain/electricity_prices/">https://www.globalpetrolprices.com/Spain/electricity_prices/&lt;/a>&lt;/li>
&lt;li>PV Tech · European Solar PPA Prices Fall Below 35 €/MWh in Q3 2025 (oct. 2025) — &lt;a href="https://www.pv-tech.org/european-solar-ppa-prices-fall-below-35-mwh-q3-2025/">https://www.pv-tech.org/european-solar-ppa-prices-fall-below-35-mwh-q3-2025/&lt;/a>&lt;/li>
&lt;li>Xi Computers · Supermicro SYS-821GE-TNHR 8U GPU Server Pricing (jun. 2026) — &lt;a href="https://www.xicomputer.com/Solutions/Data-Center-Servers/SYS-821GE-TNHR.asp">https://www.xicomputer.com/Solutions/Data-Center-Servers/SYS-821GE-TNHR.asp&lt;/a>&lt;/li>
&lt;li>Encoradvisors · Data Center Colocation Pricing 2026 — &lt;a href="https://encoradvisors.com/data-center-colocation-pricing/">https://encoradvisors.com/data-center-colocation-pricing/&lt;/a>&lt;/li>
&lt;li>CloudZero · H100 GPU Cost In 2026: Buy, Rent, and Cloud Pricing Compared — &lt;a href="https://www.cloudzero.com/blog/h100-gpu-cost/">https://www.cloudzero.com/blog/h100-gpu-cost/&lt;/a>&lt;/li>
&lt;li>Spheron · AI Inference Power Consumption and GPU Electricity Costs: 2026 Guide — &lt;a href="https://www.spheron.network/blog/ai-inference-power-electricity-cost-2026/">https://www.spheron.network/blog/ai-inference-power-electricity-cost-2026/&lt;/a>&lt;/li>
&lt;li>NVIDIA · DGX SuperPOD H100 Electrical Specifications (documentación oficial) — &lt;a href="https://docs.nvidia.com/dgx-superpod/design-guides/dgx-superpod-data-center-design-h100/latest/electrical.html">https://docs.nvidia.com/dgx-superpod/design-guides/dgx-superpod-data-center-design-h100/latest/electrical.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Sovereign on-premise vs hyperscalers: the case with data (cost, energy, performance and sovereignty)</title><link>https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/</link><pubDate>Sun, 14 Jun 2026 05:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point; when a source quotes dollars it is marked
&amp;ldquo;USD&amp;rdquo;. Data centred on Europe and Spain. The dollar symbol is not used (formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-article-covers">What this article covers&lt;/h2>
&lt;p>&lt;strong>Synthesis&lt;/strong> article of the series (S2), and the heart of the proposal: the case with data for
sovereign on-premise against the hyperscalers and the European cloud. Up to here, each track
measured its own axis, cost per token (FinOps), goodput (benchmarking), energy and carbon; here the
four are crossed, with a fourth dimension no US technical comparison puts up front: &lt;strong>data
sovereignty&lt;/strong>. The aim is to answer, with numbers and not with ideology, the question that underpins
any investment in an AI platform: &lt;strong>serve on your own iron, on a European cloud or on a
hyperscaler?&lt;/strong> And to do it honestly: on-prem &lt;strong>does not always win&lt;/strong>, and saying when it wins and
when it does not is what makes the recommendation credible.&lt;/p>
&lt;hr>
&lt;h2 id="the-framework-four-axes-not-one-number">The framework: four axes, not one number&lt;/h2>
&lt;p>The mistake in almost every comparison is reducing the decision to the hourly cost of a GPU. The real
decision crosses &lt;strong>four axes&lt;/strong>, and only by seeing them together do you decide well:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>Question&lt;/th>
&lt;th>Who measures it in the series&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cost (TCO)&lt;/td>
&lt;td>how much does it cost to serve, all in?&lt;/td>
&lt;td>FinOps (A2–A8)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Performance&lt;/td>
&lt;td>does it meet the SLO, at what goodput?&lt;/td>
&lt;td>Benchmarking (B2–B8)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy and carbon&lt;/td>
&lt;td>how many watts and grams per token?&lt;/td>
&lt;td>Energy (C2–C8)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sovereignty&lt;/td>
&lt;td>under which jurisdiction does the data live?&lt;/td>
&lt;td>GDPR / EU AI Act / CSRD&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The first three are quantifiable and meet in the cost per token; the fourth is a &lt;strong>constraint&lt;/strong>
that can rule out an option however cheap it is. The synthesis consists of scoring each option on all
four and deciding on the Pareto frontier, not on whichever axis happens to suit.&lt;/p>
&lt;hr>
&lt;h2 id="the-cost-axis-tco-and-break-even">The cost axis: TCO and break-even&lt;/h2>
&lt;p>The real on-premise cost is amortised capex plus opex, and its cost per effective hour depends on
&lt;strong>utilisation&lt;/strong>:&lt;/p>
$$\text{effective cost/GPU-hour (on-prem)} = \frac{\text{annual amortised capex} + \text{annual opex}}{8760 \times \text{utilisation}}$$
&lt;p>This formula is the key to the whole debate: &lt;strong>the cost per useful hour of on-prem rises as
utilisation falls&lt;/strong>, because capex is paid whether the GPU is working or idle. The 2026 data:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Data point&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>On-prem cost 8×H100 (floor, high util.)&lt;/td>
&lt;td>~2.83 USD/GPU-hour &lt;em>all-in&lt;/em>&lt;/td>
&lt;td>&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Annual on-prem cost (floor)&lt;/td>
&lt;td>~237,000 USD/year&lt;/td>
&lt;td>&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS H100 (p5.48xlarge)&lt;/td>
&lt;td>4.10–6.88 USD/GPU-hour&lt;/td>
&lt;td>&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS 8-GPU on-demand annual (100 % util)&lt;/td>
&lt;td>287,000–482,000 USD/year&lt;/td>
&lt;td>&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sovereign European cloud (Lyceum/Scaleway)&lt;/td>
&lt;td>from 2–2.73 €/GPU-hour, zero-egress&lt;/td>
&lt;td>&lt;a href="https://lyceum.technology/magazine/eu-sovereign-inference-platform-comparison/">Lyceum&lt;/a>, &lt;a href="https://www.scaleway.com/en/h100/">Scaleway&lt;/a>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>break-even&lt;/strong> against AWS on-demand falls around 50–83 % utilisation depending on region and
tariff; &lt;strong>below ~70 % utilisation, the cloud wins&lt;/strong> on TCO, and above it, on-prem
(&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron&lt;/a>). For very
high-utilisation workloads, on-prem pays for itself in less than 4 months (&lt;a href="https://lenovopress.lenovo.com/lp2368-on-premise-vs-cloud-generative-ai-total-cost-of-ownership-2026-edition">Lenovo&lt;/a>).&lt;/p>
&lt;h3 id="three-year-tco-the-full-calculation-for-an-8h100-node">Three-year TCO: the full calculation for an 8×H100 node&lt;/h3>
&lt;p>Abstract numbers do not convince a committee; a three-year model with declared line items does. Take a
sovereign 8×H100 node in Spain and compare it, for the same work, with AWS and with a European
cloud. Declared assumptions: amortisation over 3 years, energy at a solar PPA of 32.5 €/MWh
(with grid backup), PUE 1.3, and two utilisation scenarios (50 % and 80 %).&lt;/p>
&lt;p>&lt;strong>On-premise (own node), annual line items:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Line item&lt;/th>
&lt;th>Annual value&lt;/th>
&lt;th>Note&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Amortised capex (node ~270,000 € / 3 years)&lt;/td>
&lt;td>~90,000 €&lt;/td>
&lt;td>8×H100 server + network + storage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy (≈10.4 kW × PUE 1.3 × 8760 h)&lt;/td>
&lt;td>~3,850 € (at 32.5 €/MWh)&lt;/td>
&lt;td>with solar PPA; at grid tariff, ~12–18 k €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Operation, cooling, maintenance&lt;/td>
&lt;td>~25,000 €&lt;/td>
&lt;td>prorated staff, support, spares&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Datacenter space (rack, connectivity)&lt;/td>
&lt;td>~12,000 €&lt;/td>
&lt;td>colocation or own datacenter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Annual total&lt;/td>
&lt;td>~131,000 €&lt;/td>
&lt;td>independent of utilisation&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>At a fixed 131,000 €/year, the cost per token depends only on how many tokens you generate,
that is, on utilisation:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Utilisation&lt;/th>
&lt;th>Useful GPU-hours/year&lt;/th>
&lt;th>Cost/1M tokens&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>~35,000&lt;/td>
&lt;td>~2.9 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>65 %&lt;/td>
&lt;td>~45,500&lt;/td>
&lt;td>~2.2 €&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>~56,000&lt;/td>
&lt;td>~1.8 € (with cheap grid, ~1.1 €)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Sovereign European cloud (Scaleway/Lyceum), pay per use:&lt;/strong> at ~2.2 €/GPU-hour with zero-egress, the
cost per token is &lt;strong>constant with utilisation&lt;/strong> (you only pay for what you use): ~1.5–2.2 €/1M tokens
depending on model and batching, with no capex and no idle risk.&lt;/p>
&lt;p>&lt;strong>Hyperscaler (AWS p5), on-demand:&lt;/strong> at 4.10–6.88 USD/GPU-hour (≈3.8–6.4 €), the cost per token works
out at ~2–3.5 €/1M tokens, and on top of that you have to add the egress, quite apart from the
fact that for GDPR data the sovereignty axis rules it out already.&lt;/p>
&lt;p>The reading of the model is the thesis of the whole of S2: &lt;strong>at 50 % utilisation, on-prem (~2.9 €) does
not beat the European cloud (~1.8 €); at 80 % with cheap energy (~1.1 €), it beats it comfortably&lt;/strong>. The
crossover sits, as the literature says, around 65–70 %. Investing in on-prem is, fundamentally, a
bet that you will sustain high utilisation, and that bet is won with scheduling, not with hardware.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="Break-even on-prem vs cloud by utilisation: the cost per useful hour of on-prem falls with utilisation and crosses that of the cloud at around 65-70 per cent" xmlns="http://www.w3.org/2000/svg">
&lt;style>.ax{fill:none;stroke:currentColor;stroke-width:1}.cv{fill:none;stroke:currentColor;stroke-width:1.6}.dsh{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}&lt;/style>
&lt;line class="ax" x1="60" y1="40" x2="60" y2="200"/>
&lt;line class="ax" x1="60" y1="200" x2="720" y2="200"/>
&lt;text x="20" y="130" class="ts" transform="rotate(-90 20 130)">cost/useful hour&lt;/text>
&lt;text x="330" y="228" class="ts">utilisation (%) →&lt;/text>
&lt;path class="cv" d="M90,55 C200,90 320,130 430,150 C540,165 640,172 700,175"/>
&lt;text x="95" y="50" class="ts">on-prem (fixed capex)&lt;/text>
&lt;line class="cv" x1="60" y1="150" x2="720" y2="150"/>
&lt;text x="600" y="143" class="ts">cloud (≈ flat)&lt;/text>
&lt;line class="dsh" x1="430" y1="40" x2="430" y2="200"/>
&lt;text x="392" y="56" class="tl">break-even ~65-70%&lt;/text>
&lt;text x="110" y="190" class="ts">low util.: cloud wins&lt;/text>
&lt;text x="520" y="190" class="ts">high util.: on-prem wins&lt;/text>
&lt;text x="60" y="245" class="ts">On-prem only wins to the right of the break-even; fixed capex penalises it at low utilisation.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="the-uncomfortable-reality-the-utilisation-almost-nobody-reaches">The uncomfortable reality: the utilisation almost nobody reaches&lt;/h2>
&lt;p>Here is the honest figure missing from the &amp;ldquo;on-prem is always cheaper&amp;rdquo; speeches: &lt;strong>most inference teams
in production operate at 40–65 % GPU utilisation&lt;/strong>, because of traffic variability and the limits of
batching; the 80–90 % assumption that makes on-prem attractive &lt;strong>is rarely reached outside batch-only
pipelines&lt;/strong> (&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron&lt;/a>).&lt;/p>
&lt;p>This changes the naive conclusion: if your real utilisation is 50 %, on-prem &lt;strong>is not cheaper&lt;/strong> than
the cloud, because the capex you pay for the idle GPU eats you. That is why utilisation is not a
detail, it is &lt;strong>the variable that decides the cost axis&lt;/strong>, and it connects directly with the FinOps
track (the idle of A2, the chargeback of A5) and with scheduling: &lt;strong>raising utilisation is what makes
on-prem pay&lt;/strong>. A badly used cluster of your own is more expensive than the cloud; a well scheduled one
is far cheaper. The cost question is not &amp;ldquo;on-prem or cloud?&amp;rdquo;, it is &amp;ldquo;&lt;strong>can I sustain high
utilisation?&lt;/strong>&amp;rdquo;.&lt;/p>
&lt;hr>
&lt;h2 id="the-hidden-costs-of-the-cloud-egress">The hidden costs of the cloud: egress&lt;/h2>
&lt;p>The cloud has its own small print: &lt;strong>egress costs&lt;/strong> (taking data out of the provider). In the
hyperscalers, moving data out or between regions is billed, and in AI workloads with a lot of data
movement (datasets, checkpoints, embeddings) it can be a significant line item that does not appear in
the GPU-hour price. The advantage of the sovereign European cloud: most of them (Lyceum among
others) have adopted the zero-egress model, they do not charge for moving data out or between
regions (&lt;a href="https://lyceum.technology/magazine/eu-sovereign-inference-platform-comparison/">Lyceum&lt;/a>). When
comparing, the real hyperscaler cost is GPU-hour + egress + other charges, not just the GPU-hour;
ignoring it artificially inflates the hyperscaler&amp;rsquo;s competitiveness.&lt;/p>
&lt;p>An example of the order of magnitude: a platform moving 50 TB/month outbound (datasets,
checkpoints, responses served to systems outside the provider) at a typical egress tariff of
~0.08–0.09 €/GB pays ~4,000–4,500 €/month, that is ~50,000 €/year on egress alone, a line item
the size of a third of the cost of a node of your own, invisible in the GPU-hour price. On the European
cloud with zero-egress that line item is zero; on-prem, internal traffic is not billed either.
That is why a fair comparison must model egress according to the real data pattern: for workloads with
a lot of outbound movement, it can invert the ranking between hyperscaler and European cloud. The
cloud bill is not the GPU-hour; it is the GPU-hour plus everything you move.&lt;/p>
&lt;p>To this is added contract and lock-in risk: the hyperscaler&amp;rsquo;s on-demand GPU tariffs can change,
commitment discounts (reserved/savings plans) tie you for 1–3 years, and migrating out, because of
egress and coupling to proprietary services, has a real exit cost. On-prem and the European cloud with
standard APIs (Kubernetes, S3 compatible) reduce that coupling: the same manifest and the same vLLM run
on your cluster or on Scaleway without rewriting. Operational sovereignty, being able to move the
workload without rebuilding it, is a value that does not appear in the tariff but weighs on a
three-year decision.&lt;/p>
&lt;hr>
&lt;h2 id="the-performance-axis-the-provider-does-not-decide-goodput-does">The performance axis: the provider does not decide, goodput does&lt;/h2>
&lt;p>One point that simplifies the synthesis: &lt;strong>performance does not depend on the provider, it depends on
the hardware and the configuration&lt;/strong>. An H100 gives the same goodput on your cluster, on Scaleway or on
AWS, served with the same vLLM and the same config. What decides performance is the &lt;strong>goodput under
your SLO&lt;/strong> (track B), not who hosts the GPU. So, in a comparison at equal hardware, the performance
axis neutralises itself: what changes between options is cost, energy and sovereignty. The
exception: if a provider gives you access to newer hardware (B200, GB200) ahead of your on-prem buying
cycle, then the cloud can win on performance per GPU, a real argument in favour of the cloud for
staying at the hardware frontier without capex.&lt;/p>
&lt;hr>
&lt;h2 id="the-energy-axis-the-european-and-spanish-advantage">The energy axis: the European and Spanish advantage&lt;/h2>
&lt;p>Here on-prem (or cloud) in Spain or France has a structural advantage over a hyperscaler in a dirty
region. Recalling the data from the energy track:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Location&lt;/th>
&lt;th>Grid carbon (gCO₂/kWh)&lt;/th>
&lt;th>Price (indicative)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>France (nuclear)&lt;/td>
&lt;td>~20–60&lt;/td>
&lt;td>low and stable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Spain (renewables + gas)&lt;/td>
&lt;td>~150–170&lt;/td>
&lt;td>low, volatile; solar PPA ~32.5 €/MWh&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Germany&lt;/td>
&lt;td>~363&lt;/td>
&lt;td>high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hyperscaler (region depends on provider)&lt;/td>
&lt;td>depends; often not selectable&lt;/td>
&lt;td>provider tariff&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The same workload in &lt;strong>France emits ~9× less carbon per token than in Germany&lt;/strong>, and in Spain, with a
solar PPA at 32.5 €/MWh (an all-time low), the electricity cost, 30–50 % of the TCO, is low and,
under contract, predictable. A sovereign cluster in Spain or France controls where the energy is
consumed and with what carbon; a hyperscaler gives you the region it gives you, often with no choice of
grid intensity. For CSRD reporting, that selectability is a quantifiable advantage of on-prem and the
European cloud.&lt;/p>
&lt;p>In concrete numbers: the example 8×H100 node (~10.4 kW × PUE 1.3 ≈ 118,000 kWh/year) emits, depending
on the grid, ~2.4 t CO₂/year in France (~20 gCO₂/kWh) against ~43 t CO₂/year in Germany
(~363 gCO₂/kWh), the same machine, the same work, ~18× the difference in reportable footprint just
by choosing the location. That decision, which a hyperscaler in an imposed region does not let you
take, is exactly what on-prem and the sovereign European cloud put in your hands. The energy axis is
not an environmental detail: it is cost (the price of the kWh), compliance (CSRD) and sovereignty
(control of location) all at once.&lt;/p>
&lt;hr>
&lt;h2 id="the-sovereignty-axis-the-one-that-does-not-depend-on-utilisation">The sovereignty axis: the one that does not depend on utilisation&lt;/h2>
&lt;p>And here is the axis that invalidates the cheapest option if the data is sensitive. US hyperscalers
are subject to the &lt;strong>US CLOUD Act&lt;/strong>: US authorities can demand data held by a US company &lt;strong>even if it
sits in a European datacenter&lt;/strong>. For data subject to the &lt;strong>GDPR&lt;/strong>, that is a compliance risk. &lt;strong>Sovereign
European clouds&lt;/strong> operate under &lt;strong>EU/EFTA jurisdiction&lt;/strong>, providing data residency and GDPR compliance,
and are exempt from the US CLOUD Act
(&lt;a href="https://lyceum.technology/magazine/sovereign-cloud-providers-2026/">Lyceum · sovereign providers&lt;/a>).
On-prem of your own is the maximum degree of sovereignty: the data does not leave your cluster.&lt;/p>
&lt;p>The key difference from the other axes: sovereignty &lt;strong>does not depend on utilisation or on volume&lt;/strong>.
However much a hyperscaler cheapens the GPU-hour, for GDPR data &lt;strong>it is not an option&lt;/strong>, the
jurisdiction risk is not offset by price. It links with &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the ENS × ISO 42001 × EU AI Act controls&lt;/a>
and &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">the EU AI Act mapping&lt;/a>: compliance is a
hard constraint, not an axis to optimise.&lt;/p>
&lt;p>The four instruments that turn sovereignty into a concrete constraint rather than a slogan:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Instrument&lt;/th>
&lt;th>What it requires&lt;/th>
&lt;th>Implication for the architecture&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>US CLOUD Act&lt;/td>
&lt;td>gives the US access to data held by US companies, wherever it is&lt;/td>
&lt;td>a US hyperscaler does not guarantee jurisdictional residency even if the datacenter is in the EU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GDPR&lt;/td>
&lt;td>residency and processing of personal data under EU law&lt;/td>
&lt;td>requires an EU/EFTA provider or your own iron for personal data&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>EU AI Act&lt;/td>
&lt;td>traceability, risk management and records for AI systems&lt;/td>
&lt;td>favours the full control of the stack (logs, datasets, models) that on-prem gives&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CSRD&lt;/td>
&lt;td>verifiable reporting of environmental footprint&lt;/td>
&lt;td>the selectable energy (clean grid, PPA) of on-prem and the European cloud is auditable&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational conclusion: for a European entity processing personal data or deploying high-risk AI,
&lt;strong>three of the four technical axes can favour the hyperscaler and it can still lose&lt;/strong>, because the
fourth axis, sovereignty, acts as a prior filter. That is why S2 orders the decision like this:
&lt;strong>first the sovereignty filter&lt;/strong> (which rules out the hyperscaler for GDPR data), &lt;strong>then the
optimisation of cost, performance and energy&lt;/strong> among the options that pass the filter (sovereign
on-prem and European cloud).&lt;/p>
&lt;hr>
&lt;h2 id="the-scorecard-the-three-options-scored">The scorecard: the three options scored&lt;/h2>
&lt;p>Crossing the four axes for the three realistic options of a European platform (order-of-magnitude,
illustrative figures):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Option&lt;/th>
&lt;th>Cost/1M tok&lt;/th>
&lt;th>Break-even&lt;/th>
&lt;th>Energy/carbon&lt;/th>
&lt;th>Sovereignty&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Sovereign on-prem (ES/FR)&lt;/td>
&lt;td>~1.1 € (high util.) / ~3 € (low)&lt;/td>
&lt;td>&amp;gt;65–70 % util.&lt;/td>
&lt;td>controllable (clean grid, PPA)&lt;/td>
&lt;td>total (EU)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sovereign European cloud&lt;/td>
&lt;td>~1.5–2.2 €&lt;/td>
&lt;td>no capex, pay per use&lt;/td>
&lt;td>EU, zero-egress&lt;/td>
&lt;td>high (EU)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hyperscaler (US)&lt;/td>
&lt;td>~2–3.5 € + egress&lt;/td>
&lt;td>no capex&lt;/td>
&lt;td>imposed region&lt;/td>
&lt;td>not EU (CLOUD Act)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 220" role="img" aria-label="Scorecard: sovereign on-prem, sovereign European cloud and hyperscaler scored on cost, energy and sovereignty; the hyperscaler loses on sovereignty" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}&lt;/style>
&lt;rect class="bx" x="20" y="40" width="230" height="130" rx="6"/>
&lt;text x="32" y="62" class="tl">Sovereign on-prem (ES/FR)&lt;/text>
&lt;text x="32" y="84" class="ts">cost: lowest IF util. is high&lt;/text>
&lt;text x="32" y="104" class="ts">energy: controllable (PPA, grid)&lt;/text>
&lt;text x="32" y="124" class="ts">sovereignty: TOTAL&lt;/text>
&lt;text x="32" y="148" class="ts">risk: capex + utilisation&lt;/text>
&lt;rect class="bx" x="275" y="40" width="230" height="130" rx="6"/>
&lt;text x="287" y="62" class="tl">Sovereign European cloud&lt;/text>
&lt;text x="287" y="84" class="ts">cost: medium, no capex&lt;/text>
&lt;text x="287" y="104" class="ts">energy: EU, zero-egress&lt;/text>
&lt;text x="287" y="124" class="ts">sovereignty: HIGH (EU)&lt;/text>
&lt;text x="287" y="148" class="ts">risk: pay-per-use price&lt;/text>
&lt;rect class="bx" x="530" y="40" width="230" height="130" rx="6"/>
&lt;text x="542" y="62" class="tl">Hyperscaler (US)&lt;/text>
&lt;text x="542" y="84" class="ts">cost: medium + egress&lt;/text>
&lt;text x="542" y="104" class="ts">energy: imposed region&lt;/text>
&lt;text x="542" y="124" class="ts">sovereignty: NOT EU ✗&lt;/text>
&lt;text x="542" y="148" class="ts">ruled out for GDPR data&lt;/text>
&lt;text x="20" y="200" class="ts">For GDPR data the hyperscaler is out on sovereignty, however cheap. The real decision is on-prem vs European cloud.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The reading of the scorecard: for data subject to the GDPR, &lt;strong>the US hyperscaler is ruled out by the
sovereignty axis&lt;/strong>, however competitive its tariff. The real decision comes down to &lt;strong>sovereign on-prem
vs sovereign European cloud&lt;/strong>, and there it is decided by &lt;strong>utilisation&lt;/strong> and &lt;strong>volume&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="when-each-option-wins">When each option wins&lt;/h2>
&lt;p>The honest recommendation, by scenario:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scenario&lt;/th>
&lt;th>Winning option&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>High, sustained volume (util. &amp;gt;65–70 %), GDPR data&lt;/td>
&lt;td>Sovereign on-prem&lt;/td>
&lt;td>lowest cost/token + total sovereignty&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Variable or growing volume, GDPR data&lt;/td>
&lt;td>Sovereign European cloud&lt;/td>
&lt;td>sovereignty without capex/idle risk&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Low volume or sporadic peak&lt;/td>
&lt;td>European cloud (per use)&lt;/td>
&lt;td>you do not amortise the capex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No sovereignty requirement, hardware frontier&lt;/td>
&lt;td>Hyperscaler&lt;/td>
&lt;td>access to new hardware without capex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hybrid (base + peak)&lt;/td>
&lt;td>On-prem + European cloud (burst)&lt;/td>
&lt;td>cheap base of your own, sovereign elastic peak&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>On-prem makes sense when there is &lt;strong>very high, predictable utilisation (80 %+), strict sovereignty
requirements, or a hyperscaler contract that works out expensive&lt;/strong> (&lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">Spheron&lt;/a>).
For sovereign platforms with a sustained base load, the winning pattern is usually the &lt;strong>hybrid&lt;/strong>:
sovereign on-prem for the high-utilisation base (where the cost per token is unbeatable) and
sovereign European cloud for the peak and for growth (elastic, no capex, keeping EU jurisdiction).
The best of both without giving up sovereignty.&lt;/p>
&lt;h3 id="sizing-the-hybrid-how-much-on-iron-how-much-on-cloud">Sizing the hybrid: how much on iron, how much on cloud&lt;/h3>
&lt;p>The hybrid is not &amp;ldquo;a bit of each&amp;rdquo;; it is sized with one figure: the &lt;strong>base load percentile&lt;/strong>. The rule
is to put on on-prem the load that is almost always present (the load that keeps the GPU at
75–85 %) and to send to the European cloud only the peaks that, if covered with iron, would leave
GPUs idle most of the time. An example with a realistic traffic profile:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Load band&lt;/th>
&lt;th>% of the time&lt;/th>
&lt;th>Where to serve&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Base (p0–p70)&lt;/td>
&lt;td>always&lt;/td>
&lt;td>on-prem (1 node 8×H100 at ~80 %)&lt;/td>
&lt;td>minimum cost/token, high util. guaranteed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Middle (p70–p95)&lt;/td>
&lt;td>daily peak hours&lt;/td>
&lt;td>on-prem if it fits, otherwise European cloud&lt;/td>
&lt;td>elasticity without idle capex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Peak (p95–p100)&lt;/td>
&lt;td>sporadic&lt;/td>
&lt;td>sovereign European cloud (burst)&lt;/td>
&lt;td>absurd to buy iron for a rare peak&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With this split, the base amortises your own node at high utilisation (~1.1–1.8 €/1M tokens) and the
peak is paid per use with no idle penalty (~1.5–2.2 €/1M tokens), all under EU jurisdiction. The
expensive mistake is the opposite: sizing the on-prem for the peak, in which case the GPU spends
most of its time idle at 30–40 %, the cost per token shoots up above 3 € and the cloud would have been
cheaper. &lt;strong>You size the iron for the base, not for the peak&lt;/strong>; the peak is exactly what the cloud does
well. This principle connects with the capacity planning and the scheduling (Kueue/Volcano) of the
series: the hybrid only works if the scheduler fills your own node before overflowing to the cloud.&lt;/p>
&lt;hr>
&lt;h2 id="assumptions-and-sensitivity">Assumptions and sensitivity&lt;/h2>
&lt;p>The whole comparison hangs on assumptions that have to be declared, because moving them moves the
conclusion:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Assumption&lt;/th>
&lt;th>If it rises&lt;/th>
&lt;th>Effect&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Utilisation&lt;/td>
&lt;td>50 % → 80 %&lt;/td>
&lt;td>on-prem goes from losing to clearly winning&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy price&lt;/td>
&lt;td>expensive region → France/PPA&lt;/td>
&lt;td>lowers on-prem TCO and carbon&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Amortisation period&lt;/td>
&lt;td>24 → 36 months&lt;/td>
&lt;td>lowers the on-prem cost per hour&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Volume&lt;/td>
&lt;td>&amp;lt; 2M tok/day → much more&lt;/td>
&lt;td>crosses the break-even towards on-prem&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Egress (hyperscaler)&lt;/td>
&lt;td>low → high&lt;/td>
&lt;td>makes the hyperscaler dearer than the European cloud&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The rule: &lt;strong>no on-prem vs cloud comparison is valid without fixing these assumptions&lt;/strong>. One that says
&amp;ldquo;on-prem is 3× cheaper&amp;rdquo; without declaring the assumed utilisation is propaganda; one that fixes
utilisation, energy price, period and volume is a data point. The dossier must present the case with
explicit assumptions and a sensitivity analysis, which is what makes it defensible before a committee
that questions them.&lt;/p>
&lt;h2 id="decision-checklist">Decision checklist&lt;/h2>
&lt;p>To take S2 from theory to decision, the questions that order the choice, in order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Is the data subject to the GDPR, or is the system high-risk under the EU AI Act?&lt;/strong> If so, the US
hyperscaler is &lt;strong>ruled out on sovereignty&lt;/strong>; you choose between on-prem and European cloud. If not,
the hyperscaler enters the cost comparison.&lt;/li>
&lt;li>&lt;strong>Can I sustain utilisation above 65–70 % on the base load?&lt;/strong> If so, on-prem wins on cost for that
base. If not, the European cloud avoids paying capex for idle GPUs.&lt;/li>
&lt;li>&lt;strong>Does the traffic profile have marked peaks?&lt;/strong> If so, &lt;strong>hybrid&lt;/strong>: base on iron, peak on European
cloud. Size the iron for the base, never for the peak.&lt;/li>
&lt;li>&lt;strong>How much data do I take out of the provider each month?&lt;/strong> Model the egress; with a lot of
movement, the zero-egress of the European cloud, or on-prem, win clearly.&lt;/li>
&lt;li>&lt;strong>Which electricity grid and at what price?&lt;/strong> France or Spain with a PPA lower TCO and carbon;
include it in the model and in the CSRD report.&lt;/li>
&lt;li>&lt;strong>Have I fixed utilisation, energy, period and volume in writing?&lt;/strong> Without those four declared
assumptions, the number is not defensible.&lt;/li>
&lt;/ol>
&lt;p>Whoever answers these six questions with data, not with intuition, has the case built. The series'
recommendation for a sovereign European platform with a sustained base load is stable: &lt;strong>sovereign
on-prem for the high-utilisation base plus sovereign European cloud for the peak&lt;/strong>, with the
hyperscaler reserved only for workloads with no sovereignty requirement where frontier hardware is
needed without capex.&lt;/p>
&lt;hr>
&lt;h2 id="limits-and-traps-data-driven">Limits and traps (data-driven)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Unrealistic assumed utilisation.&lt;/strong> The 80–90 % that makes on-prem win rarely happens in production
(40–65 % typical). Model your real utilisation, not the ideal one.&lt;/li>
&lt;li>&lt;strong>Comparing only the GPU-hour.&lt;/strong> TCO includes energy, operation, cooling, egress (cloud) and capex
(on-prem). Compare totals with the same assumptions.&lt;/li>
&lt;li>&lt;strong>Ignoring sovereignty.&lt;/strong> For GDPR data, the sovereignty axis rules out the hyperscaler before cost
does; it is not negotiable with price.&lt;/li>
&lt;li>&lt;strong>Forgetting the hybrid.&lt;/strong> It is not &amp;ldquo;all on-prem or all cloud&amp;rdquo;; the base-plus-peak pattern usually
dominates.&lt;/li>
&lt;li>&lt;strong>Data in USD.&lt;/strong> US comparisons are in dollars and with dirty regions; convert them to euros and to
your region&amp;rsquo;s grid (Spain/France) for your case.&lt;/li>
&lt;/ol>
&lt;p>The synthesis of S2, in one sentence: &lt;strong>for sovereign European data, the decision is not on-prem vs
cloud in the abstract, but sovereign on-prem (high utilisation) plus sovereign European cloud (peak)
against a hyperscaler that the sovereignty axis rules out&lt;/strong>, and utilisation is the variable that
splits the base between the first two. The rest of the series gives the numbers for each axis; this one
crosses them into the recommendation. The next synthesis article (S3) sizes the investment; this one
decides the architecture.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">Cloud GPU: price comparison, commitment and sovereign neoclouds&lt;/a> — the on-demand, spot and reserved prices of the European cloud providers that appear as an alternative in this analysis, with updated 2026 data.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of the on-premise GPU cluster: amortisation, energy and infrastructure&lt;/a> — the full breakdown of on-premise TCO: server CAPEX, amortisation, energy, network and staff, with the spreadsheet that gives the real €/GPU-hour.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Spheron · LLM Inference On-Premise vs GPU Cloud: 2026 Cost and Break-Even — &lt;a href="https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/">https://www.spheron.network/blog/llm-inference-on-premise-vs-cloud/&lt;/a>&lt;/li>
&lt;li>Lenovo Press · On-Premise vs Cloud: Generative AI TCO (2026) — &lt;a href="https://lenovopress.lenovo.com/lp2368-on-premise-vs-cloud-generative-ai-total-cost-of-ownership-2026-edition">https://lenovopress.lenovo.com/lp2368-on-premise-vs-cloud-generative-ai-total-cost-of-ownership-2026-edition&lt;/a>&lt;/li>
&lt;li>Lyceum · EU Sovereign Inference Platform Comparison (2026) — &lt;a href="https://lyceum.technology/magazine/eu-sovereign-inference-platform-comparison/">https://lyceum.technology/magazine/eu-sovereign-inference-platform-comparison/&lt;/a>&lt;/li>
&lt;li>Lyceum · Sovereign Cloud Providers 2026 — &lt;a href="https://lyceum.technology/magazine/sovereign-cloud-providers-2026/">https://lyceum.technology/magazine/sovereign-cloud-providers-2026/&lt;/a>&lt;/li>
&lt;li>Scaleway · H100 GPU instance (precio €, soberanía UE) — &lt;a href="https://www.scaleway.com/en/h100/">https://www.scaleway.com/en/h100/&lt;/a>&lt;/li>
&lt;li>Nerd Level Tech · GPU Cloud TCO 2026: hidden fees, egress costs — &lt;a href="https://nerdleveltech.com/gpu-cloud-comparison-2026-the-real-cost-of-ai-compute">https://nerdleveltech.com/gpu-cloud-comparison-2026-the-real-cost-of-ai-compute&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>FinOps and multi-tenancy on the GPU cluster: who pays for what</title><link>https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/</link><pubDate>Thu, 11 Jun 2026 12:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/</guid><description>&lt;blockquote>
&lt;p>Third instalment in an operational series on how to squeeze a generic on-premise LLM inference cluster of &lt;strong>4×H100 SXM 80 GB&lt;/strong>. The sibling pieces are &lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal VLM on-premise with vLLM&lt;/a>, serving vision-language models on the same cluster, and &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a>, cutting the model load time from disk to HBM. This post closes the economic question the other two leave open: once the cluster serves several models for several teams, &lt;strong>who pays for what?&lt;/strong>&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An on-premise GPU cluster does not have the property that makes FinOps easy in the cloud: no monthly invoice arrives telling you what each compute hour cost. The cost has to be manufactured from capex and energy. The right unit is the token, and it is derived in two steps. First the €/GPU-hour: capex depreciation spread over the hours of useful life, plus energy (power × PUE × price_kWh). For an H100 SXM with an assumed capex of 30,000 € and 4 years of life at 90 % availability, that gives ≈ 1.07 €/GPU-hour (0.95 of depreciation + 0.12 of energy at 0.12 €/kWh and PUE 1.4). Second, the €/1M tokens: divide that €/GPU-hour by the sustained throughput. At 2,000 useful tok/s, ≈ 0.148 €/1M tokens; but that number only holds if the GPU is at 100 % useful utilisation. At 20 % utilisation the same token costs 4× more (0.74 €/1M), because capex and energy keep being paid even if the GPU is idle. That is the central thesis: &lt;strong>the FinOps goal is not to negotiate the price of the token down, there is no provider to negotiate with, but to raise useful utilisation&lt;/strong>, which is the only term that moves cost by an order of magnitude. Attribution (chargeback / showback) is made real with LiteLLM virtual keys: per-team and per-user budget, RPM/TPM rate limit, automatic spend tracking and &lt;code>tags&lt;/code> to assign spend to cost centres. Isolation between tenants is achieved with MIG (hard GPU partitioning), namespaces, &lt;code>ResourceQuota&lt;/code> and &lt;code>PriorityClass&lt;/code>. The FinOps Foundation rule worth carving in stone: &lt;strong>showback always, chargeback only if the organisation&amp;rsquo;s accounting policy supports it&lt;/strong>; neither of the two is &amp;ldquo;more mature&amp;rdquo;. And the governance metric of the shared cluster is not the €/token but &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>.&lt;/p>
&lt;h2 id="the-problem-the-cloud-hides-from-you-and-on-premise-forces-you-to-solve">The problem the cloud hides from you and on-premise forces you to solve&lt;/h2>
&lt;p>In the cloud, the cost of a GPU is an observable fact: AWS, GCP or Azure charge you per GPU-hour and the invoice arrives itemised. FinOps in that world consists of &lt;strong>reading&lt;/strong> an invoice that already exists and attributing it. On-premise there is no such invoice. The GPU was bought once, eighteen months ago, on a purchase order nobody remembers; the electricity bill arrives for the whole building with no breakdown of what fraction was the cluster&amp;rsquo;s; and the datacenter PUE is a number the facilities team knows but the AI team never asked about.&lt;/p>
&lt;p>The first job of on-premise FinOps, then, is not to attribute a cost but to manufacture one. You have to build, from explicit assumptions, the equivalent of the &amp;ldquo;price per GPU-hour&amp;rdquo; that in the cloud is given to you. And once manufactured, you have to understand that this number is not a property of the hardware, like VRAM or TDP, but a function of how the hardware is used. An idle H100 costs exactly the same as an H100 at 100 %: the capex is already paid and idle energy is not zero. The only thing that changes is &lt;strong>how many useful tokens&lt;/strong> that fixed cost produced. That is where the whole thesis of this post comes from.&lt;/p>
&lt;h2 id="the-analogy-the-coworking-space-with-metered-services">The analogy: the coworking space with metered services&lt;/h2>
&lt;p>A coworking space manages a finite physical space and rents it to teams. The cost model has exactly the structure of the GPU cluster, and the analogy holds down to the last detail.&lt;/p>
&lt;p>&lt;strong>The desk-hour is the spread capex.&lt;/strong> The manager paid the rent on the premises, the furniture and the refurbishment, a one-off outlay or a fixed monthly cost, and spreads it over the available desk-hours in the month. Each desk has a base cost that &lt;strong>does not depend on whether anybody uses it&lt;/strong>: the walls, the table and the chair cost the same empty as occupied. This is the &lt;strong>depreciation of the GPU&lt;/strong>: capex divided by the hours of useful life.&lt;/p>
&lt;p>&lt;strong>The metered kWh is the energy.&lt;/strong> On top of the desk-hour, the coworking space measures the actual electricity consumption, the plugged-in laptop, the screen, the air conditioning in that room, and bills it separately. This is the &lt;strong>energy of the GPU&lt;/strong>: power × hours × price_kWh, multiplied by the datacenter&amp;rsquo;s &lt;strong>PUE&lt;/strong>, which is the coworking space also charging you for the fraction of the building&amp;rsquo;s air conditioning that cools your room.&lt;/p>
&lt;p>&lt;strong>The empty desk is paid for by whoever booked it.&lt;/strong> Here is the heart of the matter. If a team books ten desks for the whole month and only uses two, &lt;strong>it pays for all ten anyway&lt;/strong>. The empty desk still takes up space the manager cannot resell, still depreciates furniture, still counts as committed capacity. In the cluster: a GPU assigned to a tenant that uses it at 20 % costs the same as if it used it at 100 %, and the idle 80 % is &lt;strong>wasted capex&lt;/strong> that somebody is paying for.&lt;/p>
&lt;p>&lt;strong>The manager optimises occupancy, not tariff.&lt;/strong> A competent coworking manager does not obsess over lowering the price of the desk-hour, it is a sunk cost, the premises are already paid for. They obsess over the &lt;strong>occupancy rate&lt;/strong>: every empty desk is margin lost forever, because an unsold desk-hour cannot be stored. The FinOps equivalent: &lt;strong>the goal is not to lower the €/token, it is to raise utilisation&lt;/strong>, because the €/token falls only as a consequence of utilisation rising. There is no provider to squeeze; the only margin lies in not leaving GPUs idle.&lt;/p>
&lt;p>The analogy has an honest limit worth naming: in a coworking space, two people cannot physically share the same chair. On a GPU you &lt;strong>can&lt;/strong>, with time-slicing, MPS or MIG (see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>), and that is precisely the technical lever that lets you raise occupancy beyond what the physical coworking space would allow. But the economics of fixed cost + metered cost is identical.&lt;/p>
&lt;h2 id="the-token-as-the-unit-of-cost">The token as the unit of cost&lt;/h2>
&lt;p>Why the token and not the GPU-hour, or the request, or the model? Because the token is the only unit comparable across heterogeneous workloads. A GPU-hour of Llama 8B and one of Llama 70B produce radically different amounts of &amp;ldquo;useful work&amp;rdquo;; a RAG request with a 50-token answer and one generating a 3,000-token report are not comparable. The token, specifically the pair (input_tokens, output_tokens), normalises all of that. It is the unit LiteLLM accounts for natively, the one that appears in the OTel attributes &lt;code>gen_ai.usage.input_tokens&lt;/code> and &lt;code>gen_ai.usage.output_tokens&lt;/code> (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>), and the one commercial providers already use to charge. Adopting the token as the internal currency makes on-premise chargeback directly comparable with the cloud alternative, which is exactly the comparison management wants to see.&lt;/p>
&lt;p>The two derived metrics that matter:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tokens/€&lt;/strong> — how many useful tokens each euro of total cluster cost buys. It rises when utilisation rises. It is the metric of economic efficiency.&lt;/li>
&lt;li>&lt;strong>Tokens/W&lt;/strong> — how many tokens each watt consumed produces. It is the metric of energy efficiency and, in a datacenter with limited power (the usual case on-premise), it is often the real &lt;strong>hard constraint&lt;/strong>: you cannot add more GPUs because there are no more kW in the rack, so every watt has to perform.&lt;/li>
&lt;/ul>
&lt;h2 id="cost-model-of-a-gpu-from-capex-to-token">Cost model of a GPU: from capex to €/token&lt;/h2>
&lt;p>The hourly cost of a GPU has two addends. Let us put all the assumptions in writing, because, as in &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>, a cost calculation without written assumptions is a disposable calculation.&lt;/p>
&lt;h3 id="depreciation-spread-capex">Depreciation (spread capex)&lt;/h3>
$$\text{depreciation cost/h} = \frac{\text{capex}_{\text{GPU}}}{\text{hours of useful life}}$$
&lt;p>where the hours of useful life are the depreciation years × 8,760 h/year × the effective availability (the GPU is not available 100 % of the time: there is maintenance, restarts, update windows). Explicit, &lt;strong>generic&lt;/strong> assumptions (they are not figures from any provider or any real purchase):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Capex per GPU&lt;/strong>: 30,000 € — a reasonable assumption that includes not just the GPU but its &lt;strong>pro-rata share&lt;/strong> of the server, power supply, cooling, NVLink/InfiniBand network and rack. A &amp;ldquo;bare&amp;rdquo; H100 SXM costs less, but honest FinOps spreads the cost of the whole node across its GPUs.&lt;/li>
&lt;li>&lt;strong>Accounting useful life&lt;/strong>: 4 years. That is aggressive-realistic for a datacenter GPU; some depreciate over 3, others over 5.&lt;/li>
&lt;li>&lt;strong>Effective availability&lt;/strong>: 90 %.&lt;/li>
&lt;/ul>
$$\text{hours of life} = 4 \times 8{,}760 \times 0.90 = 31{,}536 \text{ h}$$
$$\text{depreciation cost/h} = \frac{30{,}000}{31{,}536} \approx 0.95 \text{ €/GPU-hour}$$
&lt;h3 id="energy-power--pue--price_kwh">Energy (power × PUE × price_kWh)&lt;/h3>
$$\text{energy cost/h} = P_{\text{GPU}} \cdot \text{PUE} \cdot \text{price kWh}$$
&lt;p>Assumptions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>GPU power&lt;/strong>: the TDP of the H100 SXM is 700 W according to the NVIDIA datasheet (&lt;a href="https://resources.nvidia.com/en-us-gpu-resources/h100-datasheet-24306">H100 datasheet&lt;/a>). Under sustained inference load it hovers around that value; we use 0.7 kW as the average power under load. (At idle it drops a lot, 60–100 W, but the FinOps of reserved capacity reasons about power under load, which is what limits the rack.)&lt;/li>
&lt;li>&lt;strong>PUE&lt;/strong> (Power Usage Effectiveness): how many watts enter the datacenter for every watt that reaches the chip. A modest, well-managed on-premise datacenter sits at 1.4; a bad one at 2.0; the hyperscalers boast of 1.1. We use 1.4.&lt;/li>
&lt;li>&lt;strong>Price per kWh&lt;/strong>: 0.12 €/kWh — a generic assumption for an industrial tariff; it varies enormously by country and contract.&lt;/li>
&lt;/ul>
$$\text{energy cost/h} = 0.7 \cdot 1.4 \cdot 0.12 \approx 0.1176 \approx 0.12 \text{ €/GPU-hour}$$
&lt;h3 id="the-total-gpu-hour">The total €/GPU-hour&lt;/h3>
$$\boxed{\text{€/GPU-hour} = 0.95 + 0.12 \approx 1.07 \text{ €/GPU-hour}}$$
&lt;p>Two important readings of this number. First: depreciation dominates (89 % of the cost); energy is 11 %. This inverts many people&amp;rsquo;s intuition, who believe &amp;ldquo;the expensive part is the electricity&amp;rdquo;. With these assumptions, the expensive part is having bought the GPU, and that is why leaving it idle hurts so much. Second: the number is per-GPU; for the 4×H100 node it is ≈ 4.28 €/hour, and for the complete generic cluster of 4 nodes (16 GPUs), ≈ 17.1 €/hour ≈ 150,000 €/year of fixed cost that is paid whether it is used or not.&lt;/p>
&lt;h3 id="from-gpu-hour-to-token">From €/GPU-hour to €/token&lt;/h3>
&lt;p>Now we divide the hourly cost by the tokens the GPU produces in an hour. Here sustained throughput comes in. Let us suppose, an order-of-magnitude figure, checkable with &lt;code>vllm bench serve&lt;/code>, that a Llama 70B replica on 4×H100 (TP=4) sustains &lt;strong>2,000 useful tokens/s&lt;/strong> aggregated under good concurrency.&lt;/p>
$$\text{tokens/hour} = 2{,}000 \times 3{,}600 = 7.2 \times 10^6 \text{ tok/h}$$
&lt;p>The cost of that replica (4 GPUs) is $4 \times 1.07 = 4.28$ €/hour. Therefore:&lt;/p>
$$\text{€/1M tokens} = \frac{4.28}{7.2} \approx 0.59 \text{ €/1M tokens}$$
&lt;p>Per individual GPU, if we take a smaller replica (e.g. Llama 8B FP8 on 1 GPU at ~2,000 tok/s):&lt;/p>
$$\text{€/1M tokens} = \frac{1.07 \text{ €/h}}{7.2 \times 10^6 \text{ tok/h}} \times 10^6 \approx 0.149 \text{ €/1M tokens}$$
&lt;p>These numbers are &lt;strong>the theoretical floor at 100 % utilisation&lt;/strong>. Nobody operates at 100 %. And that is the whole story.&lt;/p>
&lt;h2 id="utilisation-is-the-lever-with-numbers">Utilisation is the lever (with numbers)&lt;/h2>
&lt;p>The €/token in the previous section assumes the GPU produces 7.2 M tokens every hour, hour after hour. In practice it produces that only during peak hours. The rest of the time it is partly idle: at night, at weekends, between traffic peaks. The &lt;strong>useful utilisation&lt;/strong> $u$ is the fraction of the theoretical capacity that really turns into billable tokens.&lt;/p>
&lt;p>The hourly cost is &lt;strong>fixed&lt;/strong> (1.07 €/GPU-hour is paid come rain or shine), but the tokens produced scale with $u$:&lt;/p>
$$\text{€/1M tokens}(u) = \frac{\text{€/GPU-hour}}{\text{max tok/hour} \cdot u} = \frac{1.07}{7.2 \times 10^6 \cdot u} \times 10^6 = \frac{0.149}{u}$$
&lt;p>The requested contrast, worked out:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Useful utilisation $u$&lt;/th>
&lt;th>Real tokens/h&lt;/th>
&lt;th>€/1M tokens&lt;/th>
&lt;th>Multiplier vs 80 %&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>100 %&lt;/td>
&lt;td>7.2 M&lt;/td>
&lt;td>0.149 €&lt;/td>
&lt;td>0.80×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>80 %&lt;/td>
&lt;td>5.76 M&lt;/td>
&lt;td>0.186 €&lt;/td>
&lt;td>1.00× (baseline)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>50 %&lt;/td>
&lt;td>3.6 M&lt;/td>
&lt;td>0.298 €&lt;/td>
&lt;td>1.60×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>20 %&lt;/td>
&lt;td>1.44 M&lt;/td>
&lt;td>0.744 €&lt;/td>
&lt;td>4.00×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10 %&lt;/td>
&lt;td>0.72 M&lt;/td>
&lt;td>1.488 €&lt;/td>
&lt;td>8.00×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Direct reading: going from 20 % to 80 % utilisation divides the cost per token by four ($0.744 \to 0.186$). No tariff negotiation, no quantization, no hardware change gives you that factor of 4 so cheaply. Quantizing from BF16 to FP8 can double throughput, and therefore halves the €/token, but it degrades quality and demands evals (see &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a>); raising utilisation from 20 to 80 % does not touch the quality of a single token.&lt;/p>
&lt;p>This reformulates GPU cluster FinOps in a single sentence: &lt;strong>the €/token is not something you negotiate, it is something you earn by filling the GPUs&lt;/strong>. And raising utilisation is an engineering problem that already has its pieces:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Autoscaling&lt;/strong> that switches replicas off when traffic drops, so as not to pay for idle GPU (see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a>). Switching off a 4-GPU replica for eight hours overnight saves ≈ 34 € a day of real cost; multiplied by replicas and by days, that is the difference between a profitable cluster and one that bleeds.&lt;/li>
&lt;li>&lt;strong>Sharing a GPU&lt;/strong> between small workloads with MIG or time-slicing, so that two tenants that individually would use 30 % together fill a GPU to 60 % (see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Overnight batch&lt;/strong> that fills the off-peak hours with non-urgent work (re-embeddings, evals, light fine-tuning) instead of leaving the GPUs switched off or ticking over.&lt;/li>
&lt;li>&lt;strong>Reducing cold start&lt;/strong> so that scaling to zero and starting again is cheap and therefore viable as a utilisation strategy (see the sibling piece &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a>).&lt;/li>
&lt;/ul>
&lt;h2 id="the-diagram-the-token-cascade-by-utilisation">The diagram: the €/token cascade by utilisation&lt;/h2>
&lt;p>The cost per token is not a number, it is a cascade that multiplies as utilisation falls:&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="cost per token cascade by utilisation">
&lt;text x="380" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">The €/token is manufactured, then inflated by low utilisation&lt;/text>
&lt;text x="380" y="48" text-anchor="middle" font-size="11" font-style="italic" fill="currentColor">fixed cost (capex + energy) → ÷ tokens produced → €/token, which grows as utilisation falls&lt;/text>
&lt;rect x="40" y="70" width="160" height="50" rx="6" fill="#8b5cf6" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="120" y="92" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Depreciation&lt;/text>
&lt;text x="120" y="108" text-anchor="middle" font-size="11" fill="#ffffff">0.95 €/GPU-h&lt;/text>
&lt;rect x="40" y="130" width="160" height="50" rx="6" fill="#f59e0b" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="120" y="152" text-anchor="middle" font-size="11" font-weight="700" fill="#ffffff">Energy × PUE&lt;/text>
&lt;text x="120" y="168" text-anchor="middle" font-size="11" fill="#ffffff">0.12 €/GPU-h&lt;/text>
&lt;path d="M200,95 L230,95 L230,150 L200,150" fill="none" stroke="currentColor" stroke-width="1.2"/>
&lt;line x1="230" y1="125" x2="265" y2="125" stroke="currentColor" stroke-width="1.4" marker-end="url(#fa)"/>
&lt;rect x="270" y="100" width="150" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="345" y="122" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">€/GPU-hour&lt;/text>
&lt;text x="345" y="138" text-anchor="middle" font-size="11" fill="currentColor">1.07 €/h (fixed)&lt;/text>
&lt;text x="345" y="172" text-anchor="middle" font-size="10" font-style="italic" fill="currentColor">÷ tokens produced/hour&lt;/text>
&lt;line x1="420" y1="125" x2="470" y2="125" stroke="currentColor" stroke-width="1.4" marker-end="url(#fa)"/>
&lt;rect x="475" y="100" width="245" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="597" y="122" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">€/1M tokens = 0.149 / u&lt;/text>
&lt;text x="597" y="138" text-anchor="middle" font-size="10" fill="currentColor">depends on useful utilisation u&lt;/text>
&lt;text x="380" y="200" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Same GPU, different cost per token by u:&lt;/text>
&lt;rect x="90" y="220" width="130" height="110" rx="6" fill="#22c55e" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="155" y="245" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">u = 80%&lt;/text>
&lt;text x="155" y="280" text-anchor="middle" font-size="18" font-weight="700" fill="#ffffff">0.186 €&lt;/text>
&lt;text x="155" y="300" text-anchor="middle" font-size="10" fill="#ffffff">/1M tokens&lt;/text>
&lt;text x="155" y="318" text-anchor="middle" font-size="10" fill="#ffffff">GPU well filled&lt;/text>
&lt;rect x="315" y="220" width="130" height="110" rx="6" fill="#f59e0b" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="380" y="245" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">u = 50%&lt;/text>
&lt;text x="380" y="280" text-anchor="middle" font-size="18" font-weight="700" fill="#ffffff">0.298 €&lt;/text>
&lt;text x="380" y="300" text-anchor="middle" font-size="10" fill="#ffffff">/1M tokens&lt;/text>
&lt;text x="380" y="318" text-anchor="middle" font-size="10" fill="#ffffff">1.6× the baseline&lt;/text>
&lt;rect x="540" y="220" width="130" height="110" rx="6" fill="#ef4444" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="605" y="245" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">u = 20%&lt;/text>
&lt;text x="605" y="280" text-anchor="middle" font-size="18" font-weight="700" fill="#ffffff">0.744 €&lt;/text>
&lt;text x="605" y="300" text-anchor="middle" font-size="10" fill="#ffffff">/1M tokens&lt;/text>
&lt;text x="605" y="318" text-anchor="middle" font-size="10" fill="#ffffff">4× the baseline&lt;/text>
&lt;defs>
&lt;marker id="fa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;/div>
&lt;h2 id="attributing-the-cost-litellm-virtual-keys">Attributing the cost: LiteLLM virtual keys&lt;/h2>
&lt;p>With the €/token manufactured, it remains to attribute it to whoever consumed it. The piece that already sits in front of the engines, the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">L7 inference router&lt;/a>, is also the natural place to count. &lt;strong>LiteLLM Proxy&lt;/strong> makes chargeback real with four mechanisms, all documented:&lt;/p>
&lt;p>&lt;strong>Virtual keys with a budget.&lt;/strong> Each team or user receives a &lt;em>virtual key&lt;/em> with a &lt;code>max_budget&lt;/code> and a &lt;code>budget_duration&lt;/code>. You can define &lt;strong>several independent budget windows&lt;/strong> (for example one of 24 h and another of 30 d) that reset on their own cycle (&lt;a href="https://docs.litellm.ai/docs/proxy/virtual_keys">Virtual Keys&lt;/a>). When a team exhausts its monthly token budget, the proxy rejects with a budget exceeded error instead of carrying on spending GPU-hours nobody will be able to charge anywhere.&lt;/p>
&lt;p>&lt;strong>RPM/TPM rate limit.&lt;/strong> Each key carries requests per minute (RPM) and tokens per minute (TPM) limits (&lt;a href="https://docs.litellm.ai/docs/proxy/users">Budgets, Rate Limits&lt;/a>). This is not just anti-abuse protection: it is the tool that stops a noisy tenant monopolising the cluster&amp;rsquo;s utilisation at everybody else&amp;rsquo;s expense. The rate limit is the capacity quota; the budget is the spend quota. They are orthogonal and you use both.&lt;/p>
&lt;p>&lt;strong>Automatic spend tracking.&lt;/strong> LiteLLM accounts for the spend of all known models by key, user and team, recording the API key, the user, the &lt;code>team_id&lt;/code>, the request&amp;rsquo;s &lt;code>tags&lt;/code>, the end user, the model group and the token counts (&lt;a href="https://docs.litellm.ai/docs/proxy/cost_tracking">Spend Tracking&lt;/a>). For the cluster&amp;rsquo;s self-hosted models, the cost per token is configured &lt;strong>with the €/token we manufactured above&lt;/strong>, and that is the hook between the cost model and the real accounting.&lt;/p>
&lt;p>&lt;strong>Tags for cost centres.&lt;/strong> The &lt;code>tags&lt;/code> let you track spend and set budgets per label, categorising costs by project, department or cost centre (&lt;a href="https://docs.litellm.ai/docs/proxy/tag_budgets">Setting Tag Budgets&lt;/a>). A tag is attached when the key is created and &lt;strong>every request made with that key inherits the tag automatically&lt;/strong>; the proxy applies the label&amp;rsquo;s budget. That way chargeback maps cleanly onto the organisation&amp;rsquo;s accounting hierarchy without every team having to remember to tag by hand.&lt;/p>
&lt;p>A declarative catalogue fragment, which fits into the &lt;code>litellm-config&lt;/code> of the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">router post&lt;/a>, materialising the manufactured cost and two tenants:&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="nt">litellm_settings&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="c"># €/token manufactured in this post for the self-hosted model.&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="c"># input/output in €/token (not per 1M); 0.186 €/1M = 1.86e-7 €/token at u=80%.&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">model_cost_map&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">llama-70b-onprem&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">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.000000186&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">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.000000186&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="c"># Virtual keys per team (via the /key/generate API or config):&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="c"># equipo-datos: max_budget=500 €/month, tpm_limit=200000, tags=[&amp;#34;cc-datos&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="c"># equipo-soporte: max_budget=150 €/month, tpm_limit=60000, tags=[&amp;#34;cc-soporte&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="c"># The proxy counts tokens, multiplies by model_cost_map, deducts from the budget,&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="c"># and attributes the spend to the tag → showback per cost centre, without touching the engine.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The complete attribution flow, from the request to the per-team report:&lt;/p>
&lt;div class="diagram" style="max-width:800px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 800 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="cost attribution flow with virtual key and tags">
&lt;text x="400" y="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Attribution flow: from the request to per-team showback&lt;/text>
&lt;rect x="20" y="60" width="140" height="70" rx="6" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="90" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Team request&lt;/text>
&lt;text x="90" y="106" text-anchor="middle" font-size="10" fill="currentColor">Authorization:&lt;/text>
&lt;text x="90" y="120" text-anchor="middle" font-size="10" fill="#3b82f6">Bearer sk-equipo-datos&lt;/text>
&lt;line x1="160" y1="95" x2="195" y2="95" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;rect x="200" y="50" width="170" height="90" rx="6" fill="none" stroke="#8b5cf6" stroke-width="1.8"/>
&lt;text x="285" y="74" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">LiteLLM Proxy&lt;/text>
&lt;text x="285" y="92" text-anchor="middle" font-size="9.5" fill="currentColor">1· validate key + budget&lt;/text>
&lt;text x="285" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">2· apply RPM/TPM&lt;/text>
&lt;text x="285" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">3· inherit tag cc-datos&lt;/text>
&lt;text x="285" y="134" text-anchor="middle" font-size="9.5" fill="currentColor">4· count tokens&lt;/text>
&lt;line x1="370" y1="95" x2="405" y2="95" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;rect x="410" y="60" width="150" height="70" rx="6" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;text x="485" y="88" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">vLLM engine&lt;/text>
&lt;text x="485" y="106" text-anchor="middle" font-size="10" fill="currentColor">4×H100 · TP=4&lt;/text>
&lt;text x="485" y="120" text-anchor="middle" font-size="10" fill="currentColor">produces N tokens&lt;/text>
&lt;line x1="485" y1="130" x2="485" y2="165" stroke="currentColor" stroke-width="1.2" stroke-dasharray="4 2" marker-end="url(#fb)"/>
&lt;text x="555" y="152" text-anchor="middle" font-size="9.5" font-style="italic" fill="currentColor">usage.tokens back&lt;/text>
&lt;rect x="200" y="170" width="170" height="60" rx="6" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="285" y="194" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Spend tracking&lt;/text>
&lt;text x="285" y="210" text-anchor="middle" font-size="9.5" fill="currentColor">tokens × €/token (model_cost_map)&lt;/text>
&lt;text x="285" y="223" text-anchor="middle" font-size="9.5" fill="currentColor">→ deduct budget, store in DB&lt;/text>
&lt;line x1="285" y1="140" x2="285" y2="170" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;line x1="370" y1="200" x2="560" y2="200" stroke="currentColor" stroke-width="1.4" marker-end="url(#fb)"/>
&lt;rect x="565" y="170" width="215" height="60" rx="6" fill="none" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="672" y="194" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">Report by tag / team&lt;/text>
&lt;text x="672" y="210" text-anchor="middle" font-size="9.5" fill="currentColor">cc-datos: 312 € · cc-soporte: 88 €&lt;/text>
&lt;text x="672" y="223" text-anchor="middle" font-size="9.5" fill="currentColor">showback (or chargeback to P&amp;amp;L)&lt;/text>
&lt;defs>
&lt;marker id="fb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;/div>
&lt;h2 id="showback-vs-chargeback-the-distinction-the-finops-foundation-insists-on">Showback vs chargeback: the distinction the FinOps Foundation insists on&lt;/h2>
&lt;p>The two words are constantly confused and the difference is one of &lt;strong>accounting formality&lt;/strong>, not technology:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Showback&lt;/strong> — giving each team visibility of what it consumed and its cost, without really billing it to their budget. The report arrives, the team sees it, but the money does not move between cost centres.&lt;/li>
&lt;li>&lt;strong>Chargeback&lt;/strong> — really transferring the cost to the team&amp;rsquo;s or product&amp;rsquo;s budget, putting it in their P&amp;amp;L. The spend stops being a central IT cost and becomes an allocated cost.&lt;/li>
&lt;/ul>
&lt;p>The FinOps Foundation is explicit on two points worth carving in stone (&lt;a href="https://www.finops.org/framework/capabilities/invoicing-chargeback/">Invoicing &amp;amp; Chargeback&lt;/a>, &lt;a href="https://www.cloudzero.com/blog/chargeback-vs-showback/">Chargeback vs Showback&lt;/a>). First: &lt;strong>showback is a requirement of any FinOps practice; chargeback depends on the organisation&amp;rsquo;s accounting policy&lt;/strong>. Not all organisations can or want to move money between departments for GPU consumption; showback you can always do. Second, and counterintuitive: neither of the two is &amp;ldquo;more mature&amp;rdquo; than the other. The narrative that chargeback is the &amp;ldquo;grown-up&amp;rdquo; version of showback is false according to the framework itself. The practical recommendation: start with showback, giving visibility, then build cost allocation aligned to the organisational hierarchy, and only then, if the accounting policy supports it, switch chargeback on.&lt;/p>
&lt;p>For the on-premise GPU cluster this means: the LiteLLM machinery (keys, tags, spend tracking) always produces the showback. Turning it into chargeback is a decision for finance, not for the platform team. The platform team guarantees that the numbers are correct and reproducible; who really pays is a governance decision.&lt;/p>
&lt;h2 id="isolation-making-the-cost-genuinely-attributable">Isolation: making the cost genuinely attributable&lt;/h2>
&lt;p>Chargeback is only honest if consumption is &lt;strong>isolable&lt;/strong>. If two tenants share a GPU without partitioning and one saturates the HBM, the other suffers degradation it did not cause but which contaminates its cost attribution. Isolation has two planes.&lt;/p>
&lt;p>&lt;strong>Hard GPU isolation: MIG.&lt;/strong> Multi-Instance GPU physically partitions an H100 into up to 7 instances with dedicated memory and SMs; in a 7-way partition each instance has ~10 GB of HBM3 and its own SMs. MIG gives the strongest isolation: tenant A in its MIG instance cannot touch tenant B&amp;rsquo;s performance, and attribution is trivial because each instance is accountable separately (see &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a> for the detail of MIG vs time-slicing vs MPS). The cost: MIG instances are fixed, they are not resized hot, and if they sit empty they are fragmented and wasted capex, the coworking desk subdivided into booths nobody rents.&lt;/p>
&lt;p>&lt;strong>Logical Kubernetes isolation.&lt;/strong> On top of the cluster:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Namespaces per tenant&lt;/strong> — the boundary for RBAC, NetworkPolicy and quotas.&lt;/li>
&lt;li>&lt;strong>&lt;code>ResourceQuota&lt;/code>&lt;/strong> — limits how many &lt;code>nvidia.com/gpu&lt;/code> (or &lt;code>nvidia.com/mig-1g.10gb&lt;/code>) a namespace can request. It is the GPU capacity quota at the scheduler level: the tenant cannot claim more GPUs than its quota grants, which &lt;strong>bounds its maximum cost&lt;/strong> by construction.&lt;/li>
&lt;li>&lt;strong>&lt;code>PriorityClass&lt;/code>&lt;/strong> — defines which workloads can evict others under pressure. The overnight re-embeddings batch runs with low priority and gives way to interactive inference; that way it fills the utilisation valleys without risking the paying tenant&amp;rsquo;s SLO. It is the piece that stops &amp;ldquo;raising utilisation with batch&amp;rdquo; from cannibalising quality of service.&lt;/li>
&lt;/ul>
&lt;p>The combination that works in the generic cluster: &lt;strong>MIG to partition the GPUs between tenants that need hard isolation and clean attribution&lt;/strong>, &lt;code>ResourceQuota&lt;/code> per namespace to bound each tenant&amp;rsquo;s cost, and &lt;code>PriorityClass&lt;/code> so that filler work raises utilisation without touching the priority tenants.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us bring it all down to the series&amp;rsquo; cluster: 4 nodes × 4×H100 SXM 80 GB, 16 GPUs, ≈ 17.1 €/hour of fixed cost (≈ 150,000 €/year). Three teams share it: Data (production RAG, office-hours traffic), Support (ticket assistant, daytime peaks) and Platform (evals and re-embeddings batch, no urgency).&lt;/p>
&lt;p>&lt;strong>Splitting the cost.&lt;/strong> The cluster&amp;rsquo;s fixed cost (150,000 €/year) is split through LiteLLM&amp;rsquo;s spend tracking in proportion to the tokens consumed by each team, valued at the manufactured €/token. If in one month Data consumed 1,700 M tokens, Support 480 M and Platform 320 M, at 0.186 €/1M the showback is ≈ 316 € / 89 € / 60 €. The residue, the fixed cost of the GPUs nobody used because average utilisation was, say, 45 %, is the cost of idleness, and the governance decision is whether it is split between the tenants (penalises everybody equally) or charged to Platform as &amp;ldquo;cost of unsold capacity&amp;rdquo; (gives Platform an incentive to raise utilisation). The FinOps Foundation would say: make it visible first (showback of the cost of idleness), decide the split afterwards.&lt;/p>
&lt;p>&lt;strong>Quotas per tenant.&lt;/strong> Each team has its namespace with &lt;code>ResourceQuota&lt;/code>: Data can claim up to 8 GPUs (2 replicas at TP=4), Support up to 4, Platform up to 4 but with a low &lt;code>PriorityClass&lt;/code>, giving up its GPUs when Data or Support need them at peak. In LiteLLM, each team has its virtual key with a monthly &lt;code>max_budget&lt;/code> and a &lt;code>tpm_limit&lt;/code> that reflects its capacity quota.&lt;/p>
&lt;p>&lt;strong>MIG to isolate and attribute.&lt;/strong> For the small models (embeddings, reranker, a utility Llama 8B), an H100 split 7-way with MIG gives seven separately attributable instances: three for Data&amp;rsquo;s embeddings, two for Support&amp;rsquo;s reranker, two free for filler. Each instance is an independent accounting &amp;ldquo;desk&amp;rdquo;; the per-tenant showback comes straight from the scheduler.&lt;/p>
&lt;p>&lt;strong>The governance metric is utilisation, not the €/token.&lt;/strong> Here we close the circle. The KPI the cluster owner should look at daily is not &amp;ldquo;what the token costs&amp;rdquo;, that number only falls as a consequence, but &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>, the DCGM exporter metric that indicates what fraction of the time the GPU is not idle (&lt;a href="https://docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html">DCGM exporter&lt;/a>). And with a critical nuance that already appeared in &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> measures &amp;ldquo;not idle&amp;rdquo;, it does not measure useful work. A GPU can show 100 % GPU-util with the HBM saturated and low SM occupancy, producing few tokens. That is why serious FinOps crosses three signals: &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> (is the GPU busy?), &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code> and &lt;code>DCGM_FI_PROF_GR_ENGINE_ACTIVE&lt;/code> (is it doing real work?), and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> (is the HBM well used?). The economic governance metric is useful tokens per GPU-hour, and it is watched from the &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> dashboard.&lt;/p>
&lt;p>An end-to-end assembly detail, LibreChat on top of LiteLLM with RAG, which closes the flow from tenant to cost, is covered in the (forthcoming) post on the end-to-end sovereign assistant; here it is enough to know that the metering point is always the proxy, never the engine.&lt;/p>
&lt;h2 id="four-traps-of-on-premise-gpu-finops">Four traps of on-premise GPU FinOps&lt;/h2>
&lt;p>&lt;strong>Trap 1 — comparing the on-premise €/token with the commercial API&amp;rsquo;s list price.&lt;/strong> The commercial API has margins, SLA and scale the on-premise cluster does not, but on-premise has the cost of idleness the API hides from you (they fill their GPUs with thousands of customers). The honest comparison is on-premise at its real utilisation vs the API at its real negotiated price, not the fantasy of on-premise at 100 %.&lt;/p>
&lt;p>&lt;strong>Trap 2 — forgetting the cost of idleness in the showback.&lt;/strong> If you only charge teams for the tokens they consumed, the cost of the idle GPUs disappears from the report and nobody sees it. That cost exists and somebody is paying it. Making it visible is the first step to reducing it.&lt;/p>
&lt;p>&lt;strong>Trap 3 — confusing high GPU-util with efficiency.&lt;/strong> A GPU at 100 % &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> with the HBM saturated and low SM occupancy spends capex without producing proportional tokens. The goal is not &amp;ldquo;GPU at 100 %&amp;rdquo;, it is &amp;ldquo;useful tokens per euro&amp;rdquo;. Always cross util with SM occupancy and real throughput.&lt;/p>
&lt;p>&lt;strong>Trap 4 — chargeback before showback.&lt;/strong> Switching chargeback on, moving money, before the teams trust that the numbers are correct generates disputes that burn the whole FinOps programme. First visibility, then trust in the data, then, if the accounting policy allows it, the money.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal VLM on-premise with vLLM&lt;/a> — sibling piece: serving vision-language models on the same shared cluster whose cost we attribute here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a> — sibling piece: cutting the model load time makes scaling to zero viable, which is the most direct utilisation lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the sustained throughput the €/token comes from is computed there; FinOps monetises it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — the partitioning that lets you isolate tenants and raise utilisation by combining small workloads.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — where LiteLLM lives and where every token is counted and attributed.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — switching off idle replicas is the number one lever to raise useful utilisation and lower the €/token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference with DCGM&lt;/a> — where &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> comes from, the governance metric of the shared cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — where the prices computed here get registered in LiteLLM, and why a model with no price silently produces zero spend and budgets that never run out.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">Virtual keys, budgets and limits in LiteLLM&lt;/a> — how the budgets computed here are enforced in the proxy, and the condition in the code by which a team key overrides its owner&amp;rsquo;s personal cap.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU Datasheet&lt;/em> (TDP 700 W, HBM3 3,35 TB/s): &lt;code>resources.nvidia.com/en-us-gpu-resources/h100-datasheet-24306&lt;/code>.&lt;/li>
&lt;li>LiteLLM — &lt;em>Virtual Keys&lt;/em> (&lt;code>docs.litellm.ai/docs/proxy/virtual_keys&lt;/code>), &lt;em>Budgets &amp;amp; Rate Limits&lt;/em> (&lt;code>/docs/proxy/users&lt;/code>), &lt;em>Spend Tracking&lt;/em> (&lt;code>/docs/proxy/cost_tracking&lt;/code>), &lt;em>Setting Tag Budgets&lt;/em> (&lt;code>/docs/proxy/tag_budgets&lt;/code>), &lt;em>Team Budgets&lt;/em> (&lt;code>/docs/proxy/team_budgets&lt;/code>).&lt;/li>
&lt;li>FinOps Foundation — &lt;em>Invoicing &amp;amp; Chargeback Capability&lt;/em> (&lt;code>finops.org/framework/capabilities/invoicing-chargeback/&lt;/code>) y &lt;em>Data Analysis and Showback&lt;/em> (&lt;code>finops.org/framework/previous-capabilities/analysis-showback/&lt;/code>).&lt;/li>
&lt;li>CloudZero — &lt;em>Chargeback vs. Showback: Cloud Cost Allocation Models Explained&lt;/em> (&lt;code>cloudzero.com/blog/chargeback-vs-showback/&lt;/code>).&lt;/li>
&lt;li>NVIDIA — &lt;em>DCGM Exporter&lt;/em> (&lt;code>docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html&lt;/code>): &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>, &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>, &lt;code>DCGM_FI_PROF_GR_ENGINE_ACTIVE&lt;/code>.&lt;/li>
&lt;/ul></description></item><item><title>GitOps for the inference stack with Flux: operating the assistant as code</title><link>https://blog.lo0.es/en/posts/gitops-inference-stack-flux/</link><pubDate>Thu, 11 Jun 2026 10:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/gitops-inference-stack-flux/</guid><description>&lt;blockquote>
&lt;p>This post is part of the operational series on how to squeeze a generic on-premise 4×H100 SXM 80 GB LLM cluster. The sibling pieces: the &lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">document ingestion pipeline for RAG&lt;/a> that fills the vector store we deploy here, the &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">embeddings and reranker service with TEI&lt;/a> that is one of the services GitOps manages, and the &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">secrets hardening of the sovereign stack&lt;/a>, which goes deep into the secrets problem we only state here. The complete end-to-end assistant (LibreChat + LiteLLM + RAG) that orchestrates all of this will get its own post.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM assistant in production is a distributed system, not a binary. At a minimum it has an &lt;strong>inference engine&lt;/strong> (vLLM or similar), an &lt;strong>L7 gateway&lt;/strong> that routes per model and applies rate limiting, a &lt;strong>chat front end&lt;/strong>, a &lt;strong>vector store&lt;/strong> for RAG, an &lt;strong>embeddings/reranker&lt;/strong> service, and an &lt;strong>observability&lt;/strong> stack. Six substantial services, spread across three or four namespaces, with start-up dependencies (the front end is useless if the gateway is not there, the gateway is useless if the engine has not loaded the model, RAG does not answer if the vector store is empty). Operating it by hand, a &lt;code>kubectl apply&lt;/code> here and a &lt;code>helm upgrade&lt;/code> there, produces a cluster whose real state nobody can reconstruct: there is no record of what was applied, or when, or why. &lt;strong>GitOps&lt;/strong> solves this with four principles (&lt;a href="https://opengitops.dev/">OpenGitOps&lt;/a>): the desired state is &lt;strong>declarative&lt;/strong>, it is &lt;strong>versioned and immutable&lt;/strong> in git, it is &lt;strong>applied automatically&lt;/strong> by an agent (nobody SSHes in to deploy) and it is &lt;strong>continuously reconciled&lt;/strong> by comparing the actual state against the declared one. &lt;strong>Flux&lt;/strong> is that agent: six controllers (source, kustomize, helm, notification, image-reflector, image-automation) that clone the repo, render Kustomize/Helm, apply to the cluster, correct drift and, optionally, write back to git to bump the tag of a new image. The unit of work is the &lt;code>Kustomization&lt;/code> or the &lt;code>HelmRelease&lt;/code>, with &lt;code>interval&lt;/code> (how often it reconciles), &lt;code>prune&lt;/code> (deletes what is no longer in git), &lt;code>dependsOn&lt;/code> (orders the start-up) and health checks. The &lt;code>interval&lt;/code> sets the MTTR of drift: with &lt;code>interval=1m&lt;/code>, a manual edit to the cluster is reverted in ≤ 1 min on average. Secrets cannot sit in the clear in git, a chicken-and-egg problem, and it is solved with SOPS/age, sealed-secrets or External Secrets + Vault. Rollback is a &lt;code>git revert&lt;/code>. GitOps is not free: the learning curve is real, and debugging why a &lt;code>Kustomization&lt;/code> is not reconciling is a new skill.&lt;/p>
&lt;h2 id="the-analogy-the-master-plan-and-the-foreman-who-does-not-negotiate">The analogy: the master plan and the foreman who does not negotiate&lt;/h2>
&lt;p>Picture a large building site with a signed and filed &lt;strong>master plan&lt;/strong>, and a &lt;strong>foreman&lt;/strong> who has a single order: the site must be identical to the plan, at all times. The foreman does not improvise. Every so often he walks the site with the plan in hand and compares: if a wall is where the plan says, he leaves it; if someone moved a partition overnight without updating the plan, he puts it back; if the plan says there is a column and it does not exist, he builds it; if a column exists but no longer appears on the plan, he knocks it down. The plan is the &lt;strong>single source of truth&lt;/strong>: to change the site you do not touch the site, you change the plan, and the foreman takes care of the rest on his next round.&lt;/p>
&lt;p>That is exactly the mechanics of GitOps. &lt;strong>Git is the master plan&lt;/strong>: declarative (it describes the end state, not the steps), versioned (every change is signed into the history, with author and reason), immutable (a commit is not rewritten). &lt;strong>Flux is the foreman&lt;/strong>: every &lt;code>interval&lt;/code> it walks the cluster, compares against git and converges. And here is the lesson that separates GitOps from &amp;ldquo;keeping the YAML in a repo&amp;rdquo;: the foreman &lt;strong>undoes manual changes&lt;/strong>. If an operator comes in with &lt;code>kubectl edit&lt;/code> and raises the inference engine replicas from 2 to 4 at three in the morning to put out a fire, on the next round Flux takes it back to 2, because the plan says 2. This enrages anyone coming from the imperative world, and it is precisely the point: if you want 4 replicas permanently, you update the plan. The cluster stops being a system with its own opaque memory and becomes a &lt;strong>reproducible projection of git&lt;/strong>. Delete the entire cluster, point a fresh Flux at the same repo, and the site is rebuilt identically.&lt;/p>
&lt;p>The analogy also marks the boundary: the plan describes the building, not who holds the keys to the materials store. &lt;strong>Secrets&lt;/strong> (passwords, tokens, keys) cannot go into the public plan. That is the chicken-and-egg problem we cover further down and that the &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening piece&lt;/a> develops in full.&lt;/p>
&lt;h2 id="the-problem-an-assistant-is-six-services-not-one">The problem: an assistant is six services, not one&lt;/h2>
&lt;p>Before Flux, it is worth fixing the size of the problem. A minimally serious sovereign LLM assistant, deployed on the generic reference cluster, has this service topology:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Service&lt;/th>
&lt;th>Function&lt;/th>
&lt;th>Typical namespace&lt;/th>
&lt;th>State&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Inference engine (vLLM)&lt;/td>
&lt;td>Serves the tokens of the general LLM and the code one&lt;/td>
&lt;td>&lt;code>llm-serving&lt;/code>&lt;/td>
&lt;td>Stateless (model from object store)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings + reranker (TEI)&lt;/td>
&lt;td>Vectorises queries and reorders candidates for RAG&lt;/td>
&lt;td>&lt;code>llm-serving&lt;/code>&lt;/td>
&lt;td>Stateless&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vector store (Qdrant)&lt;/td>
&lt;td>Stores and searches document embeddings&lt;/td>
&lt;td>&lt;code>data&lt;/code>&lt;/td>
&lt;td>&lt;strong>Stateful&lt;/strong> (PVC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>L7 gateway (Envoy AI Gateway / LiteLLM)&lt;/td>
&lt;td>Per-model routing, rate limiting, auth&lt;/td>
&lt;td>&lt;code>gateway&lt;/code>&lt;/td>
&lt;td>Semi-stateless&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chat front end&lt;/td>
&lt;td>Assistant UI&lt;/td>
&lt;td>&lt;code>apps&lt;/code>&lt;/td>
&lt;td>Stateless&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Observability (Langfuse, OTel Collector, dashboards)&lt;/td>
&lt;td>LLM-aware traces, metrics and logs&lt;/td>
&lt;td>&lt;code>observability&lt;/code>&lt;/td>
&lt;td>Stateful (Langfuse Postgres)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Six services, four or five namespaces, two components with persistent state, and a real dependency graph: the front end calls the gateway, the gateway calls the engine and the embeddings service, RAG depends on the vector store being &lt;strong>full&lt;/strong>, and everything emits traces to observability. Deploying this by hand means remembering the order, the values of each &lt;code>helm install&lt;/code>, the &lt;code>ConfigMap&lt;/code>s, the &lt;code>Secret&lt;/code>s, the &lt;code>nodeSelector&lt;/code>s that pin the GPU pods to the right nodes. Do it twice (a staging environment and a production one) and the two diverge within days. That is exactly what GitOps eliminates.&lt;/p>
&lt;h2 id="the-six-flux-controllers">The six Flux controllers&lt;/h2>
&lt;p>Flux is not a monolithic binary but a set of cooperating controllers, the &lt;em>GitOps Toolkit&lt;/em>. A default installation brings four; the two image automation ones are added with &lt;code>--components-extra&lt;/code> (&lt;a href="https://fluxcd.io/flux/installation/">Flux installation&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>source-controller&lt;/strong>: clones and keeps the sources up to date, whether git repos (&lt;code>GitRepository&lt;/code>), Helm repos (&lt;code>HelmRepository&lt;/code>), buckets or OCI artifacts. It is the one &amp;ldquo;holding the plan&amp;rdquo;: it exposes the repo contents as an internal artifact that the others consume.&lt;/li>
&lt;li>&lt;strong>kustomize-controller&lt;/strong>: takes a source artifact, renders Kustomize (bases + overlays) and applies the result to the cluster. It is responsible for &lt;code>prune&lt;/code>, &lt;code>dependsOn&lt;/code> and the health checks of the &lt;code>Kustomization&lt;/code>s.&lt;/li>
&lt;li>&lt;strong>helm-controller&lt;/strong>: reconciles &lt;code>HelmRelease&lt;/code> objects, installing and updating Helm charts declaratively, without anybody running &lt;code>helm&lt;/code> from a terminal (&lt;a href="https://github.com/fluxcd/helm-controller">helm-controller&lt;/a>).&lt;/li>
&lt;li>&lt;strong>notification-controller&lt;/strong>: the bridge to the outside world in both directions. It receives webhooks (to reconcile instantly on every push, instead of waiting for the &lt;code>interval&lt;/code>) and emits events and alerts to Slack, an internal chat or an incident system.&lt;/li>
&lt;li>&lt;strong>image-reflector-controller&lt;/strong>: scans container registries and stores the tags it finds in an internal database. It is the &amp;ldquo;eyes&amp;rdquo; of image automation.&lt;/li>
&lt;li>&lt;strong>image-automation-controller&lt;/strong>: uses what the eyes see to write back to git, committing the new image tag into the manifests when a version appears that satisfies the policy.&lt;/li>
&lt;/ul>
&lt;p>A cluster without image automation does not need the last two. But for an inference stack where the model server is updated fairly often, they are what automates version promotion without touching anything by hand.&lt;/p>
&lt;div class="diagram" style="max-width:860px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 860 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="GitOps loop: git, source-controller, kustomize and helm controllers, cluster, drift and image automation writing back to git">
&lt;defs>
&lt;marker id="ga" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>&lt;/marker>
&lt;marker id="gb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#8b5cf6"/>&lt;/marker>
&lt;marker id="gr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#ef4444"/>&lt;/marker>
&lt;/defs>
&lt;text x="430" y="26" text-anchor="middle" font-size="14" font-weight="700" fill="currentColor">The Flux reconciliation loop&lt;/text>
&lt;rect x="40" y="60" width="150" height="80" rx="8" fill="#3b82f6" fill-opacity="0.12" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="115" y="92" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">git&lt;/text>
&lt;text x="115" y="112" text-anchor="middle" font-size="10" fill="currentColor">master plan&lt;/text>
&lt;text x="115" y="127" text-anchor="middle" font-size="10" fill="currentColor">desired state&lt;/text>
&lt;rect x="270" y="50" width="180" height="46" rx="6" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="360" y="70" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">source-controller&lt;/text>
&lt;text x="360" y="86" text-anchor="middle" font-size="9.5" fill="currentColor">clones the repo · artifact&lt;/text>
&lt;rect x="270" y="106" width="180" height="46" rx="6" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="360" y="126" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">kustomize-controller&lt;/text>
&lt;text x="360" y="142" text-anchor="middle" font-size="9.5" fill="currentColor">renders overlays · applies&lt;/text>
&lt;rect x="270" y="162" width="180" height="46" rx="6" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="360" y="182" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">helm-controller&lt;/text>
&lt;text x="360" y="198" text-anchor="middle" font-size="9.5" fill="currentColor">reconciles HelmRelease&lt;/text>
&lt;rect x="560" y="90" width="170" height="120" rx="8" fill="#f59e0b" fill-opacity="0.12" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="645" y="118" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">cluster&lt;/text>
&lt;text x="645" y="138" text-anchor="middle" font-size="10" fill="currentColor">actual state&lt;/text>
&lt;text x="645" y="158" text-anchor="middle" font-size="9.5" fill="currentColor">engine · gateway&lt;/text>
&lt;text x="645" y="173" text-anchor="middle" font-size="9.5" fill="currentColor">front end · vector store&lt;/text>
&lt;text x="645" y="188" text-anchor="middle" font-size="9.5" fill="currentColor">observability&lt;/text>
&lt;path d="M190,90 L268,75" fill="none" stroke="#64748b" stroke-width="1.6" marker-end="url(#ga)"/>
&lt;path d="M450,129 L558,140" fill="none" stroke="#64748b" stroke-width="1.6" marker-end="url(#ga)"/>
&lt;path d="M450,185 L558,165" fill="none" stroke="#64748b" stroke-width="1.6" marker-end="url(#ga)"/>
&lt;path d="M645,210 C645,250 360,250 360,213" fill="none" stroke="#ef4444" stroke-width="1.8" stroke-dasharray="5 3" marker-end="url(#gr)"/>
&lt;text x="430" y="268" text-anchor="middle" font-size="10" fill="#ef4444">reconcile every interval: compares actual vs desired, fixes drift, prunes&lt;/text>
&lt;rect x="270" y="312" width="180" height="58" rx="6" fill="#8b5cf6" fill-opacity="0.12" stroke="#8b5cf6" stroke-width="1.6"/>
&lt;text x="360" y="334" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">image automation&lt;/text>
&lt;text x="360" y="350" text-anchor="middle" font-size="9.5" fill="currentColor">reflector scans registry&lt;/text>
&lt;text x="360" y="364" text-anchor="middle" font-size="9.5" fill="currentColor">policy picks tag · commit&lt;/text>
&lt;path d="M560,200 C500,300 460,320 452,335" fill="none" stroke="#8b5cf6" stroke-width="1.6" marker-end="url(#gb)"/>
&lt;text x="540" y="300" text-anchor="middle" font-size="9.5" fill="#8b5cf6">registry: new tag&lt;/text>
&lt;path d="M268,338 C160,330 115,250 115,142" fill="none" stroke="#8b5cf6" stroke-width="1.8" marker-end="url(#gb)"/>
&lt;text x="120" y="250" text-anchor="middle" font-size="9.5" fill="#8b5cf6">writes back&lt;/text>
&lt;text x="120" y="263" text-anchor="middle" font-size="9.5" fill="#8b5cf6">to git (commit)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The diagram has three paths. The &lt;strong>grey&lt;/strong> one is the main flow: git → source-controller → kustomize/helm-controller → cluster. The &lt;strong>dashed red&lt;/strong> one is the reconciliation loop that runs every &lt;code>interval&lt;/code>: it compares actual state against desired, fixes drift and prunes what is left over. The &lt;strong>purple&lt;/strong> one is image automation: the reflector sees a new tag in the registry, the policy decides whether it qualifies, and the automation-controller &lt;strong>writes back to git&lt;/strong>. That last path is the one that closes the circle and turns git into a system that updates itself.&lt;/p>
&lt;h2 id="repo-structure-clusters-infrastructure-apps">Repo structure: clusters, infrastructure, apps&lt;/h2>
&lt;p>The most widespread convention separates three levels of responsibility. It is not the only one, but it ages well:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">gitops-repo/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── clusters/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── prod/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── infrastructure.yaml # Kustomization → ./infrastructure
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── apps.yaml # Kustomization → ./apps (dependsOn infrastructure)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── infrastructure/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── controllers/ # ingress, cert-manager, GPU operator, KEDA...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── configs/ # ClusterIssuer, RuntimeClass, StorageClass...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── apps/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── base/ # manifests common to all environments
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── llm-engine/ # vLLM HelmRelease
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── gateway/ # L7 gateway HelmRelease
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── chat-front/ # Deployment + Service + Ingress
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── vector-store/ # Qdrant HelmRelease (+ PVC)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── observability/ # Langfuse + OTel HelmRelease
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── staging/ # overlay: low replicas, small model
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── prod/ # overlay: high replicas, large model, MIG
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The key piece is Kustomize&amp;rsquo;s &lt;strong>base / overlay&lt;/strong> separation. The &lt;code>base/&lt;/code> describes the service once; each overlay (&lt;code>staging/&lt;/code>, &lt;code>prod/&lt;/code>) applies a &lt;code>patch&lt;/code> with the environment&amp;rsquo;s differences: number of replicas, model size, MIG profile, &lt;code>nodeSelector&lt;/code>, &lt;code>--gpu-memory-utilization&lt;/code>. That avoids duplicating complete manifests per environment. The &lt;code>clusters/&lt;/code> folder is the entry point Flux reconciles first: it holds the root &lt;code>Kustomization&lt;/code>s that point to &lt;code>infrastructure/&lt;/code> and &lt;code>apps/&lt;/code>, with a &lt;code>dependsOn&lt;/code> that guarantees the infrastructure (CRDs, operators, storage classes) is ready &lt;strong>before&lt;/strong> the applications.&lt;/p>
&lt;h3 id="how-many-manifests-the-calculation-that-justifies-overlays">How many manifests: the calculation that justifies overlays&lt;/h3>
&lt;p>This is where the numbers make the argument. Without overlays, each service needs a complete set of manifests per environment. With $N$ services and $M$ environments, the cost in files to maintain is:&lt;/p>
$$
\text{files}_{\text{naïve}} = N \times M \times k
$$
&lt;p>where $k$ is the average number of manifests per service (Deployment/HelmRelease + Service + ConfigMap + PVC + Ingress ≈ 5). For our assistant, $N=6$ services and $M=3$ environments (dev, staging, prod), with $k=5$:&lt;/p>
$$
6 \times 3 \times 5 = 90 \text{ complete files, each maintained separately.}
$$
&lt;p>With the base/overlay structure, the base is written once and each overlay contains only the &lt;code>patch&lt;/code> with the differences (typically 1 short file per service per environment):&lt;/p>
$$
\text{files}_{\text{overlay}} = \underbrace{N \times k}_{\text{base}} + \underbrace{N \times M}_{\text{patches}} = 6 \times 5 + 6 \times 3 = 30 + 18 = 48
$$
&lt;p>It is not just that there are fewer files (48 against 90): it is that &lt;strong>the bulk of a change happens in a single place&lt;/strong>. Changing the engine&amp;rsquo;s memory limit for every environment is one edit in &lt;code>base/&lt;/code>, not three synchronised edits. The saving factor grows with $M$: for 5 environments, the naïve version is 150 files and the overlay one is 60. Duplication is the enemy of auditability, and overlays attack it at the root.&lt;/p>
&lt;h2 id="the-reconciliation-loop-desired-vs-actual">The reconciliation loop: desired vs actual&lt;/h2>
&lt;p>The heart of Flux is a control loop that never ends: every &lt;code>interval&lt;/code> it reads the desired state (git), reads the actual state (cluster), computes the difference and applies it. It is the same principle as a &lt;strong>thermostat&lt;/strong>: it reads the target temperature (git), reads the actual temperature (cluster) and switches the boiler on or off until they match. It does not &amp;ldquo;deploy once&amp;rdquo;; it converges forever.&lt;/p>
&lt;p>A &lt;code>Kustomization&lt;/code> manifest for the applications, with the pieces that matter (&lt;a href="https://fluxcd.io/flux/components/kustomize/kustomizations/">Kustomization reference&lt;/a>):&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kustomize.toolkit.fluxcd.io/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Kustomization&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&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">spec&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">interval: 1m # reconciles every minute&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sets the drift MTTR&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">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">./apps/prod &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># production overlay&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">prune&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># deletes from the cluster whatever is removed from git&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">sourceRef&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GitRepository&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gitops-repo&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">dependsOn&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">infrastructure &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># does not apply apps until infra is Ready&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">wait&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># waits for the resources to be healthy&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">5m &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># fails the reconciliation if it does not converge in 5 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="nt">healthChecks&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Four fields do the heavy lifting:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>interval&lt;/code>&lt;/strong> defines how often the foreman&amp;rsquo;s round runs. It is the direct determinant of the drift MTTR (next section).&lt;/li>
&lt;li>&lt;strong>&lt;code>prune: true&lt;/code>&lt;/strong> switches on garbage collection: objects that were applied before but are no longer in the current git revision are deleted from the cluster automatically. Without &lt;code>prune&lt;/code>, a service withdrawn from the repo keeps running, and the plan and the site diverge silently.&lt;/li>
&lt;li>&lt;strong>&lt;code>dependsOn&lt;/code>&lt;/strong> orders the graph: Flux does not apply the manifests of a &lt;code>Kustomization&lt;/code> until every referenced one is in state &lt;code>Ready: True&lt;/code>. It is the mechanism that guarantees data → gateway → front end.&lt;/li>
&lt;li>&lt;strong>&lt;code>wait&lt;/code> + &lt;code>healthChecks&lt;/code>&lt;/strong>: with &lt;code>wait: true&lt;/code>, Flux monitors every applied resource and waits for them to be ready before marking the reconciliation as successful; &lt;code>healthChecks&lt;/code> lets you tune exactly which resources to watch. That is what makes a &lt;code>dependsOn&lt;/code> mean something: the dependency is not satisfied until it is &lt;strong>healthy&lt;/strong>, not merely applied.&lt;/li>
&lt;/ul>
&lt;h3 id="mttr-and-drift-detection-as-a-function-of-the-interval">MTTR and drift detection as a function of the interval&lt;/h3>
&lt;p>The &lt;code>interval&lt;/code> is the only parameter the operator chooses to govern the speed of the loop, and it translates directly into operational metrics. If a deviation (drift) happens at a random instant within the reconciliation period $T$, the waiting time until Flux detects it is uniformly distributed over $[0, T]$, with mean:&lt;/p>
$$
\mathbb{E}[t_{\text{detection}}] = \frac{T}{2}
$$
&lt;p>To that you add the correction time $t_c$ (render, diff, apply), normally a few seconds. The drift MTTR comes out as:&lt;/p>
$$
\text{MTTR}_{\text{drift}} = \frac{T}{2} + t_c
$$
&lt;p>With &lt;code>interval=1m&lt;/code> and $t_c \approx 10\text{s}$, drift is corrected in $\frac{60}{2} + 10 = 40$ s on average, with a worst case of $60 + 10 = 70$ s. With &lt;code>interval=10m&lt;/code>, the mean rises to $5\text{min}\;10\text{s}$ and the worst case to more than 10 min: a window in which a manual change stays active. The temptation is to set &lt;code>interval=10s&lt;/code> and forget about it, but there is a cost: every reconciliation consumes CPU and, above all, makes requests to the cluster API and to the registry. With dozens of &lt;code>Kustomization&lt;/code>s reconciling every 10 s, the &lt;code>kube-apiserver&lt;/code> and the image-reflector start to feel the pressure. The practical rule: a short &lt;code>interval&lt;/code> (1m) for critical applications whose drift hurts, a long &lt;code>interval&lt;/code> (10–30m) for stable infrastructure that almost never changes, and notification-controller &lt;strong>webhooks&lt;/strong> for instant reconciliation on every push, so that the &lt;code>interval&lt;/code> governs only drift and not deployment latency.&lt;/p>
&lt;h2 id="helm-as-code-the-inference-engine-helmrelease">Helm as code: the inference engine HelmRelease&lt;/h2>
&lt;p>For the engine, the gateway and the front end, the usual approach is to package them as Helm charts and declare them with &lt;code>HelmRelease&lt;/code>. The helm-controller reconciles them without anyone ever running &lt;code>helm&lt;/code>:&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">helm.toolkit.fluxcd.io/v2&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HelmRelease&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-engine&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-serving&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">spec&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">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">10m&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">chart&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">spec&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">chart&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">sourceRef&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HelmRepository&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-charts&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">values&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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;0.8.4&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># managed by image automation (see below)&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">replicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&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">nodeSelector&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">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">H100-SXM&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">resources&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">limits&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">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&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">extraArgs&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="s2">&amp;#34;--gpu-memory-utilization=0.90&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="s2">&amp;#34;--tensor-parallel-size=1&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Everything that in the imperative world would be a &lt;code>vllm serve&lt;/code> flag, &lt;code>--gpu-memory-utilization&lt;/code> or &lt;code>--tensor-parallel-size&lt;/code>, now lives as versioned data. Changing the fraction of VRAM the engine reserves is a commit, reviewable in a PR, with history. The relationship with &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU&lt;/a> is direct: the MIG profile and the &lt;code>--gpu-memory-utilization&lt;/code> are deployment parameters, and here they are code.&lt;/p>
&lt;h2 id="image-automation-bumping-the-tag-without-touching-anything-by-hand">Image automation: bumping the tag without touching anything by hand&lt;/h2>
&lt;p>The inference engine is updated fairly often (vLLM patches, security fixes). Without automation, every new version requires somebody to edit the &lt;code>tag&lt;/code> in the &lt;code>HelmRelease&lt;/code>. Flux image automation does it on its own, with three objects (&lt;a href="https://fluxcd.io/flux/guides/image-update/">automate image updates&lt;/a>):&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">image.toolkit.fluxcd.io/v1beta2&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ImageRepository&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">metadata&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">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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&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">spec&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">registry.example.local/inference/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">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5m &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># scans the registry tags every 5 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="nn">---&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">image.toolkit.fluxcd.io/v1beta2&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ImagePolicy&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">metadata&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">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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&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">spec&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">imageRepositoryRef&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">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">policy&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">semver&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">range&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;&amp;gt;=0.8.0 &amp;lt;0.9.0&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># only patches and minors within 0.8.x–0.8.x&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="nn">---&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">image.toolkit.fluxcd.io/v1beta1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ImageUpdateAutomation&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">metadata&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">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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">flux-system&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">spec&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">interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">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="nt">sourceRef&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GitRepository&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gitops-repo&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">git&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">commit&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">author&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fluxbot&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">email&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fluxbot@example.local&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">messageTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;auto: bump vllm to {{ .NewTag }}&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The division of roles: &lt;strong>&lt;code>ImageRepository&lt;/code>&lt;/strong> scans all the tags of the image repository and stores them in the reflector&amp;rsquo;s internal database. &lt;strong>&lt;code>ImagePolicy&lt;/code>&lt;/strong> reads those tags and picks the &amp;ldquo;latest&amp;rdquo; according to the policy; the &lt;code>policy&lt;/code> field is mandatory and defines how the selection is made (&lt;a href="https://fluxcd.io/flux/components/image/imagepolicies/">ImagePolicy&lt;/a>). &lt;strong>&lt;code>ImageUpdateAutomation&lt;/code>&lt;/strong> takes the chosen tag, edits the manifest in the repo (wherever there is a &lt;code># {&amp;quot;$imagepolicy&amp;quot;: &amp;quot;flux-system:vllm&amp;quot;}&lt;/code> marker) and commits (&lt;a href="https://fluxcd.io/flux/components/image/imageupdateautomations/">ImageUpdateAutomation&lt;/a>).&lt;/p>
&lt;p>The choice of policy matters:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>semver&lt;/strong>: interprets tags as semantic versions and picks the highest one that satisfies the range (&lt;code>range: &amp;quot;&amp;gt;=0.8.0 &amp;lt;0.9.0&amp;quot;&lt;/code>). By default it excludes prereleases (&lt;code>0.8.0-rc.1&lt;/code> does not qualify) unless they are asked for explicitly. It is the right option for production: you stay inside a tested version range and do not jump to a new major by accident.&lt;/li>
&lt;li>&lt;strong>regex / alphabetical / numerical&lt;/strong>: for tag schemes that are not pure semver, for example &lt;code>main-&amp;lt;sha&amp;gt;-&amp;lt;timestamp&amp;gt;&lt;/code> from an internal pipeline. More flexible, but it forces you to trust that tag order reflects version order, which is fragile.&lt;/li>
&lt;/ul>
&lt;p>A safety guideline: instead of having &lt;code>ImageUpdateAutomation&lt;/code> push directly to &lt;code>main&lt;/code>, configure it to write to a &lt;strong>branch&lt;/strong> and open a &lt;strong>PR&lt;/strong>. That way the version bump goes through human review or a validation pipeline before reaching the cluster. Automation proposes; the human (or an automatic gate) disposes. And for production, the version bump is not the deployment: it is the trigger for the progressive rollout described in &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary, blue-green and shadow&lt;/a>.&lt;/p>
&lt;h2 id="secrets-in-gitops-the-chicken-and-egg-problem">Secrets in GitOps: the chicken-and-egg problem&lt;/h2>
&lt;p>GitOps demands that all the desired state is in git. But secrets, the Langfuse Postgres password, the registry token, the gateway API keys, cannot go into a repo in the clear, not even a private one: git history is immutable, and a secret committed once stays there forever. This is the fundamental tension: GitOps wants everything in git; security forbids secrets in git. There are three families of solution, all with the same underlying idea, that &lt;strong>only the encrypted secret goes into git and the cluster holds the key to decrypt it&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SOPS + age/KMS&lt;/strong>: you encrypt the sensitive values of a manifest with &lt;a href="https://github.com/getsops/sops">SOPS&lt;/a> (it leaves the keys readable and encrypts only the values). The encrypted YAML goes to git; the kustomize-controller carries an age key or reaches a KMS to decrypt it at apply time. Simple, with no extra components in the cluster.&lt;/li>
&lt;li>&lt;strong>sealed-secrets&lt;/strong>: a controller in the cluster holds a private key. You encrypt the secret against its public key (with &lt;code>kubeseal&lt;/code>), the encrypted &lt;code>SealedSecret&lt;/code> goes to git, and the controller decrypts it into a normal &lt;code>Secret&lt;/code> inside the cluster. The private key never leaves the cluster.&lt;/li>
&lt;li>&lt;strong>External Secrets + Vault&lt;/strong>: not even the encrypted secret goes into git, only a &lt;strong>reference&lt;/strong> (&lt;code>ExternalSecret&lt;/code>) saying &amp;ldquo;the value of this key is in Vault, at this path&amp;rdquo;. The External Secrets operator resolves it at apply time. It is the cleanest pattern for many secrets and frequent rotation, at the cost of operating a Vault.&lt;/li>
&lt;/ul>
&lt;p>The choice depends on the volume of secrets and on whether you already have a central manager. For a small stack, SOPS+age is enough and has no dependencies. The &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">sibling hardening piece&lt;/a> goes into the detail of each option, key rotation and the threat model.&lt;/p>
&lt;h2 id="promotion-and-rollback-git-revert-is-the-panic-button">Promotion and rollback: git revert is the panic button&lt;/h2>
&lt;p>The most elegant consequence of having the cluster as a projection of git is that &lt;strong>rollback is a &lt;code>git revert&lt;/code>&lt;/strong>. If a deployment breaks production, the new engine gives worse latency or the gateway starts returning 5xx, there is no need to remember which version was there before or to rebuild the state by hand. You revert the commit that introduced the change, and on the next reconciliation (≤ &lt;code>interval&lt;/code>, or instantly with a webhook) Flux returns the cluster to the previous state. The git history is, literally, the deployment history: every commit is a restore point with author, date and reason.&lt;/p>
&lt;p>This fits with the promotion strategies of the &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary, blue-green and shadow&lt;/a> post: Flux manages &lt;strong>what is declared&lt;/strong>, and the progressive rollout tool manages &lt;strong>how traffic moves&lt;/strong> between the old version and the new one. The git revert is the last-resort rollback (it returns everything to a known state); the canary is the mechanism that avoids needing it. For services where the version change is delicate, the right approach is for image automation to do the bump on a branch, for the canary to validate the regression gates, and only then to promote the commit to &lt;code>main&lt;/code>.&lt;/p>
&lt;h2 id="the-seams-gitops-is-not-free">The seams: GitOps is not free&lt;/h2>
&lt;p>It would be dishonest to sell GitOps as magic. It has real costs that any team pays:&lt;/p>
&lt;p>&lt;strong>Learning curve.&lt;/strong> The declarative model is a change of mindset. The operator who has spent years fixing incidents with &lt;code>kubectl edit&lt;/code> has to unlearn the reflex: now the cluster reverts their changes and that, at first, feels like Flux &amp;ldquo;fighting them&amp;rdquo;. Understanding that the change is made in git, not in the cluster, takes weeks of discomfort.&lt;/p>
&lt;p>&lt;strong>Debugging the reconciler.&lt;/strong> When a &lt;code>Kustomization&lt;/code> does not apply, the error is not in the pod; it is in the reconciliation chain. Did the source-controller clone the right revision? Did the kustomize-controller render the overlay properly? Did a &lt;code>dependsOn&lt;/code> health check fail? Did the timeout fire before the large model finished loading? Diagnosing this needs &lt;code>flux get&lt;/code>, &lt;code>flux logs&lt;/code>, reading the &lt;code>status.conditions&lt;/code> of the Flux objects and understanding which link got stuck. It is a new skill, different from debugging plain Kubernetes.&lt;/p>
&lt;p>&lt;strong>Timeouts and slow loads.&lt;/strong> The &lt;code>timeout&lt;/code> of a &lt;code>Kustomization&lt;/code> with &lt;code>wait: true&lt;/code> has to be longer than the start-up time of the slowest service. An inference engine that takes 4 minutes to load a large model from the object store will fail a &lt;code>Kustomization&lt;/code> with &lt;code>timeout: 2m&lt;/code>, even though everything is fine. Calibrating timeouts per service is fine-grained work.&lt;/p>
&lt;p>&lt;strong>The drift you did want.&lt;/strong> Sometimes the operator &lt;em>needs&lt;/em> an urgent temporary change and Flux reverts it. The correct answer, suspending reconciliation of that &lt;code>Kustomization&lt;/code> with &lt;code>flux suspend&lt;/code>, making the change, then reflecting it in git and resuming, is discipline that has to be built. Without that discipline, people end up fighting the foreman at 3 in the morning.&lt;/p>
&lt;p>None of these costs cancels the benefit. But a team adopting GitOps expecting &amp;ldquo;everything to be easier from day one&amp;rdquo; gets frustrated. It is easier &lt;strong>from month two onwards&lt;/strong>, once auditability, reproducibility and trivial rollback have paid off the curve.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>On the reference cluster (4×H100 SXM 80 GB per GPU node, NVLink, plus CPU and control nodes), the assistant stack is managed entirely by GitOps. Hardware decisions become data in the overlays.&lt;/p>
&lt;p>&lt;strong>nodeSelectors and MIG profiles as code.&lt;/strong> The &lt;code>prod&lt;/code> overlay pins each service to the right node and declares the GPU profile. The general engine uses a whole GPU; embeddings and the small LLM fit in MIG slices, exactly the split from the &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU&lt;/a> post, but now versioned:&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"># apps/prod/llm-engine-patch.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">spec&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">values&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">nodeSelector&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">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">H100-SXM&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">extraArgs&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="s2">&amp;#34;--gpu-memory-utilization=0.92&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="s2">&amp;#34;--tensor-parallel-size=1&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="nn">---&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="c"># apps/prod/embeddings-patch.yaml — on a MIG slice&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">spec&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">values&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">nodeSelector&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">nvidia.com/mig.config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;3g.40gb&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">resources&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">limits&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">nvidia.com/mig-3g.40gb&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The MIG profile (&lt;code>3g.40gb&lt;/code>), the VRAM fraction (&lt;code>0.92&lt;/code>), the tensor-parallel setting: all of it is text in a PR. Changing the GPU split between services is a reviewable commit, not a hand-run &lt;code>nvidia-smi mig&lt;/code> session that nobody records.&lt;/p>
&lt;p>&lt;strong>Start-up order with dependsOn.&lt;/strong> The assistant&amp;rsquo;s dependency graph is encoded with chained &lt;code>dependsOn&lt;/code>. The right order is &lt;strong>data → embeddings/engine → gateway → front end&lt;/strong>:&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 180" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="start-up order with dependsOn: data, engine and embeddings, gateway, front end">
&lt;defs>
&lt;marker id="da" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>&lt;/marker>
&lt;/defs>
&lt;text x="410" y="26" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Assistant start-up order via dependsOn&lt;/text>
&lt;rect x="20" y="60" width="150" height="70" rx="8" fill="#8b5cf6" fill-opacity="0.12" stroke="#8b5cf6" stroke-width="1.6"/>
&lt;text x="95" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">data&lt;/text>
&lt;text x="95" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">vector store&lt;/text>
&lt;text x="95" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">Langfuse Postgres&lt;/text>
&lt;rect x="220" y="60" width="160" height="70" rx="8" fill="#f59e0b" fill-opacity="0.12" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="300" y="84" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">engine + embeddings&lt;/text>
&lt;text x="300" y="102" text-anchor="middle" font-size="9.5" fill="currentColor">vLLM (whole GPU)&lt;/text>
&lt;text x="300" y="116" text-anchor="middle" font-size="9.5" fill="currentColor">TEI (MIG slice)&lt;/text>
&lt;rect x="430" y="60" width="150" height="70" rx="8" fill="#3b82f6" fill-opacity="0.12" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="505" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">L7 gateway&lt;/text>
&lt;text x="505" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">per-model routing&lt;/text>
&lt;text x="505" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">rate-limit · auth&lt;/text>
&lt;rect x="630" y="60" width="150" height="70" rx="8" fill="#22c55e" fill-opacity="0.12" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="705" y="88" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">chat front end&lt;/text>
&lt;text x="705" y="106" text-anchor="middle" font-size="9.5" fill="currentColor">assistant UI&lt;/text>
&lt;text x="705" y="120" text-anchor="middle" font-size="9.5" fill="currentColor">exposed to the client&lt;/text>
&lt;path d="M170,95 L218,95" fill="none" stroke="#64748b" stroke-width="1.8" marker-end="url(#da)"/>
&lt;path d="M380,95 L428,95" fill="none" stroke="#64748b" stroke-width="1.8" marker-end="url(#da)"/>
&lt;path d="M580,95 L628,95" fill="none" stroke="#64748b" stroke-width="1.8" marker-end="url(#da)"/>
&lt;text x="194" y="150" text-anchor="middle" font-size="9" fill="currentColor">dependsOn&lt;/text>
&lt;text x="404" y="150" text-anchor="middle" font-size="9" fill="currentColor">dependsOn&lt;/text>
&lt;text x="604" y="150" text-anchor="middle" font-size="9" fill="currentColor">dependsOn&lt;/text>
&lt;/svg>
&lt;/div>
&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"># apps/prod/gateway.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">spec&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">dependsOn&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llm-engine &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the gateway is useless without the engine&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">embeddings&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="c"># ...&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="nn">---&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="c"># apps/prod/chat-front.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">spec&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">dependsOn&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the front end is useless without the gateway&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>wait: true&lt;/code> on each &lt;code>Kustomization&lt;/code>, Flux does not mark &lt;code>llm-engine&lt;/code> as &lt;code>Ready&lt;/code> until the engine pod passes its health check, which in the case of vLLM means &lt;strong>model loaded and endpoint responding&lt;/strong>, not just pod started. Only then does it begin applying the gateway. This avoids the classic cascade of failures of manual deployment: bring up the front end first, see a 502 because the gateway is not there, bring up the gateway, see a 503 because the engine is still loading the model. With &lt;code>dependsOn&lt;/code> + &lt;code>wait&lt;/code>, the order is guaranteed by the reconciler, not by the operator&amp;rsquo;s memory.&lt;/p>
&lt;p>The result: deleting the assistant&amp;rsquo;s entire namespace and letting Flux rebuild it from git produces exactly the same stack, in the same order, with the same GPU configuration. The 4×H100 cluster stops having a configuration known only to whoever built it, and becomes a reproducible projection of a repo that anyone can audit.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>GitOps is not a tool, it is an &lt;strong>inversion of the direction of control&lt;/strong>: instead of pushing changes to the cluster, you declare the state in git and let an agent pull the cluster towards it. For an LLM assistant (six services, several namespaces, start-up dependencies, delicate GPU configuration) that inversion turns a fragile and opaque system into a reproducible and auditable one. Flux is the foreman who keeps the site identical to the plan and undoes any change that has not gone through the plan first. The price is a real learning curve and a new debugging skill. The prize is that the cluster stops keeping secrets about itself: everything it is, is written down, signed and reproducible in git.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign LLM stack&lt;/a> — the sibling piece: the detail of SOPS, sealed-secrets and External Secrets, key rotation and the threat model of the chicken-and-egg problem we only state here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases of an on-premise LLM platform&lt;/a> — GitOps is phase F3 of that journey; here we deploy it, there it is placed in the full sequence.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the on-premise LLM platform&lt;/a> — moving from &lt;code>kubectl apply&lt;/code> to git as the sole authority is the level jump this post operationalises.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — the autoscaler coexists with GitOps: KEDA adjusts replicas by metric while Flux maintains the rest of the state; how they avoid fighting each other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the progressive promotion that image automation triggers and that git revert backs up as a last-resort rollback.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Kubelet resource managers in RKE2 and NUMA&lt;/a> — the &lt;code>nodeSelector&lt;/code>s and the topology of the GPU pods we declare here as code have their counterpart in kubelet policy.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing one GPU: time-slicing, MPS and MIG&lt;/a> — the MIG profiles and the &lt;code>--gpu-memory-utilization&lt;/code> that in this post are overlay data; there, the mechanics of why.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>OpenGitOps — GitOps principles — &lt;a href="https://opengitops.dev/">opengitops.dev&lt;/a>&lt;/li>
&lt;li>Flux installation — &lt;a href="https://fluxcd.io/flux/installation/">fluxcd.io/flux/installation&lt;/a>&lt;/li>
&lt;li>Flux Kustomization — &lt;a href="https://fluxcd.io/flux/components/kustomize/kustomizations/">fluxcd.io/flux/components/kustomize/kustomizations&lt;/a>&lt;/li>
&lt;li>helm-controller — &lt;a href="https://github.com/fluxcd/helm-controller">github.com/fluxcd/helm-controller&lt;/a>&lt;/li>
&lt;li>Flux Image Policies — &lt;a href="https://fluxcd.io/flux/components/image/imagepolicies/">fluxcd.io/flux/components/image/imagepolicies&lt;/a>&lt;/li>
&lt;li>Flux Image Update Automations — &lt;a href="https://fluxcd.io/flux/components/image/imageupdateautomations/">fluxcd.io/flux/components/image/imageupdateautomations&lt;/a>&lt;/li>
&lt;li>Automate image updates to Git — &lt;a href="https://fluxcd.io/flux/guides/image-update/">fluxcd.io/flux/guides/image-update&lt;/a>&lt;/li>
&lt;li>SOPS — &lt;a href="https://github.com/getsops/sops">github.com/getsops/sops&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster</title><link>https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/</link><pubDate>Tue, 09 Jun 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>Operational&lt;/strong> companion to &lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key&lt;/a>. That post explains the &lt;em>why&lt;/em> and the &lt;em>where&lt;/em>: the threat model, the five isolation families, which domain uses each. This one is the &lt;em>how&lt;/em>, with commands. If you have not read it, read it first: here I take for granted what the blast radius is, why &lt;code>bwrap&lt;/code> runs without root and what Tetragon watches. The procedure comes in two independent tracks, &lt;strong>client&lt;/strong> and &lt;strong>cluster&lt;/strong>, because, as the sibling post argues, the control extrapolates but the primitive gets rewritten.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Two reproducible procedures. &lt;strong>Client (workstation):&lt;/strong> install &lt;code>ai-jail&lt;/code> (it wraps &lt;code>bubblewrap&lt;/code>), generate the per-project &lt;code>.ai-jail&lt;/code>, audit with &lt;code>--dry-run&lt;/code>, fix the allowlists with &lt;code>--bootstrap&lt;/code>, use &lt;code>--lockdown&lt;/code> for anything you do not trust, and leave the agent without &lt;code>git push&lt;/code> permission. &lt;strong>Cluster (RKE2 with Cilium + Tetragon):&lt;/strong> put the pod baseline in place (unprivileged &lt;code>securityContext&lt;/code>, &lt;code>seccomp: RuntimeDefault&lt;/code>, default-deny &lt;code>NetworkPolicy&lt;/code>), put the untrusted agent&amp;rsquo;s pod in a microVM with &lt;code>runtimeClassName: kata&lt;/code>, and deploy the Tetragon &lt;code>TracingPolicy&lt;/code> files in &lt;strong>two phases&lt;/strong>: observe with &lt;code>action: Post&lt;/code> to raise the baseline, then promote to &lt;code>action: Sigkill&lt;/code> on &lt;code>tcp_connect&lt;/code> (egress) and &lt;code>security_file_open&lt;/code> (secret paths). The golden rule of the Tetragon phase: &lt;strong>adopt first, block later&lt;/strong>; never put a &lt;code>Sigkill&lt;/code> into production without having seen the events in observation mode first.&lt;/p>
&lt;h2 id="the-flow-of-the-two-tracks">The flow of the two tracks&lt;/h2>
&lt;div class="diagram" style="max-width:800px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 800 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two operational tracks: client (install, configure, bootstrap, lockdown) and cluster (baseline, RuntimeClass, observe, enforce)">
&lt;defs>&lt;marker id="rm2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">CLIENT track — workstation&lt;/text>
&lt;rect x="20" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="95" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">1 · Install&lt;/text>
&lt;text x="95" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">ai-jail + bwrap&lt;/text>
&lt;rect x="200" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="275" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">2 · .ai-jail&lt;/text>
&lt;text x="275" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">--dry-run&lt;/text>
&lt;rect x="380" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="455" y="56" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">3 · --bootstrap&lt;/text>
&lt;text x="455" y="72" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">allow/deny/ask&lt;/text>
&lt;rect x="560" y="36" width="150" height="46" rx="7" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="635" y="52" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">4 · lockdown&lt;/text>
&lt;text x="635" y="68" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#0d3a66">+ git with no push&lt;/text>
&lt;path d="M170,59 L198,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M350,59 L378,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M530,59 L558,59" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;line x1="20" y1="118" x2="780" y2="118" stroke="#ccc" stroke-width="1" stroke-dasharray="3 3"/>
&lt;text x="400" y="150" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">CLUSTER track — RKE2 + Cilium/Tetragon&lt;/text>
&lt;rect x="20" y="164" width="150" height="46" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="95" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#42208a">1 · Baseline&lt;/text>
&lt;text x="95" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#42208a">secCtx+NetPol&lt;/text>
&lt;rect x="200" y="164" width="150" height="46" rx="7" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="275" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#42208a">2 · RuntimeClass&lt;/text>
&lt;text x="275" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#42208a">kata microVM&lt;/text>
&lt;rect x="380" y="164" width="150" height="46" rx="7" fill="#fde9d6" stroke="#a85a00" stroke-width="1.6"/>
&lt;text x="455" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#8a4a00">3 · Observe&lt;/text>
&lt;text x="455" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#8a4a00">Tetragon · Post&lt;/text>
&lt;rect x="560" y="164" width="150" height="46" rx="7" fill="#fbd4b8" stroke="#a85a00" stroke-width="1.8"/>
&lt;text x="635" y="184" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#8a4a00">4 · Enforce&lt;/text>
&lt;text x="635" y="200" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#8a4a00">Tetragon · Sigkill&lt;/text>
&lt;path d="M170,187 L198,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M350,187 L378,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;path d="M530,187 L558,187" stroke="#666" stroke-width="1.5" fill="none" marker-end="url(#rm2)"/>
&lt;text x="400" y="236" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#555">adopt first (observe), block later (enforce)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h1 id="track-a--client-the-developers-workstation">Track A — Client (the developer&amp;rsquo;s workstation)&lt;/h1>
&lt;h2 id="a0--install-ai-jail-and-bubblewrap">A0 — Install ai-jail and bubblewrap&lt;/h2>
&lt;p>&lt;code>ai-jail&lt;/code> wraps the sandbox; on Linux it needs &lt;code>bubblewrap&lt;/code> separately, on macOS it needs no extra dependency.&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"># ai-jail (macOS and Linux)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">brew tap akitaonrails/tap &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> brew install ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or, with cargo:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cargo install ai-jail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or, with mise:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">mise use -g ubi:akitaonrails/ai-jail
&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"># bubblewrap on Linux (pick your distro)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo pacman -S bubblewrap &lt;span class="c1"># Arch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo apt install bubblewrap &lt;span class="c1"># Debian / Ubuntu&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">sudo dnf install bubblewrap &lt;span class="c1"># Fedora&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Check the binary is there and that &lt;code>bwrap&lt;/code> runs without root (it must not ask for &lt;code>sudo&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">ai-jail --version
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">bwrap --ro-bind / / --unshare-all &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;bwrap ok without root&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>bwrap&lt;/code> fails asking for privileges, your kernel has &lt;em>unprivileged user namespaces&lt;/em> disabled; enable them (&lt;code>sysctl kernel.unprivileged_userns_clone=1&lt;/code> on older Debian/Ubuntu) before continuing.&lt;/p>
&lt;h2 id="a1--the-per-project-ai-jail-file">A1 — The per-project .ai-jail file&lt;/h2>
&lt;p>On the first launch inside the project, &lt;code>ai-jail&lt;/code> creates a &lt;code>.ai-jail&lt;/code> (TOML) that is &lt;strong>committable to the repo&lt;/strong>: any colleague who clones inherits the same policy.&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="nb">cd&lt;/span> ~/Projects/mi-app
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail claude &lt;span class="c1"># creates .ai-jail and launches Claude Code inside the sandbox&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The generated file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># .ai-jail — sandbox configuration (commit it to the repo)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">command&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;claude&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="nx">rw_maps&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[]&lt;/span> &lt;span class="c"># extra writable directories&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">ro_maps&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="p">[]&lt;/span> &lt;span class="c"># extra read-only directories&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Before trusting the sandbox, audit it.&lt;/strong> &lt;code>--dry-run --verbose&lt;/code> prints every mount point, every isolation flag and the full &lt;code>bwrap&lt;/code> command, without executing anything:&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">ai-jail --dry-run --verbose claude
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Read the output and confirm three things: that &lt;code>$HOME&lt;/code> is mounted as a tmpfs (not the real one), that &lt;code>~/.ssh&lt;/code>, &lt;code>~/.aws&lt;/code> and &lt;code>~/.gnupg&lt;/code> &lt;strong>do not appear&lt;/strong> among the mounts, and that the only writable directory is the project one. If you need an extra directory:&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">ai-jail --rw-map ~/Projects/shared-lib claude &lt;span class="c1"># extra, writable&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail --map /opt/datasets claude &lt;span class="c1"># extra, read-only&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Other agents, same binary:&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">ai-jail codex
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail opencode
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail bash &lt;span class="c1"># bare shell to debug the sandbox&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ai-jail -- python script.py &lt;span class="c1"># any command&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="a2--permission-allowlists-with---bootstrap">A2 — Permission allowlists with &amp;ndash;bootstrap&lt;/h2>
&lt;p>&lt;code>--bootstrap&lt;/code> generates each agent&amp;rsquo;s permission configuration, with sensible allow/deny/ask, and takes a backup before overwriting:&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">ai-jail --bootstrap
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>What it produces, in summary:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Agent&lt;/th>
&lt;th>File&lt;/th>
&lt;th>Base policy&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Claude Code&lt;/td>
&lt;td>&lt;code>~/.claude/settings.json&lt;/code>&lt;/td>
&lt;td>&lt;strong>allow&lt;/strong>: &lt;code>git status/diff/log&lt;/code>, &lt;code>ls&lt;/code>, &lt;code>grep&lt;/code>, &lt;code>cargo&lt;/code>, &lt;code>npm&lt;/code>, &lt;code>python&lt;/code>, &lt;code>docker compose&lt;/code> · &lt;strong>ask&lt;/strong>: &lt;code>git push&lt;/code>, &lt;code>rm&lt;/code>, &lt;code>docker run&lt;/code> · &lt;strong>deny&lt;/strong>: &lt;code>rm -rf&lt;/code>, &lt;code>sudo&lt;/code>, &lt;code>chmod 777&lt;/code>, &lt;code>git push --force&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Codex&lt;/td>
&lt;td>&lt;code>~/.codex/config.toml&lt;/code>&lt;/td>
&lt;td>&lt;code>approval_policy = &amp;quot;on-request&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenCode&lt;/td>
&lt;td>&lt;code>~/.config/opencode/opencode.json&lt;/code>&lt;/td>
&lt;td>permissions for &lt;code>bash&lt;/code>, &lt;code>edit&lt;/code>, &lt;code>write&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational key: &lt;code>git push&lt;/code> is under &lt;strong>ask&lt;/strong>, not &lt;strong>allow&lt;/strong>, and &lt;code>git push --force&lt;/code> under &lt;strong>deny&lt;/strong>. The agent can commit, branch and rebase locally as much as it wants; none of that touches the remote. (If you use the Claude Code &lt;code>/sandbox&lt;/code>, also set &lt;code>&amp;quot;allowUnsandboxedCommands&amp;quot;: false&lt;/code> to close the &lt;code>dangerouslyDisableSandbox&lt;/code> &lt;em>escape hatch&lt;/em>, which is opt-out out of the box.)&lt;/p>
&lt;h2 id="a3--lockdown-for-anything-you-do-not-trust">A3 — Lockdown for anything you do not trust&lt;/h2>
&lt;p>To audit third-party code or run an agent over a project you do not know, &lt;code>--lockdown&lt;/code> goes further: project mounted &lt;strong>read-only&lt;/strong>, GPU/Docker/display disabled, &lt;code>--rw-map&lt;/code>/&lt;code>--map&lt;/code> ignored, &lt;code>$HOME&lt;/code> a pure tmpfs with no host dotfiles, network cut with &lt;code>--unshare-net&lt;/code> and the environment wiped with &lt;code>--clearenv&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">ai-jail --lockdown bash
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the most restrictive sandbox possible short of a VM. Use it as your mental default for anything that is not your own code on your own machine.&lt;/p>
&lt;h2 id="a4--the-safety-net-git-with-no-push">A4 — The safety net: git with no push&lt;/h2>
&lt;p>It is not a flag, it is a property of the environment that changes the risk arithmetic. If the project is in git with a remote, and the agent does &lt;strong>not&lt;/strong> have &lt;code>push&lt;/code> permission, the worst case, that it corrupts every file in the project, is reverted with:&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">git checkout . &lt;span class="c1"># back to the last commit&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and if it touched .git (unlikely): delete the dir and re-clone&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The remote was never touched. &lt;strong>Sandbox for the filesystem + git for the code + manual push&lt;/strong> is already a reasonable level for daily use: &lt;code>ai-jail&lt;/code> protects your data and the system, git protects the code, and the decision to publish is still yours.&lt;/p>
&lt;hr>
&lt;h1 id="track-b--cluster-rke2-with-cilium--tetragon">Track B — Cluster (RKE2 with Cilium + Tetragon)&lt;/h1>
&lt;p>The untrusted agent, or the inference that executes generated code, runs as a pod. The same principle as on the client, different primitives. We assume a generic RKE2 cluster with Cilium as CNI and Tetragon already deployed (the eBPF agent &lt;code>DaemonSet&lt;/code> on every node).&lt;/p>
&lt;h2 id="b0--the-pod-baseline">B0 — The pod baseline&lt;/h2>
&lt;p>Before any eBPF, the standard kit. Unprivileged &lt;code>securityContext&lt;/code>, read-only root, default seccomp:&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Pod&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&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">labels&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&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">spec&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">securityContext&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">runAsNonRoot&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">runAsUser&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10001&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">seccompProfile&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RuntimeDefault&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">containers&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agent&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">registry.interno/ai-agent:pinned&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">securityContext&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">allowPrivilegeEscalation&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">readOnlyRootFilesystem&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&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">capabilities&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">drop&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;ALL&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 class="nt">volumeMounts&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="w"> &lt;/span>&lt;span class="nt">name: work, mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/work } &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the only writable 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="nt">volumes&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">work&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">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the default egress cut, the cluster twin of &lt;code>--unshare-net&lt;/code>. A default-deny egress NetworkPolicy in the namespace, opening only DNS and the bare essentials:&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">networking.k8s.io/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NetworkPolicy&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">default-deny-egress&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&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">spec&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">podSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &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">policyTypes&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;Egress&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 class="nt">egress&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">to&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">namespaceSelector&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">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">kubernetes.io/metadata.name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kube-system }&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">ports&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="w"> &lt;/span>&lt;span class="nt">protocol: UDP, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">53&lt;/span>&lt;span class="w"> &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="w"> &lt;/span>&lt;span class="nt">protocol: TCP, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">53&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="b1--kata-runtimeclass-the-untrusted-pod-in-its-own-microvm">B1 — Kata RuntimeClass: the untrusted pod in its own microVM&lt;/h2>
&lt;p>For genuinely untrusted code, take it out of the shared kernel. With Kata deployed there is a &lt;code>RuntimeClass&lt;/code>:&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">node.k8s.io/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RuntimeClass&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata&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">handler&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the pod asks for it with one line, &lt;code>runtimeClassName: kata&lt;/code>, running in its own microVM with a dedicated kernel instead of sharing the node&amp;rsquo;s:&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="nt">spec&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">runtimeClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">kata &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># ← the pod runs in a microVM, not on the node&amp;#39;s kernel&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="c"># ...the rest the same as B0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It is the cluster twin of isolation by construction: a kernel exploit inside the pod does not reach the node.&lt;/p>
&lt;h2 id="b2--tetragon-observation-phase-post">B2 — Tetragon, observation phase (Post)&lt;/h2>
&lt;p>Now the layer that separates a platform with runtime visibility from one without. &lt;strong>Observe first, never kill from the outset.&lt;/strong> A &lt;code>TracingPolicyNamespaced&lt;/code>, scoped to the namespace and to the agent&amp;rsquo;s label, that reports (does not kill) three things: process executions, network connections and opens of sensitive paths. &lt;code>action: Post&lt;/code> only emits the event.&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agente-observa&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&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">spec&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">podSelector&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">matchLabels&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&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">kprobes&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="c"># --- outbound connections ---&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&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">selectors&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&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="c"># --- opens of sensitive files ---&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&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">values&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="s2">&amp;#34;/var/run/secrets&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="s2">&amp;#34;/work/.git/config&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Post&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>(Process executions need no kprobe: Tetragon emits &lt;code>process_exec&lt;/code>/&lt;code>process_exit&lt;/code> natively.) Deploy it and watch the events live from the node&amp;rsquo;s Tetragon pod:&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">kubectl apply -f agente-observa.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># readable events, filtered by namespace:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> -n kube-system ds/tetragon -c tetragon -- &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> tetra getevents -o compact --namespace agentes
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Leave this running for a typical working day of the agent. Note &lt;strong>which destinations&lt;/strong> it really connects to (your internal registry, your HF mirror, your vLLM endpoint) and &lt;strong>which paths&lt;/strong> it opens. That is your baseline: the list of what is legitimate. Without this step, a &lt;code>Sigkill&lt;/code> kills good work and generates an availability incident, precisely what ENS asks you to avoid.&lt;/p>
&lt;h2 id="b3--tetragon-enforcement-phase-sigkill">B3 — Tetragon, enforcement phase (Sigkill)&lt;/h2>
&lt;p>With the baseline in hand, promote to blocking. Two rules. The first: &lt;strong>kill any connection whose destination is not on the allowlist&lt;/strong> — &lt;code>NotDAddr&lt;/code> inverts the match: it fires for everything that is &lt;em>not&lt;/em> those networks. The second: &lt;strong>kill any attempt to open a secrets path&lt;/strong>.&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cilium.io/v1alpha1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TracingPolicyNamespaced&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agente-enforce&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">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">agentes&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">spec&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">podSelector&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">matchLabels&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">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-agent&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">kprobes&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="c"># --- egress: kill everything that is NOT on the allowlist ---&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;tcp_connect&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;sock&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;NotDAddr&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">values&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="s2">&amp;#34;127.0.0.1&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="s2">&amp;#34;10.0.0.0/8&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal cluster network&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="s2">&amp;#34;172.16.10.20&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># internal registry (example)&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&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="c"># --- reading secrets: kill the process ---&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">call&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;security_file_open&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">syscall&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">args&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file&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">selectors&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">matchArgs&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">index&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Prefix&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">values&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="s2">&amp;#34;/var/run/secrets/kubernetes.io/serviceaccount/token&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="s2">&amp;#34;/work/.ssh&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">matchActions&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">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Sigkill&lt;/span>&lt;span class="w">
&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">kubectl apply -f agente-enforce.yaml
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now the agent can do whatever it likes inside the pod, but &lt;strong>the instant&lt;/strong> it tries to connect to a disallowed destination or read the service account token, Tetragon kills it in the kernel, before the packet leaves or the &lt;code>read&lt;/code> returns bytes. It is the cluster twin of the &lt;code>curl&lt;/code> blocklist and the unmounted &lt;code>~/.ssh&lt;/code>, but applied at runtime and over &lt;em>any&lt;/em> binary, not just the ones you know about.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Operational warning.&lt;/strong> Enforcement with &lt;code>Sigkill&lt;/code> requires a recent kernel with eBPF support for the action (5.10+ is safe). Deploy &lt;code>agente-enforce&lt;/code> in a test namespace first, and keep &lt;code>agente-observa&lt;/code> active in parallel: if the block fires, the &lt;code>Post&lt;/code> event tells you exactly what triggered it. Adopt first, block later.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-client--cluster-equivalence-table">The client ↔ cluster equivalence table&lt;/h2>
&lt;p>The same vector, the two primitives. This is &amp;ldquo;extrapolating the technology&amp;rdquo; made explicit:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Threat vector&lt;/th>
&lt;th>Client (workstation)&lt;/th>
&lt;th>Cluster (RKE2)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>$HOME&lt;/code> / writable root&lt;/td>
&lt;td>&lt;code>$HOME&lt;/code> as ephemeral tmpfs (&lt;code>bwrap&lt;/code>)&lt;/td>
&lt;td>&lt;code>readOnlyRootFilesystem: true&lt;/code> + &lt;code>emptyDir&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Arbitrary egress&lt;/td>
&lt;td>&lt;code>curl&lt;/code>/&lt;code>wget&lt;/code> blocklist · &lt;code>--unshare-net&lt;/code>&lt;/td>
&lt;td>default-deny NetworkPolicy + Tetragon &lt;code>NotDAddr&lt;/code>→&lt;code>Sigkill&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reading secrets&lt;/td>
&lt;td>&lt;code>~/.ssh&lt;/code>/&lt;code>~/.aws&lt;/code>/&lt;code>~/.gnupg&lt;/code> not mounted&lt;/td>
&lt;td>secrets outside the pod + Tetragon &lt;code>security_file_open&lt;/code>→&lt;code>Sigkill&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kernel escape&lt;/td>
&lt;td>Landlock (2nd VFS barrier)&lt;/td>
&lt;td>&lt;code>runtimeClassName: kata&lt;/code> (microVM, own kernel)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>No escape hatch&lt;/td>
&lt;td>process inside &lt;code>bwrap&lt;/code>, no way out&lt;/td>
&lt;td>no &lt;code>privileged&lt;/code>, &lt;code>drop ALL&lt;/code>, &lt;code>allowPrivilegeEscalation:false&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Damage to the code&lt;/td>
&lt;td>git remote with no &lt;code>push&lt;/code> → &lt;code>git checkout .&lt;/code>&lt;/td>
&lt;td>GitOps + PR review, the agent does not apply to &lt;code>main&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Visibility&lt;/td>
&lt;td>&lt;code>--dry-run --verbose&lt;/code> (static, pre-run)&lt;/td>
&lt;td>Tetragon &lt;code>tetra getevents&lt;/code> (dynamic, at runtime)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="checklist-of-gotchas">Checklist of gotchas&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Do not put in a &lt;code>Sigkill&lt;/code> without passing through &lt;code>Post&lt;/code>.&lt;/strong> The observation baseline is not optional: it is what separates &amp;ldquo;blocking a C2&amp;rdquo; from &amp;ldquo;killing your own fine-tuning job&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>The &lt;code>.ai-jail&lt;/code> gets committed; secrets do not.&lt;/strong> The TOML is policy, not credentials. Check you are not putting paths holding sensitive data into &lt;code>rw_maps&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;code>readOnlyRootFilesystem&lt;/code> breaks apps that write to &lt;code>/tmp&lt;/code>.&lt;/strong> Mount an &lt;code>emptyDir&lt;/code> at &lt;code>/tmp&lt;/code> as well as the work one.&lt;/li>
&lt;li>&lt;strong>A NetworkPolicy with no DNS rule leaves the pod blind.&lt;/strong> Open port 53 to &lt;code>kube-system&lt;/code> or nothing resolves.&lt;/li>
&lt;li>&lt;strong>Kata is not free.&lt;/strong> It adds startup latency and not every workload with special devices (GPU passthrough) fits; reserve it for the untrusted, not for everything.&lt;/li>
&lt;li>&lt;strong>The Claude Code &lt;code>/sandbox&lt;/code> does not cover MCP or hooks&lt;/strong> unless you enable &lt;code>sandbox-runtime&lt;/code>. If your agent uses MCP servers, assume they run with full permissions until you do.&lt;/li>
&lt;li>&lt;strong>&lt;code>NotDAddr&lt;/code> with literal IPs ages badly.&lt;/strong> Document the allowlist and review it when the registry or the inference endpoint changes; consider stable internal CIDRs instead of loose IPs.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the hardening of the whole cluster beyond agent isolation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents from workstation to cluster&lt;/a> — the landscape this runbook executes: threat model, the five isolation families and why client and cluster use different primitives.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never checked: Cilium eBPF and DRANET&lt;/a> — the Cilium eBPF layer on which Tetragon hooks its kprobes; the datapath you already have in the cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS × ISO 42001 × EU AI Act&lt;/a> — Tetragon events as technical evidence for &lt;code>op.mon&lt;/code>/&lt;code>op.exp&lt;/code>; enforcement as a protection measure.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — mitigation on the content plane; this runbook, on the execution plane.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven phases of deploying an on-premise LLM platform&lt;/a> — where runtime hardening fits in the deployment sequence (F4 identity/policies, F5 platform).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>ai-jail (Fabio Akita), GPL-3.0: &lt;a href="https://github.com/akitaonrails/ai-jail">https://github.com/akitaonrails/ai-jail&lt;/a>&lt;/li>
&lt;li>bubblewrap: &lt;a href="https://github.com/containers/bubblewrap">https://github.com/containers/bubblewrap&lt;/a>&lt;/li>
&lt;li>Landlock LSM: &lt;a href="https://landlock.io">https://landlock.io&lt;/a>&lt;/li>
&lt;li>Tetragon — TracingPolicy: &lt;a href="https://tetragon.io/docs/concepts/tracing-policy/">https://tetragon.io/docs/concepts/tracing-policy/&lt;/a>&lt;/li>
&lt;li>Tetragon — enforcement (Sigkill/Override): &lt;a href="https://tetragon.io/docs/concepts/enforcement/">https://tetragon.io/docs/concepts/enforcement/&lt;/a>&lt;/li>
&lt;li>Kata Containers — Kubernetes RuntimeClass: &lt;a href="https://katacontainers.io">https://katacontainers.io&lt;/a>&lt;/li>
&lt;li>Kubernetes — Pod Security &amp;amp; seccomp: &lt;a href="https://kubernetes.io/docs/tutorials/security/seccomp/">https://kubernetes.io/docs/tutorials/security/seccomp/&lt;/a>&lt;/li>
&lt;li>Kubernetes — Network Policies: &lt;a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/">https://kubernetes.io/docs/concepts/services-networking/network-policies/&lt;/a>&lt;/li>
&lt;li>Cilium: &lt;a href="https://cilium.io">https://cilium.io&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The contractor with the master key: isolating AI agents from workstation to cluster</title><link>https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/</link><pubDate>Tue, 09 Jun 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/</guid><description>&lt;blockquote>
&lt;p>First post of a pair on &lt;strong>AI agent isolation&lt;/strong>. This one fixes the &lt;em>what&lt;/em> and the &lt;em>where&lt;/em>: the complete map of isolation primitives and which domain each belongs to. The &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">sibling runbook&lt;/a> fixes the &lt;em>how&lt;/em>, with commands: &lt;code>ai-jail&lt;/code> and bubblewrap on the client, Tetragon &lt;code>TracingPolicy&lt;/code> and &lt;code>RuntimeClass&lt;/code> on the cluster. If you are only going to read one, this one gives you the mental model; the other one gives you the files to copy and paste.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An AI agent that runs code needs access to your filesystem and your tools: compiler, linter, &lt;code>grep&lt;/code>, &lt;code>make&lt;/code>, &lt;code>cargo&lt;/code>, &lt;code>npm&lt;/code>. That is the minimum to be useful. The problem is that alongside that access travels the ability to read &lt;code>~/.aws/credentials&lt;/code>, exfiltrate your SSH keys or fire an &lt;code>rm -rf&lt;/code> outside the project directory. And no malicious model is needed: a compromised dependency in an &lt;code>npm install&lt;/code> is enough, because the well-meaning agent and the poisoned &lt;em>post-install script&lt;/em> &lt;strong>run with the same permissions&lt;/strong>. The answer is not to trust the LLM&amp;rsquo;s good intentions; it is to &lt;strong>isolate in order to bound the blast radius&lt;/strong>. This post walks the five isolation families of 2026, from the process sandbox to the full VM, and sorts them into two columns: what applies on the &lt;strong>client&lt;/strong> (the developer&amp;rsquo;s workstation: bubblewrap, &lt;code>ai-jail&lt;/code>, sandbox-exec, Landlock, the native Claude Code and Codex sandboxes) and what applies on the &lt;strong>cluster&lt;/strong> (where the agent or the inference runs on Kubernetes: namespaces+seccomp, gVisor, Firecracker/Kata microVMs and &lt;strong>eBPF/Tetragon&lt;/strong> as the layer for live observation and enforcement). The thesis: the threat model is the same in both places; the tools are not. Policy extrapolates; the primitive gets rewritten.&lt;/p>
&lt;h2 id="the-analogy-the-contractor-with-the-master-key">The analogy: the contractor with the master key&lt;/h2>
&lt;p>You hire a builder for a renovation. He is competent and acts in good faith. But two things happen that you do not control. The first: he may misread the instruction and knock down the wrong partition wall. The second, worse: his toolbox may have been tampered with before he came through your door — somebody put something inside — and when he opens it in your living room, that something activates.&lt;/p>
&lt;p>Nobody sensible gives him the &lt;strong>master key&lt;/strong> to the whole building. You open the room he works in, you leave him the tools he needs, and you keep the study with the safe and the server room locked. If the renovation goes wrong, by error or by sabotage, the damage stays in that room.&lt;/p>
&lt;p>An AI agent is that contractor. The sandbox is the key policy: &lt;strong>you give it the project room and the tools, not the master key to the system.&lt;/strong> And here is the twist that justifies two posts: the builder works in two different buildings. One is &lt;strong>your flat&lt;/strong>, the developer&amp;rsquo;s workstation, with your credentials, your &lt;code>~/.ssh&lt;/code>, the browser&amp;rsquo;s password vault. The other is &lt;strong>the datacenter&lt;/strong>, the cluster where inference and autonomous agents serve customers, with data from several tenants at once. The key policy is identical in both: least privilege, bound the radius. But the lock on your flat&amp;rsquo;s door is not the same as the one at the datacenter. In the flat you fit a cylinder lock (&lt;code>bubblewrap&lt;/code>). At the datacenter you put a guard watching every door and a separate wing of the building (Tetragon + microVM). Same principle, different hardware. That is &lt;strong>extrapolating the technology&lt;/strong>, not copying it.&lt;/p>
&lt;h2 id="the-threat-model-what-a-runaway-agent-can-do">The threat model: what a runaway agent can do&lt;/h2>
&lt;p>Before choosing a lock it helps to enumerate the burglar. The attack surface of an agent that executes arbitrary bash breaks down into five concrete threats. Not all are defended by the same layer and, an uncomfortable fact that the security documentation itself acknowledges, &lt;strong>no single layer covers them all&lt;/strong>.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Threat&lt;/th>
&lt;th>What the agent does&lt;/th>
&lt;th>Minimum layer that cuts it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Filesystem out of scope&lt;/strong>&lt;/td>
&lt;td>Reads &lt;code>.env&lt;/code>, &lt;code>~/.ssh/id_rsa&lt;/code>, system secrets; modifies sources outside the project&lt;/td>
&lt;td>Process sandbox (path allowlist)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Arbitrary network egress&lt;/strong>&lt;/td>
&lt;td>Exfiltrates data, receives instructions from a remote C2, calls unauthorised APIs&lt;/td>
&lt;td>Network blocking / NetworkPolicy / microVM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kernel syscall surface&lt;/strong>&lt;/td>
&lt;td>A kernel exploit from inside the container escalates to the host (shared kernel)&lt;/td>
&lt;td>gVisor or microVM (dedicated kernel)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cross-tenant leakage&lt;/strong>&lt;/td>
&lt;td>One customer&amp;rsquo;s workload reads another&amp;rsquo;s data on a multi-tenant platform&lt;/td>
&lt;td>microVM (de facto standard)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Secret exfiltration&lt;/strong>&lt;/td>
&lt;td>Extracts tokens and environment variables via &lt;code>/proc&lt;/code> or the environment&lt;/td>
&lt;td>&lt;code>--clearenv&lt;/code> / tmpfs &lt;code>$HOME&lt;/code> / secrets outside the pod&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is a sixth threat that &lt;strong>no sandbox solves&lt;/strong>: &lt;em>prompt injection&lt;/em>. If an attacker manages to slip instructions into the agent&amp;rsquo;s context — a poisoned comment in the code, a malicious file the agent reads, an adversarial response from a tool — the agent will execute those instructions with whatever permissions the sandbox grants it. Isolation &lt;strong>shrinks the impact radius&lt;/strong> of a successful injection; it does not prevent the injection. That is why the sandbox is a layer, not the solution: on top go input validation, tool-call allowlists and output auditing. The sentence to internalise: &lt;em>isolation does not make the agent trustworthy; it bounds what an untrustworthy agent can break.&lt;/em>&lt;/p>
&lt;h2 id="two-domains-one-policy">Two domains, one policy&lt;/h2>
&lt;p>The builder works in two buildings. The distribution of tools reads better in two columns:&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 430" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two-column map: client domain (workstation) and cluster domain, with their isolation layers and the shared policy bridge">
&lt;defs>&lt;marker id="am" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="26" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Same least-privilege policy · different primitive&lt;/text>
&lt;!-- client column -->
&lt;rect x="24" y="46" width="360" height="350" rx="10" fill="#eef4fb" stroke="#1f5fa8" stroke-width="1.6"/>
&lt;text x="204" y="72" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="#0d3a66">CLIENT · dev workstation&lt;/text>
&lt;text x="204" y="90" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#0d3a66">one process, one user, personal data&lt;/text>
&lt;rect x="48" y="106" width="312" height="50" rx="6" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.2"/>
&lt;text x="204" y="126" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">Process sandbox&lt;/text>
&lt;text x="204" y="143" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">bubblewrap (Linux) · sandbox-exec (macOS)&lt;/text>
&lt;rect x="48" y="166" width="312" height="42" rx="6" fill="#dff0ff" stroke="#1f5fa8" stroke-width="1.1"/>
&lt;text x="204" y="183" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">Landlock (LSM, 2nd VFS barrier)&lt;/text>
&lt;text x="204" y="199" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">defence in depth, kernel ≥5.13&lt;/text>
&lt;rect x="48" y="218" width="312" height="42" rx="6" fill="#eaf4ff" stroke="#1f5fa8" stroke-width="1.1"/>
&lt;text x="204" y="235" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#0d3a66">Dev container (optional)&lt;/text>
&lt;text x="204" y="251" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#0d3a66">reproducibility + reset, shared kernel&lt;/text>
&lt;text x="204" y="284" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="#0d3a66">What wraps it:&lt;/text>
&lt;text x="204" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#0d3a66">ai-jail · Claude Code /sandbox&lt;/text>
&lt;text x="204" y="320" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#0d3a66">Codex --sandbox · Cursor /worktree&lt;/text>
&lt;text x="204" y="352" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#0d3a66">Safety net: git remote with no push&lt;/text>
&lt;text x="204" y="376" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#555">startup ~0 ms · no daemon&lt;/text>
&lt;!-- cluster column -->
&lt;rect x="436" y="46" width="360" height="350" rx="10" fill="#f3eefb" stroke="#5a2db0" stroke-width="1.6"/>
&lt;text x="616" y="72" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="#42208a">CLUSTER · inference / agents in prod&lt;/text>
&lt;text x="616" y="90" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#42208a">multi-pod, multi-tenant, RKE2&lt;/text>
&lt;rect x="460" y="106" width="312" height="50" rx="6" fill="#e6d9f2" stroke="#5a2db0" stroke-width="1.2"/>
&lt;text x="616" y="126" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">Pod baseline&lt;/text>
&lt;text x="616" y="143" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">namespaces + seccomp + cgroups + NetworkPolicy&lt;/text>
&lt;rect x="460" y="166" width="312" height="42" rx="6" fill="#ede1f7" stroke="#5a2db0" stroke-width="1.1"/>
&lt;text x="616" y="183" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">gVisor (runsc) — user-space kernel&lt;/text>
&lt;text x="616" y="199" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">cuts the syscall surface to the host&lt;/text>
&lt;rect x="460" y="218" width="312" height="42" rx="6" fill="#f0e6fb" stroke="#5a2db0" stroke-width="1.1"/>
&lt;text x="616" y="235" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#42208a">Firecracker / Kata microVM — own kernel&lt;/text>
&lt;text x="616" y="251" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#42208a">isolation by construction for untrusted code&lt;/text>
&lt;rect x="460" y="270" width="312" height="62" rx="6" fill="#fde9d6" stroke="#a85a00" stroke-width="1.6"/>
&lt;text x="616" y="290" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#8a4a00">eBPF / Tetragon (what we already have)&lt;/text>
&lt;text x="616" y="307" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#8a4a00">watches every exec · connect · open in the kernel&lt;/text>
&lt;text x="616" y="322" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#8a4a00">and kills (Sigkill) whatever strays from the script&lt;/text>
&lt;text x="616" y="356" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#42208a">Safety net: GitOps + PR review&lt;/text>
&lt;text x="616" y="376" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#555">startup &amp;lt;1 s (microVM) · runtime layer always on&lt;/text>
&lt;!-- bridge -->
&lt;path d="M384,221 L436,221" stroke="#666" stroke-width="1.6" fill="none" marker-end="url(#am)"/>
&lt;path d="M436,241 L384,241" stroke="#666" stroke-width="1.6" fill="none" marker-end="url(#am)"/>
&lt;text x="410" y="415" text-anchor="middle" font-family="sans-serif" font-size="11" font-style="italic" fill="#555">extrapolate: each client control has its cluster analogue (equivalence table in the runbook)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-client-isolating-the-agent-on-the-workstation">The client: isolating the agent on the workstation&lt;/h2>
&lt;p>Here the agent is a coding assistant — Claude Code, Codex, OpenCode, Cursor — that a developer launches on their machine. One process, one user, and right next to it the juiciest assets that exist: &lt;code>~/.aws/credentials&lt;/code>, &lt;code>~/.ssh&lt;/code>, &lt;code>~/.gnupg&lt;/code>, the browser&amp;rsquo;s password store. The matching tier is the lightest one: the &lt;strong>process sandbox&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>bubblewrap (Linux) and sandbox-exec (macOS).&lt;/strong> &lt;code>bubblewrap&lt;/code> (&lt;code>bwrap&lt;/code>) is the same sandbox Flatpak uses to isolate each desktop app: a ~50 KB binary, ~4,000 lines of C, maintained by the GNOME team and, the key property, it runs &lt;strong>without root&lt;/strong> via &lt;code>CLONE_NEWUSER&lt;/code>, creating namespaces with no elevated privileges. It mounts &lt;code>$HOME&lt;/code> as an ephemeral tmpfs and only exposes the project directory, writable; the rest of the system becomes invisible. On macOS the equivalent is &lt;code>sandbox-exec&lt;/code> with SBPL profiles: a legacy Apple API, officially deprecated and with no public replacement, but working today. Parity between the two is not exact — on macOS the GPU (Metal) and the display (Cocoa) are system services and &lt;code>sandbox-exec&lt;/code> does not restrict them — but both protect what matters: access to the sensitive areas of the filesystem.&lt;/p>
&lt;p>&lt;strong>Landlock as a second barrier.&lt;/strong> &lt;code>bubblewrap&lt;/code> isolates via &lt;em>namespaces and mounts&lt;/em>; Landlock, a Linux Security Module available since kernel 5.13, restricts access at the &lt;strong>VFS&lt;/strong> level, independently of namespaces. It does not replace &lt;code>bwrap&lt;/code>: it complements it. It closes vectors that mount-based isolation does not cover on its own (escape routes via &lt;code>/proc&lt;/code>, symlink tricks inside allowed mounts) and acts as a safety net should the namespace machinery have a bug. It is defence in depth inside the client itself, and it degrades cleanly to a no-op on kernels that do not support it.&lt;/p>
&lt;p>&lt;strong>Dev containers, when reproducibility is needed.&lt;/strong> A dev container (&lt;code>devcontainer.json&lt;/code>, what Codespaces and Cursor use) is a Docker container with a configuration layer on top. It gives reasonable filesystem isolation and easy &lt;em>reset&lt;/em> (destroy and recreate), but it &lt;strong>shares the host kernel&lt;/strong>, the same limitation as any Docker, and it tends to be long-lived, accumulating state. For an agent running your own team&amp;rsquo;s code on your own machine it is a good repeatability story; it is not, on its own, the isolation layer for untrusted code.&lt;/p>
&lt;p>&lt;strong>What wraps all of this.&lt;/strong> A hand-written bash script works, but it does not scale to a team. The tools that package it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>ai-jail&lt;/code>&lt;/strong> (Rust, GPL-3.0): wraps &lt;code>bwrap&lt;/code>/&lt;code>sandbox-exec&lt;/code> with per-project config in a &lt;code>.ai-jail&lt;/code> file (TOML, committable to the repo, so the whole team inherits the same policy), auto-detection of GPU/Docker/display, a &lt;code>--lockdown&lt;/code> mode (project read-only, network cut with &lt;code>--unshare-net&lt;/code>, &lt;code>--clearenv&lt;/code>), &lt;code>--dry-run&lt;/code> for auditing, and &lt;code>--bootstrap&lt;/code> to generate each agent&amp;rsquo;s permission allowlists. It is tool-agnostic: the same binary serves Claude, Codex, OpenCode or Crush. It also applies Landlock automatically on 5.13+ kernels as defence in depth.&lt;/li>
&lt;li>&lt;strong>The Claude Code &lt;code>/sandbox&lt;/code>&lt;/strong>: since October 2025 Claude Code ships its own sandbox that uses, exactly, &lt;code>bubblewrap&lt;/code> on Linux and &lt;code>sandbox-exec&lt;/code> on macOS. Its &lt;em>Sandboxed Bash&lt;/em> isolates shell commands, but &lt;strong>not&lt;/strong> the file tools, the MCP servers or the hooks, which run with the process&amp;rsquo;s full permissions unless you enable the beta &lt;code>sandbox-runtime&lt;/code> package, which wraps the whole process. There is one nuance worth knowing: if a command fails because of a restriction, the agent can retry with &lt;code>dangerouslyDisableSandbox&lt;/code> — it is &lt;em>opt-out&lt;/em>, not &lt;em>opt-in&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Codex CLI&lt;/strong>: three modes via &lt;code>--sandbox&lt;/code> (&lt;code>read-only&lt;/code>, &lt;code>workspace-write&lt;/code>, &lt;code>danger-full-access&lt;/code>); the recommended default is &lt;code>workspace-write&lt;/code>. The philosophy is deliberate: Codex does not provide the isolation, it delegates it to the environment that wraps it. &lt;code>danger-full-access&lt;/code> only makes sense &lt;strong>inside&lt;/strong> a microVM.&lt;/li>
&lt;li>&lt;strong>Cursor&lt;/strong>: its cloud agents run in isolated VMs; &lt;code>/worktree&lt;/code> creates a single-use isolated worktree per task, and &lt;code>/best-of-n&lt;/code> launches several attempts in parallel in separate worktrees.&lt;/li>
&lt;/ul>
&lt;h2 id="the-cluster-isolating-the-agent-in-production">The cluster: isolating the agent in production&lt;/h2>
&lt;p>The second building is the datacenter. Here the &amp;ldquo;agent&amp;rdquo; can be an autonomous agent running with no human in front of it, or the inference service itself executing generated code, or a multi-tenant workload where one customer&amp;rsquo;s pod must not touch another&amp;rsquo;s data. The process is no longer one: they are pods in a Kubernetes cluster (RKE2/RKE3 on a typical sovereign platform). The primitives change in nature.&lt;/p>
&lt;p>&lt;strong>The pod baseline.&lt;/strong> First of all, the standard kit: Linux namespaces, &lt;code>seccomp&lt;/code> (&lt;code>RuntimeDefault&lt;/code>) to trim the syscall surface, cgroups for resource limits, an unprivileged &lt;code>securityContext&lt;/code> (&lt;code>runAsNonRoot&lt;/code>, &lt;code>readOnlyRootFilesystem&lt;/code>, dropping all capabilities) and &lt;strong>NetworkPolicy&lt;/strong> to cut egress. It is the cluster equivalent of the process sandbox allowlist. Necessary, but it shares the kernel with the host: insufficient for genuinely untrusted code.&lt;/p>
&lt;p>&lt;strong>gVisor (&lt;code>runsc&lt;/code>).&lt;/strong> Google&amp;rsquo;s user-space kernel: it intercepts the workload&amp;rsquo;s syscalls before they reach the host kernel and serves them inside a Linux kernel reimplemented in Go (the &lt;em>Sentry&lt;/em>). The surface exposed to host kernel vulnerabilities drops sharply, while keeping fast startup and a low footprint. It is the middle ground when the risk of a kernel escape is real but the overhead of a microVM is not acceptable.&lt;/p>
&lt;p>&lt;strong>Firecracker / Kata microVMs.&lt;/strong> The de facto standard for untrusted code in 2026. Firecracker (AWS&amp;rsquo;s VMM written in Rust, on top of KVM) gives each sandbox a &lt;strong>dedicated Linux kernel&lt;/strong>: a kernel exploit inside the microVM does not reach the host &lt;em>by construction&lt;/em>. It is what sits underneath Vercel Sandbox (GA January 2026) and E2B. On Kubernetes, &lt;strong>Kata Containers&lt;/strong> brings that model to a &lt;code>RuntimeClass&lt;/code>: you mark the untrusted agent&amp;rsquo;s pod with &lt;code>runtimeClassName: kata&lt;/code> and it runs in its own microVM instead of sharing the node&amp;rsquo;s kernel. For multi-tenant with generated code, this is the baseline, not the luxury.&lt;/p>
&lt;p>&lt;strong>eBPF / Tetragon: the layer we already have.&lt;/strong> Here is the piece that separates a platform with runtime observability from one that only trusts its configuration. The previous layers are &lt;em>static&lt;/em>: they define what the pod can do before it starts. &lt;strong>Tetragon&lt;/strong>, Cilium&amp;rsquo;s runtime security component, based on eBPF, is &lt;em>dynamic&lt;/em>: it observes, in the kernel and at minimal cost, &lt;strong>every&lt;/strong> process execution, &lt;strong>every&lt;/strong> network connection and &lt;strong>every&lt;/strong> file open in every pod, and it can act inline. It does not replace the sandbox; it watches it from inside the kernel. Where &lt;code>bubblewrap&lt;/code> on the client blocks &lt;code>curl&lt;/code> with a command blocklist, Tetragon on the cluster hooks &lt;code>tcp_connect&lt;/code> in the kernel and, if the destination is not allowed, &lt;strong>kills the process with &lt;code>Sigkill&lt;/code>&lt;/strong> before the packet leaves. Where the client hides &lt;code>~/.ssh&lt;/code> behind a tmpfs, Tetragon hooks &lt;code>security_file_open&lt;/code> and reports, or kills, any attempt to read a sensitive mounted path. It is the guard patrolling the corridors while the microVMs are the walls. And it is exactly the kind of control that materialises the ENS monitoring and traceability measures (&lt;code>op.mon&lt;/code>, &lt;code>op.exp&lt;/code>) without instrumenting the application: visibility lives in the kernel, not in the agent&amp;rsquo;s code.&lt;/p>
&lt;h2 id="the-landscape-table">The landscape table&lt;/h2>
&lt;p>The five families, their relative isolation strength, their startup cost and the domain where they live:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tier&lt;/th>
&lt;th>Primitive&lt;/th>
&lt;th style="text-align:center">Isolation&lt;/th>
&lt;th style="text-align:center">Startup&lt;/th>
&lt;th>Natural domain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Process sandbox&lt;/td>
&lt;td>Seatbelt · bubblewrap&lt;/td>
&lt;td style="text-align:center">Baseline&lt;/td>
&lt;td style="text-align:center">~0 ms&lt;/td>
&lt;td>&lt;strong>Client&lt;/strong> (Claude Code default)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Dev container&lt;/td>
&lt;td>Docker + seccomp&lt;/td>
&lt;td style="text-align:center">Moderate&lt;/td>
&lt;td style="text-align:center">seconds&lt;/td>
&lt;td>Client / cluster (repeatability)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>User-space kernel&lt;/td>
&lt;td>gVisor (&lt;code>runsc&lt;/code>)&lt;/td>
&lt;td style="text-align:center">Strong&lt;/td>
&lt;td style="text-align:center">ms&lt;/td>
&lt;td>&lt;strong>Cluster&lt;/strong> (medium multi-tenant)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>microVM&lt;/td>
&lt;td>Firecracker · Kata&lt;/td>
&lt;td style="text-align:center">Strongest (practical)&lt;/td>
&lt;td style="text-align:center">&amp;lt;1 s&lt;/td>
&lt;td>&lt;strong>Cluster&lt;/strong> (untrusted code)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full VM&lt;/td>
&lt;td>KVM · EC2&lt;/td>
&lt;td style="text-align:center">Maximum&lt;/td>
&lt;td style="text-align:center">30 s+&lt;/td>
&lt;td>Cluster (external boundary, compliance)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;em>Runtime enforcement&lt;/em>&lt;/td>
&lt;td>&lt;strong>eBPF · Tetragon&lt;/strong>&lt;/td>
&lt;td style="text-align:center">&lt;em>Cross-cutting&lt;/em>&lt;/td>
&lt;td style="text-align:center">&lt;em>always on&lt;/em>&lt;/td>
&lt;td>&lt;strong>Cluster&lt;/strong> (watches+kills on top of any tier)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Tetragon gets its own row on purpose: it is not a rung on the ladder, it is a &lt;strong>cross-cutting layer&lt;/strong> that operates &lt;em>on top of&lt;/em> any of the others. It stacks with all of them.&lt;/p>
&lt;p>A numerical note on why the &amp;ldquo;startup&amp;rdquo; column decides as much as the &amp;ldquo;isolation&amp;rdquo; column. A full VM wins on raw isolation but takes tens of seconds to provision; for an agent that needs a fresh environment &lt;em>per request or per session&lt;/em>, that cost is prohibitive. A Firecracker microVM starts in &lt;strong>under 1 second&lt;/strong> and a process sandbox in &lt;strong>~0 ms&lt;/strong>. That is why the dominant pattern in 2026 is not &amp;ldquo;the most isolated VM&amp;rdquo;, but &lt;strong>a full VM as the external boundary plus a microVM as the per-request execution unit inside&lt;/strong>, the architecture of Vercel, AWS Lambda and E2B. On the client the arithmetic is the opposite: the developer launches the agent dozens of times a day interactively, and a startup measured in seconds would break the flow; hence the process sandbox, with its microsecond overhead, is the correct default.&lt;/p>
&lt;h2 id="extrapolate-do-not-copy">Extrapolate, do not copy&lt;/h2>
&lt;p>The thesis of this pair of posts fits in one sentence: &lt;strong>the threat model is invariant across domains; the primitive that implements it is not.&lt;/strong> The client and the cluster defend exactly the same five vectors — filesystem, network, kernel, multi-tenant, secrets — but with toolboxes that do not overlap. Every control has its twin on the other side:&lt;/p>
&lt;ul>
&lt;li>&lt;code>$HOME&lt;/code> as an ephemeral tmpfs (client) ↔ &lt;code>readOnlyRootFilesystem&lt;/code> + &lt;code>emptyDir&lt;/code> (cluster).&lt;/li>
&lt;li>A &lt;code>curl&lt;/code>/&lt;code>wget&lt;/code> blocklist in &lt;code>bwrap&lt;/code> (client) ↔ a &lt;code>TracingPolicy&lt;/code> on &lt;code>tcp_connect&lt;/code> in Tetragon plus NetworkPolicy (cluster).&lt;/li>
&lt;li>&lt;code>--unshare-net&lt;/code> in lockdown (client) ↔ default-deny NetworkPolicy (cluster).&lt;/li>
&lt;li>No &lt;em>escape hatch&lt;/em>, the process lives inside &lt;code>bwrap&lt;/code> (client) ↔ no &lt;code>privileged&lt;/code>, no &lt;code>hostPath&lt;/code>, &lt;code>RuntimeClass&lt;/code> kata (cluster).&lt;/li>
&lt;li>&lt;code>~/.ssh&lt;/code> and &lt;code>~/.aws&lt;/code> never mounted (client) ↔ secrets outside the pod plus Tetragon watching &lt;code>security_file_open&lt;/code> (cluster).&lt;/li>
&lt;/ul>
&lt;p>The sibling runbook turns each of these equivalences into concrete files. What matters here is the method: when someone shows you an agent sandbox, be it the Claude Code &lt;code>/sandbox&lt;/code> on a laptop or a microVM on a PaaS, the useful question is not &amp;ldquo;which tool does it use?&amp;rdquo;, but &amp;ldquo;which of the five vectors does it close, and which does it leave open?&amp;rdquo;. The tool gets replaced; the threat map stays.&lt;/p>
&lt;h2 id="what-no-sandbox-solves">What no sandbox solves&lt;/h2>
&lt;p>Three limits that Anthropic&amp;rsquo;s own documentation states, and which are worth keeping in view so as not to sell smoke:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Egress remains a risk&lt;/strong> in any sandbox that allows outbound connections. If the agent can open a connection, it can exfiltrate. That is why the client lockdown &lt;em>cuts&lt;/em> the network and the cluster uses default-deny NetworkPolicy plus Tetragon: you do not trust &amp;ldquo;filtering well&amp;rdquo;, you trust &amp;ldquo;not letting anything out&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Code modification remains possible&lt;/strong> in any sandbox with the project directory mounted writable. The remedy is not a sandbox-technical one, it is &lt;strong>git&lt;/strong>: with the remote untouched and no &lt;code>push&lt;/code> permission, the worst case is corrupting the local working copy — &lt;code>git checkout .&lt;/code> and start again. The damage never reaches the remote.&lt;/li>
&lt;li>&lt;strong>No sandbox prevents a compromised prompt from reaching the API.&lt;/strong> Isolation bounds the impact of an injection; it does not prevent it. The complementary defences — input validation, tool-call allowlists, output auditing — are mandatory &lt;em>alongside&lt;/em> isolation, not instead of it.&lt;/li>
&lt;/ol>
&lt;p>The operational conclusion: &lt;strong>isolation shrinks the blast radius; defence in depth is what closes the circle.&lt;/strong> A process sandbox for trusted code on a known machine is appropriate and practically free. For an agent acting on user prompts, executing generated code or running multi-tenant, the acceptable minimum in 2026 is a microVM, with Tetragon watching from above. Pick the tier that matches your real threat, check which vector it leaves open, and stack complementary controls on top.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the hardening of the stack that hosts those agents: network, secrets, runtime.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">Runbook: caging the AI agent — bubblewrap on the client, Tetragon on the cluster&lt;/a> — the operational companion to this post: the &lt;code>.ai-jail&lt;/code> files, the permissions &lt;code>--bootstrap&lt;/code> and the Tetragon &lt;code>TracingPolicy&lt;/code> files to copy and paste. With commands.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never checked: network NUMA, Cilium eBPF and DRANET&lt;/a> — the Cilium eBPF datapath Tetragon leans on in the cluster; the same kernel layer, a different use.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — mitigation on the &lt;em>content&lt;/em> plane (what the model says and what is said to it); this post is mitigation on the &lt;em>execution&lt;/em> plane (what the agent&amp;rsquo;s process can do).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS × ISO 42001 × EU AI Act&lt;/a> — the compliance framework that runtime isolation materialises: Tetragon as technical evidence for &lt;code>op.mon&lt;/code>/&lt;code>op.exp&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">Catalogue of OSS tools for LLMOps&lt;/a> — where the runtime security layer fits in the complete open stack.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>bubblewrap: &lt;a href="https://github.com/containers/bubblewrap">https://github.com/containers/bubblewrap&lt;/a>&lt;/li>
&lt;li>Landlock LSM: &lt;a href="https://landlock.io">https://landlock.io&lt;/a> · &lt;a href="https://docs.rs/landlock">https://docs.rs/landlock&lt;/a>&lt;/li>
&lt;li>ai-jail (Fabio Akita): &lt;a href="https://github.com/akitaonrails/ai-jail">https://github.com/akitaonrails/ai-jail&lt;/a>&lt;/li>
&lt;li>gVisor: &lt;a href="https://gvisor.dev">https://gvisor.dev&lt;/a>&lt;/li>
&lt;li>Firecracker: &lt;a href="https://firecracker-microvm.github.io">https://firecracker-microvm.github.io&lt;/a>&lt;/li>
&lt;li>Kata Containers: &lt;a href="https://katacontainers.io">https://katacontainers.io&lt;/a>&lt;/li>
&lt;li>Tetragon (Cilium): &lt;a href="https://tetragon.io">https://tetragon.io&lt;/a>&lt;/li>
&lt;li>Vercel Sandbox — concepts: &lt;a href="https://vercel.com/docs/vercel-sandbox/concepts">https://vercel.com/docs/vercel-sandbox/concepts&lt;/a>&lt;/li>
&lt;li>E2B: &lt;a href="https://github.com/e2b-dev/E2B">https://github.com/e2b-dev/E2B&lt;/a>&lt;/li>
&lt;li>Claude Code sandboxing: &lt;a href="https://docs.claude.com/en/docs/claude-code">https://docs.claude.com/en/docs/claude-code&lt;/a>&lt;/li>
&lt;li>Codex CLI: &lt;a href="https://github.com/openai/codex">https://github.com/openai/codex&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The corridors and the security guard: PCIe topology, GPUDirect P2P and ACS</title><link>https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/</link><pubDate>Mon, 08 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/</guid><description>&lt;blockquote>
&lt;p>This continues the &lt;em>below the engine&lt;/em> series. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink and NCCL post&lt;/a> explained the &lt;em>shared bench&lt;/em> over which the GPUs pass data to each other at 450 GB/s. But that bench only connects GPUs to each other. Everything else, disk, network, the host, travels over &lt;strong>another bus&lt;/strong>, PCIe, and through its corridors. The &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a> already brushed against this with GPUDirect Storage; this post opens up the complete floor plan of the corridors and the guard who watches them.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>On a 4×H100 SXM node, the GPUs talk over NVLink (450 GB/s per direction, around 7× PCIe), and for the all-reduce of &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">tensor parallel&lt;/a> that is the path. But PCIe does not go away: it is where everything else comes in. The weights come up from the NVMe over PCIe (the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>), the data from another node arrives via the NIC over PCIe (RDMA), and a KV that moves between nodes travels over PCIe. GPUDirect is the family that lets those bytes go &lt;strong>straight from the device into the HBM&lt;/strong> without bouncing through host RAM: P2P (GPU↔GPU), RDMA (GPU↔NIC) and Storage (GPU↔NVMe). The obstacle is a guard called ACS (Access Control Services): a PCIe security feature that by default forces &lt;em>peer-to-peer&lt;/em> traffic &lt;strong>up to the root complex&lt;/strong> for inspection, which destroys the direct path and inserts a detour through the CPU. The IOMMU (VT-d) does something similar if it is not in &lt;em>passthrough&lt;/em> mode. Disabling them gives performance; keeping them gives isolation and virtualisation, and that is a real decision in an ENS environment. This post explains the topology (&lt;code>nvidia-smi topo -m&lt;/code>), GPUDirect, why ACS and IOMMU break P2P with numbers, the 10 knobs and the trap of removing the guard without knowing what it was watching. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-corridors-not-the-bench">Where you are: the corridors, not the bench&lt;/h2>
&lt;p>Picture the kitchen as a building. The elite cooking stations, the GPUs, are in a room with a &lt;strong>huge central bench&lt;/strong> (NVLink/NVSwitch) over which they pass ingredients at full speed without standing up. That bench is for them and them alone.&lt;/p>
&lt;p>But the building has more to it: the pantry (the NVMe storage), the loading door (the network, the NIC) and reception (the CPU and its RAM). To reach any of those, the stations do not use the central bench: they use the &lt;strong>corridors of the building&lt;/strong>, the PCIe bus. And here the character of the post appears: at the entrance of each corridor there is a &lt;strong>security guard&lt;/strong> (ACS) who, by default, does not let two stations hand each other something directly along the corridor: it forces them to &lt;strong>take the parcel up to reception&lt;/strong> to be checked, and only then does it come down to its destination. It is safe, but it is an absurd detour when the two stations are right next to each other. GPUDirect is the permission to skip that detour; ACS and IOMMU are the reasons why, often, you cannot.&lt;/p>
&lt;h2 id="the-topology-of-a-node-two-buses-not-one">The topology of a node: two buses, not one&lt;/h2>
&lt;p>The most common mistake is to think that a node has &amp;ldquo;a bus&amp;rdquo;. There are (at least) two, and they do different things:&lt;/p>
&lt;ul>
&lt;li>NVLink / NVSwitch — the high-speed GPU↔GPU mesh. On H100 SXM, &lt;strong>18 links × 50 GB/s = 900 GB/s bidirectional&lt;/strong> between any two GPUs, with NVSwitch giving an &lt;em>all-to-all&lt;/em> with no contention (&lt;a href="https://www.nvidia.com/en-us/data-center/h100/">NVLink, NVIDIA&lt;/a>). It is the shared bench.&lt;/li>
&lt;li>PCIe Gen5 — the general I/O bus. An &lt;strong>x16 link gives 128 GB/s bidirectional&lt;/strong> (around 64 per direction) (&lt;a href="https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/h100/PB-11773-001_v01.pdf">H100 product brief&lt;/a>). It connects each GPU with the CPU, the RAM, the NICs and the NVMes. It is the corridor.&lt;/li>
&lt;/ul>
&lt;p>The difference is 7×: NVLink moves in one second what PCIe takes seven to move. That is why intra-node tensor parallel goes over NVLink and nobody argues about it. PCIe matters for the &lt;em>other&lt;/em> things: pulling weights off disk, receiving from the network, moving KV between nodes.&lt;/p>
&lt;p>The tool for seeing this is &lt;code>nvidia-smi topo -m&lt;/code>, which prints a matrix of how each pair is connected (&lt;a href="https://forums.developer.nvidia.com/t/nvidia-smi-topo-m-revisited/216584">NVIDIA forum&lt;/a>):&lt;/p>
&lt;pre tabindex="0">&lt;code> GPU0 GPU1 GPU2 GPU3 NIC0 CPU Affinity NUMA
GPU0 X NV18 NV18 NV18 PXB 0-47 0
GPU1 NV18 X NV18 NV18 PXB 0-47 0
GPU2 NV18 NV18 X NV18 SYS 48-95 1
GPU3 NV18 NV18 NV18 X SYS 48-95 1
&lt;/code>&lt;/pre>&lt;p>The legend is what matters: NV18 = 18 NVLink links (the bench); PXB = crosses PCIe switches but not the host; PHB = goes through the host bridge; NODE = same NUMA, crossing PCIe; SYS = crosses the inter-socket interconnect (the worst case, it traverses NUMA). The fact that &lt;code>GPU0↔NIC0&lt;/code> is PXB and &lt;code>GPU2↔NIC0&lt;/code> is SYS tells you exactly which GPU should handle that NIC&amp;rsquo;s traffic, GPU 0, without crossing NUMA. This links directly with the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">network NUMA&lt;/a> one: PCIe affinity &lt;strong>is&lt;/strong> NUMA affinity.&lt;/p>
&lt;svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Topology of a node: NVLink on top, PCIe below, ACS as the gate">
&lt;rect x="120" y="20" width="480" height="40" rx="6" fill="none" stroke="#7c3aed" stroke-width="2"/>
&lt;text x="360" y="45" text-anchor="middle" fill="#7c3aed">NVSwitch — NVLink mesh 900 GB/s (GPU↔GPU bench)&lt;/text>
&lt;rect x="120" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="165" y="103" text-anchor="middle" fill="currentColor">GPU0&lt;/text>
&lt;rect x="250" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="295" y="103" text-anchor="middle" fill="currentColor">GPU1&lt;/text>
&lt;rect x="380" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="425" y="103" text-anchor="middle" fill="currentColor">GPU2&lt;/text>
&lt;rect x="510" y="80" width="90" height="36" rx="4" fill="none" stroke="currentColor" stroke-width="1.5"/>&lt;text x="555" y="103" text-anchor="middle" fill="currentColor">GPU3&lt;/text>
&lt;line x1="165" y1="80" x2="165" y2="60" stroke="#7c3aed" stroke-width="1.5"/>&lt;line x1="295" y1="80" x2="295" y2="60" stroke="#7c3aed" stroke-width="1.5"/>&lt;line x1="425" y1="80" x2="425" y2="60" stroke="#7c3aed" stroke-width="1.5"/>&lt;line x1="555" y1="80" x2="555" y2="60" stroke="#7c3aed" stroke-width="1.5"/>
&lt;rect x="250" y="160" width="220" height="34" rx="4" fill="none" stroke="#dc2626" stroke-width="2" stroke-dasharray="5 3"/>&lt;text x="360" y="182" text-anchor="middle" fill="#dc2626">PCIe switch + ACS (the guard)&lt;/text>
&lt;line x1="165" y1="116" x2="300" y2="160" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="295" y1="116" x2="330" y2="160" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="425" y1="116" x2="390" y2="160" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="555" y1="116" x2="420" y2="160" stroke="#2563eb" stroke-width="1.2"/>
&lt;rect x="120" y="230" width="120" height="34" rx="4" fill="none" stroke="currentColor"/>&lt;text x="180" y="252" text-anchor="middle" fill="currentColor" font-size="11">CPU + RAM (root)&lt;/text>
&lt;rect x="300" y="230" width="120" height="34" rx="4" fill="none" stroke="currentColor"/>&lt;text x="360" y="252" text-anchor="middle" fill="currentColor" font-size="11">NIC (network)&lt;/text>
&lt;rect x="480" y="230" width="120" height="34" rx="4" fill="none" stroke="currentColor"/>&lt;text x="540" y="252" text-anchor="middle" fill="currentColor" font-size="11">NVMe (disk)&lt;/text>
&lt;line x1="180" y1="194" x2="180" y2="230" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="360" y1="194" x2="360" y2="230" stroke="#2563eb" stroke-width="1.2"/>&lt;line x1="430" y1="194" x2="540" y2="230" stroke="#2563eb" stroke-width="1.2"/>
&lt;text x="610" y="180" fill="#dc2626" font-size="10">ACS on →&lt;/text>
&lt;text x="610" y="195" fill="#dc2626" font-size="10">up to root&lt;/text>
&lt;/svg>
&lt;h2 id="gpudirect-skipping-reception">GPUDirect: skipping reception&lt;/h2>
&lt;p>Without GPUDirect, moving a piece of data from the NIC (or the NVMe) to the GPU takes a mandatory detour: &lt;strong>device → host RAM → GPU&lt;/strong>. That bounce through RAM consumes CPU bandwidth, spends copies and adds latency. GPUDirect removes the bounce by letting the data go &lt;strong>straight from the device into the HBM&lt;/strong>. Three flavours:&lt;/p>
&lt;ul>
&lt;li>GPUDirect P2P — GPU↔GPU over PCIe (when there is no NVLink between them, or for traffic that does not use the bench).&lt;/li>
&lt;li>GPUDirect RDMA — GPU↔NIC: the network card writes directly into the HBM. It is what makes efficient multi-node viable (NCCL over InfiniBand/RoCE).&lt;/li>
&lt;li>GPUDirect Storage (GDS) — GPU↔NVMe: the disk writes straight into the HBM, with no host buffer. It is the lever of the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>On an SXM node, the GPU↔GPU traffic of &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">tensor parallel&lt;/a> &lt;strong>does not use P2P over PCIe&lt;/strong>: it uses NVLink. That is why GPUDirect matters above all at the edges of the node: the network (RDMA, for multi-node) and the disk (GDS, for startup). That is where ACS does damage.&lt;/p>
&lt;h2 id="the-guard-why-acs-and-iommu-break-p2p">The guard: why ACS and IOMMU break P2P&lt;/h2>
&lt;p>&lt;strong>ACS (Access Control Services)&lt;/strong> is a PCIe security feature designed for virtualisation and isolation: it guarantees that one device cannot read from or write to another directly without the &lt;em>root complex&lt;/em> mediating. To achieve that, it &lt;strong>forces peer-to-peer transactions up to the root complex&lt;/strong> and back down (&lt;a href="https://docs.nvidia.com/gpudirect-storage/best-practices-guide/index.html">GDS best practices, NVIDIA&lt;/a>). It is exactly the opposite of what GPUDirect wants: the direct path stops being direct.&lt;/p>
&lt;p>The IOMMU (VT-d on Intel, the equivalent on AMD) translates addresses and isolates devices. If it is active and &lt;strong>not&lt;/strong> in passthrough mode, it also &lt;strong>redirects P2P traffic through the root complex&lt;/strong>, with the same effect: performance through the floor or, in extreme cases, &lt;em>hangs&lt;/em> (&lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting.html">NCCL troubleshooting&lt;/a>).&lt;/p>
&lt;p>Summarised without detours (&lt;a href="https://morgangiraud.medium.com/multi-gpu-nvidia-p2p-capabilities-and-debugging-tips-fb7597b4e2b5">Giraud, debugging P2P&lt;/a>): ACS forces the trip through the root &lt;em>for security checks&lt;/em>; IOMMU forces it &lt;em>for isolation and virtualisation&lt;/em>. Both break the goal of P2P (direct communication with no intermediaries) and add overhead. If you do not need that security/virtualisation on that path, disabling them recovers the performance. The operational recipe for maximum GPUDirect performance: &lt;strong>ACS off&lt;/strong> on the switches along the path and &lt;strong>IOMMU in passthrough&lt;/strong> (&lt;code>iommu=pt&lt;/code>) or disabled.&lt;/p>
&lt;h2 id="the-maths-that-matter-how-much-the-detour-costs">The maths that matter: how much the detour costs&lt;/h2>
&lt;p>Take a &lt;strong>KV SWAP&lt;/strong> of 5 GB (preemption from the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a> that sends KV to the host, or a transfer between nodes in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Path&lt;/th>
&lt;th>Effective BW&lt;/th>
&lt;th>Time for 5 GB&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>NVLink (GPU↔GPU intra-node)&lt;/td>
&lt;td>~450 GB/s&lt;/td>
&lt;td>&lt;strong>~11 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Direct PCIe Gen5 x16 (P2P, ACS off)&lt;/td>
&lt;td>~55 GB/s&lt;/td>
&lt;td>&lt;strong>~91 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PCIe via root complex (ACS on)&lt;/td>
&lt;td>~25-30 GB/s*&lt;/td>
&lt;td>&lt;strong>~170-200 ms&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>* The detour through the root does not just &amp;ldquo;add latency&amp;rdquo;: it saturates the host bridge bandwidth, contends with other traffic and, depending on the topology, can fall well below the direct path. The figure is an order of magnitude, to show the size of the problem, not a benchmark.&lt;/p>
&lt;p>The reading: on the path that does use PCIe (network, disk, swap), having ACS on can &lt;strong>double or triple&lt;/strong> the time. And if that time is on the critical path, a cold start, a preemption swap, an inter-node all-reduce, it shows up in the latency the user sees. What disabling ACS does &lt;strong>not&lt;/strong> fix: traffic that already went over NVLink (intra-node TP). There, ACS is irrelevant.&lt;/p>
&lt;h2 id="the-real-tension-performance-vs-isolation-and-ens">The real tension: performance vs isolation (and ENS)&lt;/h2>
&lt;p>Here the post gets serious, because the &amp;ldquo;disable ACS and IOMMU&amp;rdquo; recipe has a cost that in a regulated environment is not free. ACS and IOMMU &lt;strong>exist for a reason&lt;/strong>: to isolate devices. On a &lt;strong>dedicated bare-metal node&lt;/strong> for inference, with no virtualisation or multi-tenancy, you are not isolating anything that matters and disabling them is reasonable. But:&lt;/p>
&lt;ul>
&lt;li>If you do &lt;strong>GPU passthrough to VMs&lt;/strong> or use containers with strong isolation, the IOMMU is &lt;strong>necessary&lt;/strong>, not optional.&lt;/li>
&lt;li>In a multi-tenant scenario where several workloads share a node, ACS provides a guarantee that one device does not snoop on another.&lt;/li>
&lt;li>Under ENS (&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">see technical controls&lt;/a>), workload isolation and access traceability can be requirements; disabling bus isolation to gain 80 ms is a decision you have to &lt;strong>justify and document&lt;/strong>, not a silent tweak.&lt;/li>
&lt;/ul>
&lt;p>The design way out, when you need both: &lt;strong>keep the isolation where compliance requires it and design so that the hot path does not depend on P2P over PCIe&lt;/strong>. Concretely, on an SXM node, the bulk of the critical traffic (TP) already goes over NVLink and ACS does not affect it. For the network, dedicate one NIC per GPU on its own PCIe switch (PXB) and use GPUDirect RDMA only on the path you control. For the disk, cache weights on local NVMe. That way you do not pay the choice between performance and isolation: you avoid it on the path that matters.&lt;/p>
&lt;h2 id="the-10-knobs">The 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost / risk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>ACS off (switches on the path)&lt;/td>
&lt;td>P2P detour through root&lt;/td>
&lt;td>you lose bus isolation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>iommu=pt&lt;/code> / off&lt;/td>
&lt;td>P2P redirection through root&lt;/td>
&lt;td>breaks VM passthrough if off&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>nvidia-smi topo -m&lt;/code>&lt;/td>
&lt;td>audit the real topology&lt;/td>
&lt;td>— (always worth it)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>p2pBandwidthLatencyTest&lt;/code>&lt;/td>
&lt;td>measure P2P for real&lt;/td>
&lt;td>— (verify before assuming)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>NIC placement&lt;/td>
&lt;td>same PCIe switch as the GPU&lt;/td>
&lt;td>SYS if it crosses NUMA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>NVMe placement&lt;/td>
&lt;td>NUMA-local to the GPU&lt;/td>
&lt;td>H2D crossing UPI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>&lt;code>nvidia-peermem&lt;/code> (GDR)&lt;/td>
&lt;td>enables RDMA into HBM&lt;/td>
&lt;td>correct driver/kernel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>PCIe gen/lanes (x16)&lt;/td>
&lt;td>width of the corridor&lt;/td>
&lt;td>GPU silently at x8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>relaxed ordering / ASPM&lt;/td>
&lt;td>PCIe latency and power&lt;/td>
&lt;td>jitter if misconfigured&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>persistence mode&lt;/td>
&lt;td>avoids re-init of the path&lt;/td>
&lt;td>idle GPU still paid for&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With NVLink and NCCL.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink post&lt;/a> covers the GPU↔GPU bench; this one covers the &lt;em>other&lt;/em> bus, the one connecting to disk, network and host. They are complementary: ACS affects PCIe, not NVLink.&lt;/p>
&lt;p>&lt;strong>With the cold start.&lt;/strong> The GPUDirect Storage of the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">disk→HBM post&lt;/a> is GPUDirect over the storage path; ACS on strangles it just as it strangles P2P.&lt;/p>
&lt;p>&lt;strong>With NUMA.&lt;/strong> The PCIe affinity of &lt;code>topo -m&lt;/code> &lt;strong>is&lt;/strong> the NUMA affinity of the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>; placing NIC and NVMe on the correct NUMA avoids the SYS path.&lt;/p>
&lt;p>&lt;strong>With the network.&lt;/strong> NIC placement and GPUDirect RDMA is the subject of the &lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">network NUMA post&lt;/a>; the same principle of &amp;ldquo;get the CPU out of the way&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>With PagedAttention and the scheduler.&lt;/strong> The preemption &lt;strong>SWAP&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a>) moves &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">KV&lt;/a> blocks over PCIe; that is why V1 prefers RECOMPUTE and why this bus matters.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> Transferring KV between pools in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> travels PCIe→NIC→PCIe; ACS and placement decide whether it is viable.&lt;/p>
&lt;p>&lt;strong>With ENS.&lt;/strong> Bus isolation is a technical control; see &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS/42001/AI Act controls&lt;/a>.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Disable ACS everywhere, it goes faster.&amp;rdquo;&lt;/strong> On a dedicated node, fine. On one with virtualisation, multi-tenancy or isolation requirements (ENS), you are removing a security control. The correct decision is &lt;em>per path&lt;/em> and documented, not global and silent.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;NVLink and PCIe are the same bus, more or less.&amp;rdquo;&lt;/strong> No. They are two buses with a 7× difference and different purposes. TP goes over NVLink; disk, network and host go over PCIe. Confusing them leads to &amp;ldquo;optimising&amp;rdquo; ACS for traffic that does not even go through PCIe.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;P2P just works, there is nothing to check.&amp;rdquo;&lt;/strong> P2P &lt;strong>is silently disabled&lt;/strong> with ACS/IOMMU active, and many distros enable them by default. Check with &lt;code>p2pBandwidthLatencyTest&lt;/code> and &lt;code>nvidia-smi topo -m&lt;/code>; do not assume you have the direct path just because the GPUs are on the same node.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;IOMMU off always, for performance.&amp;rdquo;&lt;/strong> If you do GPU passthrough to virtual machines, the IOMMU is mandatory; disabling it breaks the passthrough. The correct mode is usually &lt;code>passthrough&lt;/code> (&lt;code>iommu=pt&lt;/code>): it keeps the necessary mapping without penalising P2P.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;More PCIe lanes = faster GPU.&amp;rdquo;&lt;/strong> PCIe is the I/O path, not the compute path. A GPU at x8 instead of x16 takes longer to &lt;em>load&lt;/em> and to &lt;em>communicate over PCIe&lt;/em>, but generates tokens at the same speed once the weights are inside. The damage from x8 is in the cold start and in multi-node, not in decode throughput.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;GPUDirect fixes any I/O bottleneck.&amp;rdquo;&lt;/strong> GPUDirect removes the bounce through the CPU; if your bottleneck is the device itself (saturated NVMe, NIC at full tilt) or the topology (a SYS path crossing NUMA), GPUDirect does not touch it. Measure where the bottleneck is first.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>This whole series has been going down floors looking for where the time goes, and this one reaches the wiring of the building. Intuition treats the node as a homogeneous box where &amp;ldquo;the GPUs talk to everything&amp;rdquo;; the reality is that there are two buses with opposite purposes, an elite bench for the GPUs (NVLink) and some service corridors for everything else (PCIe), and a security guard in the corridors who, with the best of intentions, makes every parcel go up to reception before it is delivered. GPUDirect is the permission for direct delivery; ACS and IOMMU are the legitimate reasons why sometimes you are not given it. The lesson is not &amp;ldquo;disable the guard&amp;rdquo;: it is understanding &lt;strong>which path is critical&lt;/strong> (almost never the one you think) and &lt;strong>what the guard was watching&lt;/strong> before sending it home. On a dedicated node, the direct path is almost free and worth taking. On one that shares workloads or lives under ENS, bus isolation is a control you sacrifice with justification or do not sacrifice at all. Good design does not choose between performance and isolation blindly: it puts the critical traffic on the bench that needs no guard, and leaves the corridors for what can wait.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">The shared bench: NVLink, NVSwitch and NCCL&lt;/a> — the GPU↔GPU bus that ACS does not touch; complementary to this post.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start&lt;/a> — GPUDirect Storage over the NVMe path, strangled by ACS just like P2P.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — PCIe affinity is NUMA affinity; placing NIC and NVMe on the right socket.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">Network NUMA, Cilium eBPF and DRANET&lt;/a> — NIC placement and GPUDirect RDMA, the same principle of getting the CPU out of the way.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention and the block manager&lt;/a> — the KV that travels over PCIe when a SWAP happens.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — why V1 prefers RECOMPUTE to SWAP (it avoids the trip over PCIe).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — transferring KV between nodes goes PCIe→NIC→PCIe.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls ENS / ISO 42001 / EU AI Act&lt;/a> — bus isolation as a security control to be justified.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>GPUDirect Storage Best Practices Guide&lt;/em> (ACS, IOMMU, paths): &lt;a href="https://docs.nvidia.com/gpudirect-storage/best-practices-guide/index.html">https://docs.nvidia.com/gpudirect-storage/best-practices-guide/index.html&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NCCL Troubleshooting&lt;/em> (IOMMU/VT-d and P2P): &lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting.html">https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting.html&lt;/a>.&lt;/li>
&lt;li>M. Giraud, &lt;em>Multi-GPU (NVIDIA) P2P capabilities and debugging tips&lt;/em>: &lt;a href="https://morgangiraud.medium.com/multi-gpu-nvidia-p2p-capabilities-and-debugging-tips-fb7597b4e2b5">https://morgangiraud.medium.com/multi-gpu-nvidia-p2p-capabilities-and-debugging-tips-fb7597b4e2b5&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>H100 Product Brief&lt;/em> (PCIe Gen5, NVLink 900 GB/s): &lt;a href="https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/h100/PB-11773-001_v01.pdf">https://www.nvidia.com/content/dam/en-zz/Solutions/Data-Center/h100/PB-11773-001_v01.pdf&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>GPUDirect RDMA documentation&lt;/em>: &lt;a href="https://docs.nvidia.com/cuda/gpudirect-rdma/index.html">https://docs.nvidia.com/cuda/gpudirect-rdma/index.html&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The signature-dish specialist: vLLM's attention backend (FlashAttention, FlashInfer and the prefill/decode asymmetry)</title><link>https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/</link><pubDate>Mon, 08 Jun 2026 05:40:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. The &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention post&lt;/a> explained where the KV lives (in paged blocks). This one explains who reads it and how: the attention kernel. And it connects with &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1-v4&lt;/a>, which took that kernel apart from the inside; here we look at the level above, how vLLM chooses between several kernels and why it needs more than one.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM forward is, for the most part, standard matrix multiplications that any library does well. The exception that decides performance is &lt;strong>attention&lt;/strong>, and having one good kernel is not enough: you need two, because the two phases of inference are physically opposite problems. &lt;strong>Prefill&lt;/strong> processes the whole prompt: many queries against many keys, dense and &lt;strong>compute-bound&lt;/strong>, the home ground of &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>&amp;rsquo;s IO-aware tiling. &lt;strong>Decode&lt;/strong> generates one token: a single query against all the accumulated KV, thin and &lt;strong>memory-bound&lt;/strong>, where the only thing that matters is saturating HBM bandwidth while reading the paged KV. That is why vLLM does not have &amp;ldquo;the attention kernel&amp;rdquo; but a &lt;strong>switchable backend&lt;/strong> (FLASH_ATTN, FLASHINFER, TRITON_ATTN and others) plus logic that picks according to the GPU: by default FA4 on Blackwell (SM100), FA3 on Hopper (SM90), FA2 elsewhere, with &lt;strong>FlashInfer&lt;/strong> as the alternative that compiles bespoke kernels (JIT) and knows how to do &lt;strong>cascade attention&lt;/strong> for shared prefixes. This post explains why prefill and decode are opposites (with arithmetic intensity), how the backend reads paged KV, how the engine chooses, what FlashInfer brings, the 10 knobs and the trap of pinning a backend blindly. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-specialist-not-the-commis">Where you are: the specialist, not the commis&lt;/h2>
&lt;p>In the kitchen, almost all the work is chopping and sautéing: standard operations any competent commis chef executes, the matrix multiplications of the &lt;em>feed-forward&lt;/em> layers and the projections. There is one dish that is not delegated: the signature dish, the one that defines the restaurant. That dish is &lt;strong>attention&lt;/strong>, and it has a quirk: it is cooked in two radically different ways depending on the moment of service.&lt;/p>
&lt;p>During &lt;em>prefill&lt;/em>, when a new order arrives with its whole prompt, you cook big: a lot of raw material at once, a lot of fire, an intense operation that fills the stoves. During &lt;em>decode&lt;/em>, when a table asks for &amp;ldquo;one more dish&amp;rdquo;, you cook à la carte: a single dish, but you have to go to the larder and bring back every ingredient that table has accumulated over the whole meal. One is a problem of firepower; the other, of larder speed. The same specialist does not do both well. That is why vLLM has several, and a chef who decides which one steps up depending on the GPU and the phase. That is the &lt;strong>attention backend&lt;/strong>.&lt;/p>
&lt;h2 id="why-prefill-and-decode-are-opposite-problems">Why prefill and decode are opposite problems&lt;/h2>
&lt;p>This is the central idea, and one sum proves it: &lt;strong>arithmetic intensity&lt;/strong> (FLOPs per byte read). An operation with high intensity is limited by compute; one with low intensity, by memory.&lt;/p>
&lt;p>&lt;strong>Prefill.&lt;/strong> We serve $N$ queries (the whole prompt) against $N$ keys. The $QK^\top$ operation and the $\text{softmax}\cdot V$ do on the order of $N^2 d$ FLOPs and read on the order of $N d$ of data. Intensity grows with $N$:&lt;/p>
$$I_\text{prefill} \sim \frac{N^2 d}{N d} = N$$
&lt;p>With large $N$ (a prompt of thousands of tokens), intensity is high: &lt;strong>compute-bound&lt;/strong>. This is where FlashAttention&amp;rsquo;s tiling squeezes the tensor cores and where you get close to the GPU&amp;rsquo;s peak TFLOPS.&lt;/p>
&lt;p>&lt;strong>Decode.&lt;/strong> We serve one query (the new token) against $L$ keys (all the accumulated KV). FLOPs on the order of $L d$; bytes read on the order of $L d s$ (the entire KV has to be read from HBM). Intensity is:&lt;/p>
$$I_\text{decode} \sim \frac{L d}{L d s} = \frac{1}{s} \quad (\approx 0.5 \text{ FLOP/byte in FP16})$$
&lt;p>Constant and tiny: &lt;strong>memory-bound&lt;/strong>. The decode kernel is not limited by how much the GPU can compute but by how fast it reads the KV from HBM. It makes no difference that the H100 has 132 idle SMs (&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">see the SM post&lt;/a>): the bottleneck is the 3.35 TB/s of bandwidth, and the decode kernel exists so as not to waste a single one of those bytes/s.&lt;/p>
&lt;svg viewBox="0 0 720 220" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Prefill compute-bound vs decode memory-bound">
&lt;line x1="60" y1="180" x2="700" y2="180" stroke="currentColor" stroke-width="1.5"/>
&lt;line x1="60" y1="180" x2="60" y2="30" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="40" y="34" fill="currentColor" font-size="11" transform="rotate(-90 40 34)">performance&lt;/text>
&lt;text x="380" y="205" text-anchor="middle" fill="currentColor" font-size="11">arithmetic intensity (FLOP/byte)&lt;/text>
&lt;path d="M60 180 L300 60" stroke="#16a34a" stroke-width="2" fill="none"/>
&lt;line x1="300" y1="60" x2="700" y2="60" stroke="#16a34a" stroke-width="2"/>
&lt;text x="500" y="52" fill="#16a34a" font-size="11">compute roof (peak TFLOPS)&lt;/text>
&lt;circle cx="95" cy="160" r="5" fill="#2563eb"/>&lt;text x="105" y="158" fill="#2563eb">decode (I≈0.5): memory-bound, limited by HBM BW&lt;/text>
&lt;circle cx="430" cy="60" r="5" fill="#7c3aed"/>&lt;text x="330" y="48" fill="#7c3aed">prefill (I≈N): compute-bound, limited by compute&lt;/text>
&lt;text x="70" y="120" fill="currentColor" font-size="10">the ramp = the&lt;/text>
&lt;text x="70" y="134" fill="currentColor" font-size="10">memory-bound region&lt;/text>
&lt;/svg>
&lt;p>The design consequence: a kernel optimised for prefill (dense tiling, maximum tensor-core occupancy) is not the optimum for decode (coalesced reads of the paged KV, minimum latency). Serious servers have different kernels, or one kernel with two paths. In models with &lt;strong>MLA&lt;/strong> (multi-head latent attention), vLLM goes as far as using &lt;strong>separate backends&lt;/strong> for prefill and decode, selectable independently (&lt;a href="https://docs.vllm.ai/en/latest/design/attention_backends/">attention backends, vLLM&lt;/a>).&lt;/p>
&lt;h2 id="the-schedulers-trick-prefill-and-decode-in-the-same-forward">The scheduler&amp;rsquo;s trick: prefill and decode in the same forward&lt;/h2>
&lt;p>Here the circle closes with the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler post&lt;/a>. Because vLLM V1 mixes requests in prefill and in decode in every step, one single forward has to serve both. The backend receives metadata telling it, for each sequence in the batch, how many queries it brings and how much KV it has to read, and applies the right path to each one. That is why the attention backend and the scheduler are coupled: the first has to digest the heterogeneous batch the second builds.&lt;/p>
&lt;h2 id="how-the-backend-reads-the-paged-kv">How the backend reads the paged KV&lt;/h2>
&lt;p>The kernel does not receive a contiguous KV tensor: it receives the &lt;strong>block table&lt;/strong> from the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">block manager&lt;/a> and does a gather over the physical blocks. This imposes a real constraint on the backend: it has to support the paged &lt;em>layout&lt;/em> and vLLM&amp;rsquo;s &lt;code>block_size&lt;/code>. Not every kernel in the world does; the ones vLLM integrates (FlashAttention, FlashInfer, Triton) are adapted to read KV in fixed-size blocks scattered across HBM. That is why you cannot plug in any attention kernel from a paper: it has to speak the language of the pigeonhole larder.&lt;/p>
&lt;h2 id="the-backends-and-how-the-engine-chooses">The backends and how the engine chooses&lt;/h2>
&lt;p>vLLM exposes a &lt;strong>backend abstraction&lt;/strong> with several implementations (&lt;a href="https://deepwiki.com/vllm-project/vllm/8.2-flashattention-and-flashinfer">vLLM deepwiki&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>FLASH_ATTN&lt;/strong> — the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a> family. By default the version is picked by architecture: FA4 on SM100 (Blackwell), FA3 on SM90 (Hopper), FA2 elsewhere, configurable with &lt;code>flash_attn_version&lt;/code>.&lt;/li>
&lt;li>&lt;strong>FLASHINFER&lt;/strong> — an attention engine with &lt;strong>JIT&lt;/strong> compilation and specialisable &lt;em>kernels&lt;/em>; strong on heterogeneous KV and shared prefixes.&lt;/li>
&lt;li>&lt;strong>TRITON_ATTN&lt;/strong> — written in Triton, portable and with no dependency on precompiled CUDA binaries (&lt;a href="https://vllm.ai/blog/2026-03-04-vllm-triton-backend-deep-dive">Triton backend deep dive, vLLM, mar-2026&lt;/a>).&lt;/li>
&lt;li>Specific backends for &lt;strong>MLA&lt;/strong> and for non-NVIDIA hardware.&lt;/li>
&lt;/ul>
&lt;p>Selection is automatic unless you force it with &lt;code>VLLM_ATTENTION_BACKEND&lt;/code>. The heuristic tries FlashAttention first; on Blackwell (SM100) the fallback order for MLA is TRT-LLM Ragged → FlashInfer → others; on other GPUs only FlashAttention is considered for the main path (&lt;a href="https://docs.vllm.ai/en/latest/design/attention_backends/">attention backends, vLLM&lt;/a>). The decision depends on: architecture (SM), dtype (FP16/BF16/FP8), head dimension, and whether the load needs a feature only one backend has (cascade attention, certain &lt;em>soft caps&lt;/em>, FP8 in KV).&lt;/p>
&lt;svg viewBox="0 0 720 220" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Attention backend selection">
&lt;rect x="270" y="20" width="180" height="36" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="360" y="43" text-anchor="middle" fill="currentColor">which architecture (SM)?&lt;/text>
&lt;rect x="40" y="100" width="160" height="36" rx="6" fill="none" stroke="#7c3aed" stroke-width="1.5"/>
&lt;text x="120" y="123" text-anchor="middle" fill="#7c3aed">SM100 Blackwell → FA4&lt;/text>
&lt;rect x="280" y="100" width="160" height="36" rx="6" fill="none" stroke="#2563eb" stroke-width="1.5"/>
&lt;text x="360" y="123" text-anchor="middle" fill="#2563eb">SM90 Hopper → FA3&lt;/text>
&lt;rect x="520" y="100" width="160" height="36" rx="6" fill="none" stroke="#16a34a" stroke-width="1.5"/>
&lt;text x="600" y="123" text-anchor="middle" fill="#16a34a">rest → FA2&lt;/text>
&lt;path d="M320 56 L120 100" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;path d="M360 56 V100" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;path d="M400 56 L600 100" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;rect x="200" y="170" width="320" height="36" rx="6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="4 3"/>
&lt;text x="360" y="193" text-anchor="middle" fill="currentColor">special feature? (cascade, FP8 KV, MLA) → FlashInfer / specific&lt;/text>
&lt;path d="M360 136 V170" stroke="currentColor" stroke-width="1.2" marker-end="url(#c)"/>
&lt;defs>&lt;marker id="c" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;h2 id="what-flashinfer-brings-jit-and-cascade-attention">What FlashInfer brings: JIT and cascade attention&lt;/h2>
&lt;p>FlashInfer does not compete with FlashAttention on &amp;ldquo;being a bit faster&amp;rdquo;; it attacks a different problem: the &lt;strong>heterogeneity&lt;/strong> of the KV in real serving (&lt;a href="https://arxiv.org/abs/2501.01005">FlashInfer, arXiv 2501.01005&lt;/a>). Two ideas:&lt;/p>
&lt;p>&lt;strong>JIT compilation.&lt;/strong> Instead of a monolithic kernel, FlashInfer generates bespoke kernels for the attention variant, the problem shape and the KV layout you have, injecting &lt;em>functors&lt;/em> (query/key/logit transformations, masks). It specialises rather than generalises.&lt;/p>
&lt;p>&lt;strong>Cascade attention.&lt;/strong> Here is the jewel for serving with shared prefixes. If $R$ requests share a prefix of $P$ tokens (a common system prompt), naive attention would read that prefix $R$ times. Cascade attention computes it once against the shared prefix and then combines with each request&amp;rsquo;s own suffix:&lt;/p>
$$\text{reads: } \underbrace{R \cdot (P + s_i)}_{\text{naive}} \;\longrightarrow\; \underbrace{P + \textstyle\sum_i s_i}_{\text{cascade}}$$
&lt;p>With $R=50$ requests and a prefix $P=1000$, that is reading 50,000 prefix tokens versus 1,000. It is the &lt;strong>natural complement&lt;/strong> to &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>: the block manager shares the prefix&amp;rsquo;s &lt;em>memory&lt;/em>, and cascade attention shares the &lt;em>compute&lt;/em> of attending over it.&lt;/p>
&lt;h2 id="the-arithmetic-that-matters-when-switching-backend-gives-you-something">The arithmetic that matters: when switching backend gives you something&lt;/h2>
&lt;p>The backend only moves the needle where attention is the bottleneck. In memory-bound decode, a kernel that exploits HBM bandwidth better gives a real improvement; in compute-bound prefill with long sequences, FA3/FA4 getting close to the tensor-core peak gives a real improvement. But if your bottleneck is in another layer, &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">launch overhead&lt;/a>, a badly sized &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a>, the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>, then switching backend does not touch that part. The rule, once again: measure the regime before optimising.&lt;/p>
&lt;h2 id="the-10-knobs">The 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost / risk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>VLLM_ATTENTION_BACKEND&lt;/code>&lt;/td>
&lt;td>force a backend&lt;/td>
&lt;td>mismatch with hardware/feature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>flash_attn_version&lt;/code> (2/3/4)&lt;/td>
&lt;td>FA version&lt;/td>
&lt;td>version unsupported on your SM&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>enable FlashInfer&lt;/td>
&lt;td>JIT + cascade&lt;/td>
&lt;td>initial JIT compilation time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>cascade attention&lt;/td>
&lt;td>prefix compute reuse&lt;/td>
&lt;td>only helps with a heavily shared prefix&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>kv_cache_dtype&lt;/code> (FP8)&lt;/td>
&lt;td>FP8 support in the kernel&lt;/td>
&lt;td>not every backend/SM supports it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>block_size&lt;/code>&lt;/td>
&lt;td>layout the kernel must read&lt;/td>
&lt;td>consistency with PagedAttention&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>MLA prefill backend&lt;/td>
&lt;td>kernel for the dense phase&lt;/td>
&lt;td>MLA models only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>MLA decode backend&lt;/td>
&lt;td>kernel for the thin phase&lt;/td>
&lt;td>MLA models only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>soft cap / sliding window&lt;/td>
&lt;td>features that restrict backends&lt;/td>
&lt;td>fewer kernel options&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>head_dim / variant&lt;/td>
&lt;td>which kernels are eligible&lt;/td>
&lt;td>exotic models with no support&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With FlashAttention.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FA post&lt;/a> explains the kernel from the inside (tiling, online softmax, FA1-4); this is the level above, how vLLM chooses between kernels and why it needs more than one.&lt;/p>
&lt;p>&lt;strong>With PagedAttention.&lt;/strong> The backend &lt;strong>reads&lt;/strong> the KV that the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">block manager&lt;/a> places in blocks; it has to speak block-table.&lt;/p>
&lt;p>&lt;strong>With the scheduler.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a> builds mixed prefill+decode batches; the backend has to serve both regimes in a single forward.&lt;/p>
&lt;p>&lt;strong>With CUDA graphs.&lt;/strong> Attention kernels are captured in the &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graphs&lt;/a>; a backend that launches many small kernels benefits more from capture.&lt;/p>
&lt;p>&lt;strong>With prefix caching.&lt;/strong> Cascade attention is the &lt;em>compute&lt;/em> side of what &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a> does in &lt;em>memory&lt;/em>.&lt;/p>
&lt;p>&lt;strong>With FP8.&lt;/strong> Attending over KV in &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8&lt;/a> requires the backend to have the FP8 path; not all of them do on every architecture.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;FlashInfer is always faster than FlashAttention.&amp;rdquo;&lt;/strong> No. FlashInfer wins when its specialisation (cascade, heterogeneous KV, a particular attention variant) applies to your load; in classic dense prefill, FA3/FA4 usually does as well or better. It depends on the regime, there is no universal winner.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;A good attention kernel works for everything.&amp;rdquo;&lt;/strong> The underlying error of this post. Prefill and decode are compute-bound and memory-bound respectively; a kernel tuned for one wastes effort on the other. That is why there are separate paths (and separate backends in MLA).&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Decode is compute-bound because the GPU is at 100%.&amp;rdquo;&lt;/strong> &lt;code>nvidia-smi&lt;/code> at 100% is misleading (&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">see the SM post&lt;/a>): decode is &lt;strong>memory-bound&lt;/strong>, the GPU is moving KV, not computing. Optimising decode&amp;rsquo;s compute is polishing what is not the bottleneck.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I pin &lt;code>VLLM_ATTENTION_BACKEND&lt;/code> and forget about it.&amp;rdquo;&lt;/strong> Pinning a backend by hand can leave you on a suboptimal one when you change GPU or version, or force a slow fallback if your hardware does not support what you asked for. Autoselection usually gets it right; pin it only with a measurement that justifies it.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Cascade attention always helps.&amp;rdquo;&lt;/strong> Only with a &lt;strong>heavily shared&lt;/strong> prefix across many concurrent requests. If each request has its own context, there is nothing to share and the overhead of organising the cascade is not amortised.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The attention backend is the bottleneck, that is why I am slow.&amp;rdquo;&lt;/strong> Almost always the bottleneck is higher up (launch, scheduling, memory) or lower down (bandwidth). The backend matters where attention dominates; measure it with &lt;code>nsys&lt;/code>/DCGM before changing it.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Of everything an LLM does while generating text, almost all of it is matrix multiplications that any library resolves. Performance is decided in a single kernel, attention, and the surprise is that it is not even &lt;em>one&lt;/em> kernel: it is two opposite problems wearing the same name. Prefill wants fire, dense compute over thousands of tokens, and decode wants a fast larder, reading a token&amp;rsquo;s entire KV with the minimum waste of bandwidth. That is why vLLM did not pick a winning kernel but an abstraction that switches: FlashAttention tuned to each architecture for the general case, FlashInfer compiling bespoke kernels when there is heterogeneity or prefixes to share, Triton for portability. The head chef does not cook the signature dish just one way: he looks at who is ordering and at what point of service, and sends out the right specialist. The lesson for whoever is tuning is the usual one in this series: before changing specialist, make sure the signature dish really is what is holding you back.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal on-premise: serving a VLM with vLLM (vision + language)&lt;/a> — the token and KV-cache cost that images add to prefill in a VLM.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4&lt;/a> — the kernel from the inside; this post is the level above (how a kernel is chosen).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention and the block manager&lt;/a> — the paged KV the backend reads via the block table.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — the mixed prefill+decode batch the backend digests in one forward.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — why &lt;code>nvidia-smi&lt;/code> at 100% does not mean compute-bound, and where attention kernels are captured.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — the memory side of what cascade attention does in compute.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end: weights and KV&lt;/a> — the FP8 path the backend needs to support.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — why one forward has to serve prefill and decode at the same time.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — the data the decode kernel reads in full on every step.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>Attention Backends&lt;/em> (selección, FA2/3/4 por arquitectura, MLA): &lt;a href="https://docs.vllm.ai/en/latest/design/attention_backends/">https://docs.vllm.ai/en/latest/design/attention_backends/&lt;/a>.&lt;/li>
&lt;li>vLLM / DeepWiki, &lt;em>FlashAttention and FlashInfer&lt;/em>: &lt;a href="https://deepwiki.com/vllm-project/vllm/8.2-flashattention-and-flashinfer">https://deepwiki.com/vllm-project/vllm/8.2-flashattention-and-flashinfer&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Triton Attention Backend Deep Dive&lt;/em> (mar-2026): &lt;a href="https://vllm.ai/blog/2026-03-04-vllm-triton-backend-deep-dive">https://vllm.ai/blog/2026-03-04-vllm-triton-backend-deep-dive&lt;/a>.&lt;/li>
&lt;li>Z. Ye et al., &lt;em>FlashInfer: Efficient and Customizable Attention Engine for LLM Inference Serving&lt;/em> (arXiv 2501.01005): &lt;a href="https://arxiv.org/abs/2501.01005">https://arxiv.org/abs/2501.01005&lt;/a>.&lt;/li>
&lt;li>T. Dao, &lt;em>FlashAttention-2&lt;/em> / &lt;em>FlashAttention-3&lt;/em> (kernel IO-aware, async Hopper): &lt;a href="https://github.com/Dao-AILab/flash-attention">https://github.com/Dao-AILab/flash-attention&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The pantry of pigeonholes: PagedAttention and the vLLM block manager</title><link>https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/</link><pubDate>Mon, 08 Jun 2026 05:20:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. The &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler post&lt;/a> ended with a loose end: the scheduler has a second budget, the &lt;strong>KV blocks&lt;/strong>, and when they run out it preempts. This post opens up that budget. It is the piece the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache post&lt;/a> took for granted, &lt;em>what&lt;/em> gets stored, in order to explain &lt;em>how it is managed in memory&lt;/em>. And it is the one the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention post&lt;/a> had been promising for months.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> grows a little with every token generated, and the problem was never its &lt;strong>total size&lt;/strong> but the &lt;strong>way it is reserved&lt;/strong>. The first servers asked, per request, for a contiguous chunk of HBM the size of the maximum possible context. Since almost no request reaches that maximum, the result was catastrophic: &lt;strong>60-80% of the HBM wasted&lt;/strong> on fragmentation. PagedAttention applies to the KV the oldest and most battle-tested idea in operating systems, &lt;strong>paging&lt;/strong>: split the KV into &lt;strong>fixed-size blocks&lt;/strong> (16 tokens by default), store them in non-contiguous HBM wherever there is room, and keep a &lt;strong>block table&lt;/strong> that translates each sequence&amp;rsquo;s &lt;em>logical&lt;/em> block into its &lt;em>physical&lt;/em> one. Waste falls to &lt;strong>~4%&lt;/strong> (only the last block, half-filled). And since each block can be identified by the &lt;strong>hash of its content&lt;/strong>, two requests that share a prefix point at the &lt;strong>same physical block&lt;/strong> and share memory, with &lt;strong>copy-on-write&lt;/strong> when one of them diverges: that is the engine of &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>. This post explains fragmentation with numbers, the block manager, the block table, COW, the block-size trade-off, the 10 knobs and the trap of confusing &lt;em>&amp;ldquo;fragmentation solved&amp;rdquo;&lt;/em> with &lt;em>&amp;ldquo;zero waste&amp;rdquo;&lt;/em>. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-pantry-underneath-the-scheduler">Where you are: the pantry, underneath the scheduler&lt;/h2>
&lt;p>Go back to the kitchen of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">previous post&lt;/a>. The front-of-house manager assembles trays, but behind him there is a pantry where the ingredients each table accumulates over its meal are stored, its KV cache. The question in this post is how that pantry is organised.&lt;/p>
&lt;p>The naive way: each table gets a whole contiguous shelf, sized for the greediest imaginable customer. The problem is obvious: a table that orders little leaves almost all of its shelf empty, but that shelf is already reserved and nobody else can use it. With many tables, the pantry fills up with half-empty shelves and no new table fits, even though the gaps added together leave plenty of room.&lt;/p>
&lt;p>The PagedAttention way: the pantry is divided into &lt;strong>small, identical pigeonholes&lt;/strong>. Each table is given the pigeonholes it needs, one at a time, wherever there is room, and they do not have to be next to each other. A &lt;strong>map book&lt;/strong> records which physical pigeonholes each table has and in what order. When a table leaves, its pigeonholes go back to the pile. There are no half-empty shelves: the only waste is the last pigeonhole of each table, the half-filled one. That is, almost literally, the virtual memory of an operating system applied to the KV cache.&lt;/p>
&lt;h2 id="why-contiguous-memory-fragmented">Why contiguous memory fragmented&lt;/h2>
&lt;p>Reserving contiguously and up front produces three distinct kinds of waste:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Reservation fragmentation.&lt;/strong> You set aside space for &lt;code>max_model_len&lt;/code> (8192 tokens, say) even though the request will use 800. Reserved and never used.&lt;/li>
&lt;li>&lt;strong>Internal fragmentation.&lt;/strong> Within what you reserved, everything above what you actually use at any given moment.&lt;/li>
&lt;li>&lt;strong>External fragmentation.&lt;/strong> Gaps between contiguous reservations that are too small for a new request, even though added together they would be plenty.&lt;/li>
&lt;/ol>
&lt;p>The original vLLM paper measured that earlier systems &lt;strong>wasted 60% to 80%&lt;/strong> of the KV memory through these three routes (&lt;a href="https://arxiv.org/pdf/2309.06180">Kwon et al., SOSP 2023&lt;/a>). That is: on a GPU with room for 100 real requests, only 20-40 fitted. Paging attacks all three at once. It eliminates reservation waste (on-demand allocation) and external waste (blocks need not be contiguous), and leaves only a remainder of the internal kind: the last partial block.&lt;/p>
&lt;h2 id="the-mechanism-blocks-block-table-and-the-kernel-gather">The mechanism: blocks, block table and the kernel gather&lt;/h2>
&lt;p>The KV of a sequence is chopped into &lt;strong>logical blocks&lt;/strong> of $b$ tokens (by default $b = 16$). Each logical block is mapped, via the &lt;strong>block table&lt;/strong>, to a &lt;strong>physical block&lt;/strong> somewhere in the HBM. The block table is the &amp;ldquo;map book&amp;rdquo;: a per-request list of which physical block corresponds to each logical one (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">vLLM implementation&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Logical blocks, block table and non-contiguous physical blocks">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Request A — logical blocks (sequence order)&lt;/text>
&lt;rect x="20" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="45" y="52" text-anchor="middle" fill="#2563eb">L0&lt;/text>
&lt;rect x="75" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="100" y="52" text-anchor="middle" fill="#2563eb">L1&lt;/text>
&lt;rect x="130" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5"/>&lt;text x="155" y="52" text-anchor="middle" fill="#2563eb">L2&lt;/text>
&lt;rect x="185" y="30" width="50" height="34" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="4 3"/>&lt;text x="210" y="52" text-anchor="middle" fill="#2563eb">L3*&lt;/text>
&lt;text x="20" y="100" fill="currentColor" font-size="13">block table: L0→F7 · L1→F2 · L2→F9 · L3→F4 (last one, half-filled)&lt;/text>
&lt;text x="20" y="135" fill="currentColor" font-size="13">Physical HBM — pigeonholes wherever there is room (non-contiguous)&lt;/text>
&lt;rect x="20" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="40" y="171" text-anchor="middle" fill="currentColor" font-size="10">F0&lt;/text>
&lt;rect x="65" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="85" y="171" text-anchor="middle" fill="currentColor" font-size="10">F2·L1&lt;/text>
&lt;rect x="110" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="130" y="171" text-anchor="middle" fill="currentColor" font-size="10">F3&lt;/text>
&lt;rect x="155" y="150" width="40" height="34" fill="#f59e0b" opacity="0.7"/>&lt;text x="175" y="171" text-anchor="middle" fill="currentColor" font-size="10">F4·L3&lt;/text>
&lt;rect x="200" y="150" width="40" height="34" fill="none" stroke="currentColor"/>&lt;text x="220" y="171" text-anchor="middle" fill="currentColor" font-size="10">F5&lt;/text>
&lt;rect x="290" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="310" y="171" text-anchor="middle" fill="currentColor" font-size="10">F7·L0&lt;/text>
&lt;rect x="380" y="150" width="40" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="400" y="171" text-anchor="middle" fill="currentColor" font-size="10">F9·L2&lt;/text>
&lt;text x="20" y="220" fill="currentColor" font-size="11">The attention kernel does a gather: it walks the block table and reads F7,F2,F9,F4 as if they were contiguous.&lt;/text>
&lt;text x="20" y="238" fill="#f59e0b" font-size="11">* Only F4 is half-filled: that is the only waste (≈ half a block per sequence).&lt;/text>
&lt;/svg>
&lt;p>The key point is that the attention kernel knows how to read like that. Instead of assuming a contiguous KV tensor, the PagedAttention kernel receives the block table and does a &lt;strong>gather&lt;/strong>: for each sequence it walks its physical blocks in logical order and reads K and V as if they were together. That is why PagedAttention is not just a data structure: it is a &lt;strong>kernel&lt;/strong> that knows how to attend over paged memory. And that is why the &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">attention backend&lt;/a> and the block manager are tied together, the second deciding where the KV lives and the first knowing how to read it from there.&lt;/p>
&lt;h2 id="the-block-manager-the-pantry-librarian">The block manager: the pantry librarian&lt;/h2>
&lt;p>The &lt;strong>block manager&lt;/strong> (in V1, the &lt;code>KVCacheManager&lt;/code>) is the one that keeps the map book. Its responsibilities:&lt;/p>
&lt;ul>
&lt;li>Maintain a pool of free physical blocks (a queue of available blocks).&lt;/li>
&lt;li>Allocate blocks to a sequence as it grows (a new block every $b$ tokens).&lt;/li>
&lt;li>Free the blocks when the sequence finishes or is preempted.&lt;/li>
&lt;li>Maintain the block tables (logical→physical) of each request.&lt;/li>
&lt;li>Manage prefix caching: detect blocks with identical content and share them.&lt;/li>
&lt;li>When free blocks run out, tell the scheduler to preempt (see &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">the scheduler post&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>When the block manager says &amp;ldquo;no blocks left&amp;rdquo;, the scheduler has to take somebody off the train. That is why the two budgets, tokens and blocks, are the two hands of the same engine.&lt;/p>
&lt;h2 id="prefix-caching-sharing-pigeonholes-with-copy-on-write">Prefix caching: sharing pigeonholes with copy-on-write&lt;/h2>
&lt;p>Here is the elegant part. If two requests start with the same prefix, the same system prompt, the same context document, the first KV blocks of both are identical byte for byte. Why compute and store them twice?&lt;/p>
&lt;p>vLLM gives each block a &lt;strong>hash&lt;/strong> summarising its content (the tokens that formed it, plus the hash of the previous block, so that the hash captures position). It keeps a global table of blocks by hash. When a new request produces a block whose hash already exists, it allocates no new memory: it points its block table at the physical block that was already there (&lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">automatic prefix caching, vLLM&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 200" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Prefix caching with copy-on-write">
&lt;text x="20" y="20" fill="currentColor" font-size="13">Common prefix (system prompt) shared between A and B&lt;/text>
&lt;rect x="20" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="47" y="56" text-anchor="middle" fill="currentColor" font-size="10">F1&lt;/text>
&lt;rect x="80" y="35" width="55" height="34" fill="#16a34a" opacity="0.7"/>&lt;text x="107" y="56" text-anchor="middle" fill="currentColor" font-size="10">F2&lt;/text>
&lt;text x="200" y="56" fill="currentColor">← A and B both point here (shared memory)&lt;/text>
&lt;path d="M47 90 V70" stroke="#2563eb" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="47" y="105" text-anchor="middle" fill="#2563eb" font-size="10">A&lt;/text>
&lt;path d="M107 90 V70" stroke="#7c3aed" stroke-width="1.5" marker-end="url(#b)"/>&lt;text x="120" y="105" text-anchor="middle" fill="#7c3aed" font-size="10">B&lt;/text>
&lt;text x="20" y="140" fill="currentColor" font-size="13">A and B diverge → copy-on-write: B copies the block before writing&lt;/text>
&lt;rect x="20" y="150" width="55" height="30" fill="#2563eb" opacity="0.7"/>&lt;text x="47" y="169" text-anchor="middle" fill="currentColor" font-size="10">F3·A&lt;/text>
&lt;rect x="90" y="150" width="55" height="30" fill="#7c3aed" opacity="0.7"/>&lt;text x="117" y="169" text-anchor="middle" fill="currentColor" font-size="10">F8·B (copy)&lt;/text>
&lt;defs>&lt;marker id="b" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;p>&lt;strong>Copy-on-write&lt;/strong> is the safeguard: while A and B share a block, neither can modify it. The moment one of the two needs to write something different into that block (because their sequences diverge, or in &lt;em>parallel sampling&lt;/em> / beam search where several branches share a prefix), the block manager copies the block for that branch and only then writes (&lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">details, vLLM&lt;/a>). It is the same COW that &lt;code>fork()&lt;/code> uses in an OS: share until somebody writes.&lt;/p>
&lt;p>The saving is direct: if 50 requests share a system prompt of 1,000 tokens, instead of 50 copies of that prefix&amp;rsquo;s KV there is one. How to maximise that saving in practice is the subject of the &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate post&lt;/a>.&lt;/p>
&lt;h2 id="the-maths-that-matter-how-much-kv-how-many-blocks">The maths that matter: how much KV, how many blocks&lt;/h2>
&lt;p>&lt;strong>KV bytes per token.&lt;/strong> For a transformer block with $L$ layers, $h_{kv}$ KV heads (GQA), per-head dimension $d$ and $s$ bytes per element (2 in FP16):&lt;/p>
$$\text{KV/token} = 2 \cdot L \cdot h_{kv} \cdot d \cdot s$$
&lt;p>For a Llama-70B ($L=80$, $h_{kv}=8$, $d=128$, FP16):&lt;/p>
$$\text{KV/token} = 2 \cdot 80 \cdot 8 \cdot 128 \cdot 2 = 327680 \text{ bytes} \approx 320 \text{ KB}$$
&lt;p>A block of 16 tokens takes up $16 \times 320\,\text{KB} = 5.12$ MB.&lt;/p>
&lt;p>&lt;strong>How many requests fit.&lt;/strong> If after loading the weights ~120 GB of the node&amp;rsquo;s 320 are left for KV:&lt;/p>
$$\text{KV tokens} = \frac{120 \cdot 10^9}{327680} \approx 366000 \text{ tokens} \approx 22900 \text{ blocks}$$
&lt;p>With average contexts of 4,000 tokens (250 blocks each), that is ~90 concurrent requests. That number, not &lt;code>max_num_seqs&lt;/code>, is the real concurrency ceiling, and it is exactly the &amp;ldquo;block budget&amp;rdquo; of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a>.&lt;/p>
&lt;p>&lt;strong>The waste that remains.&lt;/strong> PagedAttention does not get to zero: each sequence wastes, on average, half a block (the last one, half-filled). With 16-token blocks and sequences of 4,000, that is $8 / 4000 = 0.2\%$ per sequence, and the famous aggregate &amp;ldquo;~4%&amp;rdquo; from the paper includes other overheads. The lesson: the waste does not disappear, it is &lt;strong>bounded&lt;/strong> to the size of one block.&lt;/p>
&lt;h2 id="the-block-size-trade-off">The block-size trade-off&lt;/h2>
&lt;p>The &lt;code>block_size&lt;/code> (16 by default) is a trade-off, not a magic constant:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Block&lt;/th>
&lt;th>Advantage&lt;/th>
&lt;th>Drawback&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Small (8)&lt;/td>
&lt;td>less internal waste; finer-grained prefix sharing&lt;/td>
&lt;td>more block table entries; more management and gather overhead&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Large (32)&lt;/td>
&lt;td>fewer metadata; more efficient gather&lt;/td>
&lt;td>more waste in the last block; prefix caching shares at a coarser grain (fewer hits)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A large block shares worse: prefix caching can only reuse complete and identical blocks, so with 32-token blocks two prompts that agree on 20 tokens share nothing (they do not fill a common block), whereas with 8-token blocks they share two blocks. The default of 16 is the point vLLM found reasonable for most workloads; it is worth testing if your workload has short, heavily repeated prefixes.&lt;/p>
&lt;h2 id="the-10-knobs">The 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost if you overdo it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>block_size&lt;/code>&lt;/td>
&lt;td>tokens per block&lt;/td>
&lt;td>waste / overhead (see table)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>enable_prefix_caching&lt;/code>&lt;/td>
&lt;td>sharing blocks by hash&lt;/td>
&lt;td>almost none; usually on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>gpu_memory_utilization&lt;/code>&lt;/td>
&lt;td>how many physical blocks there are&lt;/td>
&lt;td>OOM if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>kv_cache_dtype&lt;/code> (FP8)&lt;/td>
&lt;td>bytes per KV element&lt;/td>
&lt;td>quality (measure, do not assume)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>swap_space&lt;/code>&lt;/td>
&lt;td>blocks that fit on the host (SWAP)&lt;/td>
&lt;td>PCIe traffic on preemption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>max_model_len&lt;/code>&lt;/td>
&lt;td>maximum length per request&lt;/td>
&lt;td>fewer requests if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>eviction policy&lt;/td>
&lt;td>who gets blocks taken away&lt;/td>
&lt;td>prefix cache hit rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>sliding window&lt;/td>
&lt;td>discarding old KV&lt;/td>
&lt;td>quality on long contexts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>TP / KV sharding&lt;/td>
&lt;td>splitting the KV across GPUs&lt;/td>
&lt;td>NVLink traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>num_gpu_blocks (override)&lt;/td>
&lt;td>forcing the block count&lt;/td>
&lt;td>OOM or underuse&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the scheduler.&lt;/strong> The &amp;ldquo;block budget&amp;rdquo; of the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">scheduler&lt;/a> is administered by this block manager. When it says there are no blocks, the scheduler preempts (RECOMPUTE by default).&lt;/p>
&lt;p>&lt;strong>With the KV cache.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache post&lt;/a> explains &lt;em>what&lt;/em> each token stores; this one, &lt;em>how&lt;/em> it is placed in memory without fragmenting.&lt;/p>
&lt;p>&lt;strong>With prefix caching.&lt;/strong> COW and block hashes are the mechanism; &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">hit rate engineering&lt;/a> is how to squeeze it (prompt structure, prefix-aware routing).&lt;/p>
&lt;p>&lt;strong>With KV quantisation.&lt;/strong> Moving the KV to &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8&lt;/a> halves the bytes/token: the same node holds twice the tokens. It is the most direct lever on concurrency.&lt;/p>
&lt;p>&lt;strong>With the attention backend.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention/FlashInfer&lt;/a> kernel has to know how to attend over paged blocks; the block manager decides where they live, the kernel knows how to read them.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> Moving a request from a prefill pool to a decode pool in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> is, at bottom, &lt;strong>transferring its KV blocks&lt;/strong> between engines, over NVLink or over the network.&lt;/p>
&lt;p>&lt;strong>With multi-LoRA.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA serving&lt;/a>, the base shares prefix KV between requests from different adapters as long as the prefix is identical.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;PagedAttention eliminates waste.&amp;rdquo;&lt;/strong> It &lt;strong>bounds&lt;/strong> it, it does not eliminate it. What remains is the last partial block per sequence (~half a block) plus the block table metadata. It is ~4% instead of 60-80%, but it is not zero. Sizing as if it were zero leaves you without a cushion.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Bigger blocks always perform better.&amp;rdquo;&lt;/strong> The gather is somewhat more efficient, yes, but you lose &lt;em>sharing&lt;/em> granularity: prefix caching shares worse and the waste in the last block grows. On workloads with many short repeated prefixes, small blocks can win.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Prefix caching shares KV between users, that is a privacy problem.&amp;rdquo;&lt;/strong> It shares only blocks that are &lt;strong>identical token by token&lt;/strong> (same system prompt, same document). It does not expose one user&amp;rsquo;s content to another: if the tokens do not match, there is no common block. What is worth watching is &lt;strong>information leaking through timing side channels&lt;/strong> (a hit is faster than a miss), relevant only in very adversarial multi-tenant scenarios.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;FP8 in the KV is free: twice the concurrency.&amp;rdquo;&lt;/strong> It does double the tokens that fit, yes, but KV in FP8 &lt;strong>degrades quality&lt;/strong> measurably on long contexts. It is a real lever, not a free lunch: quality has to be measured (&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end&lt;/a>), not assumed.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Going back to contiguous memory would be simpler and almost as good.&amp;rdquo;&lt;/strong> That is nostalgia for the contiguous tensor. The &amp;ldquo;simple&amp;rdquo; option reintroduces 60-80% fragmentation: on a GPU, that is the difference between 30 and 90 concurrent requests. The complexity of the block table pays for itself many times over.&lt;/p>
&lt;p>&lt;strong>SWAP versus RECOMPUTE when preempting.&lt;/strong> Configuring a lot of &lt;code>swap_space&lt;/code> &amp;ldquo;so as not to lose KV&amp;rdquo; puts gigabyte transfers over &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe&lt;/a> on the critical path. In V1, RECOMPUTE is usually better; swap is for specific cases.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The bottleneck in serving an LLM was never only how much memory you have, but &lt;strong>how you hand it out&lt;/strong>. The first servers treated the KV cache as a contiguous shelf per client and threw two thirds of the HBM in the bin without it showing up on any dashboard. PagedAttention stole from the operating system its best idea of fifty years ago, paging, and applied it at the exact place where it hurt: small pigeonholes, a map book, on-demand allocation and, as a bonus, the possibility that two requests starting the same way share the same pigeonholes until they stop resembling each other. The result is not magic: the waste is still there, but bounded to the size of one block instead of to the size of the worst imaginable case. And that difference, from 70% to 4%, is what turned a GPU serving thirty clients into one serving ninety, without touching the hardware. The pantry did not get bigger; it got better organised.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: the vLLM scheduler step&lt;/a> — the block budget this block manager administers; when it runs out, preemption.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — &lt;em>what&lt;/em> each token stores, the data that gets paged here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — how to squeeze the block sharing that COW makes possible.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4&lt;/a> — the kernel that knows how to attend over paged KV.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end: weights and KV&lt;/a> — halving the bytes/token and doubling concurrency, while measuring quality.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — moving a request between pools is transferring its KV blocks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe, GPUDirect P2P and ACS&lt;/a> — where the blocks travel when SWAP happens or KV moves between GPUs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — sharing a prefix between requests from different adapters.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>W. Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023): &lt;a href="https://arxiv.org/pdf/2309.06180">https://arxiv.org/pdf/2309.06180&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching&lt;/em> (design, block hashing): &lt;a href="https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html">https://docs.vllm.ai/en/v0.8.1/design/automatic_prefix_caching.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Automatic Prefix Caching — Implementation&lt;/em> (block table, COW): &lt;a href="https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html">https://docs.vllm.ai/en/v0.6.1/automatic_prefix_caching/details.html&lt;/a>.&lt;/li>
&lt;li>H. Elshafie, &lt;em>Paged Attention from First Principles: A View Inside vLLM&lt;/em>: &lt;a href="https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/">https://hamzaelshafie.bearblog.dev/paged-attention-from-first-principles-a-view-inside-vllm/&lt;/a>.&lt;/li>
&lt;li>&lt;em>vAttention: Dynamic Memory Management for Serving LLMs without PagedAttention&lt;/em> (alternative, critical context): &lt;a href="https://arxiv.org/pdf/2405.04437">https://arxiv.org/pdf/2405.04437&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The pass: the maître d' who builds every round — vLLM's scheduler step</title><link>https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/</link><pubDate>Mon, 08 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/</guid><description>&lt;blockquote>
&lt;p>Part of the &lt;em>under the engine&lt;/em> series. Earlier posts looked at the silicon that runs the kernels (&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs and CUDA graphs&lt;/a>) and at loading the weights (&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">from disk to HBM&lt;/a>). This one goes up a floor: &lt;em>who decides&lt;/em> what runs in each forward. Before the GPU launches a single kernel, someone has had to build this round&amp;rsquo;s order. That someone is the &lt;strong>scheduler&lt;/strong>, and it is the heart of &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An LLM server does not serve one whole request and then the next: it advances every live request at once, a little on each engine iteration. The piece that decides &lt;em>how far&lt;/em> each one advances in each step is the &lt;strong>scheduler&lt;/strong>, and its output is deceptively simple: a dictionary &lt;code>{req_id: number of tokens}&lt;/code> that the model runner turns into a single forward on the GPU. vLLM V1&amp;rsquo;s most important decision was to &lt;strong>erase the distinction between prefill and decode&lt;/strong>: for the scheduler, a prompt token and a freshly generated token are the same thing, tokens that need processing, and that is why it can put a 4000-token prompt in the same batch as 200 one-token decodes. There are four pieces: the &lt;strong>token budget&lt;/strong> (&lt;code>max_num_batched_tokens&lt;/code>), a fixed-size tray filled every round; &lt;strong>chunked prefill&lt;/strong>, which splits a huge prompt into chunks so it does not hog the tray and blow up everyone else&amp;rsquo;s latency; the two &lt;strong>queues&lt;/strong> (waiting, in arrival order, and running); and &lt;strong>preemption&lt;/strong>, because when the KV cache runs out somebody has to get off the train. This post explains the loop, the arithmetic of the budget and of concurrency, the 10 knobs and the headline trap: raising the budget improves &lt;em>throughput&lt;/em> but worsens &lt;em>ITL&lt;/em> (inter-token latency), and almost nobody measures both at once. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-pass-before-the-kitchen-starts">Where you are: the pass, before the kitchen starts&lt;/h2>
&lt;p>Picture the pass of a restaurant with a busy dining room. There is no one cook per customer; there is a shared kitchen and a &lt;strong>maître d&amp;rsquo;&lt;/strong> who, every few seconds, looks at all the open orders and builds a &lt;strong>tray&lt;/strong> to send to the stoves. That tray holds, say, 8000 &amp;ldquo;units of work&amp;rdquo;. The maître d&amp;rsquo; decides what goes in: tables already eating (requests in &lt;em>decode&lt;/em>) get one more dish each; new tables that have just ordered (requests in &lt;em>prefill&lt;/em>, with their whole prompt still to process) get as much of their order as fits in whatever is left of the tray. The tray goes out, the kitchen executes it in one go, and it starts again. Hundreds of times per second.&lt;/p>
&lt;p>That maître d&amp;rsquo; is the &lt;strong>scheduler&lt;/strong>. The kitchen is the GPU running a &lt;em>forward pass&lt;/em>. And the house rule is that the kitchen never stops to wait for a single table: if a new order is enormous (a 30,000-token prompt), it is not sent whole in one go blocking everyone else, it is sent in chunks. That is, in one sentence, the whole mechanics of vLLM&amp;rsquo;s scheduler.&lt;/p>
&lt;h2 id="the-engine-loop-one-dictionary-per-iteration">The engine loop: one dictionary per iteration&lt;/h2>
&lt;p>The inference engine is a very short loop. On each pass:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>scheduler&lt;/strong> looks at the queues and produces a decision.&lt;/li>
&lt;li>The &lt;strong>model runner&lt;/strong> runs a forward with that batch on the GPU.&lt;/li>
&lt;li>The &lt;strong>sampler&lt;/strong> draws one new token for each active sequence.&lt;/li>
&lt;li>State is updated (KV cache, positions, finished requests) and it goes back to 1.&lt;/li>
&lt;/ol>
&lt;p>The surprising part is the shape of the decision in step 1. In vLLM V1 it is not a complex structure with phases: it is literally a dictionary&lt;/p>
$$\text{schedule} = \{\, \text{req id} \rightarrow n_\text{tokens} \,\}$$
&lt;p>that says, for each request entering this round, how many tokens of it get processed. For a request that is generating text, &lt;code>n_tokens = 1&lt;/code> (one autoregressive step). For a new request, &lt;code>n_tokens&lt;/code> can be the entire length of its prompt. And it can be anything in between, a &lt;em>chunk&lt;/em> of prompt, under chunked prefill, prefix caching or speculative decoding (&lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">vLLM V1 docs&lt;/a>).&lt;/p>
&lt;svg viewBox="0 0 720 250" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="13" role="img" aria-label="Inference engine loop with the scheduler">
&lt;rect x="20" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="80" y="122" text-anchor="middle" fill="currentColor">Scheduler&lt;/text>
&lt;text x="80" y="140" text-anchor="middle" fill="#2563eb" font-size="11">{req_id: n_tokens}&lt;/text>
&lt;rect x="200" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="260" y="122" text-anchor="middle" fill="currentColor">Model runner&lt;/text>
&lt;text x="260" y="140" text-anchor="middle" fill="#16a34a" font-size="11">1 forward (GPU)&lt;/text>
&lt;rect x="380" y="100" width="120" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="440" y="122" text-anchor="middle" fill="currentColor">Sampler&lt;/text>
&lt;text x="440" y="140" text-anchor="middle" fill="#16a34a" font-size="11">+1 token / seq&lt;/text>
&lt;rect x="560" y="100" width="130" height="50" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="625" y="122" text-anchor="middle" fill="currentColor">Update&lt;/text>
&lt;text x="625" y="140" text-anchor="middle" fill="currentColor" font-size="11">KV, positions&lt;/text>
&lt;path d="M140 125 H200" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M320 125 H380" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M500 125 H560" stroke="currentColor" stroke-width="1.5" marker-end="url(#a)"/>
&lt;path d="M625 100 V60 H80 V100" stroke="currentColor" stroke-width="1.5" fill="none" marker-end="url(#a)"/>
&lt;text x="350" y="48" text-anchor="middle" fill="currentColor" font-size="11">one iteration = one step (ms order on H100)&lt;/text>
&lt;defs>&lt;marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">&lt;path d="M0 0 L8 4 L0 8 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;p>That the decision fits in a &lt;code>{id: number}&lt;/code> dictionary is not a neat implementation detail: it is &lt;strong>why continuous batching works&lt;/strong>. Because the scheduler does not think in &amp;ldquo;phases&amp;rdquo; but in &amp;ldquo;how many tokens for each&amp;rdquo;, it can mix requests at any point of their life in the same forward. The GPU receives a single heterogeneous token tensor and processes it in one go.&lt;/p>
&lt;h2 id="the-death-of-the-prefilldecode-distinction">The death of the prefill/decode distinction&lt;/h2>
&lt;p>This is the idea that costs the most effort and matters the most. In the first LLM server architectures, a request lived in &lt;strong>two separate phases&lt;/strong>: first &lt;em>prefill&lt;/em> (process the whole prompt and fill the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>), then &lt;em>decode&lt;/em> (generate token by token). The scheduler had to choreograph the move from one phase to the other, and mixing them was hard.&lt;/p>
&lt;p>vLLM V1 &lt;strong>removed the distinction&lt;/strong> (&lt;a href="https://openlm.ai/vllm-v1/">V1 design&lt;/a>). The scheduler treats prompt tokens and generated tokens uniformly: they are all tokens the model has to process in a forward. The practical consequence is enormous. A 4000-token prompt and a sequence that has generated 800 tokens and needs one more are, to the scheduler, &amp;ldquo;4000 tokens of request A&amp;rdquo; and &amp;ldquo;1 token of request B&amp;rdquo;. They fit together on the same tray. There is no phase choreography, just a budget to hand out.&lt;/p>
&lt;p>This unlocks the pattern that really pays: &lt;strong>mixing prefill and decode in every step&lt;/strong>. Prefill is &lt;em>compute-bound&lt;/em> work (a lot of matrix to multiply); decode is &lt;em>memory-bound&lt;/em> (little compute, a lot of KV to move). Mixing them in the same batch fills the gaps: while the GPU is busy with the heavy prefill, it advances the light decodes &amp;ldquo;for free&amp;rdquo;. It is the same efficiency principle as &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>, taken to its cleanest form.&lt;/p>
&lt;h2 id="the-token-budget-the-fixed-size-tray">The token budget: the fixed-size tray&lt;/h2>
&lt;p>The tray has a size: &lt;code>max_num_batched_tokens&lt;/code>. It is the maximum number of tokens the scheduler can put in a single step (&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM optimisation&lt;/a>). The policy, with chunked prefill on (it always is in V1), is clear:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Decodes first.&lt;/strong> Room is reserved for one token per request in the &lt;em>running&lt;/em> queue. They are cheap, and they are diners already eating: they are not left waiting.&lt;/li>
&lt;li>&lt;strong>What is left goes to prefills.&lt;/strong> With the remaining budget, prompt tokens from &lt;em>waiting&lt;/em> requests are added, in arrival order (FCFS), split into chunks if needed.&lt;/li>
&lt;/ol>
&lt;p>An example with numbers. Budget &lt;code>max_num_batched_tokens = 8192&lt;/code>, and at this instant there are 200 requests generating:&lt;/p>
$$\text{decode} = 200 \times 1 = 200 \text{ tokens}$$
$$\text{free budget} = 8192 - 200 = 7992 \text{ tokens for prefill}$$
&lt;p>If a new request arrives with a 4000-token prompt, it fits whole in this step (4000 &amp;lt; 7992) and 3992 are left for another. If one arrives with 30,000 tokens, it does not fit: the scheduler sends it a 7992-token chunk this step, and the remaining 22,008 in later steps. That is chunked prefill.&lt;/p>
&lt;svg viewBox="0 0 720 170" xmlns="http://www.w3.org/2000/svg" font-family="sans-serif" font-size="12" role="img" aria-label="Token budget split within a step">
&lt;text x="20" y="24" fill="currentColor" font-size="13">Tray: max_num_batched_tokens = 8192 tokens/step&lt;/text>
&lt;rect x="20" y="40" width="680" height="40" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;rect x="20" y="40" width="20" height="40" fill="#2563eb"/>
&lt;rect x="40" y="40" width="660" height="40" fill="#16a34a" opacity="0.75"/>
&lt;text x="30" y="100" text-anchor="middle" fill="#2563eb" font-size="10">200 decode&lt;/text>
&lt;text x="370" y="100" text-anchor="middle" fill="#16a34a" font-size="11">7992 for prefill (a 4000-token prompt fits whole; a 30000-token one goes in chunks)&lt;/text>
&lt;text x="20" y="140" fill="currentColor">Decode first (diners eating) · prefill fills the rest (FCFS) · if it does not fit, it is chunked&lt;/text>
&lt;/svg>
&lt;p>The budget matters because it fixes how many forwards are needed to swallow a prompt. A 30,000-token prompt with an 8192 tray takes 4 steps in prefill alone before it emits its first token. With a 2048 tray it takes 15 steps, but each of those steps leaves more room for other people&amp;rsquo;s decodes, so the other diners feel the jam less.&lt;/p>
&lt;h2 id="the-two-queues-and-preemption-when-someone-gets-off-the-train">The two queues and preemption: when someone gets off the train&lt;/h2>
&lt;p>The scheduler handles two queues. The &lt;strong>waiting&lt;/strong> queue holds requests that have not started yet (their prompt has not been processed), served in arrival order, FCFS by default, though there is a &lt;code>priority&lt;/code> policy. The &lt;strong>running&lt;/strong> queue holds those already alive and generating (&lt;a href="https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3">vLLM scheduling&lt;/a>).&lt;/p>
&lt;p>There is a second budget, harder than the token one: the &lt;strong>KV cache&lt;/strong>. Every live token occupies KV blocks in HBM, and they are finite (&lt;code>gpu_memory_utilization&lt;/code> fixes them). When the scheduler wants to advance the running requests but there are no free blocks for the next token&amp;rsquo;s KV, someone has to get off: that is &lt;strong>preemption&lt;/strong>.&lt;/p>
&lt;p>vLLM V1 preempts by &lt;strong>RECOMPUTE&lt;/strong> by default, not by SWAP (&lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">V1 guide&lt;/a>). The difference:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SWAP&lt;/strong>: copy the victim&amp;rsquo;s KV to host RAM and bring it back later. It moves gigabytes across PCIe (see &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">the PCIe and P2P post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>RECOMPUTE&lt;/strong>: throw the victim&amp;rsquo;s KV away and, when there is room again, redo its prefill from scratch. It sounds expensive, but in the V1 architecture it works out cheaper than swapping, because prefill is work the GPU does very fast and you save the round trip across the bus.&lt;/li>
&lt;/ul>
&lt;p>The victim is usually the &lt;strong>newest&lt;/strong> request in the running queue (so as not to penalise whoever has been waiting longest for their answer). The danger is &lt;strong>thrashing&lt;/strong>: admit too many requests at once and the system enters a preempt-recompute-preempt cycle that drives throughput into the floor. That is why the second ceiling exists.&lt;/p>
&lt;h2 id="the-arithmetic-that-matters-concurrency-and-the-budget-trade-off">The arithmetic that matters: concurrency and the budget trade-off&lt;/h2>
&lt;p>&lt;strong>How many requests fit at once.&lt;/strong> The real concurrency limit is not &lt;code>max_num_seqs&lt;/code> (the nominal ceiling on simultaneous sequences); it is usually the &lt;strong>KV cache&lt;/strong>. If a node has $B$ free KV blocks, each block holds $b$ tokens (16 by default), and each request occupies $L$ context tokens on average, the maximum sustainable concurrency is:&lt;/p>
$$N_\text{max} \approx \frac{B \cdot b}{L}$$
&lt;p>Take a 70B model in FP16 on 4×H100 SXM (320 GB), with the bulk of HBM in weights and, say, ~120 GB free for KV. With a KV of ~0.3 MB/token (an order-of-magnitude figure, it depends on layers and heads), that is ~400,000 KV tokens. With average contexts of 4000 tokens:&lt;/p>
$$N_\text{max} \approx \frac{400000}{4000} = 100 \text{ concurrent requests}$$
&lt;p>Raising &lt;code>max_num_seqs&lt;/code> to 400 does not give you 400 concurrent: it gives you preemption and thrashing as soon as contexts grow. The KV rules.&lt;/p>
&lt;p>&lt;strong>The budget trade-off.&lt;/strong> Raising &lt;code>max_num_batched_tokens&lt;/code> puts more work in each forward, so fewer forwards for the same total work, and more &lt;em>throughput&lt;/em>. But a large budget lets a huge prefill occupy almost the whole tray in one step, and that step takes longer, so everyone else&amp;rsquo;s decodes wait for that entire step, and &lt;strong>ITL&lt;/strong> (inter-token latency) rises for everybody. The rule of thumb (&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM optimisation&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Budget&lt;/th>
&lt;th>Effect&lt;/th>
&lt;th>At the cost of&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Low (e.g. 2048)&lt;/td>
&lt;td>more interleaving, stable ITL&lt;/td>
&lt;td>less peak throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>High (e.g. 16384)&lt;/td>
&lt;td>maximum throughput&lt;/td>
&lt;td>ITL spikes when a large prefill arrives&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>There is no &amp;ldquo;correct&amp;rdquo; value: there is a point for your load. And you only find it by &lt;strong>measuring throughput and ITL at the same time&lt;/strong>, which is exactly what almost nobody does.&lt;/p>
&lt;h2 id="the-schedulers-10-knobs">The scheduler&amp;rsquo;s 10 knobs&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it controls&lt;/th>
&lt;th>Cost if you overdo it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>max_num_batched_tokens&lt;/code>&lt;/td>
&lt;td>tray size per step&lt;/td>
&lt;td>high ITL if too large&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>max_num_seqs&lt;/code>&lt;/td>
&lt;td>nominal concurrency ceiling&lt;/td>
&lt;td>preemption if the KV falls short&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>gpu_memory_utilization&lt;/code>&lt;/td>
&lt;td>KV blocks available&lt;/td>
&lt;td>OOM if too high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>chunked prefill (threshold)&lt;/td>
&lt;td>prompt chunk size&lt;/td>
&lt;td>chunking overhead if too fine&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>policy (&lt;code>fcfs&lt;/code>/&lt;code>priority&lt;/code>)&lt;/td>
&lt;td>who gets served first&lt;/td>
&lt;td>starvation of low priority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>preemption mode&lt;/td>
&lt;td>RECOMPUTE vs SWAP&lt;/td>
&lt;td>PCIe traffic / recompute&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>&lt;code>enable_prefix_caching&lt;/code>&lt;/td>
&lt;td>reuse prefix KV&lt;/td>
&lt;td>little; almost always on&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>&lt;code>max_model_len&lt;/code>&lt;/td>
&lt;td>maximum context per request&lt;/td>
&lt;td>pessimistic KV reservation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>CUDA graph sizes&lt;/td>
&lt;td>align batch with buckets&lt;/td>
&lt;td>padding / capture (see below)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>speculative tokens&lt;/td>
&lt;td>extra tokens per step&lt;/td>
&lt;td>wasted work if acceptance drops&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-to-the-rest-of-the-stack">How it connects to the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With continuous batching.&lt;/strong> The scheduler &lt;em>is&lt;/em> continuous batching turned into code. The &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">batching post&lt;/a> explains the &lt;em>what&lt;/em> (advance every request at once); this one explains the &lt;em>how&lt;/em> (the per-step token dictionary).&lt;/p>
&lt;p>&lt;strong>With the KV cache and the block manager.&lt;/strong> The second budget, the blocks, is managed by the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention block manager&lt;/a>. The scheduler asks for blocks; if there are none, it preempts. The two pieces are coupled through memory.&lt;/p>
&lt;p>&lt;strong>With CUDA graphs.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graphs&lt;/a> are captured for specific batch sizes (&lt;em>buckets&lt;/em>). The scheduler should produce batches whose size lands in those buckets to avoid padding; otherwise part of the graph&amp;rsquo;s benefit is lost.&lt;/p>
&lt;p>&lt;strong>With chunked prefill and the prefix cache.&lt;/strong> Chunking a prompt interacts with &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix caching&lt;/a>: chunks that match an already cached prefix skip the compute, and the scheduler reflects that by lowering that request&amp;rsquo;s &lt;code>n_tokens&lt;/code>.&lt;/p>
&lt;p>&lt;strong>With speculative decoding.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> makes a step verify several tokens at once; the scheduler models it as &lt;code>n_tokens &amp;gt; 1&lt;/code> for a request in decode.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a> there are two schedulers, one per pool, each with its own budget; the phase distinction that V1 erased &lt;em>inside&lt;/em> an engine reappears &lt;em>between&lt;/em> engines.&lt;/p>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> The metrics that drive &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a>, waiting-queue length and preempted requests, come straight out of the scheduler&amp;rsquo;s state.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Raising &lt;code>max_num_batched_tokens&lt;/code> always helps.&amp;rdquo;&lt;/strong> It improves throughput and worsens ITL. If you only look at tokens/s in a large-batch benchmark, you &amp;ldquo;confirm&amp;rdquo; that more is better; in interactive production, your users feel the stutter. Measure both metrics or you are not measuring.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The engine does all the prefills first and then the decodes.&amp;rdquo;&lt;/strong> That is the old architecture&amp;rsquo;s intuition. In V1 there are no phases: each step mixes prefill and decode according to the budget. Reasoning with the phase model leads to wrong conclusions about why latency rises.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;More &lt;code>max_num_seqs&lt;/code> = more throughput.&amp;rdquo;&lt;/strong> Only until the KV cache runs out. Past that, more nominal concurrency produces &lt;strong>preemption&lt;/strong>, and cascading preemption (thrashing) &lt;em>lowers&lt;/em> throughput. The real ceiling is the KV, not the parameter.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;RECOMPUTE is a waste, SWAP is better.&amp;rdquo;&lt;/strong> In V1, RECOMPUTE usually wins: prefill is extremely fast on GPU and SWAP puts a gigabyte-scale round trip across PCIe on the critical path. Switching to SWAP &amp;ldquo;to avoid recomputing&amp;rdquo; can make latency worse.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The scheduler is the bottleneck.&amp;rdquo;&lt;/strong> Almost never. The decision is a dictionary built in microseconds; the cost of the round is the &lt;em>forward&lt;/em> on the GPU, three orders of magnitude above. If your scheduling CPU shows up in the profiler, the problem is usually host-thread jitter (see &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA and CPU isolation&lt;/a>), not the scheduler logic.&lt;/p>
&lt;p>&lt;strong>Chunked prefill that is too fine.&lt;/strong> Tiny chunks make a large prompt take many steps and add fixed per-step overhead. Chunking is there to &lt;em>bound&lt;/em> the ITL impact, not to pulverise the prompt.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>All the power of a modern LLM server, swallowing hundreds of requests at once without any of them blocking the rest, rests on a decision that fits in a &lt;code>{request: how many tokens}&lt;/code> dictionary, taken hundreds of times per second. The idea that made it possible was not a faster kernel or a bigger GPU: it was &lt;strong>to stop thinking in phases&lt;/strong>. When a prompt token and a generated token are the same thing, the scheduler can fill every tray by mixing the heavy and the light, and the kitchen never stops. The rest is two budgets, tokens and KV blocks, and a rule for when the second runs out. The maître d&amp;rsquo; does not cook; he only decides what goes to the stoves in each round. But that decision, repeated without pause, is what separates an idle GPU waiting for orders from a kitchen running flat out. And the uncomfortable lesson for whoever is tuning: throughput and latency meet in the budget, and optimising one blindly means worsening the other without noticing.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching: why we do not wait for a request to finish&lt;/a> — the &lt;em>what&lt;/em>; this post is the &lt;em>how&lt;/em> that implements it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention and the block manager&lt;/a> — the scheduler&amp;rsquo;s second budget, the KV blocks; when they run out, preemption.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — what each live token occupies and why concurrency is limited by memory, not by the parameter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — the capture buckets the scheduler should respect to avoid paying for padding.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — how cached chunks lower the &lt;code>n_tokens&lt;/code> the scheduler assigns.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — the &lt;code>n_tokens &amp;gt; 1&lt;/code> case in decode.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode apart&lt;/a> — two schedulers, the phase distinction that returns between engines.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling with KEDA&lt;/a> — scheduler metrics (waiting queue, preempted requests) as a scaling signal.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>vLLM V1: A Major Upgrade to vLLM&amp;rsquo;s Core Architecture&lt;/em>: &lt;a href="https://openlm.ai/vllm-v1/">https://openlm.ai/vllm-v1/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>vLLM V1 User Guide&lt;/em> (chunked prefill por defecto, preemption RECOMPUTE): &lt;a href="https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html">https://docs.vllm.ai/en/v0.9.2/usage/v1_guide.html&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Optimization and Tuning&lt;/em> (&lt;code>max_num_batched_tokens&lt;/code>, presupuesto y trade-off): &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">https://docs.vllm.ai/en/stable/configuration/optimization/&lt;/a>.&lt;/li>
&lt;li>A. Wong, &lt;em>Understanding vLLM Scheduling: Token Budgets, Chunked Prefill, and Policies&lt;/em>: &lt;a href="https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3">https://audreywongkg.medium.com/understanding-vllm-scheduling-token-budgets-chunked-prefill-and-policies-2c879e3980e3&lt;/a>.&lt;/li>
&lt;li>W. Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023): &lt;a href="https://arxiv.org/pdf/2309.06180">https://arxiv.org/pdf/2309.06180&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The chef who calls out every order: SMs, CUDA streams and CUDA graphs, or why the GPU gets bored generating tokens</title><link>https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/</link><pubDate>Sun, 07 Jun 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/</guid><description>&lt;blockquote>
&lt;p>This closes the &amp;ldquo;outside the API&amp;rdquo; pair. The &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> moved the weights from disk into HBM; here we look at what happens &lt;strong>once they are inside&lt;/strong>, in the silicon that executes them. It is the floor below the kernel launch that the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a> mentioned without opening: &lt;em>who&lt;/em> launches those kernels, &lt;em>how&lt;/em>, and why in decode the GPU spends more time waiting for orders than computing.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An H100 has &lt;strong>~132 streaming multiprocessors (SMs)&lt;/strong>, the &amp;ldquo;hobs&amp;rdquo; that execute the compute, and &lt;strong>occupancy&lt;/strong> measures how many &lt;em>warps&lt;/em> (groups of 32 threads) it has active to hide latency. But the &lt;strong>decode&lt;/strong> bottleneck is rarely the power of those SMs. Each decode step launches &lt;strong>hundreds of tiny kernels&lt;/strong> (several projections per layer × ~80 layers), and &lt;strong>each kernel launch costs 5-10 µs of serial CPU&lt;/strong>. Since decode kernels are small (small batch, a single token), the GPU &lt;strong>finishes them before the CPU calls out the next one&lt;/strong>: bubbles appear and the GPU sits bored waiting for orders. That regime is called &lt;strong>launch-bound&lt;/strong>, and it is the deep reason, not power, not memory, why &lt;code>--enforce-eager&lt;/code> delivers &lt;strong>54 tok/s&lt;/strong> where with optimisations you reach &lt;strong>89-140&lt;/strong>. The solution is &lt;strong>CUDA graphs&lt;/strong>: record the entire kernel sequence &lt;strong>once&lt;/strong> and replay it as &lt;strong>a single submission&lt;/strong>, removing the per-launch overhead (~28% of the latency per iteration). vLLM captures ~&lt;strong>102 graphs&lt;/strong> at startup and &lt;strong>pads&lt;/strong> the real batch to the nearest &lt;em>bucket&lt;/em> so it can replay a graph with fixed shapes. This post explains SM, occupancy, streams, launch overhead with the maths, CUDA graphs, the 10 knobs, and the trap that this capture &lt;strong>is the second half of the cold start&lt;/strong> from the previous post. With scepticism about what actually moves the needle. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-silicon-below-the-kernel-launch">Where you are: the silicon, below the kernel launch&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are in the execution silicon, below the host kernel launch">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are in the silicon&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Engine · vLLM (batching, sampling, scheduler)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Host · CPU launches kernels (NUMA post)&lt;/text>
&lt;rect x="120" y="128" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · CUDA: streams, kernels, CUDA graphs&lt;/text>
&lt;text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">the order queue that reaches the silicon&lt;/text>
&lt;rect x="120" y="192" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="216" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">SMs · 132 hobs execute the warps&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 3.35 TB/s — the weights the SMs read&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">the post's question: are the SMs computing, or waiting for orders?&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-chef-who-calls-out-every-order">The analogy: the chef who calls out every order&lt;/h2>
&lt;p>Last scene in the restaurant of the series. The kitchen is set up, the pantry is stocked (the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a>). Now the food has to be &lt;strong>plated&lt;/strong>. The &lt;strong>hobs&lt;/strong> are the SMs: 132 stations cooking in parallel. The &lt;strong>head chef&lt;/strong> is the CPU: he calls out the orders, each &lt;em>kernel launch&lt;/em> is a shout of &amp;ldquo;one matrix multiplication, coming up!&amp;rdquo;. The cooks (the SMs) execute what the chef calls out.&lt;/p>
&lt;p>In &lt;strong>prefill&lt;/strong>, processing the whole prompt, each order is a huge dish: a giant matmul over hundreds of tokens at once. The chef calls out an order and the hobs take a good while to produce it. The chef has plenty of time to call out the next one. The hobs are &lt;strong>flat out&lt;/strong>: compute-bound.&lt;/p>
&lt;p>In &lt;strong>decode&lt;/strong>, generating one token at a time, each order is minuscule: a matmul over &lt;strong>a single token&lt;/strong>. The hob finishes it in an instant&amp;hellip; and sits staring at the chef waiting for the next one. But the chef can only call out &lt;strong>one order every 5-10 µs&lt;/strong>, and there are &lt;strong>hundreds of orders per token&lt;/strong>. The hobs, lightning fast, &lt;strong>get bored&lt;/strong> between shouts. The restaurant is not slow because the cooks are bad: it is slow because &lt;strong>the chef does not call out fast enough&lt;/strong>. That is the &lt;em>launch-bound&lt;/em> regime.&lt;/p>
&lt;p>The solution is not more hobs or faster cooks. It is &lt;strong>to stop calling out order by order&lt;/strong>. If the chef prints &lt;strong>the whole night&amp;rsquo;s sequence on a single sheet&lt;/strong> and hands it to the line (&amp;ldquo;do this, in this order, without waiting for me&amp;rdquo;), the hobs run without pauses. That is a &lt;strong>CUDA graph&lt;/strong>: record the kernel sequence once and replay it in one go, without the CPU calling out each one. And &lt;code>--enforce-eager&lt;/code> is exactly the opposite: forcing the chef to call out order by order, all night long.&lt;/p>
&lt;h2 id="the-mechanism-sm-warps-and-occupancy">The mechanism: SM, warps and occupancy&lt;/h2>
&lt;p>An H100 SXM has &lt;strong>~132 SMs&lt;/strong>. Each SM executes threads in groups of 32 called &lt;strong>warps&lt;/strong>, and can have several warps &amp;ldquo;in flight&amp;rdquo; at once. &lt;strong>Occupancy&lt;/strong> is the fraction of active warps against the maximum the SM supports. What is the point of having many active warps? To &lt;strong>hide latency&lt;/strong>: while one warp waits for data from HBM (hundreds of cycles), the SM executes another ready warp. With few warps, the SM runs out of anyone to give a turn to and stalls.&lt;/p>
&lt;p>But, and this is key, occupancy is a &lt;strong>necessary, not sufficient&lt;/strong> condition, and it only matters if the SM &lt;strong>has work to do&lt;/strong>. In decode, the typical problem is not low occupancy &lt;strong>inside&lt;/strong> a kernel: it is that &lt;strong>between&lt;/strong> kernels the SM has nothing, because the CPU has not launched the next one yet. Raising the occupancy of a kernel that lasts 8 µs does not help if the GPU spends 6 µs waiting for it to be launched.&lt;/p>
&lt;h2 id="the-mechanism-streams-the-order-queue">The mechanism: streams, the order queue&lt;/h2>
&lt;p>A &lt;strong>CUDA stream&lt;/strong> is a queue of operations that the GPU executes &lt;strong>in order&lt;/strong>. Operations in the same stream are sequential; operations in different streams can &lt;strong>overlap&lt;/strong>. That is what allows, for instance, copying data H2D in one stream while another stream computes, the compute/copy overlap. vLLM uses streams to overlap work, but a stream on its own does &lt;strong>not remove&lt;/strong> the cost of launching each kernel: it only decides ordering and parallelism. The launch cost is still there, order by order, until the graphs come in.&lt;/p>
&lt;h2 id="the-maths-that-matters-when-the-gpu-is-left-waiting">The maths that matters: when the GPU is left waiting&lt;/h2>
&lt;p>The number that governs everything: &lt;strong>a kernel launch costs 5-10 µs of CPU&lt;/strong>, serially. Take a Llama-70B with ~80 layers. Each layer, without fusion, launches on the order of &lt;strong>~10 kernels&lt;/strong> (Q/K/V projections, attention, output projection, the two or three MLP matmuls, the normalisations, RoPE and so on). That gives:&lt;/p>
$$ N_{\text{kernels}} \approx 80 \text{ layers} \times 10 \approx 800 \text{ launches per token} $$
&lt;p>At 5 µs per launch, &lt;strong>serially&lt;/strong>:&lt;/p>
$$ T_{\text{launch}} \approx 800 \times 5\,\mu s = 4{.}0 \text{ ms per token} $$
&lt;p>Those 4 ms are &lt;strong>CPU calling out orders only&lt;/strong>, not counting how long the SMs take to cook. If the GPU could compute instantaneously, the launch ceiling would be ~250 tok/s, and with synchronisation points between kernels, worse. Now compare with the &lt;strong>memory&lt;/strong> ceiling of decode: each token reads the 140 GB of weights once from HBM:&lt;/p>
$$ T_{\text{mem}} = \frac{140 \text{ GB}}{3{.}35 \text{ TB/s}} \approx 42 \text{ ms} \;\Rightarrow\; \approx 24 \text{ tok/s (one sequence, no batch)} $$
&lt;p>Here is the subtlety almost nobody has in mind. For &lt;strong>a single sequence&lt;/strong>, decode is memory-bound at ~24 tok/s, and the 4 ms of launch fit inside the 42 ms of reading: the launch hides. &lt;strong>But batching changes everything.&lt;/strong> When serving a batch of B sequences, the weights are read &lt;strong>once&lt;/strong> and serve all B, so the memory cost per token amortises and falls. The GPU stops being memory-bound&amp;hellip; and what was underneath emerges: the launch cost, which &lt;strong>does not amortise with batch&lt;/strong> because you have to launch the same kernel sequence regardless. The result: &lt;strong>the better you batch, the more launch-bound you become&lt;/strong>, and the more CUDA graphs pay off. That is why the raw measurement confirms it, &lt;code>--enforce-eager&lt;/code> gives &lt;strong>54 tok/s&lt;/strong> where graphs give &lt;strong>89&lt;/strong>, and up to &lt;strong>8×&lt;/strong> in configurations where decode is very small and launch dominates completely.&lt;/p>
&lt;div class="diagram" style="max-width:680px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Eager launches kernel by kernel with bubbles; the CUDA graph replays everything in one go with no gaps">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Eager vs CUDA graph on the GPU timeline&lt;/text>
&lt;text x="60" y="58" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Eager (order by order)&lt;/text>
&lt;rect x="60" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="118" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="176" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="234" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="292" y="66" width="40" height="26" rx="3" fill="#e76f51" fill-opacity="0.85" stroke="#c1121f" stroke-width="1"/>
&lt;rect x="100" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="158" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="216" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;rect x="274" y="72" width="18" height="14" fill="currentColor" fill-opacity="0.12"/>
&lt;text x="340" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">kernel&lt;/text>
&lt;text x="430" y="84" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">↑ gaps = GPU waiting for the CPU to launch&lt;/text>
&lt;text x="60" y="150" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">CUDA graph (whole sheet)&lt;/text>
&lt;rect x="60" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="100" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="140" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="180" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;rect x="220" y="158" width="40" height="26" rx="3" fill="#2a9d8f" fill-opacity="0.85" stroke="#1f7a6e" stroke-width="1"/>
&lt;text x="340" y="176" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.7">no gaps: a single submission, replay&lt;/text>
&lt;line x1="60" y1="210" x2="620" y2="210" stroke="currentColor" stroke-width="1.2" opacity="0.4"/>
&lt;text x="60" y="228" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.8">time → same kernels, same GPU; the graph removes the launch bubbles&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="vllms-cuda-graphs-specifically">vLLM&amp;rsquo;s CUDA graphs, specifically&lt;/h2>
&lt;p>vLLM does not capture a single graph: it captures &lt;strong>~102&lt;/strong> at startup, on the order of 51 &lt;em>piecewise&lt;/em> (for the mixed prefill+decode steps) and 51 &lt;em>full&lt;/em> (for pure decode). Each one is recorded for a &lt;strong>fixed batch size&lt;/strong> (a &lt;em>bucket&lt;/em>: 1, 2, 4, 8&amp;hellip; up to a maximum). In service the real batch almost never falls exactly on a bucket, so vLLM &lt;strong>pads with zeros&lt;/strong> up to the next bucket up, replays that graph, and trims the output to the real size. That is the price of graphs: they need &lt;strong>static shapes&lt;/strong>, and padding is what makes them static.&lt;/p>
&lt;p>This has two consequences that show up in the knobs and the traps:&lt;/p>
&lt;p>&lt;strong>Capture costs time and memory.&lt;/strong> Recording 102 graphs at startup adds seconds to the cold start, &lt;strong>the second half&lt;/strong> of the startup that the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> left pending, and consumes HBM (each graph retains its buffers). The &lt;code>FULL_AND_PIECEWISE&lt;/code> mode (the default) is the fastest in service but the one that asks for the most memory and the longest capture; &lt;code>FULL_DECODE_ONLY&lt;/code> saves both in exchange for not accelerating the mixed steps.&lt;/p>
&lt;p>&lt;strong>Padding wastes some compute.&lt;/strong> Padding a batch of 33 up to the bucket of 64 computes 31 phantom sequences. It is a small cost against what removing the launch overhead saves, but it exists, and it grows if the buckets are badly chosen.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;h3 id="knob-1--measure-whether-decode-is-launch-bound">Knob 1 — Measure whether decode is launch-bound&lt;/h3>
&lt;p>Before touching anything: is the GPU computing or waiting? With &lt;code>nsys&lt;/code> (Nsight Systems) you can see the &lt;strong>gaps between kernels&lt;/strong> on the timeline; if there are gaps in decode, it is launch-bound and graphs will help. If the GPU is at 100% with no gaps, the bottleneck is something else (memory or compute) and graphs will not work miracles. &lt;code>nvidia-smi dmon&lt;/code> showing low utilisation in decode but poor TPS is the cheap signal.&lt;/p>
&lt;h3 id="knob-2--do-not-use---enforce-eager-in-production">Knob 2 — Do not use &lt;code>--enforce-eager&lt;/code> in production&lt;/h3>
&lt;p>&lt;code>--enforce-eager&lt;/code> &lt;strong>disables CUDA graphs&lt;/strong>. It is a &lt;strong>debugging&lt;/strong> tool (to isolate which kernel fails), not a production one. Leaving it on &amp;ldquo;because it used to start up&amp;rdquo; throws away 26-50% of decode throughput. If it is in your production command, take it out and measure.&lt;/p>
&lt;h3 id="knob-3--capture-buckets-cudagraph_capture_sizes">Knob 3 — Capture buckets (&lt;code>cudagraph_capture_sizes&lt;/code>)&lt;/h3>
&lt;p>Which batch sizes to capture. Buckets too far apart make padding expensive; too many of them make capture slow and eat HBM. Tuning them to the &lt;strong>real distribution&lt;/strong> of batch sizes you see in production is the fine adjustment, but only after measuring that distribution.&lt;/p>
&lt;h3 id="knob-4--cuda-graph-mode">Knob 4 — CUDA graph mode&lt;/h3>
&lt;p>&lt;code>FULL_AND_PIECEWISE&lt;/code> (default, fastest, more memory/capture), &lt;code>FULL_DECODE_ONLY&lt;/code> (saves memory and capture, ideal for pure decode pods in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>), &lt;code>PIECEWISE&lt;/code>, or &lt;code>NONE&lt;/code> (= eager). The right mode depends on whether the pod does pure or mixed decode.&lt;/p>
&lt;h3 id="knob-5--torchcompile">Knob 5 — &lt;code>torch.compile&lt;/code>&lt;/h3>
&lt;p>vLLM leans on &lt;code>torch.compile&lt;/code> to fuse and optimise kernels before capturing them in graphs. Fewer kernels (fusion) = fewer launches = less dependence on the graph and better decode even in eager. The compilation level is a knob, with its startup time cost.&lt;/p>
&lt;h3 id="knob-6--batch-size-fill-the-hobs">Knob 6 — Batch size: fill the hobs&lt;/h3>
&lt;p>Memory-bound decode amortises with batching (as we saw in &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>): read the weights once for B sequences. More batch = more SM occupancy &lt;strong>and&lt;/strong> more memory amortisation. The limit is set by the HBM available for the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>. It is the knob that moves aggregate throughput most.&lt;/p>
&lt;h3 id="knob-7--do-not-break-stream-overlap">Knob 7 — Do not break stream overlap&lt;/h3>
&lt;p>vLLM overlaps compute and copy with streams. Patching the code to &amp;ldquo;simplify&amp;rdquo; can serialise what was overlapped. If you do not know why there are several streams, do not collapse them.&lt;/p>
&lt;h3 id="knob-8--persistence-mode--locked-clocks">Knob 8 — Persistence mode + locked clocks&lt;/h3>
&lt;p>&lt;code>nvidia-smi -pm 1&lt;/code> keeps the driver resident (avoiding reinitialisations that add launch latency). Locking clocks to the boost frequency stops the GPU dropping P-state between tiny decode kernels and paying the ramp-up latency. It is the same &lt;em>anti-jitter&lt;/em> spirit as the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA post&lt;/a>, applied to the GPU.&lt;/p>
&lt;h3 id="knob-9--fused-kernels-flashattention-fp8-kernels">Knob 9 — Fused kernels (FlashAttention, FP8 kernels)&lt;/h3>
&lt;p>Fewer kernels = fewer orders to call out. &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">FlashAttention&lt;/a> fuses attention into one kernel instead of several; fused FP8 kernels reduce the count. Fusion attacks the problem at the root: it does not speed up the launch, it &lt;strong>removes launches&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--accept-the-capture-cost-in-the-cold-start">Knob 10 — Accept the capture cost in the cold start&lt;/h3>
&lt;p>Graph capture adds seconds to startup. In a pod that lives for hours, it amortises easily. In one that scales up and down every minute, that cost is paid over and over; there, &lt;code>FULL_DECODE_ONLY&lt;/code> (shorter capture) or accepting slightly less throughput can be worth it. It is the same warm-vs-elastic tension as in the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">cold start&lt;/a>.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it attacks&lt;/th>
&lt;th>Risk / cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>nsys&lt;/code> / &lt;code>dmon&lt;/code>&lt;/td>
&lt;td>knowing whether it is launch-bound&lt;/td>
&lt;td>none; do it first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>remove &lt;code>--enforce-eager&lt;/code>&lt;/td>
&lt;td>graphs disabled&lt;/td>
&lt;td>it was for debugging; reactivate if a bug returns&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>capture buckets&lt;/td>
&lt;td>expensive padding / slow capture&lt;/td>
&lt;td>requires measuring the real distribution&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>graph mode&lt;/td>
&lt;td>memory and capture&lt;/td>
&lt;td>less coverage on mixed steps&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>torch.compile&lt;/code>&lt;/td>
&lt;td>unfused kernels&lt;/td>
&lt;td>startup time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>batch size&lt;/td>
&lt;td>occupancy + memory&lt;/td>
&lt;td>HBM for KV cache&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>streams&lt;/td>
&lt;td>broken overlap&lt;/td>
&lt;td>do not touch if not understood&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>persistence + clocks&lt;/td>
&lt;td>jitter / P-states&lt;/td>
&lt;td>electrical consumption&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>fused kernels&lt;/td>
&lt;td>number of launches&lt;/td>
&lt;td>kernel compatibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>capture vs cold start&lt;/td>
&lt;td>slower startup&lt;/td>
&lt;td>less throughput if trimmed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-this-connects-with-the-rest-of-the-stack">How this connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the cold start.&lt;/strong> CUDA graph capture is the &lt;strong>second half&lt;/strong> of the startup that the &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">previous post&lt;/a> opened: loading weights + capturing graphs = the complete cold start.&lt;/p>
&lt;p>&lt;strong>With continuous batching.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> is what &lt;strong>makes decode launch-bound&lt;/strong> (it amortises memory and leaves the launch exposed), and that is why graphs and batching reinforce each other.&lt;/p>
&lt;p>&lt;strong>With the KV cache.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> decides how much batch fits in HBM, and the batch decides SM occupancy and how much launch overhead matters. Everything is coupled through memory.&lt;/p>
&lt;p>&lt;strong>With the interconnect.&lt;/strong> In TP, between the compute kernels there are &lt;strong>all-reduces&lt;/strong> (&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink/NCCL&lt;/a>) that also get launched and synchronised. vLLM&amp;rsquo;s custom all-reduce is integrated into the same graph so the sequence is not broken by a CPU synchronisation.&lt;/p>
&lt;p>&lt;strong>With NUMA.&lt;/strong> &lt;em>Who&lt;/em> launches the kernels is the CPU from the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>; if that thread suffers jitter or lands on the wrong socket, launch overhead gets worse. Graphs reduce the dependence on that thread, which is another reason they help.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> The &lt;strong>pure decode&lt;/strong> pods of &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>: they maximise the graph benefit exactly in the most launch-bound phase.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Raising occupancy will fix slow decode.&amp;rdquo;&lt;/strong> Not if the problem is launch-bound. Occupancy matters &lt;strong>inside&lt;/strong> a kernel that has work; if the GPU is idle &lt;strong>between&lt;/strong> kernels waiting for the CPU, more occupancy does not touch that bubble. Measure before optimising what is not the bottleneck.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;CUDA graphs always speed things up.&amp;rdquo;&lt;/strong> They speed things up when decode is launch-bound. If the GPU is already at 100% (compute-bound in prefill, or memory saturated with a huge batch), graphs add little. Their territory is decode with small kernels.&lt;/p>
&lt;p>&lt;strong>&amp;quot;&lt;code>--enforce-eager&lt;/code> gives more stable results.&amp;quot;&lt;/strong> It gives &lt;strong>slower&lt;/strong> results. The stability it appears to give is that it avoids graph capture bugs on new hardware (for example a newly supported architecture). It is a temporary patch, not a production configuration.&lt;/p>
&lt;p>&lt;strong>Capturing too many buckets &amp;ldquo;just in case&amp;rdquo;.&lt;/strong> Each bucket adds capture time and HBM. Capturing 30 sizes when in production you only see 4 means paying cold start and memory for graphs that are never replayed. Tune to the real distribution.&lt;/p>
&lt;p>&lt;strong>Confusing utilisation with efficiency.&lt;/strong> &lt;code>nvidia-smi&lt;/code> at 100% &amp;ldquo;utilisation&amp;rdquo; only says &lt;strong>there is a kernel running&lt;/strong>, not that the SM is full of useful work. A low-occupancy kernel keeps &amp;ldquo;utilisation&amp;rdquo; high while wasting the SM. &lt;code>nvidia-smi&lt;/code> utilisation is a coarse thermometer; to know whether the silicon is performing you need &lt;code>nsys&lt;/code>/DCGM and to look at real occupancy and gaps.&lt;/p>
&lt;p>&lt;strong>Optimising the silicon before memory.&lt;/strong> If decode is limited by HBM bandwidth (large batch, large model), fighting with graphs and occupancy is polishing what is not the bottleneck. The right order: measure the regime (memory / compute / launch) and attack the one in charge.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Intuition says that a GPU generating slow tokens is &amp;ldquo;working hard&amp;rdquo;. Almost never: in decode it is &lt;strong>waiting for orders&lt;/strong>. The 132 SMs cook a tiny token in an instant and then sit staring at the CPU, which can only call out one order every 5-10 µs and has hundreds to call out per token. That bottleneck, neither power nor memory but &lt;strong>launch&lt;/strong>, is invisible on any dashboard that looks at &amp;ldquo;GPU utilisation&amp;rdquo;, and it is the real reason why &lt;code>--enforce-eager&lt;/code> performs at half speed. CUDA graphs solve it with a simple idea: stop calling out order by order and hand over the &lt;strong>whole sheet&lt;/strong> for the night, so the silicon runs without pauses. And there is an uncomfortable truth that reorders the optimisation priority: &lt;strong>the better you batch, the more launch-bound you become&lt;/strong>, because batching kills the memory bottleneck and exposes the launch one. That is why graphs and batching are not separate optimisations: they are the same lever seen from two sides. The chef who learns not to call out every dish is what finally makes the kitchen run as fast as the hobs always could.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — &lt;em>who&lt;/em> assembles the batch whose sizes should land in the CUDA graph capture buckets; scheduler and graphs are coupled through batch size.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the first half of startup; the graph capture in this post is the second half of the same cold start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">The floor next door: NUMA, hugepages and CPU isolation&lt;/a> — &lt;em>who&lt;/em> launches the kernels is that host thread; its jitter is the launch overhead graphs reduce.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">The shared table: NVLink, NVSwitch and NCCL&lt;/a> — the TP all-reduces are launched and synchronised between kernels; vLLM&amp;rsquo;s custom all-reduce is integrated into the same graph.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — what makes decode launch-bound by amortising memory; hence batching and graphs reinforce each other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — the memory that decides how much batch fits, and therefore SM occupancy and how much launch overhead weighs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — pure decode pods are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a> — fused FP8 kernels reduce the number of launches at the root.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — where real occupancy shows up, and the counters that separate &amp;ldquo;utilisation&amp;rdquo; from efficiency.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>CUDA Graphs&lt;/em> (diseño, modos FULL/PIECEWISE, captura): &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Inside vLLM: Anatomy of a High-Throughput LLM Inference System&lt;/em>: &lt;a href="https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html">https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Getting Started with CUDA Graphs&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/cuda-graphs/">https://developer.nvidia.com/blog/cuda-graphs/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Achieved Occupancy&lt;/em> (ocupación de SM): &lt;a href="https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm">https://archive.docs.nvidia.com/gameworks/content/developertools/desktop/analysis/report/cudaexperiments/kernellevel/achievedoccupancy.htm&lt;/a>.&lt;/li>
&lt;li>PyTorch, &lt;em>torch.compile y CUDA Graphs para inferencia LLM&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/design/cuda_graphs/">https://docs.vllm.ai/en/stable/design/cuda_graphs/&lt;/a>.&lt;/li>
&lt;li>&lt;em>Understanding the Overheads of Launching CUDA Kernels&lt;/em> (ICPP 2019): &lt;a href="https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf">https://www.hpcs.cs.tsukuba.ac.jp/icpp2019/data/posters/Poster17-abst.pdf&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The pantry lift: from disk to HBM, or why the kitchen opens late</title><link>https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/</link><pubDate>Sun, 07 Jun 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/</guid><description>&lt;blockquote>
&lt;p>This is a trip down to the basement. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">under the engine&lt;/a> series optimised the &lt;strong>hot path&lt;/strong>, what happens with every token once you are already serving. This post looks at the journey that comes &lt;strong>before&lt;/strong> serving: how the weights climb from disk to HBM. It is the first of a pair on the two things that happen outside the API and that almost nobody times: &lt;strong>model loading&lt;/strong> (this one) and &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">execution on the silicon&lt;/a> (the next one).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Before an inference pod generates its first token, it has to &lt;strong>push the whole model into HBM&lt;/strong>. A Llama-70B in FP16 is &lt;strong>140 GB&lt;/strong> travelling along a path nobody draws: &lt;strong>disk → page cache → host buffer → PCIe → HBM&lt;/strong>. Intuition fails here: HBM is not the bottleneck, it moves 3.35 TB/s and swallows 140 GB in &lt;strong>42 ms&lt;/strong>; the bottleneck is the &lt;strong>supply chain&lt;/strong>. The Gen5 NVMe disk reads at ~14 GB/s (10 s for 140 GB); PCIe Gen5 copies host→GPU at ~50 GB/s (2.8 s); and the &lt;strong>default safetensors loader&lt;/strong>, which deserialises tensor by tensor and bounces every byte through a CPU buffer, inflates all of that up to &lt;strong>30-60 s&lt;/strong>. That time is the &lt;strong>cold start&lt;/strong>, and it is the hidden tax paid by &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling&lt;/a> (scale-from-zero), by &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary/blue-green&lt;/a> and by &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> every time a pod is born. There are three families of solution, &lt;strong>GPUDirect Storage&lt;/strong> (direct DMA disk→HBM, no bounce through the CPU), &lt;strong>fastsafetensors&lt;/strong> (4.8-7.5× over the default loader) and the &lt;strong>Run:ai Model Streamer&lt;/strong> (concurrent reads that saturate the disk), plus the simplest lever of all: &lt;strong>move fewer bytes&lt;/strong> (FP8 is half of FP16). This post explains the path, the maths, the 10 knobs, and the cruellest trap: &lt;em>&amp;ldquo;the second time it loaded fast&amp;rdquo;&lt;/em> is not your loader being good, it is the &lt;strong>page cache&lt;/strong> lying to you. On the generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="where-you-are-the-basement-before-opening">Where you are: the basement, before opening&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The loading path: from disk to HBM, before the hot path of the token">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The loading path · before the first token&lt;/text>
&lt;rect x="120" y="40" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="65" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 80 GB, 3.35 TB/s — the destination, never the bottleneck&lt;/text>
&lt;rect x="120" y="86" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="111" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">PCIe Gen5 x16 · ~50 GB/s host→GPU (H2D)&lt;/text>
&lt;rect x="120" y="132" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="156" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · loader + page cache + host buffer&lt;/text>
&lt;text x="280" y="174" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">deserialise, bounce through CPU or direct DMA (GDS)&lt;/text>
&lt;rect x="120" y="196" width="320" height="40" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="221" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">NVMe Gen5 · ~14 GB/s per disk — the origin&lt;/text>
&lt;rect x="120" y="248" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="273" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Network / Ceph RGW · shared weights (slower still)&lt;/text>
&lt;text x="280" y="312" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">The hot path of the token lives above; this post opens up what is below&lt;/text>
&lt;text x="280" y="330" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">the journey that decides whether the kitchen opens in 10 s or in 60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-pantry-lift">The analogy: the pantry lift&lt;/h2>
&lt;p>Let us stay in the restaurant of the series. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">shared table&lt;/a> was the NVSwitch, the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">floor next door&lt;/a> was NUMA, the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">maître&lt;/a> was the kubelet. All of that describes the restaurant &lt;strong>running&lt;/strong>, with diners seated. But there is a moment no post looked at: &lt;strong>before opening&lt;/strong>, somebody has to bring the entire pantry up from the basement store to the kitchen.&lt;/p>
&lt;p>The model weights are the ingredients. They live in the &lt;strong>basement store&lt;/strong> (the disk). The kitchen, the &lt;strong>hot line&lt;/strong> where tokens are plated, is the GPU&amp;rsquo;s HBM. And between one and the other there is a &lt;strong>service lift&lt;/strong>: the path disk → host → PCIe → HBM. The kitchen cannot serve the first dish until the pantry is upstairs and in place. That restocking time is the &lt;strong>cold start&lt;/strong>.&lt;/p>
&lt;p>The intuition trap: the kitchen (HBM) is enormous and blazingly fast, it places ingredients at 3.35 TB/s. So we blame the kitchen when the restaurant opens late. But the kitchen is standing idle &lt;strong>waiting for the lift&lt;/strong>. The bottleneck is never the hot line: it is the &lt;strong>lift and the store&lt;/strong>. And worse still, there is a porter (the default loader) who, instead of carrying whole crates, &lt;strong>takes the ingredients out one by one, writes each one down in a notebook and packs them again&lt;/strong> before sending them up. That porter, not the lift, is half the problem.&lt;/p>
&lt;h2 id="the-mechanism-what-really-happens-when-a-model-loads">The mechanism: what really happens when a model loads&lt;/h2>
&lt;p>When vLLM starts up with a model in &lt;code>safetensors&lt;/code>, the 140 GB of the Llama-70B FP16 make this journey:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Disk → page cache.&lt;/strong> The kernel reads the &lt;code>.safetensors&lt;/code> files from NVMe into the &lt;strong>page cache&lt;/strong> (host RAM). If this is the first time after a reboot, it is a physical read from disk (~14 GB/s on Gen5). If the files are already in page cache from an earlier start-up, this is almost free, and here is where the trap we will see is born.&lt;/li>
&lt;li>&lt;strong>Deserialise.&lt;/strong> The default Hugging Face loader &lt;code>mmap&lt;/code>s the file and builds the tensors &lt;strong>one by one&lt;/strong>, copying them into a CPU tensor before moving them. This is single-threaded CPU work that saturates neither the disk nor PCIe: most of the &amp;ldquo;slow&amp;rdquo; loading time goes here, not on moving bytes.&lt;/li>
&lt;li>&lt;strong>Host → HBM (H2D).&lt;/strong> Each tensor is copied from the host buffer into HBM over &lt;strong>PCIe Gen5 x16&lt;/strong> (~50 GB/s in practice). For the DMA to be efficient, the host buffer should be &lt;strong>pinned&lt;/strong>, which connects directly with the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">hugepages and pinned memory&lt;/a> of the NUMA post.&lt;/li>
&lt;li>&lt;strong>Place in HBM.&lt;/strong> HBM receives the 140 GB. At 3.35 TB/s, &lt;strong>this takes 42 ms&lt;/strong>. It is never the bottleneck.&lt;/li>
&lt;/ol>
&lt;p>The path has a &lt;strong>shortcut&lt;/strong>: &lt;strong>GPUDirect Storage (GDS)&lt;/strong>. Instead of bouncing through the CPU buffer (steps 2-3), a DMA engine close to the NVMe controller writes &lt;strong>straight from disk to HBM&lt;/strong>, without involving the CPU. It is the same principle as the &lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">GPUDirect RDMA of the network&lt;/a>: get the CPU out of the way. &lt;code>fastsafetensors&lt;/code> uses GDS and reaches &lt;strong>26.4 GB/s&lt;/strong> reading a Llama-70B from NVMe across 4 GPUs.&lt;/p>
&lt;div class="diagram" style="max-width:680px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two paths: default loader bouncing through the CPU vs direct GPUDirect Storage">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Two paths from disk to HBM&lt;/text>
&lt;text x="170" y="48" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Default: bounce through the CPU&lt;/text>
&lt;rect x="60" y="60" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="190" y="60" width="90" height="40" rx="5" fill="currentColor" fill-opacity="0.06" stroke="#888" stroke-width="1.3"/>
&lt;text x="235" y="78" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">CPU buffer&lt;/text>
&lt;text x="235" y="92" text-anchor="middle" font-family="sans-serif" font-size="9" fill="currentColor" opacity="0.7">+ deserialise&lt;/text>
&lt;line x1="150" y1="80" x2="188" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;defs>&lt;marker id="dha" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c1121f"/>&lt;/marker>&lt;/defs>
&lt;rect x="320" y="60" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;line x1="280" y1="80" x2="318" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;text x="235" y="120" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">the CPU touches every byte · single-threaded · slow&lt;/text>
&lt;text x="170" y="168" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">GPUDirect Storage: direct DMA&lt;/text>
&lt;rect x="60" y="180" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="320" y="180" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;path d="M150,200 C230,200 240,200 318,200" stroke="#2a9d8f" stroke-width="2.4" fill="none" marker-end="url(#dhb)"/>
&lt;defs>&lt;marker id="dhb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#2a9d8f"/>&lt;/marker>&lt;/defs>
&lt;text x="235" y="240" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">the CPU never touches the data · ~26 GB/s NVMe→HBM&lt;/text>
&lt;line x1="470" y1="55" x2="470" y2="240" stroke="currentColor" stroke-width="1" opacity="0.25"/>
&lt;text x="575" y="90" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="currentColor">140 GB FP16&lt;/text>
&lt;text x="575" y="112" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">HBM: 42 ms&lt;/text>
&lt;text x="575" y="130" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">PCIe: 2.8 s&lt;/text>
&lt;text x="575" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">NVMe: 10 s&lt;/text>
&lt;text x="575" y="170" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#c1121f" opacity="0.9">default: 30-60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="why-the-problem-exists-the-economics-of-bytes">Why the problem exists: the economics of bytes&lt;/h2>
&lt;p>The size of the model in bytes is decided by quantisation, and that sets the floor of the cold start, because &lt;strong>all&lt;/strong> those bytes have to move before the first token. For a 70B parameter model:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Bytes/param&lt;/th>
&lt;th>Size 70B&lt;/th>
&lt;th>Read 1 NVMe @14 GB/s&lt;/th>
&lt;th>H2D PCIe @50 GB/s&lt;/th>
&lt;th>HBM @3.35 TB/s&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>FP16 / BF16&lt;/td>
&lt;td>2&lt;/td>
&lt;td>140 GB&lt;/td>
&lt;td>10.0 s&lt;/td>
&lt;td>2.8 s&lt;/td>
&lt;td>42 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FP8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>5.0 s&lt;/td>
&lt;td>1.4 s&lt;/td>
&lt;td>21 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 (GPTQ/AWQ)&lt;/td>
&lt;td>0.5&lt;/td>
&lt;td>~35 GB&lt;/td>
&lt;td>2.5 s&lt;/td>
&lt;td>0.7 s&lt;/td>
&lt;td>10 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three readings of this table:&lt;/p>
&lt;p>&lt;strong>HBM never shows up as a problem.&lt;/strong> The last column is always milliseconds. Anyone saying &amp;ldquo;the GPU is slow to load&amp;rdquo; is blaming the wrong place.&lt;/p>
&lt;p>&lt;strong>Quantising is the most underrated cold start lever.&lt;/strong> Going from FP16 to FP8 does not only double inference throughput (less HBM bandwidth per token, as we saw in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantization&lt;/a>): &lt;strong>it also halves the cold start&lt;/strong>, because there are half as many bytes to push up. It is a two-for-one that sizing usually ignores.&lt;/p>
&lt;p>&lt;strong>The disk is the bottleneck on bytes; the loader is the bottleneck on time.&lt;/strong> The figures in the table are the &lt;strong>theoretical floor&lt;/strong>, just moving bytes. The default loader adds single-threaded deserialisation on top, which is the difference between the theoretical 10 s and the real 30-60 s. That is why the solutions attack on two fronts: &lt;strong>fewer bytes&lt;/strong> (quantisation) and &lt;strong>a better porter&lt;/strong> (concurrent loaders / GDS).&lt;/p>
&lt;h2 id="the-maths-that-matter-cold-start-as-a-tax-on-autoscaling">The maths that matter: cold start as a tax on autoscaling&lt;/h2>
&lt;p>Cold start is not paid once. It is paid &lt;strong>every time a pod is born&lt;/strong>. And on an elastic platform, pods are born continuously.&lt;/p>
&lt;p>Take an &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling setup with KEDA&lt;/a> that scales from 2 to 6 replicas when the queue grows. The 4 new replicas take this long to be ready:&lt;/p>
$$ T_{\text{ready}} = T_{\text{schedule}} + T_{\text{pull-image}} + T_{\text{load-weights}} + T_{\text{cuda-graphs}} $$
&lt;p>With a Llama-70B FP16 and the default loader, $T_{\text{load-weights}}$ dominates: it can be &lt;strong>40 s&lt;/strong> of the ~60 s total. During those 40 s, the queue that triggered the autoscaling &lt;strong>keeps growing&lt;/strong>, since the new replicas absorb no traffic until they have loaded. The real number in the formula is not &amp;ldquo;how many replicas&amp;rdquo;, it is &lt;strong>how long each one takes to start serving&lt;/strong>, and that number is written by the loading path.&lt;/p>
&lt;p>This has a harsh operational consequence: &lt;strong>scale-to-zero is unworkable for workloads with a latency SLO if the cold start is 40 s.&lt;/strong> Nobody waits 40 s for the first token. The real elasticity of an inference platform is not limited by GPU availability, it is limited by &lt;strong>how long that GPU takes to have the model inside it&lt;/strong>. Bringing the cold start down from 40 s to 8 s (with streamer + FP8) is what turns &amp;ldquo;theoretical scale-to-zero&amp;rdquo; into &amp;ldquo;usable scale-to-zero&amp;rdquo;.&lt;/p>
&lt;p>The published numbers give the order of magnitude of the improvement: &lt;code>fastsafetensors&lt;/code> cuts start-up from &lt;strong>12.39 s to 4.74 s&lt;/strong> on a Llama-2-13B over 4×L40S, and from &lt;strong>16.04 s to 6.88 s&lt;/strong> on 1×A100, &lt;strong>4.8-7.5×&lt;/strong> over the default deserialiser. The &lt;strong>Run:ai Model Streamer&lt;/strong> loads in &lt;strong>4.88 s from S3 at concurrency 32&lt;/strong> and &lt;strong>7.53 s from SSD IO2 at concurrency 8&lt;/strong>; integrated into vLLM, total time to &lt;em>ready&lt;/em> drops to ~23 s from S3. There is no magic here: it is &lt;strong>getting the CPU out of the loop&lt;/strong> (GDS) and &lt;strong>reading in parallel&lt;/strong> (concurrency) to saturate the disk instead of leaving it half idle while one thread deserialises.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;h3 id="knob-1--measure-where-the-time-goes-read-vs-deserialise-vs-h2d">Knob 1 — Measure where the time goes (read vs deserialise vs H2D)&lt;/h3>
&lt;p>Before touching anything: time it. Is the time going on reading from disk, on deserialising, or on the H2D? &lt;code>iostat -x 1&lt;/code> during the load tells you whether the NVMe is saturated (disk bottleneck) or nearly idle (loader/CPU bottleneck). If the disk is at 20%, the problem is &lt;strong>not&lt;/strong> the disk: it is the porter. Changing disk would fix nothing; changing loader would.&lt;/p>
&lt;h3 id="knob-2----load-format-choosing-the-porter">Knob 2 — &lt;code>--load-format&lt;/code>: choosing the porter&lt;/h3>
&lt;p>vLLM exposes several loaders via &lt;code>--load-format&lt;/code>: &lt;code>safetensors&lt;/code> (default), &lt;code>runai_streamer&lt;/code>, &lt;code>fastsafetensors&lt;/code>, &lt;code>tensorizer&lt;/code>. The default is the slowest. Changing one flag can be the cheapest 4-7× there is.&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"># Run:ai Model Streamer (concurrent reads, saturates the disk)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format runai_streamer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># fastsafetensors (GPUDirect Storage, direct DMA disk→HBM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format fastsafetensors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-3--streamer-concurrency">Knob 3 — Streamer concurrency&lt;/h3>
&lt;p>The Run:ai Model Streamer splits the read across N threads according to the size of each tensor in order to saturate the storage bandwidth. Concurrency is the key parameter: &lt;strong>16 is usually enough for local NVMe; 32 (sometimes 64)&lt;/strong> for high-throughput network storage. One thread does not saturate a Gen5 NVMe; 32 do.&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 &amp;lt;model&amp;gt; --load-format runai_streamer &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-loader-extra-config &lt;span class="s1">&amp;#39;{&amp;#34;concurrency&amp;#34;: 32}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-4--gpudirect-storage-gds--fastsafetensors">Knob 4 — GPUDirect Storage (GDS) + fastsafetensors&lt;/h3>
&lt;p>If there is an &lt;code>nvidia-fs&lt;/code> driver, a supported filesystem and local NVMe, GDS writes straight disk→HBM with no bounce through the CPU. It is the difference between the two paths in the diagram. &lt;strong>But&lt;/strong>: it requires the stack to be in place, and it only wins if the bottleneck is the CPU bounce, not the disk itself. Check with &lt;code>gdscheck&lt;/code>.&lt;/p>
&lt;h3 id="knob-5--local-nvme-for-the-weights-not-network">Knob 5 — Local NVMe for the weights, not network&lt;/h3>
&lt;p>Serving the weights from Ceph RGW / NFS is convenient (one shared place) but it puts the &lt;strong>network&lt;/strong> into the loading path. For cold start, &lt;strong>weights on the node&amp;rsquo;s local NVMe&lt;/strong> (or a local cache). Network storage is for the model repository; the inference node should have a hot local copy.&lt;/p>
&lt;h3 id="knob-6--pre-pull--local-model-cache">Knob 6 — Pre-pull / local model cache&lt;/h3>
&lt;p>An &lt;code>initContainer&lt;/code> that downloads the model onto a &lt;code>local&lt;/code> or &lt;code>hostPath&lt;/code> NVMe volume before starting vLLM turns a cold start &amp;ldquo;from the network&amp;rdquo; into one &amp;ldquo;from local NVMe&amp;rdquo;. Combined with a per-node cache DaemonSet, new pods on an already warm node read from local disk, not from the network.&lt;/p>
&lt;h3 id="knob-7--quantisation-move-fewer-bytes">Knob 7 — Quantisation: move fewer bytes&lt;/h3>
&lt;p>Weights already in FP8 or INT4 on disk = half or a quarter of the cold start. It is the knob from the table above. And it composes with all the others: FP8 + streamer + GDS is multiplicative.&lt;/p>
&lt;h3 id="knob-8--parallel-loading-across-gpus-vllm-v1">Knob 8 — Parallel loading across GPUs (vLLM V1)&lt;/h3>
&lt;p>vLLM&amp;rsquo;s V1 engine (the default since 0.19) loads the weight shards &lt;strong>in parallel&lt;/strong> across the GPUs of a TP group, instead of sequentially. At TP=4, each GPU loads its quarter at the same time. Check it is active; in old versions loading was serial and the cold start of TP=4 was almost 4× that of TP=1.&lt;/p>
&lt;h3 id="knob-9--numa-locality-of-the-nvme">Knob 9 — NUMA locality of the NVMe&lt;/h3>
&lt;p>The NVMe hangs off a &lt;strong>PCIe root under a specific socket&lt;/strong>, exactly the same NUMA map as the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a>. If the load&amp;rsquo;s host buffer lands on the wrong socket, the H2D crosses the UPI. The fifth list to align, alongside &lt;code>isolcpus&lt;/code>, &lt;code>reserved-cpus&lt;/code> and the NIC IRQs: &lt;strong>which socket is local to the NVMe and to the destination GPU&lt;/strong>. &lt;code>nvidia-smi topo -m&lt;/code> shows it.&lt;/p>
&lt;h3 id="knob-10--do-not-pay-the-cold-start-keep-pods-warm">Knob 10 — Do not pay the cold start: keep pods warm&lt;/h3>
&lt;p>Sometimes the answer is not to load faster, but &lt;strong>not to unload&lt;/strong>. A floor of always-live replicas (no scale-to-zero), or a pool of preloaded warm standbys, swaps &amp;ldquo;wait 40 s&amp;rdquo; for &amp;ldquo;0 s&amp;rdquo;. It is the cost of idle GPU in exchange for start-up latency: a &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> decision, not a technical one.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>What it attacks&lt;/th>
&lt;th>Risk / cost&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>iostat&lt;/code> while loading&lt;/td>
&lt;td>knowing whether the bottleneck is disk or loader&lt;/td>
&lt;td>none; always do it first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>--load-format&lt;/code>&lt;/td>
&lt;td>single-threaded deserialisation&lt;/td>
&lt;td>format compatibility&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>streamer concurrency&lt;/td>
&lt;td>underused disk&lt;/td>
&lt;td>host RAM for buffers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>GPUDirect Storage&lt;/td>
&lt;td>bounce through the CPU&lt;/td>
&lt;td>requires nvidia-fs + supported FS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>local NVMe vs network&lt;/td>
&lt;td>the network in the path&lt;/td>
&lt;td>duplicating weights per node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>pre-pull / node cache&lt;/td>
&lt;td>network on every start-up&lt;/td>
&lt;td>local disk space&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>FP8/INT4 quantisation&lt;/td>
&lt;td>bytes to move&lt;/td>
&lt;td>quality (measure, do not assume)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>V1 parallel loading&lt;/td>
&lt;td>serial loading across GPUs&lt;/td>
&lt;td>none if V1 is active&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>NUMA of the NVMe&lt;/td>
&lt;td>H2D crossing the UPI&lt;/td>
&lt;td>align with the rest of the NUMA lists&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>warm pods / no zero&lt;/td>
&lt;td>the whole cold start&lt;/td>
&lt;td>idle GPU paid for&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> The whole of &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">scale-from-zero with KEDA&lt;/a> rests on this: real elasticity is limited by the cold start, not by GPU availability. An autoscaler with 40 s of loading reacts late to every peak.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a>, bringing up a decode pool on demand pays the cold start of loading the model into each new pod. The elasticity of the pattern depends on how fast those pods start.&lt;/p>
&lt;p>&lt;strong>With canary/blue-green.&lt;/strong> Every &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary deployment&lt;/a> loads a new version of the model alongside the old one. The validation time of a canary includes its cold start; large models make deployments slower and more expensive.&lt;/p>
&lt;p>&lt;strong>With NUMA and hugepages.&lt;/strong> The load&amp;rsquo;s host buffer wants to be &lt;strong>pinned&lt;/strong> and &lt;strong>NUMA-local&lt;/strong>, the same thing the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host post&lt;/a> asked for on the hot path. The loading path is another client of the same NUMA map.&lt;/p>
&lt;p>&lt;strong>With quantisation.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8/INT4&lt;/a> is not only inference throughput: it is the direct lever on the bytes of the cold start.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> that ignores cold start underestimates how many replicas are needed to absorb a peak: if they take 40 s to start, you need a bigger permanent cushion.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;The second time it loaded really fast.&amp;rdquo;&lt;/strong> This is the star trap. The first load filled the &lt;strong>page cache&lt;/strong> (host RAM); the second reads from RAM, not from disk, and flies. But in production pods are ephemeral and are born on different nodes: the start-up that counts is the &lt;strong>cold&lt;/strong> one, on a node where those files are not in page cache. Benchmarking the second load is measuring a situation that almost never happens at the moment that matters (the peak that triggers the autoscaler).&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;GDS always speeds things up.&amp;rdquo;&lt;/strong> No. GDS removes the bounce through the CPU; if your bottleneck is the disk itself (saturated NVMe) or the deserialisation, GDS does not touch that part. Measure first (knob 1). It also demands &lt;code>nvidia-fs&lt;/code>, a supported filesystem, and sometimes it does not work over whatever network storage you have.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;mmap makes loading instant.&amp;rdquo;&lt;/strong> &lt;code>mmap&lt;/code> maps the file but &lt;strong>reads nothing yet&lt;/strong>: the cost is deferred to the first access to each page. The time does not disappear, it moves, and the &lt;strong>first token&lt;/strong> pays the page faults that start-up did not. You have moved the cold start into the latency of the first request, which is probably a worse place to have it.&lt;/p>
&lt;p>&lt;strong>Weights on network storage &amp;ldquo;because it is cleaner&amp;rdquo;.&lt;/strong> Sharing a model repository on Ceph RGW is fine for storing them; serving the cold start from there puts the network (and its latency and its contention) into the critical path. Local NVMe cache on the inference node.&lt;/p>
&lt;p>&lt;strong>Loading FP16 and quantising at start-up.&lt;/strong> Quantising on the fly during loading (for example FP16→FP8 on the GPU) can be &lt;strong>slower&lt;/strong> than having the weights already quantised on disk: you move twice the bytes and on top of that you do conversion work. If you are going to serve in FP8, store the weights in FP8.&lt;/p>
&lt;p>&lt;strong>Optimising the load and ignoring &lt;code>T_cuda-graphs&lt;/code>.&lt;/strong> Bringing weight loading down to 8 s and forgetting that CUDA graph capture adds several more seconds leaves the cold start half done. That second half of start-up is the subject of the &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">next post&lt;/a>.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The whole series optimised what happens &lt;strong>with every token&lt;/strong>: the cable, the host, the network, the silicon. But before the first token there is a journey almost nobody times and that decides whether an inference pod opens in 10 s or in 60 s: pushing the model from disk to HBM. Intuition blames the GPU, and the GPU is innocent: HBM swallows 140 GB in 42 ms. The bottleneck is the &lt;strong>supply chain&lt;/strong>: a disk reading at 14 GB/s, a PCIe copying at 50, and above all a default loader that deserialises tensor by tensor with a single thread and turns 10 s of bytes into 60 s of waiting. The solutions attack the two right fronts, &lt;strong>fewer bytes&lt;/strong> (quantisation) and &lt;strong>better transport&lt;/strong> (GDS, concurrent streamers, local NVMe), and give 4-7× almost for free. And above the technique, an idea that reorders the priorities: on an elastic platform, cold start &lt;strong>is not a start-up detail, it is the ceiling on elasticity&lt;/strong>. The fastest GPU in the world does not scale if it takes 40 s to have the model inside. The pantry lift, the one nobody timed, is what decides what time the kitchen really opens.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — the runbook that attacks each of the five line items of start-up.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU: co-residency, model-swapping and sleep mode&lt;/a> — sleep mode dodges this cold start by parking the weights in RAM instead of on disk.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the bus GDS uses to push the weights up from NVMe; ACS enabled throttles that direct path just as it throttles P2P between GPUs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: vLLM&amp;rsquo;s scheduler step&lt;/a> — cold start is the ceiling on the elasticity the scheduler exercises: preemption and scale-from-zero pay this load in every new pod.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">The shared table: NVLink, NVSwitch and NCCL&lt;/a> — the first of &amp;ldquo;under the engine&amp;rdquo;; this post goes down to the basement that series took for granted as full.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">The floor next door: NUMA, hugepages and CPU isolation&lt;/a> — the pinned, NUMA-local memory the loading path needs for an efficient H2D.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door: network NUMA, Cilium eBPF and DRANET&lt;/a> — the same &amp;ldquo;get the CPU out of the way&amp;rdquo; principle (GPUDirect) that GDS applies here to the disk.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — the second half of the cold start (graph capture) and what happens on the silicon once the weights are inside.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a> — the lever that halves the bytes of the cold start, not just HBM bandwidth per token.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — why cold start is the real ceiling on elasticity and kills scale-to-zero.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — bringing up pools on demand pays the cold start in every new pod.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for models&lt;/a> — the validation time of a canary includes its model loading.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — cold start as a parameter of the replica cushion and of the warm-vs-zero decision.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Loading model weights with fastsafetensors&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/">https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/&lt;/a>.&lt;/li>
&lt;li>foundation-model-stack, &lt;em>fastsafetensors&lt;/em> (high-performance loader, GDS): &lt;a href="https://github.com/foundation-model-stack/fastsafetensors">https://github.com/foundation-model-stack/fastsafetensors&lt;/a>.&lt;/li>
&lt;li>&lt;em>Speeding up Model Loading with fastsafetensors&lt;/em> (arXiv 2505.23072): &lt;a href="https://arxiv.org/html/2505.23072v1">https://arxiv.org/html/2505.23072v1&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>GPUDirect Storage: A Direct Path Between Storage and GPU Memory&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/gpudirect-storage/">https://developer.nvidia.com/blog/gpudirect-storage/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Magnum IO GPUDirect Storage&lt;/em> (benchmarking and configuration): &lt;a href="https://developer.nvidia.com/gpudirect-storage">https://developer.nvidia.com/gpudirect-storage&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The kitchen door the maître d' never looked at: network NUMA, Cilium eBPF and DRANET, the fourth leg of pinning</title><link>https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/</link><pubDate>Sat, 06 Jun 2026 12:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/</guid><description>&lt;blockquote>
&lt;p>Fourth instalment, a coda, of &amp;ldquo;under the engine&amp;rdquo;. The series closed with three legs of locality: the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">cable between GPUs&lt;/a>, the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host by hand&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">declarative orchestration of the kubelet&lt;/a>. But the maître d&amp;rsquo; of the last post seated the group looking at CPU, memory and GPU, and never asked &lt;strong>which door the dishes come through&lt;/strong>. That door is the NIC. Here is the fourth leg.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Topology Manager&lt;/a> admits a pod under &lt;code>single-numa-node&lt;/code> if its CPUs, its memory and its GPU fit on the &lt;strong>same&lt;/strong> NUMA node. The &lt;strong>NIC is not part of that count&lt;/strong>: the kubelet has no Hint Provider for the network card. On an inference node with the network at 200/400 Gb/s, the case of &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, where the KV-cache travels over RDMA between the prefill pool and the decode pool, a NIC on the wrong socket makes &lt;strong>every packet cross the UPI/QPI&lt;/strong>, exactly the &amp;ldquo;remote NUMA&amp;rdquo; the series fights on the compute side, but coming in through the network door. And there is a second front: the &lt;strong>softirq&lt;/strong> (&lt;code>NET_RX&lt;/code>) that processes the datapath runs on the CPU that services the NIC&amp;rsquo;s IRQ; if that CPU is one of the cores &lt;code>isolcpus&lt;/code>/&lt;code>reserved-cpus&lt;/code> gave exclusively to vLLM, the softirq steals cycles from it and injects jitter into the p99 tail. &lt;strong>Cilium eBPF&lt;/strong> replaces two RKE2 pieces, &lt;code>kube-proxy&lt;/code> (with eBPF/XDP load balancing) and the default CNI &lt;strong>Canal&lt;/strong> (with a native datapath), and its own tuning guide tells you to &lt;strong>kill &lt;code>irqbalance&lt;/code> and pin the NIC IRQs&lt;/strong>: a &lt;strong>fourth list&lt;/strong> to align alongside &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>. The 2026 state of the art closes the gap from above: &lt;strong>netkit&lt;/strong> (kernel ≥6.8, zero namespace overhead), &lt;strong>BIG TCP&lt;/strong> (192k super-packets for 100Gb/s+), &lt;strong>host-routing&lt;/strong> (iptables bypass), and above all &lt;strong>DRA/DRANET&lt;/strong>, the network driver that finally co-schedules &lt;strong>NUMA-local GPU and NIC on the same PCIe root&lt;/strong>, enabling GPUDirect RDMA with &lt;strong>+59.6% bus bandwidth in &lt;code>all_gather&lt;/code> and +58.1% in &lt;code>all_reduce&lt;/code>&lt;/strong>. On a generic RKE2 cluster with 4×H100 SXM nodes.&lt;/p>
&lt;h2 id="where-you-are-the-network-plane-the-trilogy-did-not-open">Where you are: the network plane the trilogy did not open&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The fourth leg: the network plane under the same NUMA as CPU, memory and GPU">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · the fourth leg of locality&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM pod (TP, batching, KV-cache)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Orchestration · kubelet: CPU/Mem/Topology Mgr (post 3)&lt;/text>
&lt;rect x="120" y="128" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · network plane: Cilium eBPF + DRA/NIC&lt;/text>
&lt;text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">NIC NUMA locality · IRQ · GPUDirect RDMA&lt;/text>
&lt;rect x="120" y="192" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="216" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Host · NUMA, hugepages, isolcpus (post 2)&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL + NVLink (post 1)&lt;/text>
&lt;rect x="120" y="280" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="304" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, 4×H100 SXM, 400 Gb/s NIC&lt;/text>
&lt;text x="280" y="332" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">CPU+memory+GPU are pinned by the kubelet; the NIC, until 2026, was pinned by nobody&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-door-the-dishes-come-through">The analogy: the door the dishes come through&lt;/h2>
&lt;p>Go back to the restaurant of the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">previous post&lt;/a>. The maître d&amp;rsquo;, the Topology Manager, seated the party of eight at a single table (one NUMA node) because the diners (CPUs), the cutlery (memory) and the reserved bottle (the GPU) all fitted. A perfect table. But the maître d&amp;rsquo; &lt;strong>never looked at where the kitchen pass is&lt;/strong>: the door every dish comes in and goes out through.&lt;/p>
&lt;p>That door is the &lt;strong>NIC&lt;/strong>. The prompt comes in there, the tokens go out there, and, in disaggregated serving, the KV-cache that the prefill pool sends to the decode pool circulates there. If the table is in the left-hand room (socket 0) but the kitchen pass is in the right-hand one (socket 1), &lt;strong>every dish crosses the entire restaurant&lt;/strong> (the UPI/QPI), over and over, however impeccably the table is laid. The diner does not notice the perfect table: they notice the dish arriving late and cold.&lt;/p>
&lt;p>And there is a finer detail: the waiter who crosses the room with the dishes (the &lt;strong>softirq&lt;/strong> that processes the packets) is &lt;strong>one of the seated diners&lt;/strong>. If the maître d&amp;rsquo; gave them a chair of their own so they could eat in peace (a core isolated with &lt;code>isolcpus&lt;/code> for vLLM) but the restaurant also has them waiting on the far door, that diner does not eat: they spend the whole dinner crossing the room. The jitter shows up exactly where you thought you had bought calm.&lt;/p>
&lt;p>The trilogy levelled three legs of the table: the cable, the host and the orchestration. The fourth, &lt;strong>which door the dishes come through and who carries them&lt;/strong>, is levelled by no kubelet manager. Until 2026.&lt;/p>
&lt;h2 id="the-gap-why-the-topology-manager-does-not-look-at-the-nic">The gap: why the Topology Manager does not look at the NIC&lt;/h2>
&lt;p>The mechanism of &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">post 3&lt;/a> is a coordinator (Topology Manager) that consults three &lt;strong>Hint Providers&lt;/strong>: CPU Manager, Memory Manager and Device Manager (the GPU plugin). Each says on which NUMA node it can satisfy its part; the coordinator computes the intersection and admits or rejects.&lt;/p>
&lt;p>The problem is one of &lt;strong>census&lt;/strong>: the classic NIC is not a Device Manager &amp;ldquo;device&amp;rdquo;. A standard Ethernet/InfiniBand card is managed by the CNI and the kernel, it is not requested in the pod&amp;rsquo;s &lt;code>resources:&lt;/code> like &lt;code>nvidia.com/gpu&lt;/code>, and therefore &lt;strong>it emits no NUMA hint&lt;/strong>. The Topology Manager aligns CPU+memory+GPU and leaves the NIC wherever the hardware put it, which may be the other socket. The maître d&amp;rsquo; has three assistants and is missing the fourth: the one who knows which door the dishes come through.&lt;/p>
&lt;p>This did not matter when a node&amp;rsquo;s network was 10/25 Gb/s and the bottleneck was elsewhere. It matters &lt;strong>now&lt;/strong>, with two workloads that saturate the node&amp;rsquo;s network:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Disaggregated serving.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">KV-cache that travels between the prefill pool and the decode pool&lt;/a> moves over RDMA. These are large transfers, sensitive to latency and bandwidth, which in multi-node setups go out through the NIC.&lt;/li>
&lt;li>&lt;strong>Multi-node NCCL collectives.&lt;/strong> When &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">tensor/pipeline parallelism crosses the node boundary&lt;/a>, the &lt;code>all-reduce&lt;/code>/&lt;code>all-gather&lt;/code> operations no longer go over NVLink but over GPUDirect RDMA on the NIC.&lt;/li>
&lt;/ul>
&lt;p>In both, &lt;strong>where the NIC sits relative to the GPU and to the pod&amp;rsquo;s cores&lt;/strong> decides performance. And the kubelet, on its own, does not coordinate that.&lt;/p>
&lt;h2 id="the-network-datapath-under-numa-irq-softirq-and-dma">The network datapath under NUMA: IRQ, softirq and DMA&lt;/h2>
&lt;p>To see why NIC locality carries weight, you have to look at the path of an incoming packet:&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Path of a packet: NIC, IRQ, softirq and cross-NUMA DMA">
&lt;defs>&lt;marker id="nm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Socket 0 (NIC here) vs Socket 1 (vLLM pod here): the crossing you do not see&lt;/text>
&lt;!-- Socket 0 -->
&lt;rect x="30" y="46" width="360" height="240" rx="10" fill="none" stroke="#4a6fa5" stroke-width="1.6" stroke-dasharray="5 3"/>
&lt;text x="210" y="66" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#4a6fa5">NUMA node 0 · PCIe root with the NIC&lt;/text>
&lt;rect x="60" y="80" width="120" height="44" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="120" y="100" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">NIC 400 Gb/s&lt;/text>
&lt;text x="120" y="115" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">multi-queue, RSS&lt;/text>
&lt;rect x="60" y="150" width="120" height="44" rx="7" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="120" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">IRQ + softirq&lt;/text>
&lt;text x="120" y="185" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">NET_RX on core 0..&lt;/text>
&lt;rect x="60" y="220" width="120" height="44" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="120" y="240" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">RAM node 0&lt;/text>
&lt;text x="120" y="255" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">packet DMA&lt;/text>
&lt;path d="M120,124 L120,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#nm)"/>
&lt;path d="M120,194 L120,220" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#nm)"/>
&lt;!-- Socket 1 -->
&lt;rect x="430" y="46" width="360" height="240" rx="10" fill="none" stroke="#a85454" stroke-width="1.6" stroke-dasharray="5 3"/>
&lt;text x="610" y="66" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#a85454">NUMA node 1 · the kubelet pinned the pod here&lt;/text>
&lt;rect x="550" y="150" width="160" height="44" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="2.2"/>
&lt;text x="630" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">vLLM pod + GPU&lt;/text>
&lt;text x="630" y="185" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">exclusive CPUs node 1&lt;/text>
&lt;!-- cross-numa arrow -->
&lt;path d="M180,242 C320,242 470,172 550,172" fill="none" stroke="#a85454" stroke-width="2.4" marker-end="url(#nm)"/>
&lt;text x="370" y="225" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#a85454">every packet crosses the UPI/QPI&lt;/text>
&lt;text x="370" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#a85454">+latency, +load on the inter-socket link&lt;/text>
&lt;text x="410" y="306" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#777">The Topology Manager did its job on node 1; the NIC stayed on node 0. Nobody aligned the two.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Three kernel facts the analogy compresses:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The IRQ has affinity.&lt;/strong> Each NIC queue raises an interrupt that the kernel services on a specific CPU (&lt;code>/proc/irq/&amp;lt;n&amp;gt;/smp_affinity&lt;/code>). The heavy processing is deferred to a &lt;strong>softirq&lt;/strong> (&lt;code>NET_RX&lt;/code>/&lt;code>NET_TX&lt;/code>), which runs on &lt;strong>that same CPU&lt;/strong>. If &lt;code>irqbalance&lt;/code> is left loose, it migrates them non-deterministically, poison for the p99.&lt;/li>
&lt;li>&lt;strong>The softirq competes with the pod.&lt;/strong> If the IRQ lands on a core that &lt;code>isolcpus&lt;/code> reserved for vLLM, that queue&amp;rsquo;s &lt;code>NET_RX&lt;/code> steals cycles from the model. The signal in &lt;code>/proc/softirqs&lt;/code>: a &lt;code>NET_RX&lt;/code> column that spikes on a single CPU. It is the same jitter from &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">post 2&lt;/a>, coming in through the network.&lt;/li>
&lt;li>&lt;strong>DMA has a NUMA origin.&lt;/strong> The NIC writes the packet by DMA into the RAM of the socket of its PCIe root. If the consumer (the pod&amp;rsquo;s thread) is on the other socket, it reads across the UPI/QPI. RFS (Receive Flow Steering) tries to bring the processing to the consumer&amp;rsquo;s CPU, but it cannot teleport the NIC to the other socket.&lt;/li>
&lt;/ol>
&lt;h3 id="one-number-with-its-caveat">One number, with its caveat&lt;/h3>
&lt;p>Take a 2-socket node, a &lt;strong>400 Gb/s = 50 GB/s&lt;/strong> NIC on the PCIe root of socket 0, and a decode pod pinned to socket 1. If the NIC saturates, those ~50 GB/s of receive traffic &lt;strong>cross the UPI&lt;/strong> towards socket 1. A UPI 2.0 link runs at around &lt;strong>20–40 GB/s&lt;/strong> per direction and link depending on generation; even with several links, 50 GB/s of network traffic &lt;strong>going against the grain&lt;/strong> eats a far from negligible fraction of the inter-socket budget, the same budget already contended by the pod&amp;rsquo;s remote memory accesses and, if there is multi-node traffic, the &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">KV-cache of the disaggregation&lt;/a>. I am not giving a firm &amp;ldquo;X% degradation&amp;rdquo; because it depends on CPU generation, number of UPI links, MTU and traffic pattern; without that methodology, any exact figure is marketing.&lt;/p>
&lt;p>What &lt;strong>is&lt;/strong> measured with public methodology is the aggregate effect of aligning GPU and NIC: the &lt;strong>DRANET&lt;/strong> project reports &lt;strong>+59.6% bus bandwidth in &lt;code>all_gather&lt;/code> and +58.1% in &lt;code>all_reduce&lt;/code>&lt;/strong> (NCCL collectives) when the assigned NIC is &lt;strong>NUMA-local to the GPU&lt;/strong> rather than not. That is the size of the gap the Topology Manager was leaving open.&lt;/p>
&lt;h2 id="what-rke2s-cilium-ebpf-replaces-and-why-it-touches-this-story">What RKE2&amp;rsquo;s Cilium eBPF replaces (and why it touches this story)&lt;/h2>
&lt;p>RKE2 ships &lt;strong>Canal&lt;/strong> (Flannel + Calico) by default as the CNI and &lt;strong>&lt;code>kube-proxy&lt;/code>&lt;/strong> (iptables/IPVS rules) for Service balancing. Switching to Cilium (&lt;code>cni: cilium&lt;/code> in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>) replaces both pieces with an eBPF datapath:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>RKE2 piece&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>What Cilium eBPF puts in&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>kube-proxy&lt;/code> (iptables/IPVS)&lt;/td>
&lt;td>Service balancing&lt;/td>
&lt;td>LB in eBPF; with &lt;code>kubeProxyReplacement=true&lt;/code>, and acceleration in &lt;strong>XDP&lt;/strong> (driver layer)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Canal (Flannel+Calico)&lt;/td>
&lt;td>VXLAN overlay + NetworkPolicy&lt;/td>
&lt;td>native datapath (&lt;code>routingMode=native&lt;/code>), L3/L4 and L7 NetworkPolicy in eBPF&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>veth per pod&lt;/td>
&lt;td>namespace interface pair&lt;/td>
&lt;td>&lt;strong>netkit&lt;/strong> (kernel ≥6.8): namespace overhead ~0&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>host iptables traversal&lt;/td>
&lt;td>netfilter hooks&lt;/td>
&lt;td>eBPF &lt;strong>host-routing&lt;/strong>: bypasses iptables and the upper part of the stack&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Up to here this is pure networking and it &lt;strong>does not touch&lt;/strong> the kubelet&amp;rsquo;s resource managers: Cilium does not assign exclusive CPUs nor emit compute NUMA hints. The ten knobs of &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">post 3&lt;/a> stay identical whether you run Canal or Cilium.&lt;/p>
&lt;p>&lt;strong>But&lt;/strong> Cilium does enter the fourth leg through two doors. The first: its own &lt;a href="https://docs.cilium.io/en/stable/operations/performance/tuning/">tuning guide&lt;/a> recommends, literally, &lt;em>&amp;ldquo;kill &lt;code>irqbalance&lt;/code> and pin the NIC IRQs to specific CPUs for maximum workload isolation&amp;rdquo;&lt;/em>, along with the &lt;code>tuned network-latency&lt;/code> profile, the &lt;code>performance&lt;/code> governor and &lt;code>CONFIG_PREEMPT_NONE&lt;/code>. In other words: the eBPF datapath only really performs &lt;strong>if you coordinate IRQ affinity&lt;/strong>, and that affinity has to point at the &lt;strong>housekeeping&lt;/strong> cores (&lt;code>reserved-cpus&lt;/code>), never at the isolated ones. So a &lt;strong>fourth list&lt;/strong> appears that must be kept consistent with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">isolcpus = 2-31,34-63 # cores exclusive to vLLM (host, post 2)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">reserved-cpus = 0-1,32-33 # kubelet housekeeping (post 3)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">IRQ affinity = 0-1,32-33 # NIC IRQs → housekeeping ONLY (this post)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> # never 2-31: there the softirq would steal from the model
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The second door: &lt;strong>netkit + host-routing + BIG TCP&lt;/strong> reduce how many times the packet crosses the stack and the namespace, which &lt;strong>cushions&lt;/strong> (does not eliminate) the cost of the NUMA crossing. BIG TCP assembles super-packets of up to 192k (against 64k) for 100Gb/s+; fewer stack traversals means less softirq work on the core, and therefore less pressure on the inter-socket budget. It is the &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a> analogy applied to the network stack: amortising a fixed cost over larger batches.&lt;/p>
&lt;h3 id="cilium-performance-profile-state-119-kernel-68">Cilium performance profile (state 1.19, kernel ≥6.8)&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">&lt;span class="c1"># Helm, recommended performance profile (summary of the tuning guide)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install cilium cilium/cilium --version 1.19.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> --namespace kube-system &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">routingMode&lt;/span>&lt;span class="o">=&lt;/span>native &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set bpf.datapathMode&lt;span class="o">=&lt;/span>netkit &lt;span class="se">\ &lt;/span> &lt;span class="c1"># namespace overhead ~0 (kernel &amp;gt;=6.8)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set bpf.masquerade&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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> --set &lt;span class="nv">kubeProxyReplacement&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># replaces RKE2&amp;#39;s kube-proxy&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set &lt;span class="nv">enableIPv4BIGTCP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># 192k super-packets (mlx5/ice NIC)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set &lt;span class="nv">enableIPv6BIGTCP&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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> --set bpf.distributedLRU.enabled&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\#&lt;/span> per-CPU BPF maps: less spinlock contention
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --set bpf.mapDynamicSizeRatio&lt;span class="o">=&lt;/span>0.08 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set &lt;span class="nv">bpfClockProbe&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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"># Verification inside a Cilium pod:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cilium status --verbose &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;Device Mode|Host Routing|BIG TCP|XDP&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Device Mode: netkit · Host Routing: BPF · IPv4 BIG TCP: enabled · XDP Acceleration: Native&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Sceptical caveat: netkit and BIG TCP are &lt;strong>beta&lt;/strong> and require kernel ≥6.8 and specific NICs (mlx4/mlx5/ice). They are not in-place: they change datapath fundamentals and force you to restart pods or, better, to apply them by &lt;em>per-node config&lt;/em> only on new nodes. For an ENS cluster in production, that is a maintenance window, not a blind &lt;code>helm upgrade&lt;/code>.&lt;/p>
&lt;h2 id="the-2026-state-of-the-art-dra-and-dranet-the-maître-d-who-finally-looks-at-the-door">The 2026 state of the art: DRA and DRANET, the maître d&amp;rsquo; who finally looks at the door&lt;/h2>
&lt;p>What closes the gap at the root is not Cilium, it is the &lt;strong>admission mechanism&lt;/strong> the kubelet did not have for the NIC: &lt;strong>Dynamic Resource Allocation (DRA)&lt;/strong>, beta since Kubernetes 1.32 and with advances in every release up to 1.36 (May 2026). DRA generalises the &amp;ldquo;devices&amp;rdquo; model beyond the GPU: a driver discovers the hardware, publishes &lt;code>ResourceSlices&lt;/code> with its attributes, including the &lt;strong>NUMA topology and the PCIe root&lt;/strong>, and the scheduler resolves &lt;code>ResourceClaims&lt;/code> that can demand affinity between devices.&lt;/p>
&lt;p>&lt;strong>DRANET&lt;/strong> (a &lt;code>kubernetes-sigs&lt;/code> project) is the network DRA driver. It discovers the NICs (including the RDMA-capable ones), advertises them as &lt;code>ResourceSlices&lt;/code>, and via &lt;strong>NRI&lt;/strong> injects them into the pod&amp;rsquo;s namespace, compatible with whatever CNI you already have, Cilium included. The key piece for this story: combined with the &lt;strong>NVIDIA GPU DRA driver&lt;/strong>, it allows &lt;strong>co-scheduling GPU and NIC that share a PCIe root&lt;/strong> (the relationship NVIDIA calls &lt;code>NODE&lt;/code>), which is exactly the condition for &lt;strong>GPUDirect RDMA&lt;/strong>. The maître d&amp;rsquo; finally has a fourth assistant: &lt;em>&amp;ldquo;is there a NIC NUMA-local to this GPU?&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>The &lt;code>ResourceClaimTemplate&lt;/code> uses CEL selectors to request exactly that alignment:&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"># Request an RDMA NIC NUMA-local to the assigned GPU (illustrative DRANET/DRA schema)&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">resource.k8s.io/v1beta1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ResourceClaimTemplate&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-nic-numa-aligned&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">spec&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">spec&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">devices&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">requests&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rdma-nic&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">deviceClassName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dra.net &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># NICs published by DRANET&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">constraints&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">requests&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;rdma-nic&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 class="nt">matchAttribute&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;dra.net/pcieRoot&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># same PCIe root as the GPU&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="c"># → enables GPUDirect RDMA over a NUMA-local path&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Why it matters for inference, not for abstract &amp;ldquo;AI training&amp;rdquo;: in &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a>, RDMA is what moves the KV-cache between the prefill pool and the decode pool with the latency that &lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">TTFT&lt;/a> demands; and in multi-node, GPUDirect RDMA replaces &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink&lt;/a> as the medium of the collective. Aligning GPU+NIC on the same PCIe root is what turns an &amp;ldquo;RDMA that works&amp;rdquo; into an &amp;ldquo;RDMA that performs&amp;rdquo;, the +60% bus bandwidth from DRANET.&lt;/p>
&lt;p>Status and caveats: DRA is &lt;strong>beta&lt;/strong> (gates to enable by hand), DRANET is young (a SIG project, still evolving) and the managed offering exists mostly in cloud (GKE managed DRANET in preview, AKS for RDMA). For on-premise ENS it is a &lt;strong>path, not a finished product&lt;/strong>: the value today is understanding that the fourth leg already has a standard OSS mechanism, and starting to pilot it on a lab node, not putting it into critical production this quarter.&lt;/p>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the host (post 2).&lt;/strong> The NIC&amp;rsquo;s IRQ affinity is a &lt;strong>third list&lt;/strong> to marry with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>. The IRQs go to housekeeping; the isolated cores, untouched. Uncoordinating them lets in through the network door the jitter that &lt;code>isolcpus&lt;/code> threw out through the compute one.&lt;/p>
&lt;p>&lt;strong>With the orchestration (post 3).&lt;/strong> DRA is the natural extension of the Topology Manager: the same &amp;ldquo;admit only if it fits on the NUMA node&amp;rdquo; principle carried over to the NIC. Where the Device Manager left the network out of the census, DRANET puts it in.&lt;/p>
&lt;p>&lt;strong>With the interconnect (post 1).&lt;/strong> Inside the node NVLink rules; on crossing the node boundary, GPUDirect RDMA over the NIC is the medium of the collective. The kubelet&amp;rsquo;s NUMA policy guarantees that GPU and CPUs share a socket; &lt;strong>DRANET adds that the NIC does too&lt;/strong>, and only then does the RDMA take the short path.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> The prefill→decode KV-cache is the traffic that punishes a badly placed NIC hardest. The fourth leg is what stops &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">separating prefill and decode&lt;/a> from being paid for in transfer latency.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> gains a dimension: &amp;ldquo;GPUs per node and cores per NUMA node&amp;rdquo; is not enough; you have to count &lt;strong>how many NICs NUMA-local to a GPU&lt;/strong> the chassis has. A node with 4 GPUs and a single NIC on socket 0 has two GPUs &amp;ldquo;far from the door&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>With observability.&lt;/strong> What confirms that the fourth leg is properly set is not an application dashboard: it is &lt;code>/proc/softirqs&lt;/code> (is &lt;code>NET_RX&lt;/code> concentrated on housekeeping?), &lt;code>nvidia-smi topo -m&lt;/code> (is the GPU↔NIC relationship &lt;code>NODE&lt;/code>/&lt;code>PHB&lt;/code>?) and the NIC counters. It fits with &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a>: the GPU &amp;ldquo;at 60% for no reason&amp;rdquo; may be the host waiting for packets that cross the socket.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>Believing that switching to Cilium &amp;ldquo;already optimises the network&amp;rdquo;.&lt;/strong> Cilium eBPF replaces kube-proxy and Canal and performs better out of the box, but the default deployment prioritises compatibility, not performance. Without &lt;code>irqbalance&lt;/code> disabled, without IRQs pinned to housekeeping and without netkit/host-routing, you leave most of the improvement on the table. Cilium&amp;rsquo;s docs say so; plenty of people do not read the tuning guide.&lt;/p>
&lt;p>&lt;strong>Pinning the NIC IRQs to isolated cores.&lt;/strong> The symmetric mistake to knob 6 of post 3: if you set IRQ affinity over &lt;code>isolcpus&lt;/code>, the &lt;code>NET_RX&lt;/code> softirq steals cycles from vLLM on exactly the cores you isolated so nobody would disturb it. The IRQs go to &lt;code>reserved-cpus&lt;/code>, always.&lt;/p>
&lt;p>&lt;strong>Assuming the Topology Manager already aligns the NIC.&lt;/strong> It does not: the classic NIC is not a Hint Provider. If you need NIC↔GPU locality, today the mechanism is DRA/DRANET, not a kubelet policy. Waiting for &lt;code>single-numa-node&lt;/code> to solve it is waiting for something that is not in its design.&lt;/p>
&lt;p>&lt;strong>Putting DRA/DRANET into ENS production this quarter.&lt;/strong> It is beta and young. The sensible move is to pilot it on a lab node, measure &lt;code>all_reduce&lt;/code>/&lt;code>all_gather&lt;/code> with and without alignment, and decide with data. The +60% figure is from a specific environment; reproduce it in yours before promising it.&lt;/p>
&lt;p>&lt;strong>BIG TCP / netkit without reading the requirements.&lt;/strong> Kernel ≥6.8, mlx4/mlx5/ice NICs, no tunnel or encryption for BIG TCP, and nothing in-place: it forces pod restarts or per-node config. On a cluster with IPsec or with unsupported NICs, part of this does not apply. Check &lt;code>cilium status --verbose&lt;/code> before taking for granted that it is active.&lt;/p>
&lt;p>&lt;strong>Confusing the eBPF datapath (kernel) with the Cilium agent (pod).&lt;/strong> &lt;code>cilium-agent&lt;/code> is a &lt;code>Burstable&lt;/code> DaemonSet that should live on housekeeping (covered by &lt;code>system-reserved&lt;/code>). But datapath processing runs in &lt;strong>softirq&lt;/strong>, governed by the host&amp;rsquo;s IRQ affinity, &lt;strong>not&lt;/strong> by &lt;code>reserved-cpus&lt;/code>. They are two different things; pinning the pod well does not pin the softirq.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The &amp;ldquo;under the engine&amp;rdquo; series chased one idea: performance that looks like an engine problem (vLLM is slow) or a model problem (quantisation) is, far too often, a &lt;strong>locality&lt;/strong> problem one layer down. The trilogy covered three: the cable (NVLink not used), the host (remote NUMA, jitter) and the orchestration (pinning that never happened). The fourth was miss&lt;strong>ing&lt;/strong>: &lt;strong>the network&lt;/strong>. The Topology Manager seats the pod at a perfect NUMA table and never asks which door the dishes come through nor who carries them. On a node at 25 Gb/s it made no difference; on one at 400 Gb/s with the KV-cache crossing over RDMA, that door decides TTFT and the collective&amp;rsquo;s bandwidth. &lt;strong>Cilium eBPF&lt;/strong> replaces kube-proxy and Canal with a datapath that performs, if you coordinate IRQ affinity with &lt;code>isolcpus&lt;/code>/&lt;code>reserved-cpus&lt;/code>, a fourth list to align, and &lt;strong>DRA/DRANET&lt;/strong> finally provides the census that was missing: co-scheduling NUMA-local GPU and NIC on the same PCIe root, with an improvement of a size (+60% NCCL bus bandwidth) that measures how big the gap was. Going down a level is not snobbery: it is that the root cause lived, once again, one layer below where the dashboard looks.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets in the sovereign LLM stack: defence in depth&lt;/a> — the default-deny NetworkPolicies and mTLS with Cilium in the hardening of the stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the GPUDirect RDMA that DRANET places NUMA-locally is broken by ACS if it forces traffic through the root complex; the bus beneath NIC↔GPU locality.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">The maître d&amp;rsquo; who only seats you if you all fit at one table: resource managers on RKE2&lt;/a> — post 3, the direct parent of this one: the Topology Manager pins CPU+memory+GPU but not the NIC; here that fourth leg is opened.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — post 2; the NIC&amp;rsquo;s IRQ affinity is a third list to marry with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>, and the &lt;code>NET_RX&lt;/code> softirq is the same jitter coming in through the network.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a> — post 1; on crossing the node, GPUDirect RDMA over the NIC replaces NVLink, and DRANET is what guarantees that RDMA takes the NUMA-local path.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — the case that punishes a badly placed NIC hardest: the prefill→decode KV-cache travels over RDMA and pays for every socket crossing.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the complete building; the network is the plane that holds up multi-node inference.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — every new replica does not only go through the kubelet&amp;rsquo;s NUMA admission; with DRA, also through the NIC &lt;code>ResourceClaim&lt;/code>&amp;rsquo;s.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — sizing gains a dimension: how many NICs NUMA-local to a GPU the chassis has, not just how many GPUs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — NIC↔accelerator NUMA affinity gets complicated when the node mixes GPUs, accelerators and heterogeneous NICs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — how to confirm, metric in hand, that the &amp;ldquo;GPU at 60%&amp;rdquo; is not the host waiting for packets crossing the socket.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the same &amp;ldquo;get the CPU out of the way&amp;rdquo; principle that GPUDirect RDMA gives here, applied to disk with GPUDirect Storage to load weights straight from NVMe to HBM.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — one floor further down: once the data is in HBM, what happens in the silicon that executes it and why decode becomes launch-bound.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">The contractor with the master key: isolating AI agents from the workstation to the cluster&lt;/a> — the other use of this same kernel layer: on top of Cilium&amp;rsquo;s eBPF datapath, Tetragon hooks its kprobes to observe and kill what an AI agent does in the cluster. Its &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> brings the concrete &lt;code>TracingPolicy&lt;/code> objects.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Cilium, &lt;em>Tuning Guide&lt;/em> (netkit, host-routing, BIG TCP, XDP, pin the IRQs and kill irqbalance): &lt;a href="https://docs.cilium.io/en/stable/operations/performance/tuning/">https://docs.cilium.io/en/stable/operations/performance/tuning/&lt;/a>.&lt;/li>
&lt;li>Cilium 1.19 (February 2026), &lt;em>Cilium at Ten Years&lt;/em> — hardening of encryption, policies and observability: &lt;a href="https://www.infoq.com/news/2026/02/cilium-119/">https://www.infoq.com/news/2026/02/cilium-119/&lt;/a>.&lt;/li>
&lt;li>Isovalent, &lt;em>Cilium 1.18&lt;/em> (IPv6, encrypted overlay, ingress bandwidth, policy perf): &lt;a href="https://isovalent.com/blog/post/cilium-1-18/">https://isovalent.com/blog/post/cilium-1-18/&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Network Options&lt;/em> (Canal by default; Cilium with kube-proxy replacement): &lt;a href="https://docs.rke2.io/networking/basic_network_options">https://docs.rke2.io/networking/basic_network_options&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Dynamic Resource Allocation&lt;/em>: &lt;a href="https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/">https://kubernetes.io/docs/concepts/scheduling-eviction/dynamic-resource-allocation/&lt;/a>.&lt;/li>
&lt;li>Kubernetes blog, &lt;em>v1.36: More Drivers, New Features, and the Next Era of DRA&lt;/em> (May 2026): &lt;a href="https://kubernetes.io/blog/2026/05/07/kubernetes-v1-36-dra-136-updates/">https://kubernetes.io/blog/2026/05/07/kubernetes-v1-36-dra-136-updates/&lt;/a>.&lt;/li>
&lt;li>DRANET (kubernetes-sigs), the network DRA driver and the paper &lt;em>The Kubernetes Network Driver Model&lt;/em> (+59.6% all_gather / +58.1% all_reduce): &lt;a href="https://github.com/kubernetes-sigs/dranet">https://github.com/kubernetes-sigs/dranet&lt;/a>.&lt;/li>
&lt;li>AKS Engineering, &lt;em>Optimizing RDMA performance for AI workloads on AKS with DRANET&lt;/em> (April 2026): &lt;a href="https://blog.aks.azure.com/2026/04/01/dranet-rdma-optimization-for-ai-on-aks">https://blog.aks.azure.com/2026/04/01/dranet-rdma-optimization-for-ai-on-aks&lt;/a>.&lt;/li>
&lt;li>Linux network tuning — IRQ affinity, RSS/RPS/RFS and NUMA softirq: &lt;a href="https://andreaskaris.github.io/blog/networking/rss-irq-affinity-and-rps/">https://andreaskaris.github.io/blog/networking/rss-irq-affinity-and-rps/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Choosing the switchboard: which OSS gateway to put in front, and why the licence decides before the features</title><link>https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/</link><pubDate>Sat, 06 Jun 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/</guid><description>&lt;blockquote>
&lt;p>This post is the &lt;strong>decision companion&lt;/strong> to &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a>. That one explained &lt;em>what&lt;/em> an inference router is and &lt;em>why&lt;/em> it exists (catalogue, traffic splitting, cross-cutting policy, prefix-aware routing). This one answers the question that actually blocks a deployment: &lt;strong>which one to choose&lt;/strong>, with verified licences and an order of criteria that is not the one most people use.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The gateway is the piece &lt;strong>100 % of the traffic&lt;/strong> passes through and the most expensive to replace later: the client SDK, the auth and rate-limit policies, the tracing and the model catalogue all couple to it. That is why the &lt;strong>order of the criteria&lt;/strong> matters, and it is not the usual one. First the &lt;strong>licence&lt;/strong>: not &amp;ldquo;is it open source?&amp;rdquo; but &amp;ldquo;are the features I need under a permissive licence or behind an Enterprise wall?&amp;rdquo;. Second the &lt;strong>architectural fit&lt;/strong>: is it a citizen of your Kubernetes (Gateway API) or a separate process you have to operate on the side? Third, &lt;strong>maturity and documentation&lt;/strong>. Fourth, the features. With data verified as of June 2026: LiteLLM is MIT in the core, but SSO, audit logs, fine-grained RBAC and several guardrails are Enterprise; Kong has an Apache 2.0 core but the AI plugins that matter (semantic cache, prompt guard, AI proxy advanced) are gated; Envoy AI Gateway, Gateway API Inference Extension (GIE), Higress, APISIX and Bifrost are Apache 2.0 end to end. For an RKE2 + vLLM stack with a K8s-native priority (the case we assume here), the recommendation is to adopt the &lt;strong>Gateway API Inference Extension model&lt;/strong>, the Endpoint Picker that routes with awareness of prefix cache, KV and LoRA, which is exactly what multiplies the hit rate. Implement it with Envoy AI Gateway if the AI-native trajectory weighs more, or with Higress if today&amp;rsquo;s maturity weighs more; and put LiteLLM (MIT) behind it as an optional multi-provider control plane. With explicit scepticism about v0.x releases, about &amp;ldquo;OpenAI-compatible ≠ inference-aware&amp;rdquo; and about phone-home telemetry.&lt;/p>
&lt;h2 id="the-principle-that-reorders-the-criteria-the-gateway-is-a-marriage-not-a-date">The principle that reorders the criteria: the gateway is a marriage, not a date&lt;/h2>
&lt;p>Some pieces of the stack can be swapped on a Sunday afternoon: the embedding model, the reranker, even the inference engine behind an OpenAI-compatible interface. The gateway &lt;strong>cannot&lt;/strong>. It is the piece everything else couples to: every client SDK points at its URL, the security policies live in it, the tracing is born in it, it defines the model catalogue. Ripping it out two years later means touching &lt;strong>every&lt;/strong> consumer at once. It is the infrastructure decision with the highest cost of reversal in the whole serving layer.&lt;/p>
&lt;p>When a decision is expensive to reverse, the dominant criterion is not &amp;ldquo;what does it do today?&amp;rdquo; but &amp;ldquo;&lt;strong>can I own it and operate it for years without surprises?&lt;/strong>&amp;rdquo;. And that puts the &lt;strong>licence&lt;/strong> ahead of the features. A brilliant tool whose SSO, audit log or RBAC live behind an Enterprise contract is a tool that, the day your ENS deployment needs those controls, forces you to pay or to migrate, which is exactly the scenario the choice was supposed to avoid. That is why this post&amp;rsquo;s filter order is &lt;strong>licence → fit → maturity/docs → features&lt;/strong>, and not the other way round.&lt;/p>
&lt;div class="diagram" style="max-width:620px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 620 230" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Criteria funnel: licence, fit, maturity, features">
&lt;defs>&lt;marker id="ga" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="310" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The decision funnel · in this order&lt;/text>
&lt;rect x="60" y="40" width="500" height="34" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="2"/>
&lt;text x="310" y="62" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#1f5c34">1 · Licence — truly permissive? features gated?&lt;/text>
&lt;rect x="110" y="86" width="400" height="34" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.8"/>
&lt;text x="310" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">2 · Fit — a citizen of your Kubernetes?&lt;/text>
&lt;rect x="160" y="132" width="300" height="34" rx="6" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="310" y="154" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">3 · Maturity + documentation&lt;/text>
&lt;rect x="210" y="178" width="200" height="34" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.4"/>
&lt;text x="310" y="200" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#333">4 · Features&lt;/text>
&lt;path d="M310,74 L310,86" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#ga)"/>
&lt;path d="M310,120 L310,132" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#ga)"/>
&lt;path d="M310,166 L310,178" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#ga)"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-field-of-candidates-june-2026">The field of candidates (June 2026)&lt;/h2>
&lt;p>The real OSS pieces anyone would put in front of an on-premise vLLM fleet:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Candidate&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Language&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM Proxy&lt;/td>
&lt;td>OpenAI-compatible gateway, 100+ providers, virtual keys, spend&lt;/td>
&lt;td>Python (FastAPI)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy AI Gateway&lt;/td>
&lt;td>AI layer on top of Envoy Gateway; integrates GIE/EPP, InferencePool&lt;/td>
&lt;td>Go (Envoy)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gateway API Inference Extension (GIE)&lt;/td>
&lt;td>K8s-SIG extension: inference-aware Endpoint Picker&lt;/td>
&lt;td>Go&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Higress&lt;/td>
&lt;td>API gateway + AI plugins, Envoy/Istio, CNCF&lt;/td>
&lt;td>Go/C++&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Apache APISIX&lt;/td>
&lt;td>Mature API gateway with AI plugins&lt;/td>
&lt;td>Lua/Nginx&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong (+ AI plugins)&lt;/td>
&lt;td>API gateway; Apache 2.0 core, Enterprise AI plugins&lt;/td>
&lt;td>Lua/Nginx&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bifrost&lt;/td>
&lt;td>High-performance AI-first gateway&lt;/td>
&lt;td>Go&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM Production Stack / Semantic Router&lt;/td>
&lt;td>vLLM-specific router, KV/prefix/intent-aware&lt;/td>
&lt;td>Go/Python&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="filter-1--licence-where-the-small-print-is">Filter 1 — Licence: where the small print is&lt;/h2>
&lt;p>This is where half the options die, and where &amp;ldquo;open source&amp;rdquo; misleads if you do not look at the detail. What has been verified:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Candidate&lt;/th>
&lt;th>Core licence&lt;/th>
&lt;th>What is gated (paid)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>SSO (Okta/Azure AD), audit logs, JWT auth, fine-grained RBAC, several guardrails (llmguard, llamaguard, prompt-injection) → Enterprise (~250 USD/month and rising)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy AI Gateway&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>— (full AI layer is OSS)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GIE (Inference Extension)&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>— (K8s-SIG project)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Higress&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>— (AI plugins included)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>APISIX&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>— (more built-in AI than Kong OSS)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong&lt;/td>
&lt;td>Apache 2.0 (core)&lt;/td>
&lt;td>AI Semantic Cache, AI Prompt Guard, AI Proxy Advanced, AI RAG Injector → Enterprise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bifrost&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two conclusions that settle the field:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Kong falls out for an OSS AI deployment.&lt;/strong> Its core is Apache 2.0, but precisely the AI plugins that would justify choosing it (semantic cache, prompt guard, proxy advanced) are Enterprise, with contracts that the sector&amp;rsquo;s own comparisons put above five figures a year. For AI on an OSS budget, APISIX offers more built-in at no cost. Kong remains excellent as a classic API gateway; as an &lt;strong>OSS AI gateway&lt;/strong>, no.&lt;/li>
&lt;li>&lt;strong>LiteLLM really is MIT in the core&lt;/strong>, and that is genuine: you can fork it, modify it and use it commercially. But SSO, audit logs, fine-grained RBAC and several guardrails are Enterprise. Under the criterion we set (&lt;em>&amp;ldquo;a permissive core is enough, governance is solved with OIDC and external auditing in the stack&amp;rdquo;&lt;/em>) LiteLLM &lt;strong>stays in play&lt;/strong>; under an &amp;ldquo;all OSS or nothing&amp;rdquo; criterion it would be wounded. It pays to know exactly what falls on which side of the line before committing.&lt;/li>
&lt;/ul>
&lt;h3 id="litellm-what-is-gated-and-its-oss-equivalent">LiteLLM: what is gated and its OSS equivalent&lt;/h3>
&lt;p>The nuance that decides whether LiteLLM&amp;rsquo;s gating is a real problem or not: &lt;strong>&amp;ldquo;paid built-in feature&amp;rdquo; is not the same as &amp;ldquo;capability impossible in OSS&amp;rdquo;&lt;/strong>. In almost every case the capability is achieved by changing the &lt;em>how&lt;/em>, wiring OSS through LiteLLM&amp;rsquo;s open hook, or solving it in the layer next door (the gateway, the observability). The breakdown, verified against the docs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Capability&lt;/th>
&lt;th>In LiteLLM Enterprise&lt;/th>
&lt;th>OSS equivalent&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Guardrails&lt;/td>
&lt;td>prebuilt integrations (&lt;code>llmguard&lt;/code>, &lt;code>llamaguard&lt;/code>, &lt;code>lakera&lt;/code>, &lt;code>aporia&lt;/code>, &lt;code>hide_secrets&lt;/code>)&lt;/td>
&lt;td>&lt;strong>the framework + custom guardrail hook + Presidio are OSS (MIT core)&lt;/strong>; you invoke &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>, Llama Guard or Presidio from the hook yourself&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Audit log&lt;/td>
&lt;td>turnkey UI with retention policies&lt;/td>
&lt;td>request/response logging + &lt;strong>custom callbacks + OTel/Langfuse exporters&lt;/strong> (official OSS integration) → you build the trail and you own it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RBAC&lt;/td>
&lt;td>fine-grained (&lt;code>enforce_rbac&lt;/code>, org/team/user roles)&lt;/td>
&lt;td>coarse (virtual keys per team/budget/model) is OSS; fine-grained is done &lt;strong>at the edge in the gateway&lt;/strong> (&lt;code>ext_authz&lt;/code> + OPA)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SSO&lt;/td>
&lt;td>Admin UI SSO (Okta/Azure/Google/OIDC)&lt;/td>
&lt;td>user/API SSO is solved by fronting with OSS OIDC (Keycloak + oauth2-proxy) or in the gateway itself; JWT auth + JWT→virtual-key mapping are already in the core&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The conclusion that closes the decision: in a &lt;strong>K8s-native design with a gateway in front&lt;/strong>, OIDC and authorisation live in the gateway (Envoy/Higress + OPA) and the audit trail in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">Langfuse/OTel&lt;/a>, both Apache 2.0 and already in the stack, so &lt;strong>LiteLLM placed behind barely needs its Enterprise tier&lt;/strong>: the governance pieces sit where they architecturally belong and happen to be OSS. The only real cost is the &lt;strong>DIY integration and maintenance&lt;/strong>, and, for ENS, designing the guarantees of the audit trail (retention, immutability/WORM) is on you, it does not come out of the box.&lt;/p>
&lt;p>The rest, Envoy AI Gateway, GIE, Higress, APISIX and Bifrost, pass the licence filter clean: &lt;strong>Apache 2.0 end to end&lt;/strong>, with no critical feature behind a wall.&lt;/p>
&lt;h2 id="filter-2--fit-kubernetes-citizen-or-tenant">Filter 2 — Fit: Kubernetes citizen or tenant?&lt;/h2>
&lt;p>With a &lt;strong>K8s-native&lt;/strong> priority (RKE2), the question is whether the gateway is modelled as &lt;strong>cluster resources&lt;/strong>, versioned with GitOps, observed with the same tools and integrated with the scheduler, or whether it is a separate process you have to operate on the side. This is where the structural novelty of 2025-2026 appears: the &lt;strong>Gateway API Inference Extension (GIE)&lt;/strong>.&lt;/p>
&lt;p>GIE is an extension of the standard Kubernetes Gateway API, from SIG-Network, which adds two pieces: the &lt;strong>InferencePool&lt;/strong> (a pool of replicas of a model as a native resource) and the &lt;strong>Endpoint Picker (EPP)&lt;/strong>, a scheduler that decides which replica each request goes to &lt;strong>according to the inference state&lt;/strong>: queue length, available LoRA adapters, and, the key piece, the &lt;strong>prefix cache state&lt;/strong> of each replica. It is exactly the &lt;em>prefix-aware routing&lt;/em> that the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">router post&lt;/a> explained as the reason hit rate goes from 5-15 % to 60-85 %, now as a &lt;strong>community standard&lt;/strong> instead of a proprietary feature of each product.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 820 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Gateway API Inference Extension model over vLLM">
&lt;defs>&lt;marker id="gx" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Gateway API Inference Extension · inference-aware routing&lt;/text>
&lt;rect x="30" y="120" width="120" height="50" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="90" y="142" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">Client&lt;/text>
&lt;text x="90" y="158" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">OpenAI-compatible&lt;/text>
&lt;rect x="190" y="116" width="140" height="58" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.8"/>
&lt;text x="260" y="138" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">Gateway&lt;/text>
&lt;text x="260" y="154" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">Envoy AI GW / Higress&lt;/text>
&lt;text x="260" y="166" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">(Gateway API impl.)&lt;/text>
&lt;rect x="370" y="60" width="150" height="64" rx="9" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="2"/>
&lt;text x="445" y="84" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Endpoint Picker&lt;/text>
&lt;text x="445" y="100" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">prefix cache · KV · queue&lt;/text>
&lt;text x="445" y="114" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">· LoRA available&lt;/text>
&lt;rect x="370" y="150" width="150" height="40" rx="7" fill="#f7efda" stroke="#c79a32" stroke-width="1.4"/>
&lt;text x="445" y="174" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#222">InferencePool&lt;/text>
&lt;rect x="580" y="70" width="210" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>
&lt;text x="685" y="94" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#222">vLLM replica A (hot KV)&lt;/text>
&lt;rect x="580" y="120" width="210" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>
&lt;text x="685" y="144" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#222">vLLM replica B&lt;/text>
&lt;rect x="580" y="170" width="210" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>
&lt;text x="685" y="194" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#222">vLLM replica C&lt;/text>
&lt;path d="M150,145 L190,145" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#gx)"/>
&lt;path d="M330,135 L370,100" fill="none" stroke="#7a5aa5" stroke-width="1.5" marker-end="url(#gx)"/>
&lt;text x="350" y="112" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#7a5aa5">queries&lt;/text>
&lt;path d="M445,124 L445,150" fill="none" stroke="#666" stroke-width="1.3" marker-end="url(#gx)"/>
&lt;path d="M520,90 L580,90" fill="none" stroke="#3c8c54" stroke-width="1.8" marker-end="url(#gx)"/>
&lt;text x="550" y="82" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-weight="700" fill="#3c8c54">cache hit&lt;/text>
&lt;path d="M520,170 L580,140" fill="none" stroke="#999" stroke-width="1" marker-end="url(#gx)" stroke-dasharray="3 2"/>
&lt;text x="445" y="240" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">The EPP routes to the replica whose prefix is already hot → 60-85% hit rate instead of blind round-robin&lt;/text>
&lt;text x="445" y="262" text-anchor="middle" font-family="sans-serif" font-size="9.5" font-style="italic" fill="#999">all as native Kubernetes resources (Gateway API), versionable with GitOps&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Why this settles the decision when the priority is K8s-native: GIE turns inference-aware routing into a &lt;strong>standard interface&lt;/strong> that several implementations satisfy (Envoy AI Gateway, Higress, kgateway, Istio, GKE Inference Gateway). You pick an implementation today and, if you change it tomorrow, the resource model (&lt;code>InferencePool&lt;/code>, &lt;code>HTTPRoute&lt;/code>) is preserved. That is precisely the property you wanted from a decision that is expensive to reverse: &lt;strong>the coupling is to a standard, not to a product&lt;/strong>. A standalone proxy like LiteLLM, however good it is, lives &lt;em>outside&lt;/em> this model. It is one more Deployment, with its own config, its own catalogue format and its own management plane.&lt;/p>
&lt;h2 id="filter-3--maturity-and-documentation-the-uncomfortable-honesty">Filter 3 — Maturity and documentation: the uncomfortable honesty&lt;/h2>
&lt;p>Here comes the tension no marketing comparison admits: &lt;strong>what is K8s-native-correct and what is battle-tested-today do not quite coincide in June 2026&lt;/strong>.&lt;/p>
&lt;ul>
&lt;li>Envoy AI Gateway: the AI layer is at v0.5, pre-1.0 and young. &lt;em>But&lt;/em> it runs on Envoy and Envoy Gateway, which are among the most mature things that exist in proxies. It has the most complete GIE/EPP integration, model virtualisation and OpenInference tracing. Risk: the AI layer still moves fast (breaking changes are possible). Docs are growing, and good.&lt;/li>
&lt;li>GIE: the project is reaching GA (one of the first standardised inference-aware extensions). Standard maturity is high and rising; the maturity of each implementation varies.&lt;/li>
&lt;li>Higress: the most mature today among the K8s-native Apache-2.0 options with AI. Production at Alibaba scale, CNCF, Envoy/Istio base, AI plugins included, Gateway API support. If &amp;ldquo;mature&amp;rdquo; weighs more than &amp;ldquo;cutting-edge AI-native&amp;rdquo;, it is the safe bet.&lt;/li>
&lt;li>APISIX: a very mature classic gateway, with growing AI plugins; less specialised in inference-aware routing (prefix/KV) than the GIE model. Battle-tested at massive scale.&lt;/li>
&lt;li>LiteLLM: the one with the best documentation and the widest adoption in the field, by a distance. Mature as a proxy. Its ceiling is the language (Python/FastAPI: ~250-300 RPS per instance, scaling out by replicas) and the governance gating already covered.&lt;/li>
&lt;li>Bifrost: Apache 2.0, Go, the highest performer (~11 µs overhead at 5,000 RPS), with semantic caching and governance built in. Younger and less proven over years, but technically strong.&lt;/li>
&lt;/ul>
&lt;h2 id="decision-matrix">Decision matrix&lt;/h2>
&lt;p>Weighting the four filters for the case of &lt;strong>RKE2 + vLLM, K8s-native priority, permissive core acceptable&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Candidate&lt;/th>
&lt;th>Licence&lt;/th>
&lt;th>K8s-native&lt;/th>
&lt;th>Maturity&lt;/th>
&lt;th>Docs&lt;/th>
&lt;th>Inference-aware&lt;/th>
&lt;th>Verdict&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Envoy AI GW + GIE/EPP&lt;/td>
&lt;td>✅ Apache 2.0&lt;/td>
&lt;td>✅✅ standard&lt;/td>
&lt;td>⚠️ v0.5 (mature Envoy)&lt;/td>
&lt;td>✅ good&lt;/td>
&lt;td>✅✅ prefix/KV/LoRA&lt;/td>
&lt;td>Primary (trajectory)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Higress (+ GIE)&lt;/td>
&lt;td>✅ Apache 2.0&lt;/td>
&lt;td>✅✅&lt;/td>
&lt;td>✅✅ production&lt;/td>
&lt;td>✅ good&lt;/td>
&lt;td>✅ via GIE/plugins&lt;/td>
&lt;td>Primary (maturity today)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>APISIX&lt;/td>
&lt;td>✅ Apache 2.0&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>✅✅&lt;/td>
&lt;td>✅✅&lt;/td>
&lt;td>⚠️ less specialised&lt;/td>
&lt;td>Solid alternative&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LiteLLM&lt;/td>
&lt;td>⚠️ MIT core, gov. gated&lt;/td>
&lt;td>❌ separate proxy&lt;/td>
&lt;td>✅✅&lt;/td>
&lt;td>✅✅✅&lt;/td>
&lt;td>⚠️ basic&lt;/td>
&lt;td>Control plane behind&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bifrost&lt;/td>
&lt;td>✅ Apache 2.0&lt;/td>
&lt;td>⚠️&lt;/td>
&lt;td>⚠️ young&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>One to watch (perf)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong&lt;/td>
&lt;td>⚠️ AI plugins Enterprise&lt;/td>
&lt;td>✅&lt;/td>
&lt;td>✅✅&lt;/td>
&lt;td>✅✅&lt;/td>
&lt;td>❌ gated&lt;/td>
&lt;td>Ruled out (OSS AI)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="the-recommendation">The recommendation&lt;/h2>
&lt;p>For the case we fixed, &lt;strong>RKE2 + vLLM, K8s-native priority, permissive licence with a sufficient core&lt;/strong>, the recommendation has two layers, not one:&lt;/p>
&lt;p>&lt;strong>Data layer (what sits in front of the replicas): adopt the Gateway API Inference Extension model.&lt;/strong> It is the decision that ages well because it couples you to an Apache 2.0 &lt;strong>standard&lt;/strong>, not to a product, and because it brings the prefix/KV/LoRA-aware routing that really moves the hit rate. For the implementation:&lt;/p>
&lt;ul>
&lt;li>If the &lt;strong>AI-native trajectory&lt;/strong> weighs more and you accept operating a v0.x layer on top of a mature Envoy: &lt;strong>Envoy AI Gateway + GIE/EPP&lt;/strong>. That is where the cutting edge and the most complete integration are.&lt;/li>
&lt;li>If &lt;strong>today&amp;rsquo;s maturity&lt;/strong> outweighs everything else (ENS, critical production, zero appetite for v0.x in the path of 100 % of the traffic): &lt;strong>Higress&lt;/strong> with GIE. CNCF, proven at scale, Apache 2.0, and migratable to the standard EPP.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Control layer (optional, behind): LiteLLM (MIT)&lt;/strong> if you need a single multi-provider point with virtual keys, spend tracking and fallbacks to external models. It goes &lt;strong>behind&lt;/strong> the data gateway, not in its place, and you accept that SSO, audit and RBAC are solved with the cluster&amp;rsquo;s own OIDC and auditing (as we set out in the criterion). If your front end is &lt;strong>only&lt;/strong> on-premise vLLM with no external providers, this layer is probably unnecessary.&lt;/p>
&lt;p>&lt;strong>What I would not recommend here&lt;/strong>: Kong (its AI features are gated, which contradicts the licence criterion); and starting with LiteLLM &lt;strong>as the main data gateway&lt;/strong> in a stack that will be multi-replica and K8s-native, since its Python ceiling and its position outside the Gateway API model turn it into debt the day you scale.&lt;/p>
&lt;h2 id="applied-to-our-infrastructure-rke2--vllm--4h100">Applied to our infrastructure: RKE2 + vLLM + 4×H100&lt;/h2>
&lt;p>The concrete deployment on the generic reference cluster:&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"># The InferencePool groups the replicas of a model (GIE 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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference.networking.x-k8s.io/v1alpha2&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferencePool&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">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b-pool }&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">spec&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">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama70b }&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">extensionRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b-epp } &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the Endpoint Picker&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="nn">---&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="c"># The standard Gateway API route points at the pool&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gateway.networking.k8s.io/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HTTPRoute&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">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b }&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">spec&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">parentRefs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ai-gateway } ]&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">rules&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">backendRefs&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">group&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference.networking.x-k8s.io&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferencePool&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b-pool&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Three notes on how this fits the blog series:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>It lives in layer 1&lt;/strong> of the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, in front of the replicas pinned by the &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers&lt;/a>. The gateway routes; the kubelet pins; both are resources of the same GitOps cluster.&lt;/li>
&lt;li>&lt;strong>The EPP reads the prefix cache state&lt;/strong> of each replica, which connects directly with the engineering of &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache hit rate&lt;/a>: the gateway is what turns that affinity into actual routing.&lt;/li>
&lt;li>&lt;strong>The gateway&amp;rsquo;s &lt;code>gen_ai.*&lt;/code> tracing&lt;/strong> feeds the &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel observability&lt;/a> pipeline and lands in Langfuse, closing the loop of the Observe layer.&lt;/li>
&lt;/ul>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;It is open source&amp;rdquo; without reading what falls under Enterprise.&lt;/strong> The mistake this post tries to avoid: Kong and LiteLLM are OSS, but the features that justify choosing them for AI (AI plugins in Kong; SSO/audit/RBAC/guardrails in LiteLLM) are partly or wholly gated. &amp;ldquo;Open source&amp;rdquo; is the wrong question; &amp;ldquo;is what I need permissive?&amp;rdquo; is the right one.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;OpenAI-compatible&amp;rdquo; ≠ &amp;ldquo;inference-aware&amp;rdquo;.&lt;/strong> Almost all of them expose the OpenAI API. That does not mean they understand the KV cache state, the queue of each replica or the LoRA adapters. API compatibility is table stakes; intelligent routing (EPP) is a different league. Do not confuse &amp;ldquo;speaks OpenAI&amp;rdquo; with &amp;ldquo;routes well&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Choosing by the RPS benchmark.&lt;/strong> Bifrost wins on raw overhead, but the bottleneck of an on-premise vLLM fleet is rarely the proxy: it is the GPUs. Optimising the gateway for 5,000 RPS when your replicas serve 300 req/s is solving the problem you do not have. The latency the gateway adds matters; its peak throughput almost never does, in this context.&lt;/p>
&lt;p>&lt;strong>Phone-home telemetry in a sovereign deployment.&lt;/strong> Several gateways send telemetry home by default. In an ENS or sovereignty context, that is an audit finding, not a detail. Verify and disable the phone-home &lt;strong>before&lt;/strong> putting the gateway in the path of 100 % of the traffic; document it for the &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">ENS/42001/EU AI Act controls file&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Betting on a v0.x in the critical path with no rollback plan.&lt;/strong> Envoy AI Gateway v0.5 is promising, but it sits in the path of 100 % of the traffic. If you choose it, have the rollback rehearsed and follow the changelog: v0.x releases break things. The GIE standard mitigates this (you can change implementation), but the specific implementation has to be operated with a safety net.&lt;/p>
&lt;p>&lt;strong>Confusing the data gateway with the control plane.&lt;/strong> LiteLLM in front of everything &lt;em>seems&lt;/em> to simplify, but it mixes two roles: routing data traffic (where you want K8s-native + inference-aware) and managing multi-provider keys and spend (where LiteLLM shines). Separate them: a GIE-native data gateway, with LiteLLM as a control plane behind it if needed.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The gateway is the piece with the highest cost of reversal in the serving layer, and that forces you to invert the intuitive order of criteria. &lt;strong>First the licence&lt;/strong>, not &amp;ldquo;is it OSS?&amp;rdquo; but &amp;ldquo;is what I need permissive or is it gated?&amp;rdquo;, which already rules Kong out for OSS AI and puts an asterisk on LiteLLM for its Enterprise governance. &lt;strong>Then the fit&lt;/strong>, where the Gateway API Inference Extension turns inference-aware routing into an Apache 2.0 standard you can couple to without marrying a product. And &lt;strong>only after that&lt;/strong> maturity, docs and features, where honesty forces you to admit that the cutting-edge AI-native option (Envoy AI Gateway v0.5) and the battle-tested-today one (Higress) are still not the same thing. For an RKE2 + vLLM stack with a K8s-native priority, the answer is not a product but a &lt;strong>model&lt;/strong>, GIE/EPP, implemented with Envoy AI Gateway if you look at the trajectory or with Higress if you look at maturity, and LiteLLM as an optional control plane behind. The decision that ages well is the one that couples you to a standard, not to a vendor; in gateways, in June 2026, that standard finally has a name.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster: who pays for what&lt;/a> — budgets and gateway spend tracking for multi-tenancy.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router: the L7 switchboard&lt;/a> — the post explaining &lt;em>what&lt;/em> an inference router is and &lt;em>why&lt;/em> it exists; this one picks &lt;em>which&lt;/em> with verified licences. Read them in order.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers&lt;/a> — the gateway routes towards the replicas these managers pin to the right NUMA node; both are resources of the same GitOps cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — the affinity the Endpoint Picker turns into actual routing; the gateway is what converts a hot cache into hit rate.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the gateway is layer 1; this post picks the concrete piece that occupies it.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers&lt;/a> — the gateway inside the full OSS map and its translation to the public clouds.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the &lt;code>gen_ai.*&lt;/code> tracing born in the gateway that feeds the Observe layer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls ENS × 42001 × EU AI Act&lt;/a> — why the licence, the sovereignty of the deployment and the gateway&amp;rsquo;s phone-home are audit matters, not details.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the EPP routes with awareness of which LoRA adapter each replica has loaded; the gateway and multi-adapter serving coordinate here.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kubernetes, &lt;em>Introducing Gateway API Inference Extension&lt;/em> (blog oficial, jun 2025): &lt;a href="https://kubernetes.io/blog/2025/06/05/introducing-gateway-api-inference-extension/">https://kubernetes.io/blog/2025/06/05/introducing-gateway-api-inference-extension/&lt;/a>.&lt;/li>
&lt;li>Gateway API Inference Extension, doc del proyecto (Endpoint Picker, InferencePool): &lt;a href="https://gateway-api-inference-extension.sigs.k8s.io/">https://gateway-api-inference-extension.sigs.k8s.io/&lt;/a>.&lt;/li>
&lt;li>Envoy AI Gateway, sitio y release notes (v0.x, GIE integration): &lt;a href="https://aigateway.envoyproxy.io/">https://aigateway.envoyproxy.io/&lt;/a>.&lt;/li>
&lt;li>LiteLLM, &lt;em>Enterprise Features&lt;/em> (qué está gated): &lt;a href="https://docs.litellm.ai/docs/proxy/enterprise">https://docs.litellm.ai/docs/proxy/enterprise&lt;/a> · licencia MIT: &lt;a href="https://github.com/BerriAI/litellm/blob/main/LICENSE">https://github.com/BerriAI/litellm/blob/main/LICENSE&lt;/a>.&lt;/li>
&lt;li>Kong, &lt;em>Announcing Kong&amp;rsquo;s Open Source AI Gateway&lt;/em> y matriz de plugins Enterprise: &lt;a href="https://konghq.com/blog/product-releases/announcing-kong-ai-gateway">https://konghq.com/blog/product-releases/announcing-kong-ai-gateway&lt;/a>.&lt;/li>
&lt;li>Higress (Apache 2.0, CNCF, AI gateway): &lt;a href="https://higress.cn/en/">https://higress.cn/en/&lt;/a>.&lt;/li>
&lt;li>Apache APISIX, &lt;em>APISIX vs Kong&lt;/em> (cobertura de IA OSS): &lt;a href="https://apisix.apache.org/learning-center/apisix-vs-kong/">https://apisix.apache.org/learning-center/apisix-vs-kong/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Semantic Router v0.1 Iris&lt;/em> y &lt;em>Production Stack&lt;/em> (routing específico de vLLM): &lt;a href="https://blog.vllm.ai/2026/01/05/vllm-sr-iris.html">https://blog.vllm.ai/2026/01/05/vllm-sr-iris.html&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The maître d' who only seats you if you all fit at one table: CPU, Memory and Topology Manager on RKE2</title><link>https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/</link><pubDate>Sat, 06 Jun 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/</guid><description>&lt;blockquote>
&lt;p>The close of the &amp;ldquo;under the engine&amp;rdquo; series. We saw the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">cable between GPUs&lt;/a> and the &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">host: NUMA, hugepages and CPU isolation&lt;/a> done by hand. Here is the piece that makes it &lt;strong>declarative and at scale&lt;/strong>: how the RKE2 kubelet pins each vLLM pod to the right NUMA node without a single script.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Pinning NUMA with &lt;code>numactl&lt;/code>/&lt;code>isolcpus&lt;/code>/&lt;code>taskset&lt;/code>, the subject of the previous post, &lt;strong>does not scale&lt;/strong> to a cluster where pods come and go and there are dozens of nodes. The &lt;strong>kubelet&lt;/strong> automates it with three components that work as &lt;em>Hint Providers&lt;/em> for a central coordinator, the &lt;strong>Topology Manager&lt;/strong>: the &lt;strong>CPU Manager&lt;/strong> (assigns exclusive CPUs to containers of &lt;code>Guaranteed&lt;/code> pods with whole CPUs), the &lt;strong>Memory Manager&lt;/strong> (NUMA-local memory and hugepages) and the Device Manager/GPU plugin (which knows which GPU is on which NUMA node). With the &lt;strong>&lt;code>single-numa-node&lt;/code>&lt;/strong> policy, the Topology Manager only admits the pod if its CPUs, its memory and its GPU fit in the same NUMA domain; if they do not fit, it rejects the pod, strict admission, like the maître d&amp;rsquo; who will not seat a party of eight unless there is a table for eight. On RKE2 all of this is configured with &lt;code>kubelet-arg&lt;/code> in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>. This post explains the mechanism, gives the 10 knobs and takes apart the gotchas that break pinning silently: the &lt;code>cpu_manager_state&lt;/code> file you have to delete when changing policy, the QoS that has to be exactly &lt;code>Guaranteed&lt;/code>, and the &lt;code>reserved-cpus&lt;/code> that must match the host&amp;rsquo;s &lt;code>isolcpus&lt;/code>. On a generic RKE2 cluster with 4×H100 SXM nodes.&lt;/p>
&lt;h2 id="where-you-are-the-orchestration-that-materialises-the-host">Where you are: the orchestration that materialises the host&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are at the orchestration layer: the kubelet materialises host pinning">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are at orchestration&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM pod (TP, batching)&lt;/text>
&lt;rect x="120" y="84" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · kubelet: CPU/Memory/Topology Mgr&lt;/text>
&lt;text x="280" y="126" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">declarative pinning + NUMA admission&lt;/text>
&lt;rect x="120" y="150" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="174" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Host · NUMA, hugepages, isolcpus (post 2)&lt;/text>
&lt;rect x="120" y="194" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="218" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL + NVLink (post 1)&lt;/text>
&lt;rect x="120" y="240" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="264" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, 4×H100 SXM&lt;/text>
&lt;text x="280" y="300" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">the kubelet turns declarative intent into the raw pinning below&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-maître-d-of-a-restaurant-whose-tables-cannot-be-pushed-together">The analogy: the maître d&amp;rsquo; of a restaurant whose tables cannot be pushed together&lt;/h2>
&lt;p>A restaurant has tables of different sizes and, house rule, &lt;strong>tables are never pushed together&lt;/strong>. A party of eight arrives. The maître d&amp;rsquo; looks for a single table where all eight fit. If there is one, he seats them; if only tables for four are left, &lt;strong>he does not accept them&lt;/strong>. He would rather turn the booking away than seat the group split across two separate tables, because he knows a split dinner goes badly.&lt;/p>
&lt;p>That maître d&amp;rsquo; is the &lt;strong>Topology Manager&lt;/strong> under the &lt;code>single-numa-node&lt;/code> policy. The &amp;ldquo;party&amp;rdquo; is an inference pod asking for CPUs, memory and a GPU. The &amp;ldquo;table&amp;rdquo; is a NUMA node. The maître d&amp;rsquo; asks three assistants (are there free CPUs on any node? the CPU Manager; is there free memory? the Memory Manager; is there a free GPU? the Device Manager) and only admits the pod if all three resources fit on the same node. If not, he rejects it (the pod ends up &lt;code>Failed&lt;/code> with &lt;code>TopologyAffinityError&lt;/code>), and the scheduler will try another node.&lt;/p>
&lt;p>The difference from the previous post: there you were the maître d&amp;rsquo;, seating each process by hand with &lt;code>numactl&lt;/code>. Here the maître d&amp;rsquo; is the kubelet, and it does it for &lt;strong>every pod, on every node, automatically, rejecting whatever does not fit&lt;/strong>. That is what turns artisanal pinning into a declarative property of the cluster.&lt;/p>
&lt;h2 id="the-mechanism-hint-providers-and-the-coordinator">The mechanism: Hint Providers and the coordinator&lt;/h2>
&lt;p>The Topology Manager does not assign resources; it coordinates the ones that do. The flow, when a &lt;code>Guaranteed&lt;/code> pod arrives at a node:&lt;/p>
&lt;div class="diagram" style="max-width:800px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 800 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Topology Manager coordinates CPU, Memory and Device Manager">
&lt;defs>&lt;marker id="km" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Admission of a Guaranteed pod · single-numa-node&lt;/text>
&lt;rect x="320" y="44" width="160" height="50" rx="9" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="2"/>
&lt;text x="400" y="66" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Topology Manager&lt;/text>
&lt;text x="400" y="83" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">coordinator · admits/rejects&lt;/text>
&lt;!-- hint providers -->
&lt;rect x="60" y="150" width="180" height="64" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="150" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">CPU Manager&lt;/text>
&lt;text x="150" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">static: exclusive CPUs&lt;/text>
&lt;text x="150" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: cores on node X?&lt;/text>
&lt;rect x="310" y="150" width="180" height="64" rx="8" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="400" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Memory Manager&lt;/text>
&lt;text x="400" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">Static: cpuset.mems&lt;/text>
&lt;text x="400" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: RAM/hugepages?&lt;/text>
&lt;rect x="560" y="150" width="180" height="64" rx="8" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="650" y="174" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Device Manager&lt;/text>
&lt;text x="650" y="191" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">NVIDIA GPU plugin&lt;/text>
&lt;text x="650" y="205" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">hint: GPU on node X?&lt;/text>
&lt;path d="M360,94 L170,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;path d="M400,94 L400,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;path d="M440,94 L630,150" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#km)"/>
&lt;text x="150" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;text x="400" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;text x="650" y="240" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#888">NUMA hint&lt;/text>
&lt;path d="M170,214 L380,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;path d="M400,214 L400,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;path d="M630,214 L420,255" fill="none" stroke="#999" stroke-width="1.2" marker-end="url(#km)" stroke-dasharray="3 2"/>
&lt;rect x="250" y="262" width="300" height="56" rx="9" fill="#f4f4f4" stroke="#444" stroke-width="1.6"/>
&lt;text x="400" y="284" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Do the three hints meet on 1 node?&lt;/text>
&lt;text x="335" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#1f5c34">YES → admit&lt;/text>
&lt;text x="470" y="304" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#a85454">NO → reject&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The three managers are &lt;strong>Hint Providers&lt;/strong>: each one tells the Topology Manager on which NUMA node(s) it could satisfy its part. The Topology Manager computes the intersection and, depending on the policy, decides:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>none&lt;/code>&lt;/strong> (default): no coordination; each manager does its own thing without aligning. No NUMA guarantee.&lt;/li>
&lt;li>&lt;strong>&lt;code>best-effort&lt;/code>&lt;/strong>: it tries to align on one node; if it cannot, it admits anyway (on whichever node). Better than nothing, no guarantee.&lt;/li>
&lt;li>&lt;strong>&lt;code>restricted&lt;/code>&lt;/strong>: if it fails to align, it rejects the pod. Strict, but it allows multi-node affinity if the intersection provides it.&lt;/li>
&lt;li>&lt;strong>&lt;code>single-numa-node&lt;/code>&lt;/strong>: it requires everything to fit on a single NUMA node, or it rejects. The strictest one, and the one that genuinely guarantees the locality of the previous post.&lt;/li>
&lt;/ul>
&lt;p>And two &lt;strong>preconditions&lt;/strong> without which none of this kicks in:&lt;/p>
&lt;ol>
&lt;li>The pod has to be &lt;strong>QoS &lt;code>Guaranteed&lt;/code>&lt;/strong>: &lt;code>requests == limits&lt;/code> on CPU and memory, and whole CPUs (not &lt;code>500m&lt;/code>). Only then does the CPU Manager assign exclusive CPUs.&lt;/li>
&lt;li>The &lt;strong>CPU Manager&lt;/strong> has to be on the &lt;strong>&lt;code>static&lt;/code>&lt;/strong> policy (not &lt;code>none&lt;/code>).&lt;/li>
&lt;/ol>
&lt;p>Without those two, the Topology Manager has nothing to align and pinning &lt;strong>does not happen&lt;/strong>, even with the policy in place. That is gotcha no. 1.&lt;/p>
&lt;h2 id="how-it-is-configured-on-rke2">How it is configured on RKE2&lt;/h2>
&lt;p>RKE2 passes arguments to the kubelet with the &lt;strong>&lt;code>kubelet-arg&lt;/code>&lt;/strong> key in &lt;code>/etc/rancher/rke2/config.yaml&lt;/code>. The reference configuration for GPU inference nodes:&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"># /etc/rancher/rke2/config.yaml (on every agent node with GPUs)&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">kubelet-arg&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="s2">&amp;#34;cpu-manager-policy=static&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="s2">&amp;#34;topology-manager-policy=single-numa-node&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="s2">&amp;#34;topology-manager-scope=pod&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="s2">&amp;#34;memory-manager-policy=Static&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="s2">&amp;#34;reserved-cpus=0-1,64-65&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># housekeeping; must match the host&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="s2">&amp;#34;system-reserved=memory=8Gi&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="s2">&amp;#34;kube-reserved=memory=4Gi&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="s2">&amp;#34;reserved-memory=0:memory=4Gi;1:memory=4Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># required by Memory Manager Static&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">node-label&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="s2">&amp;#34;fibercli.local/numa-pinned=true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>After deploying it: &lt;code>systemctl restart rke2-agent&lt;/code>. &lt;strong>Critical gotcha&lt;/strong>: if the node has already run with &lt;code>cpu-manager-policy=none&lt;/code>, there is a state file &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> that pins the old policy; changing the arg without deleting that file makes the kubelet fail to start or ignore the new policy. You have to stop the agent, &lt;code>rm /var/lib/kubelet/cpu_manager_state&lt;/code>, and start again. (The same applies to &lt;code>memory_manager_state&lt;/code>.)&lt;/p>
&lt;p>And the vLLM pod, to be eligible, &lt;strong>Guaranteed with whole CPUs&lt;/strong>:&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="nt">resources&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">requests&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># whole, not a weird fractional &amp;#34;16000m&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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;200Gi&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">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># TP=2 → 2 GPUs on the same NUMA node&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">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&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">limits&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">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># == requests → Guaranteed QoS&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">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;200Gi&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">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;2&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">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With this, on a node with the config above, the kubelet assigns 16 exclusive CPUs from the NUMA node where the 2 requested GPUs live, its local memory and the hugepages, or it rejects the pod if they do not fit together. The artisanal pinning of the previous post, now declarative.&lt;/p>
&lt;h2 id="the-10-knobs-to-reach-for">The 10 knobs to reach for&lt;/h2>
&lt;p>Ordered by dependency (the first ones are a precondition for the later ones). The canonical reference is the &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/">Kubernetes Topology Manager documentation&lt;/a> and the &lt;a href="https://docs.rke2.io/install/configuration">RKE2 configuration&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--cpu-manager-policystatic-the-foundation">Knob 1 — &lt;code>cpu-manager-policy=static&lt;/code>: the foundation&lt;/h3>
&lt;p>Without this there are no exclusive CPUs and nothing else kicks in.&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="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;cpu-manager-policy=static&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>&lt;strong>Gotcha&lt;/strong>: changing it requires deleting &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> and restarting the kubelet, or startup fails. It is cause no. 1 of &amp;ldquo;I set the policy and it does not pin&amp;rdquo;.&lt;/p>
&lt;h3 id="knob-2--guaranteed-qos--whole-cpus-the-pods-precondition">Knob 2 — &lt;code>Guaranteed&lt;/code> QoS + whole CPUs: the pod&amp;rsquo;s precondition&lt;/h3>
&lt;p>This is not node config, it is &lt;strong>pod&lt;/strong> config, but without it knob 1 does nothing for that pod. &lt;code>requests == limits&lt;/code> on CPU and memory, and whole CPUs. A &lt;code>cpu: 500m&lt;/code> or a &lt;code>requests != limits&lt;/code> downgrades the pod to &lt;code>Burstable&lt;/code> and it loses the pinning. Plenty of people set the node policy and forget the pod&amp;rsquo;s QoS.&lt;/p>
&lt;h3 id="knob-3--topology-manager-policysingle-numa-node-strict-admission">Knob 3 — &lt;code>topology-manager-policy=single-numa-node&lt;/code>: strict admission&lt;/h3>
&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="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology-manager-policy=single-numa-node&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 strict maître d&amp;rsquo;. For GPU inference it is the right policy: it guarantees that CPU+memory+GPU share a node. &lt;code>best-effort&lt;/code> guarantees nothing (it admits misaligned); &lt;code>restricted&lt;/code> allows multi-node affinity. Start with &lt;code>single-numa-node&lt;/code> and drop to &lt;code>restricted&lt;/code> only if you have admission problems.&lt;/p>
&lt;h3 id="knob-4--topology-manager-scopepod-group-the-whole-pod">Knob 4 — &lt;code>topology-manager-scope=pod&lt;/code>: group the whole pod&lt;/h3>
&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="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;topology-manager-scope=pod&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>With &lt;code>container&lt;/code> scope (the default), each container is aligned separately; with &lt;code>pod&lt;/code> scope, the whole pod goes to the same node. For a vLLM pod with sidecars (metrics, proxy), &lt;code>pod&lt;/code> scope stops the sidecar from dragging the main container onto another node. Recommended for inference.&lt;/p>
&lt;h3 id="knob-5--memory-manager-policystatic--reserved-memory-numa-local-memory">Knob 5 — &lt;code>memory-manager-policy=Static&lt;/code> + &lt;code>reserved-memory&lt;/code>: NUMA-local memory&lt;/h3>
&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="nt">kubelet-arg&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="s2">&amp;#34;memory-manager-policy=Static&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="s2">&amp;#34;reserved-memory=0:memory=4Gi;1:memory=4Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Memory Manager Static forces &lt;code>cpuset.mems&lt;/code> so that the pod&amp;rsquo;s memory comes out of the right node (and so do the hugepages). It requires declaring &lt;code>reserved-memory&lt;/code> per node, or the kubelet does not start. It is the declarative equivalent of the previous post&amp;rsquo;s &lt;code>--membind&lt;/code>.&lt;/p>
&lt;h3 id="knob-6--reserved-cpus-the-housekeeping-cores-must-match-isolcpus">Knob 6 — &lt;code>reserved-cpus&lt;/code>: the housekeeping cores (must match &lt;code>isolcpus&lt;/code>)&lt;/h3>
&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="nt">kubelet-arg&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;reserved-cpus=0-1,64-65&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>It reserves cores for the system and the daemons; the rest are left for exclusive pods. &lt;strong>Key point of the series&lt;/strong>: these &lt;code>reserved-cpus&lt;/code> must be the same cores you left out of &lt;code>isolcpus&lt;/code> on the host (previous post). If the host isolates 2-31 but RKE2 reserves 0-3, there is a mismatch: isolated cores that the kubelet assigns to pods without them actually being quiet. Coordinate the two layers.&lt;/p>
&lt;h3 id="knob-7--gpu-plugin-with-numa-topology-nvidia-gpu-operator">Knob 7 — GPU plugin with NUMA topology (NVIDIA GPU Operator)&lt;/h3>
&lt;p>The Device Manager can only give a correct NUMA hint if the &lt;strong>GPU plugin exposes which node each GPU is on&lt;/strong>. The NVIDIA device plugin / GPU Operator does this, but you have to verify that the topology information arrives (in some versions it requires flags). Without a GPU hint, the Topology Manager aligns CPU and memory but not the GPU, and GPU locality is precisely the one that matters most.&lt;/p>
&lt;h3 id="knob-8--hugepages-as-a-pod-resource">Knob 8 — hugepages as a pod resource&lt;/h3>
&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="nt">resources&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">limits&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">hugepages-1Gi&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;16Gi&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># the node must have them pre-reserved (post 2, knob 4)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The hugepages you reserved at host boot (previous post) are requested as a resource. The Memory Manager assigns them NUMA-local. If you request them without having reserved them on the node, the pod does not get scheduled.&lt;/p>
&lt;h3 id="knob-9--system-reserved--kube-reserved-do-not-oversubscribe">Knob 9 — &lt;code>system-reserved&lt;/code> / &lt;code>kube-reserved&lt;/code>: do not oversubscribe&lt;/h3>
&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="nt">kubelet-arg&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="s2">&amp;#34;system-reserved=cpu=500m,memory=8Gi&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="s2">&amp;#34;kube-reserved=cpu=500m,memory=4Gi&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It reserves resources for the system and the K8s components so the node does not run out of air under load. Badly calibrated, either the node chokes (too little reserved) or you waste capacity (too much). It must be consistent with &lt;code>reserved-cpus&lt;/code>.&lt;/p>
&lt;h3 id="knob-10--labels--taints-so-vllm-lands-here-and-nothing-else-does">Knob 10 — Labels + taints: so vLLM lands here and nothing else does&lt;/h3>
&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"># GPU node: taint to repel anything that does not need a GPU&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">node-taint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;nvidia.com/gpu=present:NoSchedule&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="nt">node-label&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;fibercli.local/numa-pinned=true&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>Keep the NUMA-pinned nodes for inference and push out whatever does not need it (databases, &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">the Langfuse backend&lt;/a>, runners). A ClickHouse stealing memory bandwidth from a carefully pinned vLLM pod throws away all the work of the previous nine knobs. Workload isolation is the closing move.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Where&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>cpu-manager-policy=static&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>exclusive CPUs (foundation)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>Guaranteed&lt;/code> QoS + whole CPUs&lt;/td>
&lt;td>pod spec&lt;/td>
&lt;td>pinning precondition&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>topology-manager-policy=single-numa-node&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>strict NUMA admission&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>&lt;code>topology-manager-scope=pod&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>group the whole pod&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>&lt;code>memory-manager-policy=Static&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>NUMA-local memory/hugepages&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>reserved-cpus&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>housekeeping (match isolcpus)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>GPU plugin with topology&lt;/td>
&lt;td>GPU Operator&lt;/td>
&lt;td>NUMA hint for the GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>&lt;code>hugepages-1Gi&lt;/code>&lt;/td>
&lt;td>pod spec&lt;/td>
&lt;td>hugepages as a resource&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>&lt;code>system/kube-reserved&lt;/code>&lt;/td>
&lt;td>kubelet-arg&lt;/td>
&lt;td>do not oversubscribe&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>taints + labels&lt;/td>
&lt;td>node config&lt;/td>
&lt;td>isolate GPU workloads&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="verifying-that-the-pinning-really-happened">Verifying that the pinning really happened&lt;/h2>
&lt;p>Do not trust that the config &amp;ldquo;is in place&amp;rdquo;. Check:&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"># Is the active policy the one you set?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cat /var/lib/kubelet/cpu_manager_state &lt;span class="p">|&lt;/span> jq .policyName &lt;span class="c1"># &amp;#34;static&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"># Which exclusive CPUs does the container have?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> &amp;lt;pod&amp;gt; -- cat /sys/fs/cgroup/cpuset.cpus.effective
&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"># Inside the pod: is the assigned GPU local to those cores?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">exec&lt;/span> &amp;lt;pod&amp;gt; -- nvidia-smi topo -m
&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"># Were there topology rejections?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl describe pod &amp;lt;pod&amp;gt; &lt;span class="p">|&lt;/span> grep -i TopologyAffinityError
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A pod in &lt;code>Failed&lt;/code> with &lt;code>TopologyAffinityError&lt;/code> is not a bug: it is the maître d&amp;rsquo; &lt;strong>doing his job&lt;/strong>. That node did not have a table where CPU+memory+GPU fitted together. The answer is to review the sizing of the pod or the node, not to relax the policy casually.&lt;/p>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the host (previous post).&lt;/strong> This post is the &lt;strong>declarative automation&lt;/strong> of that one. &lt;code>cpu-manager-policy=static&lt;/code> materialises the &lt;code>taskset&lt;/code>; &lt;code>memory-manager-policy=Static&lt;/code> materialises the &lt;code>--membind&lt;/code>; &lt;code>reserved-cpus&lt;/code> must match &lt;code>isolcpus&lt;/code>. The two layers are one single decision seen from two places: the host executes it, the kubelet declares it. Uncoordinating them (isolcpus 2-31 vs reserved-cpus 0-3) breaks both.&lt;/p>
&lt;p>&lt;strong>With the interconnect (post 1).&lt;/strong> The Topology Manager pins the right GPU to the pod, but if you ask for 2 GPUs for TP=2, you will want those two to share NVLink. The NUMA policy guarantees they are on the same socket; that they are NVLink-connected is guaranteed by the baseboard hardware (post 1, knob 1). The two things together are what makes &lt;code>TP=2&lt;/code> perform.&lt;/p>
&lt;p>&lt;strong>With autoscaling.&lt;/strong> When &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">KEDA scales vLLM pods&lt;/a>, each new replica goes through Topology Manager admission. If the node has no free &amp;ldquo;table&amp;rdquo;, the pod stays pending. Pod autoscaling and node autoscaling (cluster-autoscaler) have to account for NUMA granularity, not just aggregate CPU/memory.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Sizing&lt;/a> changes: it is not &amp;ldquo;128 vCPU per machine&amp;rdquo;, it is &amp;ldquo;128 minus the reserved-cpus, in blocks that fit per NUMA node&amp;rdquo;. A machine with 2 sockets × 64 cores does not serve a pod asking for 80 cores under single-numa-node: they do not fit at one table. Planning has to reason per NUMA node, not per machine.&lt;/p>
&lt;p>&lt;strong>With service coexistence.&lt;/strong> The knob 10 taint is what keeps &lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse&lt;/a>, databases and runners &lt;strong>off&lt;/strong> the inference nodes. Without that boundary, all the fine-grained pinning gets eaten by a noisy neighbour. Observability goes on its own nodes; inference, pinned, on its own.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>Policy in place, QoS forgotten.&lt;/strong> The most common mistake: &lt;code>cpu-manager-policy=static&lt;/code> on the node but the pod is &lt;code>Burstable&lt;/code> (&lt;code>requests != limits&lt;/code> or fractional CPU). Pinning &lt;strong>does not happen&lt;/strong> and nobody warns you. &lt;code>Guaranteed&lt;/code> QoS with whole CPUs is a necessary condition.&lt;/p>
&lt;p>&lt;strong>Fossilised &lt;code>cpu_manager_state&lt;/code>.&lt;/strong> Changing policy without deleting &lt;code>/var/lib/kubelet/cpu_manager_state&lt;/code> (and &lt;code>memory_manager_state&lt;/code>) makes the kubelet fail or ignore the change. Stop agent → delete file → start.&lt;/p>
&lt;p>&lt;strong>&lt;code>reserved-cpus&lt;/code> ≠ &lt;code>isolcpus&lt;/code>.&lt;/strong> If the host isolates some cores and RKE2 reserves others, the managers assign cores to pods that are not really quiet, or they leave isolated cores idle. The two lists have to be consistent. It is the coordination failure between the previous post and this one.&lt;/p>
&lt;p>&lt;strong>GPU plugin without NUMA topology.&lt;/strong> If the device plugin does not expose each GPU&amp;rsquo;s NUMA node, the Topology Manager aligns CPU and memory but leaves the GPU to chance, and GPU locality is the one that weighs most. Verify that the GPU Operator publishes the topology.&lt;/p>
&lt;p>&lt;strong>&lt;code>single-numa-node&lt;/code> that rejects too much.&lt;/strong> If the pods ask for more resources than fit on one node (for example, more cores than a socket has), rejection is constant. The answer is not to drop to &lt;code>best-effort&lt;/code> (which silences the problem by serving misaligned), but to &lt;strong>size the pod so it fits at one table&lt;/strong>, or to accept &lt;code>restricted&lt;/code> with full knowledge of what you are doing.&lt;/p>
&lt;p>&lt;strong>Believing &lt;code>best-effort&lt;/code> &amp;ldquo;is nearly the same&amp;rdquo;.&lt;/strong> &lt;code>best-effort&lt;/code> admits the pod even if it fails to align: it gives you a false sense of NUMA-awareness while you serve from the wrong socket. For inference with a tail SLO, &lt;code>single-numa-node&lt;/code> or &lt;code>restricted&lt;/code>; &lt;code>best-effort&lt;/code> only if the alternative is scheduling nothing at all.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The previous post pinned by hand; this one does it at scale and with a guarantee that artisanal &lt;code>numactl&lt;/code> did not give: &lt;strong>strict admission&lt;/strong>. The kubelet, via CPU Manager, Memory Manager and Topology Manager, acts as a maître d&amp;rsquo; who only seats the pod if its CPUs, its memory and its GPU fit at the same NUMA table, and who rejects what does not fit instead of serving a split dinner. Of the ten knobs, the first two, &lt;strong>&lt;code>cpu-manager-policy=static&lt;/code>&lt;/strong> and &lt;strong>&lt;code>Guaranteed&lt;/code> QoS with whole CPUs&lt;/strong>, are the precondition without which the other eight do nothing, and they are exactly the ones most often forgotten; the rest tune the policy, the memory, the hugepages and the coexistence. The thread that closes the series: the inference performance that looked like an engine problem (vLLM is slow) or a model problem (quantisation) is, far too often, a cable problem (NVLink not used), a host problem (remote NUMA, jitter) or an orchestration problem (pinning that never happened because the QoS was wrong). Going down a level is not infrastructure snobbery: it is where the root causes live that no application-layer dashboard is ever going to point out for you.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing one GPU between several workloads: time-slicing, MPS and MIG&lt;/a> — splitting the GPU (time-slicing/MPS/MIG) is the accelerator&amp;rsquo;s equivalent of what these managers do for CPU and memory.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> — the previous post; the raw layer (numactl, isolcpus, membind) that this one automates declaratively. &lt;code>reserved-cpus&lt;/code> here must match &lt;code>isolcpus&lt;/code> there.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a> — the first of the series; the NUMA policy pins the right GPU, but whether the GPUs of a TP group share NVLink is decided by the baseboard hardware.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the whole building; orchestration is the control plane layer that holds inference up.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — every replica KEDA creates goes through Topology Manager admission; autoscaling has to account for NUMA granularity.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — why sizing starts being reasoned per NUMA node, not per machine: a pod does not fit if it asks for more than one table.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside: v3 architecture and the 10 backend knobs&lt;/a> — the kind of workload that the knob 10 taints keep &lt;strong>off&lt;/strong> the pinned inference nodes.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — how to confirm, with metrics in hand, that the pinning translates into a saturated GPU with no bubbles.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître d&amp;rsquo; never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — the coda: the Topology Manager pins CPU+memory+GPU but &lt;strong>not the NIC&lt;/strong>; that fourth leg (network locality, IRQ affinity, DRA/DRANET) is the one left out of the census here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the NVMe also hangs off a PCIe root under a socket; its NUMA locality is the fifth list to align, and cold start is the real ceiling on the elasticity this pinning holds up.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kubernetes, &lt;em>Control Topology Management Policies on a node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/">https://kubernetes.io/docs/tasks/administer-cluster/topology-manager/&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Control CPU Management Policies on the Node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/">https://kubernetes.io/docs/tasks/administer-cluster/cpu-management-policies/&lt;/a>.&lt;/li>
&lt;li>Kubernetes, &lt;em>Control Memory Management Policies on a Node&lt;/em>: &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/">https://kubernetes.io/docs/tasks/administer-cluster/memory-manager/&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Configuration Options&lt;/em> (kubelet-arg in config.yaml): &lt;a href="https://docs.rke2.io/install/configuration">https://docs.rke2.io/install/configuration&lt;/a>.&lt;/li>
&lt;li>RKE2, &lt;em>Advanced Options and Configuration&lt;/em>: &lt;a href="https://docs.rke2.io/advanced">https://docs.rke2.io/advanced&lt;/a>.&lt;/li>
&lt;li>rancher/rke2, discussion #3034 &lt;em>CPU Management Policies for RKE2&lt;/em> (the cpu_manager_state gotcha): &lt;a href="https://github.com/rancher/rke2/discussions/3034">https://github.com/rancher/rke2/discussions/3034&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The floor next door: NUMA, hugepages and CPU isolation, or why your GPU waits on the kernel</title><link>https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/</link><pubDate>Sat, 06 Jun 2026 07:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/</guid><description>&lt;blockquote>
&lt;p>Second post in the &amp;ldquo;below the engine&amp;rdquo; series. The &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">first&lt;/a> opened up the cable between GPUs (NVLink/NCCL). This one goes down to the host: the cores, the memory and the kernel that surround those GPUs and that, badly configured, leave them waiting. The &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">third&lt;/a> will explain how Kubernetes automates all of this; here is the raw layer, the one you have to understand before delegating it.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A node with 4×H100 SXM is, physically, a two-socket server = two NUMA domains. Each socket has its own cores, its own memory channels and PCIe lanes towards half of the GPUs and NICs. Inference is not only GPU: the host does work on the hot path of every token, launching CUDA kernels, sampling the next token, tokenising, moving &lt;em>pinned&lt;/em> buffers between host and GPU, running the NCCL threads. If those threads and their memory land on the socket that is &lt;strong>not&lt;/strong> local to the GPU, every access crosses the inter-socket link (UPI/Infinity Fabric): &lt;strong>2-3× slower and with p99 spikes&lt;/strong>. There are three kernel levers that decide the latency tail and that almost nobody touches: locality (NUMA affinity: CPU, memory, GPU and NIC on the same &amp;ldquo;floor&amp;rdquo;), page tables (hugepages: a few large pages instead of millions of small ones, plus &lt;em>pinned memory&lt;/em> for DMA), and jitter (CPU isolation with &lt;code>isolcpus&lt;/code>/&lt;code>nohz_full&lt;/code>/&lt;code>rcu_nocbs&lt;/code> plus IRQ affinity, so that the kernel does not interrupt the thread launching the next decode kernel). This post explains the mechanism, gives the 10 real knobs, and connects with the interconnect and latency-bound decode. With scepticism about which knobs move the needle in inference and which are cargo cult inherited from low-latency trading.&lt;/p>
&lt;h2 id="where-you-are-the-host-below-the-cable">Where you are: the host below the cable&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="You are at the host: NUMA, kernel, memory, below the interconnect">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are at the host&lt;/text>
&lt;rect x="120" y="40" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="64" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM / SGLang (TP, batching)&lt;/text>
&lt;rect x="120" y="84" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="108" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA + NCCL (collectives)&lt;/text>
&lt;rect x="120" y="128" width="320" height="38" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="152" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">NVLink + NVSwitch (previous post)&lt;/text>
&lt;rect x="120" y="172" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="196" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · host: NUMA + kernel + memory&lt;/text>
&lt;text x="280" y="214" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">cores, memory channels, scheduler, IRQs&lt;/text>
&lt;rect x="120" y="236" width="320" height="38" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="260" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · 2 sockets, PCIe, HBM&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">the GPU computes, but the host launches, samples and moves data per token&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-two-floor-office">The analogy: the two-floor office&lt;/h2>
&lt;p>Picture a consultancy in a building with two floors. On each floor there are desks (CPU cores), an archive with the files (the memory of that socket) and a loading bay connecting to the outside (the PCIe lanes towards the GPUs and NICs of that socket). An analyst works fast &lt;strong>while everything they need is on their floor&lt;/strong>: they reach out and take the file from the archive next to them.&lt;/p>
&lt;p>The problem starts when the analyst is on floor 1 but their file is in the archive on floor 2. Every time they need it, they &lt;strong>take the lift&lt;/strong>. The work &amp;ldquo;functions&amp;rdquo;, but every lookup costs a trip. And if on top of that the loading bay through which their materials arrive (their GPU) is on the other floor, &lt;strong>every delivery crosses the building&lt;/strong>. This is NUMA: local access (same floor) is fast; remote access (another floor, via the inter-socket link) is 2-3× slower.&lt;/p>
&lt;p>And there are two more ways to ruin that analyst even when they are on the right floor:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Interrupting them constantly.&lt;/strong> Every few minutes, a tannoy announcement, a colleague with a question, a fire alarm test. Every interruption breaks their concentration right when they were about to deliver. This is kernel jitter: the scheduler tick, device IRQs, RCU callbacks, interrupting the host thread right when it was about to launch the next GPU kernel. CPU isolation is putting them in an &lt;strong>office with a &amp;ldquo;do not disturb&amp;rdquo; sign&lt;/strong>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Giving them an index of a thousand tiny tabs.&lt;/strong> If to find each file they have to search an index with a million minuscule entries, they lose time in the search. If the index has &lt;strong>a few large entries&lt;/strong>, they find it instantly. These are hugepages: pages of 2 MB or 1 GB instead of 4 KB reduce the pressure on the TLB (the cache of the page index).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>The thesis: &lt;strong>the GPU is expensive and fast, but it spends a surprising fraction of decode waiting on the host.&lt;/strong> If the host is on the wrong floor, interrupted, and searching a giant index, the GPU, the 30,000 € resource, waits. The three levers in this post exist so that it does not wait.&lt;/p>
&lt;h2 id="the-mechanism-what-the-host-does-on-the-token-path">The mechanism: what the host does on the token path&lt;/h2>
&lt;p>It is tempting to think that in inference &amp;ldquo;the GPU does everything&amp;rdquo;. It is not true. For every token, the host (CPU) does, at a minimum:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Launch the CUDA kernels&lt;/strong> for each operation. The GPU does not decide what to execute; the host keeps putting kernels in its queue. In decode, where each kernel is short, the host has to run &lt;strong>ahead&lt;/strong> feeding the queue; if the host thread stalls, the GPU runs out of work: a bubble.&lt;/li>
&lt;li>Sample the next token (argmax/top-p/top-k over the logits), which comes back from device to host.&lt;/li>
&lt;li>Tokenise the input and detokenise the output.&lt;/li>
&lt;li>&lt;strong>Move pinned buffers&lt;/strong> (page-locked) between host and GPU by DMA: prompts, logits, and in offload configurations, part of the KV cache.&lt;/li>
&lt;li>&lt;strong>Run the NCCL threads&lt;/strong> that coordinate the collectives of the &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">previous post&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>All of that is CPU and host memory work. And all of it suffers if:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The process runs on the socket that is not local to its GPU&lt;/strong> → every DMA and every memory access crosses the inter-socket link.&lt;/li>
&lt;li>&lt;strong>The kernel interrupts the threads&lt;/strong> → bubbles in the GPU queue.&lt;/li>
&lt;li>&lt;strong>The memory is not pinned or uses small pages&lt;/strong> → page faults, TLB misses, and worse: if the memory of the DMA transfer is not pinned, the driver makes an intermediate copy.&lt;/li>
&lt;/ol>
&lt;h3 id="the-map-nvidia-smi-topo--m">The map: &lt;code>nvidia-smi topo -m&lt;/code>&lt;/h3>
&lt;p>Everything starts by looking at the map. &lt;code>nvidia-smi topo -m&lt;/code> shows, for each GPU, which NUMA node and which cores it is local to, and through what kind of path it talks to each NIC and to each other GPU:&lt;/p>
&lt;pre tabindex="0">&lt;code> GPU0 GPU1 GPU2 GPU3 NIC0 CPU Affinity NUMA Affinity
GPU0 X NV18 NV18 NV18 PIX 0-31,64-95 0
GPU1 NV18 X NV18 NV18 SYS 0-31,64-95 0
GPU2 NV18 NV18 X NV18 SYS 32-63,96-127 1
GPU3 NV18 NV18 NV18 X SYS 32-63,96-127 1
&lt;/code>&lt;/pre>&lt;p>Read it like this: GPU0 and GPU1 are local to NUMA node 0 (cores 0-31, 64-95); GPU2 and GPU3 to NUMA node 1. &lt;code>NV18&lt;/code> between GPUs = 18 NVLink links (the good stuff, from the previous post). In the NIC column: &lt;code>PIX&lt;/code> = a single PCIe switch in between (optimal for GPUDirect RDMA); &lt;code>SYS&lt;/code> = the path crosses the inter-socket link (the worst). &lt;strong>The rule&lt;/strong>: the process serving on GPU0/1 must be pinned to cores 0-31/64-95 and to the memory of node 0; and if it also uses RDMA, you want the NIC that is on &lt;code>PIX&lt;/code> with its GPU.&lt;/p>
&lt;h2 id="the-maths-that-matter-a-kernel-tick-is-a-gpu-bubble">The maths that matter: a kernel tick is a GPU bubble&lt;/h2>
&lt;p>The figure worth internalising is not the bandwidth one, it is the jitter one. In decode, the host launches many short kernels per token. If the host thread that launches them is &lt;strong>preempted&lt;/strong> by the scheduler (a timer tick, an IRQ, an RCU callback) for $t_{\text{stall}}$, and the GPU drains its queue in that time, a bubble appears: the GPU stops.&lt;/p>
&lt;p>Put numbers on it. A typical timer tick or the handling of an IRQ costs of the order of &lt;strong>tens of microseconds&lt;/strong> of diversion. If a decode kernel lasts around 50-100 µs and the queue carries 2-3 kernels in flight, a host stall of &lt;strong>50-100 µs&lt;/strong> drains the queue and the GPU sits idle until the host resumes. Multiply by the interrupt frequency of a &lt;strong>non&lt;/strong>-isolated kernel (the default tick is 250-1000 Hz, plus network and disk IRQs): the p99/p999 tail of TTFT and of inter-token time fills up with these episodes.&lt;/p>
$$ \text{jitter}_{p99} \approx f_{\text{interrupts}} \times t_{\text{stall}} \times \mathbb{1}[\text{GPU queue drained}] $$
&lt;p>The intuition: in average throughput it is barely noticeable (the bubbles average out), but in &lt;strong>the tail&lt;/strong>, which is what an SLO measures, kernel jitter is a first-order contributor. That is why CPU isolation, which was born in low-latency trading, makes sense in LLM decode: &lt;strong>it is the same physics, a critical thread that cannot afford the kernel stopping it&lt;/strong>.&lt;/p>
&lt;p>And the NUMA cost, in parallel: a remote memory access (another floor) has latency around 1.5-2× the local one and half the bandwidth. For the pinned buffers moved by DMA on every step, and for the vLLM scheduler structures that live on the host, that penalty is paid token by token.&lt;/p>
&lt;h2 id="the-three-levers-one-by-one">The three levers, one by one&lt;/h2>
&lt;h3 id="locality-numa-getting-everything-on-the-same-floor">Locality (NUMA): getting everything on the same floor&lt;/h3>
&lt;p>The goal is that the inference process using GPU0/1 has its cores, its memory and (if applicable) its NIC on NUMA node 0. Raw, without Kubernetes:&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"># Pin process to node 0 (cores and memory) to serve on GPU0/1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">numactl --cpunodebind&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> --membind&lt;span class="o">=&lt;/span>&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> vllm serve meta-llama/Llama-3-70B --tensor-parallel-size &lt;span class="m">2&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--membind=0&lt;/code> is the key: it forces &lt;strong>all&lt;/strong> the process memory to be allocated on node 0. Without &lt;code>--membind&lt;/code>, the kernel can place pages on node 1 under pressure, and you start paying for the lift without knowing it.&lt;/p>
&lt;h3 id="page-tables-hugepages-a-few-large-pages-and-pinned-memory">Page tables (hugepages): a few large pages and pinned memory&lt;/h3>
&lt;p>Two different things under the same umbrella. First, hugepages reduce TLB pressure for the large host buffers (pinned, KV offload). Second, pinned memory (page-locked) is what allows direct DMA with no intermediate copy. The silent trap is transparent hugepages (THP): their background compaction causes &lt;strong>latency spikes&lt;/strong>, exactly what you do not want.&lt;/p>
&lt;h3 id="jitter-cpu-isolation-the-office-with-do-not-disturb">Jitter (CPU isolation): the office with &amp;ldquo;do not disturb&amp;rdquo;&lt;/h3>
&lt;p>Three kernel boot parameters, coordinated:&lt;/p>
&lt;pre tabindex="0">&lt;code>isolcpus=2-31,66-95 # take these cores out of the scheduler&amp;#39;s balancing
nohz_full=2-31,66-95 # tickless: no timer tick if there is only 1 runnable thread
rcu_nocbs=2-31,66-95 # offload RCU callbacks to housekeeping cores
&lt;/code>&lt;/pre>&lt;p>&lt;code>isolcpus&lt;/code> sets the cores aside; &lt;strong>you&lt;/strong> have to pin the inference threads there (the non-isolated cores, 0-1, are left for the system). &lt;code>nohz_full&lt;/code> removes the periodic tick (it only works if there is &lt;strong>a single&lt;/strong> runnable thread on the core). &lt;code>rcu_nocbs&lt;/code> takes RCU work off those cores. And separately, IRQ affinity: moving device interrupts away from the inference cores.&lt;/p>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;p>Ordered by impact/frequency. Almost all of them are sysctls, kernel boot parameters or &lt;code>numactl&lt;/code>. The low-level reference is &lt;a href="https://rigtorp.se/low-latency-guide/">Rigtorp&amp;rsquo;s low-latency guide&lt;/a> and &lt;a href="https://access.redhat.com/articles/3720611">Red Hat&amp;rsquo;s real-time docs&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--nvidia-smi-topo--m-look-at-the-map-before-touching-anything">Knob 1 — &lt;code>nvidia-smi topo -m&lt;/code>: look at the map before touching anything&lt;/h3>
&lt;p>Just as in the interconnect post: &lt;strong>the map first&lt;/strong>. Which GPU is local to which NUMA node and to which cores, and what path (PIX/PHB/SYS) there is to each NIC. Without this, any pinning is blind. Half of the &amp;ldquo;inference has latency spikes&amp;rdquo; problems are processes running on the wrong socket without anyone having looked.&lt;/p>
&lt;h3 id="knob-2--numactl---cpunodebind---membind-pin-to-the-local-node">Knob 2 — &lt;code>numactl --cpunodebind --membind&lt;/code>: pin to the local node&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">numactl --cpunodebind&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> --membind&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> &amp;lt;process&amp;gt; &lt;span class="c1"># cores AND memory on node 0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">numactl --hardware &lt;span class="c1"># see nodes, distances, free memory&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>--membind&lt;/code> is what really matters: without it, the memory scatters. It is the highest-impact knob on sustained throughput.&lt;/p>
&lt;h3 id="knob-3--kernelnuma_balancing0-turn-off-automatic-migration">Knob 3 — &lt;code>kernel.numa_balancing=0&lt;/code>: turn off automatic migration&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">sysctl -w kernel.numa_balancing&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The kernel&amp;rsquo;s &lt;em>automatic NUMA balancing&lt;/em> migrates pages between nodes trying to &amp;ldquo;bring them closer&amp;rdquo;, but that background work &lt;strong>causes jitter&lt;/strong> and, with the memory already pinned by knob 2, it adds nothing. On dedicated inference nodes, turn it off.&lt;/p>
&lt;h3 id="knob-4--explicit-hugepages-1-gb-for-host-buffers">Knob 4 — Explicit hugepages (1 GB) for host buffers&lt;/h3>
&lt;pre tabindex="0">&lt;code># Kernel boot, for KV offload / large pinned buffers
default_hugepagesz=1G hugepagesz=1G hugepages=32
&lt;/code>&lt;/pre>&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">grep Huge /proc/meminfo &lt;span class="c1"># verify the reservation&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Useful &lt;strong>when there is host memory on the hot path&lt;/strong> (vLLM with &lt;code>--cpu-offload-gb&lt;/code>, or large pinned buffers). If your deployment does not touch host memory in the hot path, explicit hugepages add little, so do not set them out of cargo cult.&lt;/p>
&lt;h3 id="knob-5--thp-on-madvise-or-never-avoid-the-compaction-spikes">Knob 5 — THP on &lt;code>madvise&lt;/code> or &lt;code>never&lt;/code>: avoid the compaction spikes&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">&lt;span class="nb">echo&lt;/span> madvise &amp;gt; /sys/kernel/mm/transparent_hugepage/enabled
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> never &amp;gt; /sys/kernel/mm/transparent_hugepage/defrag
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Transparent hugepages on &lt;code>always&lt;/code> save TLB but their &lt;strong>compaction&lt;/strong> triggers unpredictable latency. For tail-sensitive workloads, &lt;code>madvise&lt;/code> (only where the app asks for it) or &lt;code>never&lt;/code> is what is recommended. It is one of the few knobs with clear consensus: &lt;strong>THP always is bad for latency&lt;/strong>.&lt;/p>
&lt;h3 id="knob-6--isolcpus-set-the-inference-cores-aside-from-the-scheduler">Knob 6 — &lt;code>isolcpus&lt;/code>: set the inference cores aside from the scheduler&lt;/h3>
&lt;pre tabindex="0">&lt;code>isolcpus=2-31,66-95
&lt;/code>&lt;/pre>&lt;p>Takes those cores out of the scheduler&amp;rsquo;s load balancing; the system (kernel threads, daemons) stays on the non-isolated ones. &lt;strong>You have to pin&lt;/strong> the inference threads explicitly to the isolated cores (via &lt;code>numactl&lt;/code>/&lt;code>taskset&lt;/code> or, on K8s, the CPU Manager of the next post). Isolating without pinning is useless.&lt;/p>
&lt;h3 id="knob-7--nohz_full--rcu_nocbs-tickless-and-no-rcu-on-the-critical-cores">Knob 7 — &lt;code>nohz_full&lt;/code> + &lt;code>rcu_nocbs&lt;/code>: tickless and no RCU on the critical cores&lt;/h3>
&lt;pre tabindex="0">&lt;code>nohz_full=2-31,66-95 rcu_nocbs=2-31,66-95
&lt;/code>&lt;/pre>&lt;p>Removes the periodic timer tick and the RCU callbacks from the inference cores. &lt;strong>Two warnings from practice&lt;/strong>: &lt;code>nohz_full&lt;/code> only eliminates the tick if there is &lt;strong>a single runnable thread&lt;/strong> on the core (if you pin two threads there, the tick comes back); and &lt;code>nohz_full&lt;/code> &lt;strong>is not compatible with the &lt;code>intel_pstate&lt;/code> driver&lt;/strong> in some configurations, which has to be validated, not assumed.&lt;/p>
&lt;h3 id="knob-8--irq-affinity-interrupts-away-from-the-inference-cores">Knob 8 — IRQ affinity: interrupts, away from the inference cores&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">systemctl stop irqbalance &lt;span class="c1"># or configure it to respect isolcpus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># move a device&amp;#39;s IRQs to the housekeeping cores (0-1)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="m">3&lt;/span> &amp;gt; /proc/irq/&amp;lt;N&amp;gt;/smp_affinity &lt;span class="c1"># mask for cores 0-1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A network or disk IRQ landing on an inference core is a direct interruption of the thread feeding the GPU. Move them to the housekeeping cores. (&lt;code>irqbalance&lt;/code> can respect &lt;code>isolcpus&lt;/code> automatically if it is configured to.)&lt;/p>
&lt;h3 id="knob-9--cpu-governor-performance--c-states">Knob 9 — CPU governor &lt;code>performance&lt;/code> + C-states&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">cpupower frequency-set -g performance
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># stop idle cores entering deep C-states (wakeup latency)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">cpupower idle-set -D &lt;span class="m">0&lt;/span> &lt;span class="c1"># or limit the C-state depth&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the &lt;code>powersave&lt;/code>/&lt;code>ondemand&lt;/code> governor, a core that was idle takes time to ramp up its frequency: wakeup latency exactly when work arrives. &lt;code>performance&lt;/code> keeps it flat out. On servers dedicated to inference, the energy saving does not compensate for the latency tail.&lt;/p>
&lt;h3 id="knob-10--memory-locking--swappiness0">Knob 10 — Memory locking + &lt;code>swappiness=0&lt;/code>&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">sysctl -w vm.swappiness&lt;span class="o">=&lt;/span>&lt;span class="m">0&lt;/span> &lt;span class="c1"># do not evict inference pages to swap&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># and in the app / container: ulimit -l unlimited (memlock) for pinned memory&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>An inference page that the kernel decides to swap to disk is a page fault of milliseconds on the hot path. &lt;code>swappiness=0&lt;/code> and adequate &lt;code>memlock&lt;/code> limits (so the driver can pin memory) close that door. On K8s, this translates into &lt;code>Guaranteed&lt;/code> QoS and memory limits, the bridge to the next post.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Mechanism&lt;/th>
&lt;th>What it attacks&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>nvidia-smi topo -m&lt;/code>&lt;/td>
&lt;td>diagnosis&lt;/td>
&lt;td>see GPU–NUMA–NIC affinity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>numactl --cpunodebind --membind&lt;/code>&lt;/td>
&lt;td>pinning&lt;/td>
&lt;td>locality (the biggest lever)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>&lt;code>kernel.numa_balancing=0&lt;/code>&lt;/td>
&lt;td>sysctl&lt;/td>
&lt;td>jitter from page migration&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>explicit 1G hugepages&lt;/td>
&lt;td>boot param&lt;/td>
&lt;td>TLB on host buffers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>THP &lt;code>madvise&lt;/code>/&lt;code>never&lt;/code>&lt;/td>
&lt;td>sysfs&lt;/td>
&lt;td>compaction spikes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>&lt;code>isolcpus&lt;/code>&lt;/td>
&lt;td>boot param&lt;/td>
&lt;td>scheduler off the critical cores&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>&lt;code>nohz_full&lt;/code>+&lt;code>rcu_nocbs&lt;/code>&lt;/td>
&lt;td>boot param&lt;/td>
&lt;td>tick + RCU jitter&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>IRQ affinity&lt;/td>
&lt;td>&lt;code>/proc/irq&lt;/code>&lt;/td>
&lt;td>device interrupts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>&lt;code>performance&lt;/code> governor&lt;/td>
&lt;td>cpupower&lt;/td>
&lt;td>frequency wakeup latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>&lt;code>swappiness=0&lt;/code> + memlock&lt;/td>
&lt;td>sysctl/ulimit&lt;/td>
&lt;td>page faults on the hot path&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>&lt;strong>With the interconnect (previous post).&lt;/strong> The NCCL host threads want cores local to their GPU; and for multi-node, the RDMA NIC must be on the &lt;code>PIX&lt;/code> path with its GPU (knob 1). A GPUDirect RDMA with the NIC under the other socket loses half its advantage. NUMA and NVLink are the same story seen from the host and from the cable.&lt;/p>
&lt;p>&lt;strong>With vLLM and decode.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Decode is latency-bound&lt;/a>: the host thread feeding the kernel queue is exactly the one CPU isolation protects. And vLLM&amp;rsquo;s &lt;code>--cpu-offload-gb&lt;/code> puts host memory on the hot path, where NUMA locality plus hugepages (knobs 2, 4) go from &amp;ldquo;nice&amp;rdquo; to &amp;ldquo;critical&amp;rdquo;. &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> helps here too: more tokens per iteration amortise both the collective latency and the fixed cost of the host launches.&lt;/p>
&lt;p>&lt;strong>With Kubernetes (next post).&lt;/strong> Everything in this post is done &lt;strong>by hand&lt;/strong> (numactl, taskset, boot parameters). In production it is not done by hand: the kubelet automates it with CPU Manager, Memory Manager and Topology Manager. The &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">next post&lt;/a> is exactly how this is declared so that every vLLM pod is born pinned to the right NUMA node, with no scripts.&lt;/p>
&lt;p>&lt;strong>With observability.&lt;/strong> The p99 spikes from jitter or from remote access &lt;strong>are visible&lt;/strong>: in DCGM, low GPU utilisation with a full queue (bubbles); in system metrics, inter-socket traffic and CPU migrations. &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> is where you diagnose a &amp;ldquo;the GPU is at 60 % and I do not know why&amp;rdquo; that many times is the host waiting.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> Reserving cores for the system (housekeeping) and dedicating the rest to inference changes the calculation of how many pods/replicas fit per node. &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> has to count those reserved cores, not assume that all 128 vCPUs are available for serving.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>Cargo cult from low-latency trading.&lt;/strong> Many &lt;code>isolcpus&lt;/code>/&lt;code>nohz_full&lt;/code> guides come from HFT, where the last microsecond is squeezed out. In LLM inference, CPU isolation &lt;strong>does&lt;/strong> help on the decode tail, but do not expect a miracle: if your bottleneck is HBM bandwidth or the interconnect, isolating cores does not move the needle. Measure first; apply it where the host is the limit.&lt;/p>
&lt;p>&lt;strong>Isolating without pinning.&lt;/strong> &lt;code>isolcpus&lt;/code> takes the cores out of the scheduler, but if you do not pin the inference threads there, those cores stay empty and inference runs on the housekeeping ones, worse than before. Isolating and pinning always go together.&lt;/p>
&lt;p>&lt;strong>Forgotten &lt;code>--membind&lt;/code>.&lt;/strong> Pinning cores but not memory (&lt;code>--cpunodebind&lt;/code> without &lt;code>--membind&lt;/code>) lets the pages scatter to the other node under pressure. Memory pinning is the half that gets forgotten most and the one that pays off most.&lt;/p>
&lt;p>&lt;strong>THP &lt;code>always&lt;/code> &amp;ldquo;because it saves TLB&amp;rdquo;.&lt;/strong> It saves TLB and throws in latency spikes from compaction. For workloads with a tail SLO, it is a bad trade. &lt;code>madvise&lt;/code>/&lt;code>never&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>nohz_full&lt;/code> with two threads on the core.&lt;/strong> Tickless only works with a single runnable thread per core. If you pin two inference threads to the same isolated core, the tick comes back and you have complicated the kernel boot for nothing.&lt;/p>
&lt;p>&lt;strong>Assuming the topology instead of reading it.&lt;/strong> Different servers wire GPUs and NICs to different sockets. &lt;code>nvidia-smi topo -m&lt;/code> and &lt;code>numactl --hardware&lt;/code> are the truth; the manufacturer&amp;rsquo;s diagram is indicative. Read it on &lt;strong>every&lt;/strong> node model.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>The GPU is the expensive resource, but in decode it spends a surprising part of its time waiting on the host: waiting for it to launch the next kernel, to sample, to move a buffer. If that host is on the wrong floor (remote NUMA), interrupted (kernel jitter) or searching a giant index (4 KB pages), the GPU is left with an empty queue and p99 blows up, without any API dashboard saying why. Of the ten knobs, the first (&lt;strong>reading the map with &lt;code>nvidia-smi topo -m&lt;/code>&lt;/strong>) and the second (&lt;strong>pinning cores and memory to the local node with &lt;code>--membind&lt;/code>&lt;/strong>) solve most of it; CPU isolation (&lt;code>isolcpus&lt;/code>/&lt;code>nohz_full&lt;/code>/IRQ affinity) is the second layer, the one that trims the decode tail, and it makes sense &lt;strong>where the host is the limit&lt;/strong>, not as a ritual. The idea that reorders the intuition: inference is not &amp;ldquo;all GPU&amp;rdquo;; it is a dance between GPU and host, and the host dances better close by, uninterrupted, and with few large pages. The next post shows how Kubernetes choreographs that dance for every pod without a single hand-written script.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the PCIe affinity that &lt;code>nvidia-smi topo -m&lt;/code> prints &lt;em>is&lt;/em> the NUMA affinity of this post; placing NIC and NVMe on the right socket avoids the SYS path.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token crosses&lt;/a> — the previous post in the series; the NCCL host threads and the RDMA NIC want the same NUMA locality explained here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers: how the kubelet pins NUMA for you&lt;/a> — the next post; the declarative automation of everything done here by hand with numactl and isolcpus.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the complete building; this post is the basement (host/kernel) everything rests on.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Decode optimisations in vLLM&lt;/a> — the latency-bound phase where host jitter turns into a p99 tail and where CPU isolation pays off.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — batching amortises the fixed cost of the host launches as well as that of the collectives.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — what a GPU &amp;ldquo;at 60 % for no reason&amp;rdquo; looks like when it is really the host waiting: bubbles, CPU migrations, inter-socket traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — why you have to subtract the reserved housekeeping cores from the compute budget per node.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — NUMA affinity gets more complicated (and more important) when the node mixes GPUs, accelerators and heterogeneous NICs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — the NIC&amp;rsquo;s IRQ affinity is a third list to reconcile with &lt;code>isolcpus&lt;/code> and &lt;code>reserved-cpus&lt;/code>; the &lt;code>NET_RX&lt;/code> softirq is this same jitter coming in over the network.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the host buffer of the weight loading wants to be &lt;em>pinned&lt;/em> and NUMA-local, exactly what is asked here for the hot path; it is another client of the same NUMA map.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — &lt;em>who&lt;/em> launches the kernels is the host thread of this post; its jitter is the launch overhead that CUDA graphs come to reduce.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Erik Rigtorp, &lt;em>Low Latency Tuning Guide&lt;/em> (isolcpus, nohz_full, IRQ affinity, THP): &lt;a href="https://rigtorp.se/low-latency-guide/">https://rigtorp.se/low-latency-guide/&lt;/a>.&lt;/li>
&lt;li>Red Hat, &lt;em>Usage, constraints and implications of isolcpus=, nohz_full= and rcu_nocbs=&lt;/em>: &lt;a href="https://access.redhat.com/articles/3720611">https://access.redhat.com/articles/3720611&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>nvidia-smi topo -m&lt;/em> and the GPU–NUMA–NIC affinity matrix (CUDA docs / Developer Forums): &lt;a href="https://forums.developer.nvidia.com/t/nvidia-smi-topo-m-revisited/216584">https://forums.developer.nvidia.com/t/nvidia-smi-topo-m-revisited/216584&lt;/a>.&lt;/li>
&lt;li>Chaim Rand, &lt;em>The Crucial Role of NUMA Awareness in High-Performance Deep Learning&lt;/em>: &lt;a href="https://chaimrand.medium.com/the-crucial-role-of-numa-awareness-in-high-performance-deep-learning-99ae3e8eb49a">https://chaimrand.medium.com/the-crucial-role-of-numa-awareness-in-high-performance-deep-learning-99ae3e8eb49a&lt;/a>.&lt;/li>
&lt;li>SUSE Labs, &lt;em>CPU Isolation – nohz_full (part 3)&lt;/em>: &lt;a href="https://www.suse.com/c/cpu-isolation-nohz_full-part-3/">https://www.suse.com/c/cpu-isolation-nohz_full-part-3/&lt;/a>.&lt;/li>
&lt;li>Linux kernel, &lt;em>Automatic NUMA Balancing&lt;/em> and &lt;em>Transparent Hugepage Support&lt;/em> (Documentation/admin-guide): &lt;a href="https://docs.kernel.org/admin-guide/mm/transhuge.html">https://docs.kernel.org/admin-guide/mm/transhuge.html&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>The shared bench: NVLink, NVSwitch and NCCL, the cable every token crosses in tensor parallel</title><link>https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/</link><pubDate>Sat, 06 Jun 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/</guid><description>&lt;blockquote>
&lt;p>This post goes one floor below the engine. In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer inference stack&lt;/a> and in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a> the decision was &lt;em>how many&lt;/em> GPUs and &lt;em>how&lt;/em> to split the model; here we explain the cable that makes that split work, or that strangles it. It is the first of a mini-series &amp;ldquo;below the engine&amp;rdquo;: interconnect (this one) → kernel and NUMA → Kubernetes resource managers.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>&lt;strong>Tensor parallelism (TP) does not split a model into four chunks that run on their own.&lt;/strong> It spreads each layer across the GPUs, but after attention and after the MLP the GPUs have to &lt;strong>add up their partial results&lt;/strong> with an &lt;em>all-reduce&lt;/em>. On a Llama-70B with 80 layers, that is around 160 all-reduces &lt;strong>per generated token&lt;/strong>. That all-reduce travels over the interconnect, so the interconnect sits on the &lt;strong>critical path of every token&lt;/strong>, not in the background plumbing. On an HGX H100 baseboard, the 8 GPUs all talk to each other at &lt;strong>900 GB/s bidirectional&lt;/strong> via four NVSwitch chips; without NVSwitch/NVLink, that same traffic falls back to the CPU over PCIe and loses an order of magnitude. NCCL is the library that decides how each collective is done (ring, tree, or NVLS = NVLink-SHARP, which offloads the summation to the switch itself). And there is an asymmetry almost nobody keeps in mind: &lt;strong>decode is latency-bound&lt;/strong> (tiny messages, 16 KB) and &lt;strong>prefill is bandwidth-bound&lt;/strong> (huge batched activations), which is why &amp;ldquo;more NVLink bandwidth&amp;rdquo; speeds up prefill but barely touches token-by-token decode. This post explains the mechanism, gives the 10 real NCCL/driver knobs worth touching, and connects with vLLM&amp;rsquo;s custom all-reduce, disaggregated serving and GPU observability. With scepticism about which levers move the needle.&lt;/p>
&lt;h2 id="where-you-are-the-floor-below-the-engine">Where you are: the floor below the engine&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Stack layers: you are at the interconnect, below the engine">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">The vertical stack · you are at the cable&lt;/text>
&lt;rect x="120" y="40" width="320" height="40" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="65" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Model · weights, quantisation, KV cache&lt;/text>
&lt;rect x="120" y="86" width="320" height="40" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="111" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Engine · vLLM / SGLang (TP, PP, batching)&lt;/text>
&lt;rect x="120" y="132" width="320" height="40" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="157" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">CUDA · kernels + NCCL (collectives)&lt;/text>
&lt;rect x="120" y="178" width="320" height="52" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="200" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">YOU ARE HERE · NVLink + NVSwitch&lt;/text>
&lt;text x="280" y="218" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">the physical interconnect between GPUs&lt;/text>
&lt;rect x="120" y="236" width="320" height="40" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="261" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#333">Hardware · H100 SXM GPU, HBM3, SM&lt;/text>
&lt;text x="280" y="298" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#777">every all-reduce from the engine above crosses this layer, per token&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-four-mechanics-and-a-single-bench">The analogy: four mechanics and a single bench&lt;/h2>
&lt;p>Four mechanics assemble one and the same car engine. It is not that each builds their own engine in parallel, that would be four cars (four replicas of the model, a different strategy). Here they build &lt;strong>a single one, at the same time&lt;/strong>, splitting the parts: one does the pistons, another the cylinder head, another the crankshaft. The problem is that the parts fit into each other: before going on, &lt;strong>all four have to put together what they have and check that it matches&lt;/strong>. That &amp;ldquo;put together and check&amp;rdquo; happens dozens of times during the build.&lt;/p>
&lt;p>There are two ways to organise the workshop:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>A single large bench, everyone around it (NVSwitch).&lt;/strong> Each mechanic reaches out and hands their part directly to any other, all at once, without standing up. It is instant and simultaneous. This is &lt;strong>NVLink + NVSwitch&lt;/strong>: the GPUs form an &lt;em>all-to-all&lt;/em> where anyone talks to anyone at 900 GB/s at the same time.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Four separate workshops with a courier (PCIe via CPU).&lt;/strong> Every part a mechanic wants to hand to another goes into a box, goes down to reception (host memory, via CPU), and from there up to the destination workshop. Slower, and serialised by reception. This is what happens when &lt;strong>there is no NVLink&lt;/strong>: inter-GPU traffic falls back to PCIe and bounces through the CPU, around 14× slower than NVLink.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>The thesis of the post follows on its own: &lt;strong>tensor parallelism only makes sense if the mechanics share the bench.&lt;/strong> As soon as the &amp;ldquo;put together and check&amp;rdquo; (the all-reduce) has to go through reception, splitting the work costs more than it saves. That is why, on a serious platform, TP &lt;strong>does not cross the NVLink boundary&lt;/strong>: TP=4 or 8 &lt;em>inside&lt;/em> the baseboard where there is NVSwitch, and above that you replicate or use pipeline, never stretch TP over PCIe or over the network. When each option makes sense is covered in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a>; here we explain &lt;em>why&lt;/em> the cable dictates that decision.&lt;/p>
&lt;h2 id="the-mechanism-what-an-all-reduce-really-is-and-why-there-are-160-per-token">The mechanism: what an all-reduce really is and why there are 160 per token&lt;/h2>
&lt;p>Tensor parallelism splits the weight matrices by columns/rows across the $N$ GPUs. Each GPU computes a portion of the layer output. But the next operation needs the complete output, so it has to be recombined. That recombination is a collective operation: an &lt;code>all-reduce&lt;/code>, which adds the partial tensors of all the GPUs element by element and leaves the result &lt;strong>identical on all of them&lt;/strong>.&lt;/p>
&lt;p>In a standard transformer block there are &lt;strong>two synchronisation points per layer&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>After the output projection of attention (the &lt;code>o_proj&lt;/code> that recombines the split heads).&lt;/li>
&lt;li>After the second matrix of the MLP (the &lt;code>down_proj&lt;/code> that recombines the split feed-forward).&lt;/li>
&lt;/ol>
$$ \text{all-reduces per token} = 2 \times L_{\text{layers}} $$
&lt;p>For a Llama-70B ($L = 80$): $2 \times 80 = 160$ all-reduces &lt;strong>per generated token&lt;/strong>. Not per request, not per sequence: &lt;strong>per token&lt;/strong>. Multiply by decode throughput and you understand why the interconnect is not background infrastructure but a hot path.&lt;/p>
&lt;div class="diagram" style="max-width:800px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="All-reduce in the decode loop of tensor parallel">
&lt;defs>&lt;marker id="nva" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="400" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">One transformer layer at TP=4 · two all-reduces per layer&lt;/text>
&lt;!-- 4 GPUs -->
&lt;g>
&lt;rect x="40" y="50" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="75" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU0 · ¼ heads&lt;/text>
&lt;rect x="40" y="98" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="123" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU1 · ¼ heads&lt;/text>
&lt;rect x="40" y="146" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="171" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU2 · ¼ heads&lt;/text>
&lt;rect x="40" y="194" width="150" height="40" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="1.4"/>&lt;text x="115" y="219" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">GPU3 · ¼ heads&lt;/text>
&lt;/g>
&lt;!-- AllReduce 1 -->
&lt;rect x="240" y="70" width="120" height="144" rx="9" fill="#f7efda" stroke="#c79a32" stroke-width="2"/>
&lt;text x="300" y="130" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">ALL-REDUCE&lt;/text>
&lt;text x="300" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">attn o_proj&lt;/text>
&lt;text x="300" y="164" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#777">(NVLink)&lt;/text>
&lt;!-- MLP -->
&lt;rect x="410" y="70" width="120" height="144" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="470" y="135" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">MLP&lt;/text>
&lt;text x="470" y="152" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">(split)&lt;/text>
&lt;!-- AllReduce 2 -->
&lt;rect x="580" y="70" width="120" height="144" rx="9" fill="#f7efda" stroke="#c79a32" stroke-width="2"/>
&lt;text x="640" y="130" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">ALL-REDUCE&lt;/text>
&lt;text x="640" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">mlp down_proj&lt;/text>
&lt;text x="640" y="164" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#777">(NVLink)&lt;/text>
&lt;!-- next layer -->
&lt;rect x="720" y="98" width="60" height="88" rx="6" fill="#f4f4f4" stroke="#888" stroke-width="1.2"/>
&lt;text x="750" y="138" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#333">layer&lt;/text>
&lt;text x="750" y="152" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#333">i+1&lt;/text>
&lt;path d="M190,142 L240,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;path d="M360,142 L410,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;path d="M530,142 L580,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;path d="M700,142 L720,142" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#nva)"/>
&lt;text x="400" y="262" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#444">× 80 layers = 160 all-reduces per token · each one crosses the interconnect&lt;/text>
&lt;text x="400" y="282" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-style="italic" fill="#999">if the cable is slow, decode collapses — the engine waits on the cable&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="how-the-all-reduce-is-done-ring-tree-nvls">How the all-reduce is done: ring, tree, NVLS&lt;/h3>
&lt;p>NCCL does not have a single way of doing an all-reduce; it picks an algorithm according to topology and message size:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ring.&lt;/strong> The GPUs form a ring; each one passes a chunk to its neighbour, adds, and rotates. It takes $2(N-1)$ steps. It is &lt;strong>bandwidth-optimal&lt;/strong> for large messages: the cost of moving the data is $\frac{2(N-1)}{N} \times M$ bytes over the link, almost independent of $N$. The downside: $2(N-1)$ latency hops, bad for small messages.&lt;/li>
&lt;li>&lt;strong>Tree.&lt;/strong> Tree reduction: $\log N$ levels. &lt;strong>Better latency&lt;/strong> for small messages and many nodes, worse bandwidth utilisation.&lt;/li>
&lt;li>&lt;strong>NVLS (NVLink SHARP).&lt;/strong> The Hopper trick: the summation &lt;strong>is not done by the GPUs, it is done by the NVSwitch&lt;/strong>. The switch has reduction units; the GPUs send their tensors, the switch adds them in flight and returns the result. It takes work away from the GPUs (frees up SMs) and reduces hops. Available &lt;strong>only with a 3rd-generation NVSwitch (NVLink4) plus Hopper or newer&lt;/strong>.&lt;/li>
&lt;/ul>
&lt;p>The mental rule: &lt;strong>decode (tiny messages) wants latency → tree/LL or vLLM&amp;rsquo;s custom kernel; prefill (huge messages) wants bandwidth → ring/NVLS&lt;/strong>. That is why there is no global &amp;ldquo;optimal NCCL_ALGO&amp;rdquo;; it depends on which phase you are looking at.&lt;/p>
&lt;h2 id="the-maths-that-matter-why-decode-and-prefill-stress-the-cable-the-other-way-round">The maths that matter: why decode and prefill stress the cable the other way round&lt;/h2>
&lt;p>Here is the asymmetry almost everyone skips. The size of the tensor that gets all-reduced in each layer is, approximately:&lt;/p>
$$ M \approx B \times S \times h \times 2\ \text{bytes (BF16)} $$
&lt;p>where $B$ = batch, $S$ = tokens processed in this forward pass, $h$ = hidden size.&lt;/p>
&lt;p>&lt;strong>In decode&lt;/strong>, you generate &lt;strong>1 token per sequence&lt;/strong> per iteration. For a single sequence ($B \times S = 1$) and $h = 8192$ (Llama-70B):&lt;/p>
$$ M_{\text{decode}} \approx 1 \times 8192 \times 2 = 16\ \text{KB per all-reduce} $$
&lt;p>16 KB is tiny. At 900 GB/s, moving 16 KB takes around 18 nanoseconds of pure transfer, but the real cost is dominated by the &lt;strong>launch latency of the collective&lt;/strong> (synchronisation, kernel launch), of the order of single-digit microseconds. With 160 all-reduces per token:&lt;/p>
$$ t_{\text{comms/token}} \approx 160 \times (5\text{–}10\,\mu s) \approx 0.8\text{–}1.6\ \text{ms} $$
&lt;p>That is the communication floor per token, &lt;strong>independent of bandwidth&lt;/strong>. An uncomfortable and counter-intuitive implication: &lt;strong>buying more NVLink bandwidth does not speed up token-by-token decode of a single sequence.&lt;/strong> What helps in decode is &lt;strong>lowering the latency per collective&lt;/strong> (LL protocol, vLLM&amp;rsquo;s custom all-reduce, NVLS to remove hops) and batching (raising $B$ amortises the fixed latency over more tokens, the deep reason continuous batching exists, covered in &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>).&lt;/p>
&lt;p>&lt;strong>In prefill&lt;/strong>, you process the whole prompt at once: $S$ can be thousands of tokens, and with batching $B \times S$ reaches tens of thousands. There:&lt;/p>
$$ M_{\text{prefill}} \approx 8000 \times 8192 \times 2 \approx 131\ \text{MB per all-reduce} $$
&lt;p>131 MB does stress the bandwidth. At 900 GB/s (NVSwitch) the ring all-reduce moves $\frac{2 \cdot 3}{4} \times 131 \approx 196$ MB effective in around 0.22 ms; over PCIe (around 64 GB/s aggregate, bouncing through the CPU) it would be &lt;strong>around 3 ms and serialised&lt;/strong>. Here the cable is the bottleneck and NVLS/bandwidth rule.&lt;/p>
&lt;p>Summary in one line: &lt;strong>prefill is bandwidth-bound, decode is latency-bound.&lt;/strong> Any interconnect tuning that does not say which phase it helps is noise.&lt;/p>
&lt;h2 id="the-hardware-nvlink-4-and-nvswitch-on-the-hgx-baseboard">The hardware: NVLink 4 and NVSwitch on the HGX baseboard&lt;/h2>
&lt;p>On the generic reference cluster, &lt;strong>4×H100 SXM&lt;/strong> inside an HGX baseboard, the concrete figures:&lt;/p>
&lt;ul>
&lt;li>Each H100 SXM5 has &lt;strong>18 NVLink 4 links&lt;/strong>, each one 50 GB/s bidirectional ⇒ &lt;strong>900 GB/s bidirectional aggregate per GPU&lt;/strong>. That is &lt;strong>&amp;gt;14× the bandwidth of a PCIe Gen4 x16&lt;/strong> (around 64 GB/s bidir).&lt;/li>
&lt;li>On an 8-GPU HGX H100 baseboard, the 18 links of each GPU are spread against &lt;strong>four 3rd-generation NVSwitch&lt;/strong> chips (grouped 5+4+4+5). The result is all-to-all: any GPU talks to any other at 900 GB/s simultaneously, without going through CPU or PCIe.&lt;/li>
&lt;li>A 4-GPU baseboard is half a board: same principle, via NVSwitch. &lt;strong>Design key&lt;/strong>: if your 4 H100s are connected by NVSwitch, you have real all-to-all; if they are on &lt;strong>different boards connected by PCIe&lt;/strong> (some &amp;ldquo;4×PCIe&amp;rdquo; configurations), &lt;strong>you do not have NVLink between all of them&lt;/strong> and TP=4 suffers. Verify it, do not assume it.&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:800px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="NVSwitch all-to-all vs PCIe through the CPU">
&lt;defs>&lt;marker id="nvt" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#3c8c54"/>&lt;/marker>&lt;/defs>
&lt;text x="200" y="24" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">With NVSwitch · all-to-all 900 GB/s&lt;/text>
&lt;circle cx="120" cy="80" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="120" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G0&lt;/text>
&lt;circle cx="280" cy="80" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="280" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G1&lt;/text>
&lt;circle cx="120" cy="220" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="120" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G2&lt;/text>
&lt;circle cx="280" cy="220" r="26" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>&lt;text x="280" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G3&lt;/text>
&lt;rect x="170" y="130" width="60" height="40" rx="6" fill="#f7efda" stroke="#c79a32" stroke-width="1.8"/>&lt;text x="200" y="155" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">NVSw&lt;/text>
&lt;path d="M138,98 L180,135" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M262,98 L220,135" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M138,202 L180,165" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M262,202 L220,165" stroke="#3c8c54" stroke-width="1.6" fill="none"/>
&lt;path d="M120,106 L120,194" stroke="#3c8c54" stroke-width="1.2" fill="none" stroke-dasharray="3 2"/>
&lt;path d="M280,106 L280,194" stroke="#3c8c54" stroke-width="1.2" fill="none" stroke-dasharray="3 2"/>
&lt;line x1="420" y1="40" x2="420" y2="270" stroke="#ccc" stroke-width="1"/>
&lt;text x="610" y="24" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#a85454">No NVLink · PCIe via CPU (around 14× slower)&lt;/text>
&lt;circle cx="530" cy="80" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="530" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G0&lt;/text>
&lt;circle cx="690" cy="80" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="690" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G1&lt;/text>
&lt;circle cx="530" cy="220" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="530" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G2&lt;/text>
&lt;circle cx="690" cy="220" r="26" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>&lt;text x="690" y="224" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">G3&lt;/text>
&lt;rect x="580" y="130" width="60" height="40" rx="6" fill="#e8e8e8" stroke="#888" stroke-width="1.6"/>&lt;text x="610" y="155" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#333">CPU&lt;/text>
&lt;path d="M548,98 L590,135" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;path d="M672,98 L630,135" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;path d="M548,202 L590,165" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;path d="M672,202 L630,165" stroke="#b35454" stroke-width="1.4" fill="none"/>
&lt;text x="610" y="292" text-anchor="middle" font-family="sans-serif" font-size="10" font-style="italic" fill="#999">all inter-GPU traffic bounces through host memory, serialised&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-10-knobs-worth-touching">The 10 knobs worth touching&lt;/h2>
&lt;p>Almost all of them are NCCL environment variables (injected into the inference engine process) or driver settings. Ordered by impact/frequency in an on-premise deployment. The canonical detail is in the &lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html">NCCL env vars docs&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--nccl_debug--topology-dump-see-what-is-happening-before-touching-anything">Knob 1 — &lt;code>NCCL_DEBUG&lt;/code> + topology dump: see what is happening before touching anything&lt;/h3>
&lt;p>Do not optimise blind: &lt;strong>first confirm which topology and algorithms NCCL picked&lt;/strong>. This tells you whether it really is using NVLink or whether, silently, it fell back to PCIe/SHM, the number one failure and the most expensive one.&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="nv">NCCL_DEBUG&lt;/span>&lt;span class="o">=&lt;/span>INFO &lt;span class="c1"># prints topology, rings/trees built, transport chosen&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_DEBUG_SUBSYS&lt;/span>&lt;span class="o">=&lt;/span>GRAPH,TUNING,NET &lt;span class="c1"># narrows it down to what matters&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># look in the log for: &amp;#34;via NVLink&amp;#34; / &amp;#34;via P2P&amp;#34; (good) vs &amp;#34;via SHM&amp;#34; / &amp;#34;via PCI&amp;#34; (bad)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you see &lt;code>via SHM&lt;/code> or &lt;code>via PCI&lt;/code> between GPUs that should have NVLink, &lt;strong>you have a topology problem&lt;/strong> (PCIe ACS enabled, IOMMU, GPUs on different boards) and no other knob fixes it. This is knob 1 for a reason: half of the &amp;ldquo;NVLink is slow&amp;rdquo; cases are &amp;ldquo;NVLink is not being used&amp;rdquo;.&lt;/p>
&lt;h3 id="knob-2--nccl_algo-ring-vs-tree-vs-nvls">Knob 2 — &lt;code>NCCL_ALGO&lt;/code>: ring vs tree vs NVLS&lt;/h3>
&lt;p>Forces or excludes algorithms. By default NCCL picks according to size, and it usually gets it right; touch it only with a measurement in front of you.&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="nv">NCCL_ALGO&lt;/span>&lt;span class="o">=&lt;/span>NVLS,Tree,Ring &lt;span class="c1"># order of preference&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_ALGO&lt;/span>&lt;span class="o">=&lt;/span>^Ring &lt;span class="c1"># exclude Ring (^ prefix)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Rule: prefill/training (bandwidth) ⇒ Ring/NVLS; decode (latency) ⇒ Tree or, better, vLLM&amp;rsquo;s custom kernel (knob 10/stack). In most inference workloads, &lt;strong>leaving it on auto and validating with knob 1 is the right thing&lt;/strong>; forcing it &amp;ldquo;just in case&amp;rdquo; usually makes things worse.&lt;/p>
&lt;h3 id="knob-3--nccl_proto-ll--ll128--simple">Knob 3 — &lt;code>NCCL_PROTO&lt;/code>: LL / LL128 / Simple&lt;/h3>
&lt;p>The protocol controls the latency/bandwidth trade-off at low level:&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="nv">NCCL_PROTO&lt;/span>&lt;span class="o">=&lt;/span>Simple &lt;span class="c1"># maximum bandwidth, more latency (large messages)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_PROTO&lt;/span>&lt;span class="o">=&lt;/span>LL &lt;span class="c1"># low-latency, half-bandwidth (tiny messages: decode)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_PROTO&lt;/span>&lt;span class="o">=&lt;/span>LL128 &lt;span class="c1"># compromise, default on platforms that support it&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>LL&lt;/code> (low-latency) uses flags instead of barriers and wins on the 16 KB messages of decode; &lt;code>Simple&lt;/code> wins on the 131 MB of prefill. The default &lt;code>LL,LL128,Simple&lt;/code> lets NCCL choose by size, which again is normally the best option.&lt;/p>
&lt;h3 id="knob-4--nccl_nvls_enable-offload-the-summation-to-the-nvswitch">Knob 4 — &lt;code>NCCL_NVLS_ENABLE&lt;/code>: offload the summation to the NVSwitch&lt;/h3>
&lt;p>NVLink SHARP (NVLS) makes the switch do the reduction, freeing up GPU SMs:&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="nv">NCCL_NVLS_ENABLE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span> &lt;span class="c1"># default: ON where there is an NVLink4+ NVSwitch (Hopper)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>An important sceptical caveat&lt;/strong>: NVLS &lt;strong>requires NVSwitch&lt;/strong> (3rd gen, NVLink4). On a node with NVLink through direct GPU-to-GPU &lt;em>bridges&lt;/em> (no switch) or on 4×PCIe, &lt;strong>NVLS is not available&lt;/strong> and this knob does nothing. Before &amp;ldquo;enabling it&amp;rdquo;, confirm with knob 1 that your topology has a switch. Where it does apply, its biggest advantage is freeing SMs for compute, which is relevant when comms and kernels compete (knob 5).&lt;/p>
&lt;h3 id="knob-5--nccl_min_nchannels--nccl_max_nchannels-how-many-sms-communication-steals">Knob 5 — &lt;code>NCCL_MIN_NCHANNELS&lt;/code> / &lt;code>NCCL_MAX_NCHANNELS&lt;/code>: how many SMs communication steals&lt;/h3>
&lt;p>Each NCCL &amp;ldquo;channel&amp;rdquo; consumes GPU SMs to move data. More channels = more collective bandwidth, but &lt;strong>fewer SMs for the inference kernel&lt;/strong>. It is a split of a fixed resource.&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="nv">NCCL_MIN_NCHANNELS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_MAX_NCHANNELS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">16&lt;/span> &lt;span class="c1"># raising it helps prefill (bandwidth); steals SMs from decode&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In decode, where the GPU is under-used on compute but tied to latency, trimming channels rarely hurts and sometimes helps; in prefill, more channels squeeze out the bandwidth. A measurement knob, not a faith knob.&lt;/p>
&lt;h3 id="knob-6--nccl_buffsize-the-buffer-size-per-channel">Knob 6 — &lt;code>NCCL_BUFFSIZE&lt;/code>: the buffer size per channel&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">&lt;span class="nv">NCCL_BUFFSIZE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">8388608&lt;/span> &lt;span class="c1"># 8 MB (default 4 MB); larger buffers → better BW on large messages&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Raising it helps bandwidth-bound prefill at the cost of memory per channel. For workloads dominated by small messages (pure decode), the default is plenty.&lt;/p>
&lt;h3 id="knob-7--nccl_p2p_level--nccl_p2p_disable-guarantee-p2p-over-nvlink">Knob 7 — &lt;code>NCCL_P2P_LEVEL&lt;/code> / &lt;code>NCCL_P2P_DISABLE&lt;/code>: guarantee P2P over NVLink&lt;/h3>
&lt;p>P2P is what lets one GPU read another&amp;rsquo;s memory directly over NVLink without going through the host. If it is disabled or degraded, traffic falls back to SHM/PCIe.&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="nv">NCCL_P2P_LEVEL&lt;/span>&lt;span class="o">=&lt;/span>NVL &lt;span class="c1"># use P2P up to the NVLink level&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NCCL_P2P_DISABLE=1 ← only as a workaround if P2P HANGS (multi-NUMA PCIe, certain Blackwell)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Watch out for the trap: &lt;code>NCCL_P2P_DISABLE=1&lt;/code> and &lt;code>--disable-custom-all-reduce&lt;/code> are recommended as a &lt;strong>patch&lt;/strong> when vLLM hangs on PCIe-only multi-NUMA topologies. It is a &lt;strong>robustness patch that sacrifices performance&lt;/strong>: use it if it hangs, never &amp;ldquo;by default&amp;rdquo;.&lt;/p>
&lt;h3 id="knob-8--gpudirect-rdma-for-multi-node-nccl_net_gdr_level">Knob 8 — GPUDirect RDMA for multi-node: &lt;code>NCCL_NET_GDR_LEVEL&lt;/code>&lt;/h3>
&lt;p>When TP fits in one node, this does not apply. When you have to cross nodes (huge model, pipeline parallel between baseboards), GPUDirect RDMA lets the GPU talk to the NIC &lt;strong>without bouncing through host memory&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">&lt;span class="nv">NCCL_NET_GDR_LEVEL&lt;/span>&lt;span class="o">=&lt;/span>PHB &lt;span class="c1"># enables GDR according to GPU–NIC proximity on the PCIe bus&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without GDR, every inter-node hop adds a host copy. With InfiniBand/RoCE plus GDR, the KV or the activations travel GPU→NIC→network→NIC→GPU. It is the basis of serious multi-node work and of &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed environments&lt;/a>.&lt;/p>
&lt;h3 id="knob-9--nccl_ib_hca--nccl_socket_ifname-pin-the-right-nic">Knob 9 — &lt;code>NCCL_IB_HCA&lt;/code> / &lt;code>NCCL_SOCKET_IFNAME&lt;/code>: pin the right NIC&lt;/h3>
&lt;p>The most common and silent multi-node mistake: NCCL picks the &lt;strong>management NIC&lt;/strong> (1 GbE) instead of the fabric one (InfiniBand/100 GbE). Result: collectives at a snail&amp;rsquo;s pace with no visible error.&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="nv">NCCL_SOCKET_IFNAME&lt;/span>&lt;span class="o">=&lt;/span>eth0 &lt;span class="c1"># control interface (bootstrap)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_IB_HCA&lt;/span>&lt;span class="o">=&lt;/span>mlx5_0,mlx5_1 &lt;span class="c1"># the real InfiniBand HCAs of the fabric&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">NCCL_IB_GID_INDEX&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span> &lt;span class="c1"># correct GID for RoCE v2&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Pin them explicitly. &amp;ldquo;Auto&amp;rdquo; gets it right on clean clusters and fails as soon as there is more than one NIC.&lt;/p>
&lt;h3 id="knob-10--driver-persistence-mode-clocks-and-nvlink-error-counters">Knob 10 — Driver: persistence mode, clocks and NVLink error counters&lt;/h3>
&lt;p>Below NCCL, the driver has levers and, above all, &lt;strong>telemetry you have to look at&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">nvidia-smi -pm &lt;span class="m">1&lt;/span> &lt;span class="c1"># persistence mode: avoids driver re-init (latency/jitter)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi nvlink --status &lt;span class="c1"># are all 18 links active and at full speed?&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi nvlink -e &lt;span class="c1"># error/CRC counters per link&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">nvidia-smi -q -d ECC &lt;span class="c1"># memory errors that degrade silently&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>An NVLink link that negotiates at half speed or accumulates CRC errors degrades the all-reduce &lt;strong>without raising any error&lt;/strong>: the system &amp;ldquo;works&amp;rdquo;, it is just slower. These counters are the difference between diagnosing in five minutes and chasing a ghost for days. They are integrated in DCGM (knob/stack: observability).&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Variable / command&lt;/th>
&lt;th>Phase it helps&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Topology diagnosis&lt;/td>
&lt;td>&lt;code>NCCL_DEBUG=INFO&lt;/code> + &lt;code>SUBSYS=GRAPH&lt;/code>&lt;/td>
&lt;td>always, first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Collective algorithm&lt;/td>
&lt;td>&lt;code>NCCL_ALGO&lt;/code> (NVLS/Tree/Ring)&lt;/td>
&lt;td>depends on phase; auto usually wins&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Protocol&lt;/td>
&lt;td>&lt;code>NCCL_PROTO&lt;/code> (LL/LL128/Simple)&lt;/td>
&lt;td>LL=decode, Simple=prefill&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>NVLink SHARP&lt;/td>
&lt;td>&lt;code>NCCL_NVLS_ENABLE=1&lt;/code>&lt;/td>
&lt;td>prefill; frees SMs (requires NVSwitch)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>Channels (SMs)&lt;/td>
&lt;td>&lt;code>NCCL_MIN/MAX_NCHANNELS&lt;/code>&lt;/td>
&lt;td>+prefill bandwidth / −SM theft in decode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Buffer&lt;/td>
&lt;td>&lt;code>NCCL_BUFFSIZE&lt;/code>&lt;/td>
&lt;td>bandwidth-bound prefill&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>NVLink P2P&lt;/td>
&lt;td>&lt;code>NCCL_P2P_LEVEL=NVL&lt;/code>&lt;/td>
&lt;td>critical; disable only if it hangs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>GPUDirect RDMA&lt;/td>
&lt;td>&lt;code>NCCL_NET_GDR_LEVEL&lt;/code>&lt;/td>
&lt;td>multi-node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>Fabric NIC&lt;/td>
&lt;td>&lt;code>NCCL_IB_HCA&lt;/code>/&lt;code>SOCKET_IFNAME&lt;/code>&lt;/td>
&lt;td>multi-node (avoids mgmt NIC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>Driver + telemetry&lt;/td>
&lt;td>&lt;code>nvidia-smi -pm 1&lt;/code> / &lt;code>nvlink -e&lt;/code>&lt;/td>
&lt;td>jitter + silent diagnosis&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-it-connects-with-the-rest-of-the-stack">How it connects with the rest of the stack&lt;/h2>
&lt;p>The interconnect is not an island; it touches almost every layer above.&lt;/p>
&lt;p>&lt;strong>With vLLM, the custom all-reduce.&lt;/strong> vLLM does not always use NCCL: for the tiny messages of decode (&lt;code>world_size==2&lt;/code> or a fully-connected NVLink topology, below a certain &lt;code>max_size&lt;/code>) it uses its &lt;strong>own all-reduce kernel&lt;/strong> that beats NCCL on latency, exactly the decode bottleneck we saw in the maths. It falls back to NCCL for large messages and for topologies without NVLink (where its custom kernel &amp;ldquo;adds little over NCCL&amp;rdquo;). The flag &lt;code>--disable-custom-all-reduce&lt;/code> / &lt;code>VLLM_DISABLE_CUSTOM_ALL_REDUCE&lt;/code> turns it off; it is the patch for hangs on multi-NUMA PCIe. Translation: &lt;strong>the most effective decode latency knob is sometimes not an NCCL one, it is choosing well between vLLM&amp;rsquo;s custom kernel and NCCL.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>With TP vs replicas.&lt;/strong> Everything in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a> rests on this: high TP is only viable inside the NVLink domain. The boundary of &amp;ldquo;TP=4 or 4 replicas at TP=1?&amp;rdquo; is drawn by the cable: crossing NVLink with TP means paying for the all-reduce at PCIe prices.&lt;/p>
&lt;p>&lt;strong>With disaggregated serving.&lt;/strong> In &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode&lt;/a>, the KV cache generated in the prefill pool has to travel to the decode pool. That transfer is &lt;strong>another consumer of the interconnect&lt;/strong> (NVLink intra-node, GPUDirect RDMA inter-node) and competes with the all-reduces. Designing the disaggregation without counting the cost of transferring the KV is the classic trap.&lt;/p>
&lt;p>&lt;strong>With MoE.&lt;/strong> Mixture-of-Experts models add expert parallelism: an &lt;code>all-to-all&lt;/code> (not an all-reduce) that routes each token to its expert, possibly on another GPU. It is a different communication pattern and heavier on bandwidth; &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE in inference&lt;/a> lives or dies by the same cable, with an even more demanding collective.&lt;/p>
&lt;p>&lt;strong>With GPU observability.&lt;/strong> The NVLink counters (&lt;code>nvidia-smi nvlink -e&lt;/code>, TX/RX bytes per link, CRC errors) and NVSwitch utilisation are exposed via DCGM and land in Prometheus/Grafana. The question &amp;ldquo;is the interconnect healthy and saturated?&amp;rdquo; is answered there, alongside the rest of &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a>. A slow all-reduce shows up sooner in an NVLink error counter than in API latency.&lt;/p>
&lt;p>&lt;strong>With capacity planning.&lt;/strong> The &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">inference sizing&lt;/a> that assumes &amp;ldquo;TP=4 scales almost linearly&amp;rdquo; &lt;strong>only holds inside NVLink&lt;/strong>. Outside it, scaling efficiency collapses and the capacity plan lies. The cable is a parameter of the capacity model, not a detail.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;More NVLink bandwidth = faster decode.&amp;rdquo;&lt;/strong> False for a single sequence. Decode is latency-bound; bandwidth is barely touched by 16 KB messages. What speeds up decode is batching (amortising the fixed latency) and lowering latency per collective (LL, custom kernel, NVLS). Bandwidth rules in prefill.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I have 4 H100s, so I have NVLink between all four.&amp;rdquo;&lt;/strong> Not necessarily. There are configurations where the GPUs are on different boards joined by PCIe, or with NVLink bridges only in pairs. Confirm it with &lt;code>nvidia-smi nvlink --status&lt;/code> and knob 1 before planning TP=4. A TP=4 over P2P-over-PCIe performs far worse than the brochure says.&lt;/p>
&lt;p>&lt;strong>Forcing &lt;code>NCCL_ALGO&lt;/code>/&lt;code>NCCL_PROTO&lt;/code> &amp;ldquo;to go faster&amp;rdquo;.&lt;/strong> NCCL picks well by size in most cases. Forcing an algorithm without measuring usually makes one of the two phases worse. The correct sequence is: knob 1 (see what it is doing) → measure → touch only if there is evidence.&lt;/p>
&lt;p>&lt;strong>Disabling P2P/custom all-reduce by default.&lt;/strong> They are robustness patches for broken topologies (multi-NUMA PCIe, certain Blackwell). Leaving them on &amp;ldquo;for stability&amp;rdquo; on a node with healthy NVLink throws performance in the bin.&lt;/p>
&lt;p>&lt;strong>Stretching TP over the network.&lt;/strong> TP=8 crossing two nodes over InfiniBand because &amp;ldquo;there is bandwidth&amp;rdquo; ignores that the per-layer all-reduce now pays network latency ×160 per token. To cross nodes, pipeline parallel (which communicates once per micro-batch, not per layer) almost always wins. The communication pattern, not just the bandwidth, decides.&lt;/p>
&lt;p>&lt;strong>Ignoring the NVLink error counters.&lt;/strong> A degraded link does not raise an exception: the system works, it is just slow. Without watching &lt;code>nvlink -e&lt;/code> and ECC, you chase a performance ghost that a counter would have pointed out in five minutes.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Tensor parallelism sells a simple promise, split the model, multiply the VRAM, serve models that do not fit in one GPU, but the small print is that every layer forces the GPUs to come together and add up, twice, dozens of times per token. That all-reduce is the real hidden protagonist of performance, and it lives in the cable: NVLink does it on the shared bench of the NVSwitch at 900 GB/s, or PCIe drags it through the CPU&amp;rsquo;s reception desk 14× slower. Of the ten knobs, the first one, &lt;strong>looking with &lt;code>NCCL_DEBUG&lt;/code> at what is really happening&lt;/strong>, solves half the problems, because half of the &amp;ldquo;NVLink is slow&amp;rdquo; cases are &amp;ldquo;NVLink is not being used&amp;rdquo;. The rest are refinements that only mean something if you know &lt;strong>which phase&lt;/strong> you are in: prefill wants bandwidth (NVLS, Simple, channels, buffer), decode wants latency (LL, vLLM&amp;rsquo;s custom kernel, batching). And above all, an idea that reorders the intuition: in on-premise inference, the interconnect is not plumbing you install and forget, it is a hot path, a capacity parameter and, when it degrades silently, the root cause that no API dashboard will point out to you unless you look at the counters of the cable itself.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">The corridors and the guard: PCIe, GPUDirect P2P and ACS&lt;/a> — the &lt;em>other&lt;/em> bus in the node; what does not fit on the NVLink bench (disk, network, KV between nodes) travels over PCIe, and ACS decides whether GPUDirect goes straight through or bounces via the root complex.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — the complete building where the interconnect is the foundation the seven layers rest on; here that foundation is opened up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big replica or many small ones: TP and replicas&lt;/a> — the decision of how many GPUs and how to split the model; this post explains &lt;em>why&lt;/em> the NVLink limit draws that boundary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode separated&lt;/a> — moving the KV cache between pools is another consumer of the same interconnect that competes with the all-reduces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the deep reason batching speeds up decode is that it amortises the fixed all-reduce latency over more tokens.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Decode optimisations in vLLM&lt;/a> — the latency-bound phase where vLLM&amp;rsquo;s custom all-reduce and the LL protocol decide the TPS per sequence.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE in inference&lt;/a> — expert parallelism adds an even more demanding &lt;code>all-to-all&lt;/code> over the same cable.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> — where the NVLink and NVSwitch counters land to answer &amp;ldquo;is the interconnect healthy and saturated?&amp;rdquo;.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — why &amp;ldquo;TP scales almost linearly&amp;rdquo; is only true inside the NVLink domain, and how the cable enters the capacity model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — when you cross the node boundary, GPUDirect RDMA over InfiniBand/RoCE replaces NVLink as the medium of the collective.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — making that GPUDirect RDMA take the NUMA-local path (GPU and NIC on the same PCIe root) is exactly what DRA/DRANET co-schedules; +60% NCCL bus bandwidth when it is aligned.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — between the compute kernels the TP all-reduces are interleaved; vLLM&amp;rsquo;s custom all-reduce is integrated into the same CUDA graph so as not to break the sequence with a CPU synchronisation.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>NVIDIA Hopper Architecture In-Depth&lt;/em> (NVLink 4, 900 GB/s): &lt;a href="https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/">https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Introducing NVIDIA HGX H100&lt;/em> (4× NVSwitch, all-to-all): &lt;a href="https://developer.nvidia.com/blog/introducing-nvidia-hgx-h100-an-accelerated-server-platform-for-ai-and-high-performance-computing/">https://developer.nvidia.com/blog/introducing-nvidia-hgx-h100-an-accelerated-server-platform-for-ai-and-high-performance-computing/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NCCL Environment Variables&lt;/em> (all the knobs in this post): &lt;a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html">https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/env.html&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>The NVLink-Network Switch&lt;/em> (Hot Chips 2022, NVLink SHARP): &lt;a href="https://hc34.hotchips.org/">https://hc34.hotchips.org/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Why does vLLM use a custom all-reduce method?&lt;/em> (discussion #6159) and &lt;code>custom_all_reduce.py&lt;/code>: &lt;a href="https://github.com/vllm-project/vllm/discussions/6159">https://github.com/vllm-project/vllm/discussions/6159&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NCCL Multi-Node NVLink Tuning Guide&lt;/em>: &lt;a href="https://docs.nvidia.com/multi-node-nvlink-systems/multi-node-tuning-guide/nccl.html">https://docs.nvidia.com/multi-node-nvlink-systems/multi-node-tuning-guide/nccl.html&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Langfuse from the inside: the sorting centre that must not become the bottleneck it came to observe</title><link>https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/</link><pubDate>Sat, 06 Jun 2026 06:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/</guid><description>&lt;blockquote>
&lt;p>This post closes a trilogy of the &lt;strong>Observe&lt;/strong> layer: in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> the &lt;code>SDK → Collector → backend&lt;/code> pipeline was set up and Langfuse was treated as a black box that receives spans; in &lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> its prompt management layer was used. Here we open the box: what is inside Langfuse, why v3 stopped being a monolith on Postgres, and how it is operated so it holds the traffic of an inference cluster without becoming the problem.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Langfuse v3 (stable since December 2024) &lt;strong>is not an application, it is six services&lt;/strong>: two containers of its own (Web and Worker) and four stateful dependencies (Postgres, ClickHouse, Redis/Valkey and an S3-compatible blob store). The key architectural change compared with v2, which was a Next.js monolith on Postgres, is the &lt;strong>asynchronous ingestion pipeline&lt;/strong>: traces are received in batches, written &lt;em>immediately&lt;/em> to S3, only a &lt;em>reference&lt;/em> is queued in Redis, and a Worker ingests them into ClickHouse in the background. This decouples reception speed (limited only by Redis write latency, ~1-5 ms) from the cost of persisting and merging into the analytical database. The result: the Web container sustains hundreds of events per second without a spike blocking the client that serves the inference. But that design only performs with the right settings. This post covers the architecture, its interaction with the rest of the on-premise stack, and &lt;strong>ten backend knobs&lt;/strong>, from batching to ClickHouse to queue sharding, from the &lt;code>FINAL&lt;/code> modifier to system log table hygiene, that decide the real throughput and the storage cost. And it marks where the async design hides data loss windows worth knowing about before promising &amp;ldquo;full traceability&amp;rdquo;.&lt;/p>
&lt;h2 id="you-are-here-observe-and-the-layer-that-holds-up-the-rest">You are here: OBSERVE (and the layer that holds up the rest)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Observe">
&lt;defs>&lt;marker id="lfm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="currentColor">You are here: OBSERVE · the storage substrate that makes tracing operable&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="85" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="210" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="335" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="460" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" rx="6" fill="#c9a8e9" stroke="#444" stroke-width="3"/>&lt;text x="585" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="710" y="58" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="600" fill="#222">6 · Retrain&lt;/text>
&lt;path d="M140,52 L155,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M265,52 L280,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M390,52 L405,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M515,52 L530,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;path d="M640,52 L655,52" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfm)"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-postal-sorting-centre">The analogy: the postal sorting centre&lt;/h2>
&lt;p>Picture the central mail sorting office of a large city at rush hour. Lorries loaded with &lt;strong>sacks&lt;/strong> (batches of letters) arrive at a rate that does not stop. If the counter clerk had to &lt;strong>open every sack, read every letter, decide its destination and file it&lt;/strong> before accepting the next lorry, the queue of lorries would go round the block in ten minutes. No serious sorting centre works like that.&lt;/p>
&lt;p>What they do is &lt;strong>decouple reception from processing&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>reception counter&lt;/strong> accepts the sack, stamps a receipt on it, leaves it in a &lt;strong>pigeonhole&lt;/strong> in the warehouse and drops a &lt;strong>ticket&lt;/strong> on a conveyor belt. Time per sack: seconds. The counter never blocks.&lt;/li>
&lt;li>Further back, in the &lt;strong>sorting room&lt;/strong>, a team of operators picks tickets off the belt, retrieves the sack from its pigeonhole, opens it, sorts the letters and files them in the &lt;strong>permanent archive&lt;/strong>, ordered, indexed, searchable.&lt;/li>
&lt;/ol>
&lt;p>Langfuse v3 is exactly this sorting centre:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Postal centre&lt;/th>
&lt;th>Langfuse v3&lt;/th>
&lt;th>Function&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Reception counter&lt;/td>
&lt;td>&lt;strong>Web&lt;/strong> container (ingestion endpoint)&lt;/td>
&lt;td>Accepts event batches, gives an immediate receipt (HTTP 207)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pigeonhole warehouse&lt;/td>
&lt;td>&lt;strong>S3 / Blob store&lt;/strong> (MinIO on-prem)&lt;/td>
&lt;td>Stores the raw sack (the full event)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ticket on the belt&lt;/td>
&lt;td>&lt;strong>Redis / Valkey&lt;/strong> (BullMQ queue)&lt;/td>
&lt;td>Only the &lt;em>reference&lt;/em> to the object in S3, not the content&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sorting room&lt;/td>
&lt;td>&lt;strong>Worker&lt;/strong> container&lt;/td>
&lt;td>Takes tickets, reads S3, transforms and files&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indexed permanent archive&lt;/td>
&lt;td>&lt;strong>ClickHouse&lt;/strong> (OLAP)&lt;/td>
&lt;td>Traces, observations and scores, queryable by project+time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Administrative register&lt;/td>
&lt;td>&lt;strong>Postgres&lt;/strong> (OLTP)&lt;/td>
&lt;td>Users, projects, API keys, prompts, datasets, config&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The thesis of the whole post follows from this analogy: &lt;strong>the value of Langfuse lies in the counter never blocking the client that serves the inference&lt;/strong>. An observability tool that adds latency or outages to the token-serving path is worse than having no observability, because it degrades precisely the system it set out to look after. The whole of the v3 design, and every knob in this post, exists to keep that promise under load.&lt;/p>
&lt;h2 id="the-mechanism-itself-six-services-two-planes">The mechanism itself: six services, two planes&lt;/h2>
&lt;p>Langfuse v3 separates two planes that in v2 were fused together:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Ingestion and query plane&lt;/strong> (the two containers of its own, &lt;em>stateless&lt;/em>, horizontally scalable): Web and Worker.&lt;/li>
&lt;li>&lt;strong>State plane&lt;/strong> (four dependencies, each with its own load profile): Postgres (transactional OLTP), ClickHouse (analytical OLAP), Redis/Valkey (queue + cache), Blob store (raw objects).&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:820px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 820 470" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Six-service architecture of Langfuse v3">
&lt;defs>&lt;marker id="lfa" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Langfuse v3 · six services, two planes&lt;/text>
&lt;!-- Clients -->
&lt;rect x="30" y="50" width="150" height="60" rx="8" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="105" y="74" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">SDKs / OTel&lt;/text>
&lt;text x="105" y="92" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#444">apps, vLLM, gateway&lt;/text>
&lt;!-- Stateless plane -->
&lt;rect x="240" y="44" width="300" height="150" rx="10" fill="none" stroke="#999" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;text x="390" y="40" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">Stateless plane (horizontal scale)&lt;/text>
&lt;rect x="262" y="62" width="120" height="58" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.8"/>
&lt;text x="322" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Web&lt;/text>
&lt;text x="322" y="101" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">ingestion + UI/API&lt;/text>
&lt;rect x="398" y="62" width="120" height="58" rx="8" fill="#dceede" stroke="#3c8c54" stroke-width="1.8"/>
&lt;text x="458" y="84" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Worker&lt;/text>
&lt;text x="458" y="101" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">sorts → CH&lt;/text>
&lt;rect x="262" y="135" width="256" height="46" rx="8" fill="#f7efda" stroke="#c79a32" stroke-width="1.6"/>
&lt;text x="390" y="153" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">Redis / Valkey&lt;/text>
&lt;text x="390" y="170" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">BullMQ queue (refs) + API key/prompt cache&lt;/text>
&lt;!-- State plane -->
&lt;rect x="240" y="232" width="540" height="200" rx="10" fill="none" stroke="#999" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;text x="510" y="228" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="600" fill="currentColor">State plane&lt;/text>
&lt;rect x="262" y="250" width="150" height="74" rx="8" fill="#f3dede" stroke="#b35454" stroke-width="1.6"/>
&lt;text x="337" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Blob store (S3)&lt;/text>
&lt;text x="337" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">MinIO on-prem&lt;/text>
&lt;text x="337" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">raw events + media&lt;/text>
&lt;rect x="437" y="250" width="150" height="74" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.6"/>
&lt;text x="512" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">ClickHouse&lt;/text>
&lt;text x="512" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">OLAP · traces,&lt;/text>
&lt;text x="512" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">observations, scores&lt;/text>
&lt;rect x="612" y="250" width="150" height="74" rx="8" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="1.6"/>
&lt;text x="687" y="274" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#222">Postgres&lt;/text>
&lt;text x="687" y="291" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">OLTP · orgs, users,&lt;/text>
&lt;text x="687" y="306" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">API keys, prompts&lt;/text>
&lt;!-- ingestion flow -->
&lt;text x="510" y="356" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="#3c8c54">Ingestion route (asynchronous)&lt;/text>
&lt;text x="510" y="376" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">① Web writes event → S3 ② Web queues ref → Redis&lt;/text>
&lt;text x="510" y="394" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">③ Worker pulls ref from Redis ④ reads S3 → ⑤ inserts into ClickHouse&lt;/text>
&lt;text x="510" y="416" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#444">UI/API: Web reads from ClickHouse (traces) + Postgres (config)&lt;/text>
&lt;!-- connections -->
&lt;path d="M180,80 L262,84" fill="none" stroke="#666" stroke-width="1.6" marker-end="url(#lfa)"/>
&lt;text x="218" y="72" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#666">batch&lt;/text>
&lt;path d="M322,120 L322,135" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M390,181 L420,232" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M458,135 L458,120" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M458,120 L458,135" fill="none" stroke="#666" stroke-width="1.4"/>
&lt;path d="M412,287 L437,287" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M448,120 L500,250" fill="none" stroke="#666" stroke-width="1.4" marker-end="url(#lfa)"/>
&lt;path d="M518,120 L660,250" fill="none" stroke="#666" stroke-width="1.2" marker-end="url(#lfa)" stroke-dasharray="3 2"/>
&lt;/svg>
&lt;/div>
&lt;p>What to take away from this diagram:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Web and Worker are interchangeable and stateless.&lt;/strong> They store nothing locally. You can run 1 or 20 replicas of each; the state lives in the four dependencies. This is what allows scaling by load without choreography.&lt;/li>
&lt;li>&lt;strong>Redis never carries the event content, only the reference&lt;/strong> to the object in S3. That is why Redis holds the spike: a Redis write is ~1-5 ms and moves bytes, not kilobytes. The bottleneck of the Web container is, literally, &lt;em>Redis write speed&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Postgres and ClickHouse have opposite profiles.&lt;/strong> Postgres is OLTP: many small transactional reads and writes (is this API key valid? which version does the &lt;code>production&lt;/code> label point to?). ClickHouse is OLAP: few enormous batch writes and analytical queries over billions of rows (give me the p95 TTFT of project X over the last 7 days). Putting traces in Postgres, which is what v2 did, works until it does not: at production volume, Postgres drowns in a workload it was not designed for. That was the reason for the redesign.&lt;/li>
&lt;/ul>
&lt;h2 id="the-ingestion-flow-step-by-step-and-the-maths-of-the-decoupling">The ingestion flow step by step (and the maths of the decoupling)&lt;/h2>
&lt;p>The heart of the design is the ingestion route. Seen in detail, a &lt;code>POST /api/public/ingestion&lt;/code> request with a batch of events does this:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Langfuse asynchronous ingestion sequence">
&lt;defs>&lt;marker id="lfs" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;!-- lifelines -->
&lt;text x="80" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="currentColor">Client&lt;/text>
&lt;text x="250" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#3c8c54">Web&lt;/text>
&lt;text x="420" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#b35454">S3&lt;/text>
&lt;text x="560" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#c79a32">Redis&lt;/text>
&lt;text x="700" y="30" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#4a6fa5">CH+Worker&lt;/text>
&lt;line x1="80" y1="40" x2="80" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="250" y1="40" x2="250" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="420" y1="40" x2="420" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="560" y1="40" x2="560" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;line x1="700" y1="40" x2="700" y2="280" stroke="#ccc" stroke-width="1"/>
&lt;path d="M80,60 L250,60" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="165" y="54" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">POST event batch&lt;/text>
&lt;path d="M250,85 L420,85" fill="none" stroke="#b35454" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="335" y="79" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">① writes raw event&lt;/text>
&lt;path d="M250,110 L560,110" fill="none" stroke="#c79a32" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="405" y="104" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">② queues REFERENCE (not the event)&lt;/text>
&lt;path d="M250,135 L80,135" fill="none" stroke="#3c8c54" stroke-width="1.8" marker-end="url(#lfs)"/>
&lt;text x="165" y="129" text-anchor="middle" font-family="sans-serif" font-size="9.5" font-weight="700" fill="#3c8c54">HTTP 207 (receipt) ~ms&lt;/text>
&lt;text x="165" y="160" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#999">— the client has already moved on —&lt;/text>
&lt;path d="M560,190 L700,190" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="630" y="184" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">③ Worker pulls ref&lt;/text>
&lt;path d="M700,215 L420,215" fill="none" stroke="#666" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="560" y="209" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">④ reads event from S3&lt;/text>
&lt;path d="M700,250 L700,265 L660,265" fill="none" stroke="#4a6fa5" stroke-width="1.5" marker-end="url(#lfs)"/>
&lt;text x="700" y="244" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="#444">⑤ buffer + batch flush → INSERT CH&lt;/text>
&lt;rect x="40" y="120" width="220" height="22" rx="4" fill="#dceede" stroke="#3c8c54" stroke-width="1" opacity="0.5"/>
&lt;rect x="610" y="178" width="160" height="100" rx="4" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1" opacity="0.4"/>
&lt;text x="690" y="294" text-anchor="middle" font-family="sans-serif" font-size="9" font-style="italic" fill="#999">async, off the client path&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The mathematical point is the &lt;strong>early ACK&lt;/strong>. The latency the client perceives when sending traces is:&lt;/p>
$$ t_{\text{client}} = t_{\text{S3 write}} + t_{\text{Redis enqueue}} \approx 10\text{–}40\,\text{ms} $$
&lt;p>whereas the real cost of persisting, reading S3, transforming, merging against the previous version, inserting into ClickHouse, letting the background merges compact, happens &lt;strong>off that path&lt;/strong>, in the Worker, and can take hundreds of ms or seconds without the client caring. The decoupling turns a system whose throughput would be limited by ClickHouse speed into one limited by Redis speed. And Redis, on modest hardware, sustains on the order of &lt;strong>50,000 operations/second&lt;/strong>.&lt;/p>
&lt;p>This has an important sizing consequence. If your inference load generates $E$ events/second (a chat with RAG + 2 tool calls easily produces 6-10 spans = events per request), the Web container absorbs them while $E \ll 50{,}000$. The Worker, by contrast, scales with the cost of &lt;em>processing&lt;/em>: that is the component to watch and replicate, and the first knob of the post.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Honest scepticism.&lt;/strong> The early ACK has a flip side: between the HTTP 207 and persistence in ClickHouse there is a &lt;strong>potential loss window&lt;/strong>. If the event is in S3 and the reference in Redis, and Redis goes down without persistence (AOF/RDB) before the Worker processes it, the reference is lost; the data is still in S3 but nobody claims it any more. More subtly: the Worker buffers writes to ClickHouse in memory and flushes them in batches; a Worker crash with a full buffer loses that batch. There is a &lt;a href="https://github.com/langfuse/langfuse/issues/13468">reported bug&lt;/a> where the &lt;code>ClickhouseWriter&lt;/code> discards rows after exhausting flush retries &lt;strong>with no dead-letter queue&lt;/strong>. For observability this is usually tolerable (losing 0.01 % of the traces breaks nothing). For &lt;em>regulatory auditing&lt;/em>, where the trace is evidence, it is not, and Langfuse is better treated as &amp;ldquo;best-effort&amp;rdquo; and not as a ledger. We will come back to this at the close.&lt;/p>
&lt;/blockquote>
&lt;h2 id="interaction-with-the-rest-of-the-stack-langfuse-in-the-example-4h100-cluster">Interaction with the rest of the stack: Langfuse in the example 4×H100 cluster&lt;/h2>
&lt;p>Langfuse does not live in isolation. In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> it occupies the LLM-aware observability layer, and it relates to almost every other one. On the generic reference cluster we use throughout the blog, &lt;strong>4×H100 SXM 80 GB (320 GB aggregate VRAM), NVLink, 640 GB of system RAM, NVMe-oF, 25/100 GbE network&lt;/strong>, the telemetry flow looks like this:&lt;/p>
&lt;div class="diagram" style="max-width:840px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 840 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Interaction of Langfuse with the inference stack on a 4xH100 cluster">
&lt;defs>&lt;marker id="lfx" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="420" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">Data plane vs telemetry plane · 4×H100 SXM cluster&lt;/text>
&lt;!-- Data plane -->
&lt;rect x="24" y="44" width="430" height="356" rx="10" fill="none" stroke="#4a6fa5" stroke-width="1.4" stroke-dasharray="6 3"/>
&lt;text x="239" y="62" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#4a6fa5">Data plane (serves tokens · hot path)&lt;/text>
&lt;rect x="50" y="78" width="170" height="50" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="135" y="98" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">App / Agent&lt;/text>
&lt;text x="135" y="115" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">Langfuse / OTel SDK&lt;/text>
&lt;rect x="50" y="150" width="170" height="50" rx="7" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="135" y="170" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">L7 Gateway&lt;/text>
&lt;text x="135" y="187" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">router / LiteLLM&lt;/text>
&lt;rect x="50" y="222" width="170" height="58" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="135" y="244" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">vLLM (TP=4)&lt;/text>
&lt;text x="135" y="261" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">general LLM · H100×4&lt;/text>
&lt;rect x="252" y="222" width="180" height="58" rx="7" fill="#dceede" stroke="#3c8c54" stroke-width="1.6"/>
&lt;text x="342" y="240" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="700" fill="#222">Embeddings + Reranker&lt;/text>
&lt;text x="342" y="256" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">MIG slices&lt;/text>
&lt;text x="342" y="270" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">+ vector store&lt;/text>
&lt;rect x="50" y="306" width="382" height="42" rx="7" fill="#f7efda" stroke="#c79a32" stroke-width="1.4"/>
&lt;text x="241" y="332" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#333">Guardrails · semantic cache · tool services&lt;/text>
&lt;!-- OTel Collector centre -->
&lt;rect x="486" y="150" width="150" height="74" rx="9" fill="#f0e6d2" stroke="#b58a2e" stroke-width="1.8"/>
&lt;text x="561" y="178" text-anchor="middle" font-family="sans-serif" font-size="11" font-weight="700" fill="#222">OTel Collector&lt;/text>
&lt;text x="561" y="195" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">batch · tail-sampling&lt;/text>
&lt;text x="561" y="209" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">attributes (tenant_id)&lt;/text>
&lt;!-- telemetry plane -->
&lt;rect x="660" y="44" width="160" height="356" rx="10" fill="none" stroke="#7a5aa5" stroke-width="1.4" stroke-dasharray="6 3"/>
&lt;text x="740" y="62" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="700" fill="#7a5aa5">Telemetry (cold)&lt;/text>
&lt;rect x="676" y="80" width="128" height="120" rx="9" fill="#e6ddf3" stroke="#7a5aa5" stroke-width="1.8"/>
&lt;text x="740" y="104" text-anchor="middle" font-family="sans-serif" font-size="12" font-weight="700" fill="#222">Langfuse&lt;/text>
&lt;text x="740" y="124" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">Web + Worker&lt;/text>
&lt;text x="740" y="140" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">CH · PG · Redis&lt;/text>
&lt;text x="740" y="156" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#444">MinIO&lt;/text>
&lt;text x="740" y="178" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-style="italic" fill="#777">dedicated CPU node&lt;/text>
&lt;text x="740" y="191" text-anchor="middle" font-family="sans-serif" font-size="8.5" font-style="italic" fill="#777">(off the GPUs)&lt;/text>
&lt;rect x="676" y="220" width="128" height="50" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="740" y="242" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">Tempo&lt;/text>
&lt;text x="740" y="258" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#444">infra spans&lt;/text>
&lt;rect x="676" y="288" width="128" height="50" rx="8" fill="#dde6f3" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="740" y="310" text-anchor="middle" font-family="sans-serif" font-size="10" font-weight="700" fill="#222">Prometheus&lt;/text>
&lt;text x="740" y="326" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#444">DCGM · vLLM&lt;/text>
&lt;!-- data arrows -->
&lt;path d="M135,128 L135,150" fill="none" stroke="#4a6fa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;path d="M135,200 L135,222" fill="none" stroke="#4a6fa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;path d="M220,251 L252,251" fill="none" stroke="#4a6fa5" stroke-width="1.4" marker-end="url(#lfx)"/>
&lt;!-- spans to collector -->
&lt;path d="M220,160 C360,150 420,180 486,180" fill="none" stroke="#b58a2e" stroke-width="1.5" marker-end="url(#lfx)" stroke-dasharray="4 2"/>
&lt;text x="350" y="146" text-anchor="middle" font-family="sans-serif" font-size="9" fill="#b58a2e">gen_ai.* spans&lt;/text>
&lt;path d="M220,250 C400,300 430,210 486,200" fill="none" stroke="#b58a2e" stroke-width="1.3" marker-end="url(#lfx)" stroke-dasharray="4 2"/>
&lt;!-- collector to backends -->
&lt;path d="M636,170 L676,130" fill="none" stroke="#7a5aa5" stroke-width="1.6" marker-end="url(#lfx)"/>
&lt;text x="660" y="138" text-anchor="middle" font-family="sans-serif" font-size="8.5" fill="#7a5aa5">LLM traces&lt;/text>
&lt;path d="M636,200 L676,240" fill="none" stroke="#4a6fa5" stroke-width="1.4" marker-end="url(#lfx)"/>
&lt;path d="M636,210 L676,305" fill="none" stroke="#4a6fa5" stroke-width="1.2" marker-end="url(#lfx)"/>
&lt;/svg>
&lt;/div>
&lt;p>Three ideas from this topology:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Langfuse receives from the OTel Collector, not from the application directly&lt;/strong> (in the recommended pattern). The app SDK or vLLM emit spans with the &lt;code>gen_ai.*&lt;/code> semantic conventions; the Collector does &lt;code>batch&lt;/code>, &lt;code>tail-sampling&lt;/code> (preserving 100 % of errors and high latencies, sampling the rest) and enriches with its own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>); and it &lt;em>distributes&lt;/em>: LLM traces go to Langfuse, infrastructure spans to Tempo, metrics (GPU DCGM, vLLM metrics) to Prometheus. Langfuse is &lt;strong>one more exporter&lt;/strong>, not the only destination. This is covered in detail in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the OTel tracing post&lt;/a>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Langfuse runs off the GPUs.&lt;/strong> It is a consumer of CPU, RAM, disk and network (ClickHouse wants memory, MinIO wants disk, Redis wants CPU for networking) but &lt;strong>it does not touch VRAM&lt;/strong>. On the 4×H100 cluster, Langfuse lives on a CPU node (or on the GPU nodes but with &lt;code>nodeSelector&lt;/code>/&lt;code>taints&lt;/code> keeping it away from the vLLM pods). Mixing ClickHouse with vLLM on the same node without resource limits is asking for an ingestion spike to steal memory bandwidth from inference. Isolation by design.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The telemetry route is &amp;ldquo;cold&amp;rdquo; and the data route is &amp;ldquo;hot&amp;rdquo;.&lt;/strong> The data plane (left) serves tokens with a millisecond latency budget; the telemetry plane (right) tolerates seconds. The early ingestion ACK is what keeps these two clocks apart: the app does not wait for Langfuse to file anything before returning the response to the user.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="the-10-backend-knobs-that-move-the-needle-most">The 10 backend knobs that move the needle most&lt;/h2>
&lt;p>These are, in approximate order of impact/frequency, the settings that decide whether your self-hosted Langfuse ingests 50 events/s or 5,000, and whether your disk grows sustainably or explodes in three weeks. They are all environment variables or config injected into the &lt;strong>Web and Worker&lt;/strong> containers (except the ClickHouse ones, which go in its server-side config). The canonical detail is in the &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">Langfuse scaling doc&lt;/a>.&lt;/p>
&lt;h3 id="knob-1--scale-the-worker-by-load-the-first-lever-always">Knob 1 — Scale the Worker by load (the first lever, always)&lt;/h3>
&lt;p>The Worker is the component that saturates first, because it does the expensive work: reading S3, transforming, merging, inserting into ClickHouse. The Langfuse operational rule is simple: &lt;strong>a 2-CPU Worker container above 50 % CPU use is saturated&lt;/strong>; add replicas. Better than CPU, the Worker publishes via statsd the metric &lt;code>langfuse.queue.ingestion.length&lt;/code> (ingestion queue length), which is the direct signal for autoscaling: if the queue grows without draining, there are not enough Workers.&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"># The ideal autoscaler watches queue depth, not just CPU.&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="c"># (KEDA ScaledObject over the statsd metric → 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">triggers&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">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &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">metadata&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">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">langfuse_queue_ingestion_length&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">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10000&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if the queue goes past 10k refs, scale&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In AWS deployments there is &lt;code>ENABLE_AWS_CLOUDWATCH_METRIC_PUBLISHING=true&lt;/code> to push these metrics to CloudWatch. On-premise, the path is statsd → Prometheus → KEDA, slotting into the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling on Kubernetes with KEDA&lt;/a> we already covered for vLLM. &lt;strong>Always start here&lt;/strong>: most &amp;ldquo;Langfuse is slow&amp;rdquo; problems are simply insufficient Workers, not fine tuning.&lt;/p>
&lt;h3 id="knob-2--separate-the-ingestion-deployment-from-the-ui-one">Knob 2 — Separate the ingestion deployment from the UI one&lt;/h3>
&lt;p>When ingestion is heavily loaded, UI queries and the public API become slow because they share the same Web container. The solution is to &lt;strong>split langfuse-web into two identical deployments&lt;/strong> and route by path: everything under &lt;code>/api/public/ingestion*&lt;/code>, &lt;code>/api/public/media*&lt;/code> and &lt;code>/api/public/otel*&lt;/code> goes to the ingestion deployment; the rest (UI, read API) to the interface one.&lt;/p>
&lt;pre tabindex="0">&lt;code># Ingress / gateway rule
location ~ ^/api/public/(ingestion|media|otel) {
proxy_pass http://langfuse-web-ingest; # replicas dedicated to writing
}
location / {
proxy_pass http://langfuse-web-ui; # replicas dedicated to reading
}
&lt;/code>&lt;/pre>&lt;p>It is the same idea as the read/write separation of any system with mixed loads: a storm of writes should not starve whoever is trying to &lt;em>look at&lt;/em> the dashboard right in the middle of the incident, which is precisely when they need it most.&lt;/p>
&lt;h3 id="knob-3--batching-writes-to-clickhouse-interval--batch-size">Knob 3 — Batching writes to ClickHouse (interval + batch size)&lt;/h3>
&lt;p>ClickHouse hates small, frequent inserts: every &lt;code>INSERT&lt;/code> creates a &lt;em>part&lt;/em> on disk that then has to be merged, and thousands of tiny inserts generate thousands of parts and a storm of background merges that saturates the disk. The defence is to &lt;strong>accumulate in an in-memory buffer in the Worker and flush in large batches&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">&lt;span class="c1"># Worker: fewer flushes, larger batches → fewer parts, fewer merges&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1000&lt;/span> &lt;span class="c1"># raise e.g. to 2000-5000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_CLICKHOUSE_WRITE_BATCH_SIZE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">10000&lt;/span> &lt;span class="c1"># raise if there is throughput&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Raising the interval and the batch size &lt;strong>reduces the flush frequency&lt;/strong> and improves sustained throughput. The trade-off is direct and has to be understood: larger, less frequent batches mean &lt;strong>more data in the Worker&amp;rsquo;s volatile buffer&lt;/strong>, that is, a bigger loss window if the Worker goes down (a knob coupled to the scepticism at the close). Langfuse also uses ClickHouse &lt;code>async_insert&lt;/code>, which accumulates server-side before confirming; it adds another layer of buffering to keep in mind.&lt;/p>
&lt;h3 id="knob-4--skip-the-clickhouse-read-before-write-in-ingestion">Knob 4 — Skip the ClickHouse read-before-write in ingestion&lt;/h3>
&lt;p>By default, when ingesting an event the Worker &lt;strong>reads the existing event from ClickHouse and merges it&lt;/strong> with the incoming one (necessary when legacy SDKs send partial events: a &lt;code>start&lt;/code>, then an &lt;code>end&lt;/code>, then an &lt;code>update&lt;/code> of the same observation). That per-event read loads ClickHouse on the write path and limits total throughput.&lt;/p>
&lt;p>If your projects are not migrated from an old version, because the full history already lives in S3, you can &lt;strong>disable that read&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">&lt;span class="c1"># Date earlier than the creation of your first project&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE&lt;/span>&lt;span class="o">=&lt;/span>2025-01-01
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With modern Langfuse SDKs or with ingestion via OpenTelemetry, this does not affect you negatively and removes one read per event. A warning from the doc itself: if you combine this with aggressive deletion (lifecycle) rules in S3 plus late event updates, you can generate duplicates in the history. Know that before enabling it.&lt;/p>
&lt;h3 id="knob-5--write-concurrency-to-s3blob-storage">Knob 5 — Write concurrency to S3/Blob storage&lt;/h3>
&lt;p>In high-throughput scenarios, the S3 client can &lt;strong>exhaust its sockets&lt;/strong> and start queueing and throttling writes. The symptom is unmistakable in the logs of the Web container that handles ingestion:&lt;/p>
&lt;pre tabindex="0">&lt;code>@smithy/node-http-handler:WARN - socket usage at capacity=150
and 387 additional requests are enqueued.
&lt;/code>&lt;/pre>&lt;p>…accompanied by a rise in memory in that container (queued requests pile up in RAM). The cure is to raise the concurrent write limit from its default of 50:&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="nv">LANGFUSE_S3_CONCURRENT_WRITES&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">100&lt;/span> &lt;span class="c1"># raise gradually from 50&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each additional socket has a small memory cost, so the official advice is to raise it &lt;strong>gradually&lt;/strong> while observing the behaviour, not to jump to 1000 in one go.&lt;/p>
&lt;h3 id="knob-6--redis-queue-sharding--concurrency-per-shard">Knob 6 — Redis queue sharding + concurrency &lt;em>per shard&lt;/em>&lt;/h3>
&lt;p>If Redis goes past 90 % CPU, first the obvious: an instance with &lt;strong>at least 4 CPUs&lt;/strong> (so Redis spreads networking and background tasks across different cores) and &lt;strong>Redis Cluster mode&lt;/strong> enabled. If CPU still does not come down, you can &lt;strong>shard the queues&lt;/strong> Langfuse uses:&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"># Advanced: only if Redis is genuinely drowning and you already did the above&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">6&lt;/span> &lt;span class="c1"># ~2-3× the number of Redis cluster shards&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_TRACE_UPSERT_QUEUE_SHARD_COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">6&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Concurrency counts PER SHARD; target ~20 per worker&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_INGESTION_QUEUE_PROCESSING_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span> &lt;span class="c1"># 6 shards × ~3 ≈ 18&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_TRACE_UPSERT_WORKER_CONCURRENCY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">3&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two traps the doc underlines and that are worth tattooing: &lt;strong>once you shard, do not reduce the number of shards&lt;/strong> (it breaks the distribution); and concurrency counts &lt;strong>per shard&lt;/strong>, not globally, so if you have 10 shards and want concurrency 20 per worker, set &lt;code>2&lt;/code>, not &lt;code>20&lt;/code>. It is an advanced knob: most on-premise deployments never need it.&lt;/p>
&lt;h3 id="knob-7--the-final-modifier-for-otel-only-projects">Knob 7 — The &lt;code>FINAL&lt;/code> modifier for OTel-only projects&lt;/h3>
&lt;p>Langfuse stores observations in a ClickHouse &lt;code>ReplacingMergeTree&lt;/code> and, by default, adds the &lt;code>FINAL&lt;/code> modifier to API queries so the last version of each row wins at read time. &lt;code>FINAL&lt;/code> is necessary when ingestion produces several versions of the same observation (legacy SDKs with their &lt;code>start&lt;/code>/&lt;code>end&lt;/code>/&lt;code>update&lt;/code> events), but &lt;strong>it adds merge work to every read and slows it down&lt;/strong>.&lt;/p>
&lt;p>Projects that ingest &lt;strong>exclusively over OpenTelemetry&lt;/strong> write each observation as a single immutable row, so &lt;code>FINAL&lt;/code> is redundant for them:&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"># Recommended in mixed deployments: per-project, flagged in Redis with a 24h TTL&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&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"># Only if ALL projects are OTel-only: global, no Redis lookup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LANGFUSE_API_CLICKHOUSE_DISABLE_OBSERVATIONS_FINAL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Since on the example cluster the instrumentation is 100 % OTel (&lt;code>gen_ai.*&lt;/code> via Collector), this knob is &lt;strong>free money in dashboard read latency&lt;/strong>. Careful with the global version: do not enable it if some project is still using legacy ingestion, or reads may return duplicate or stale rows.&lt;/p>
&lt;h3 id="knob-8--separate-analytical-reads-from-the-write-path-compute-compute">Knob 8 — Separate analytical reads from the write path (compute-compute)&lt;/h3>
&lt;p>Heavy dashboard queries (percentiles over millions of spans) compete with ingestion inserts and with background merges on the &lt;em>same&lt;/em> ClickHouse. If your deployment supports &lt;strong>compute-compute separation&lt;/strong> (ClickHouse Cloud or BYOC), you can route reads to a read-only compute group:&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="nv">CLICKHOUSE_URL&lt;/span>&lt;span class="o">=&lt;/span>http://clickhouse-primary:8123 &lt;span class="c1"># writes, migrations, ingestion&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_READ_ONLY_URL&lt;/span>&lt;span class="o">=&lt;/span>http://clickhouse-reader:8123 &lt;span class="c1"># UI + public API reads&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Critical nuance for on-premise&lt;/strong>, and here it is time to be sceptical about the usefulness of this knob in our context: on a &lt;strong>single-node&lt;/strong> ClickHouse or on a self-managed cluster without compute separation, this variable &lt;strong>adds nothing&lt;/strong>, because the read endpoint would be the same as the write one. It is a knob for cloud architectures with storage separated from compute. On an on-premise 4×H100 cluster with ClickHouse on one node, the real alternative is to &lt;strong>scale ClickHouse vertically&lt;/strong> (the doc recommends ≥16 GiB of RAM for large deployments; ClickHouse scales vertically well) and to make sure &lt;strong>every query filters by &lt;code>projectId&lt;/code> and time&lt;/strong>, which is how the tables are indexed. Without a time filter, even the fattest ClickHouse suffers.&lt;/p>
&lt;h3 id="knob-9--data-retention-ttl-in-clickhouse--lifecycle-in-s3">Knob 9 — Data retention: TTL in ClickHouse + lifecycle in S3&lt;/h3>
&lt;p>Disk is the cost that grows on its own. LLM traces carry whole inputs and outputs (sometimes prompts of tens of KB), and ClickHouse also accumulates its own system tables. The first-order lever is a &lt;strong>retention policy&lt;/strong> that nightly deletes traces, observations, scores and media older than N days, coordinating ClickHouse and blob storage. Where the retention feature is not available, it is done by hand:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- ClickHouse: TTL over the tracing tables
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">traces&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&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="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">observations&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">start_time&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&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="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">scores&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">90&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&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="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">toDateTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">timestamp&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">30&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&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;pre tabindex="0">&lt;code># S3/MinIO: lifecycle rule, e.g. 30 days for the raw event bucket
# WATCH OUT! Do NOT apply retention to the MEDIA bucket:
# - it breaks the files referenced in traces
# - it breaks future uploads (state is tracked by hash in Postgres)
&lt;/code>&lt;/pre>&lt;p>Two operational parameters that avoid nasty surprises on large deletions:&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="nv">LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">600000&lt;/span> &lt;span class="c1"># default 10 min; raise it if deletions time out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ClickHouse 25.7+: less mutation pressure on bulk deletions&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_LIGHTWEIGHT_DELETE_MODE&lt;/span>&lt;span class="o">=&lt;/span>lightweight_update
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">CLICKHOUSE_USE_LIGHTWEIGHT_UPDATE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The mental rule: &lt;strong>short retention for raw events&lt;/strong> (S3, 30 days is usually enough, they are recoverable/recomputable), &lt;strong>retention by business value for the ClickHouse tables&lt;/strong> (90 days, 180, whatever compliance asks for), and &lt;strong>never touch the media bucket with blind lifecycle rules&lt;/strong>.&lt;/p>
&lt;h3 id="knob-10--hygiene-of-the-clickhouse-system-log-tables-the-silent-disk-killer">Knob 10 — Hygiene of the ClickHouse &lt;em>system log tables&lt;/em> (the silent disk killer)&lt;/h3>
&lt;p>This is the knob nobody configures and that fills the disk without showing up in any Langfuse metric, because &lt;strong>it is not Langfuse data&lt;/strong>: they are the system tables of ClickHouse itself (&lt;code>trace_log&lt;/code>, &lt;code>text_log&lt;/code>, &lt;code>opentelemetry_span_log&lt;/code>, &lt;code>asynchronous_metric_log&lt;/code>, &lt;code>metric_log&lt;/code>, &lt;code>latency_log&lt;/code>). By default &lt;strong>they have no TTL&lt;/strong>, and the query profiler writes to &lt;code>system.trace_log&lt;/code> continuously. On a ClickHouse with traffic, these tables can &lt;strong>dominate disk usage&lt;/strong> while you look for the problem in your traces. Langfuse does not read from them, so they can be trimmed without fear. Two options:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-xml" data-lang="xml">&lt;span class="line">&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Option A — disable the ones Langfuse never reads
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c"> (file in /etc/clickhouse-server/config.d/) --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;clickhouse&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;trace_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;text_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;opentelemetry_span_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;asynchronous_metric_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;metric_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;lt;latency_log&lt;/span> &lt;span class="na">remove=&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="nt">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nt">&amp;lt;/clickhouse&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Keep query_log, part_log and error_log: useful for debugging and small --&amp;gt;&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-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- Option B — aggressive TTL + turn off the profiler, if you want to keep them for debugging
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">-- (in config: query_profiler_real_time_period_ns = 0)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">SET&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">max_table_size_to_drop&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&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="k">TRUNCATE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_log&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="k">ALTER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">TABLE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">trace_log&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">MODIFY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">TTL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">event_date&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nb">INTERVAL&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">7&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DAY&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="c1">-- repeat for each log table to cap
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>To identify which table is eating the disk, the golden query:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">table&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">formatReadableSize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">size&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">rows&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">rows&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="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">system&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parts&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">active&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">GROUP&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">table&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">ORDER&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">BY&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">bytes&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">DESC&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>If you take only one knob from this post to your first real deployment, make it this one: the difference between a ClickHouse that grows 2 GB/day of useful data and one that grows 20 GB/day of system logs nobody looks at.&lt;/p>
&lt;h3 id="summary-table-of-the-10-knobs">Summary table of the 10 knobs&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Variable / action&lt;/th>
&lt;th>When&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Scale Worker&lt;/td>
&lt;td>replicas by CPU&amp;gt;50 % / &lt;code>langfuse.queue.ingestion.length&lt;/code>&lt;/td>
&lt;td>always, first&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Separate ingestion/UI&lt;/td>
&lt;td>route &lt;code>/ingestion*&lt;/code>,&lt;code>/media*&lt;/code>,&lt;code>/otel*&lt;/code> to a dedicated replica&lt;/td>
&lt;td>slow UI under load&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Batching to ClickHouse&lt;/td>
&lt;td>&lt;code>LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS&lt;/code> / &lt;code>_BATCH_SIZE&lt;/code>&lt;/td>
&lt;td>high throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Skip CH read-before-write&lt;/td>
&lt;td>&lt;code>LANGFUSE_SKIP_INGESTION_CLICKHOUSE_READ_MIN_PROJECT_CREATE_DATE&lt;/code>&lt;/td>
&lt;td>non-migrated projects&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>S3 concurrency&lt;/td>
&lt;td>&lt;code>LANGFUSE_S3_CONCURRENT_WRITES&lt;/code> (def. 50)&lt;/td>
&lt;td>&amp;ldquo;socket usage at capacity&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>Redis queue sharding&lt;/td>
&lt;td>&lt;code>LANGFUSE_*_QUEUE_SHARD_COUNT&lt;/code> + &lt;code>*_CONCURRENCY&lt;/code> (per shard)&lt;/td>
&lt;td>Redis CPU &amp;gt;90 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>Remove &lt;code>FINAL&lt;/code> (OTel)&lt;/td>
&lt;td>&lt;code>LANGFUSE_SKIP_FINAL_FOR_OTEL_PROJECTS=true&lt;/code>&lt;/td>
&lt;td>100 % OTel instrumentation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>CH read/write split&lt;/td>
&lt;td>&lt;code>CLICKHOUSE_READ_ONLY_URL&lt;/code> (cloud/BYOC only)&lt;/td>
&lt;td>compute-compute available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>Retention + TTL&lt;/td>
&lt;td>TTL in CH + S3 lifecycle + &lt;code>LANGFUSE_CLICKHOUSE_DELETION_TIMEOUT_MS&lt;/code>&lt;/td>
&lt;td>always (disk cost)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>CH system log hygiene&lt;/td>
&lt;td>&lt;code>&amp;lt;trace_log remove=&amp;quot;1&amp;quot;/&amp;gt;&lt;/code> or aggressive TTL&lt;/td>
&lt;td>always (hidden disk)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="how-to-maximise-langfuse-on-the-example-4h100-cluster">How to maximise Langfuse on the example 4×H100 cluster&lt;/h2>
&lt;p>With the architecture and the knobs clear, this is a concrete sizing to get the most out of Langfuse on the generic reference cluster (&lt;strong>4×H100 SXM, 320 GB VRAM, 640 GB RAM, NVMe-oF, 25/100 GbE&lt;/strong>), without stealing a single GB of VRAM from inference.&lt;/p>
&lt;h3 id="component-distribution">Component distribution&lt;/h3>
&lt;p>Langfuse is &lt;strong>100 % CPU/RAM/disk/network load&lt;/strong>, so its natural place is &lt;strong>off the GPU nodes&lt;/strong> or, if it cohabits, with &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code> confining it away from the vLLM pods. Suggested distribution:&lt;/p>
&lt;pre tabindex="0">&lt;code>nodo-cpu-01 (control + observability, no GPU)
├── langfuse-web-ingest ×3 (2 CPU / 4 GiB each) ← ingestion, scales with load
├── langfuse-web-ui ×2 (2 CPU / 4 GiB each) ← dashboard/read API
├── langfuse-worker ×4 (2 CPU / 4 GiB each) ← the one that scales most
├── redis/valkey ×1 (4 CPU / 4 GiB, cluster mode)
└── postgres ×1 (2 CPU / 8 GiB, replica for HA)
nodo-storage-01 (heavy state, local NVMe)
├── clickhouse ×1 (8 CPU / 32 GiB / NVMe) ← ≥16 GiB is the minimum; 32 is comfortable
└── minio (S3) ×1 (4 CPU / 8 GiB / HDD+NVMe cache)
nodo-gpu-01..02 (4×H100 SXM each) → inference ONLY
└── vLLM, embeddings, reranker, guardrails (emit spans, do not host Langfuse)
&lt;/code>&lt;/pre>&lt;h3 id="sizing-by-real-load">Sizing by real load&lt;/h3>
&lt;p>Let us put numbers on an example load. Suppose the cluster serving &lt;strong>300 requests/second&lt;/strong> of chat-with-RAG, where each request generates on the order of &lt;strong>8 spans&lt;/strong> (request, retrieval, rerank, 2× tool, guardrail in, llm, guardrail out):&lt;/p>
$$ E = 300\,\tfrac{\text{req}}{\text{s}} \times 8\,\tfrac{\text{spans}}{\text{req}} = 2,400\ \text{events/s} $$
&lt;p>Against the Redis ceiling (~50,000 ops/s), $E = 2{,}400$ leaves the reception counter at &lt;strong>~5 % of its capacity&lt;/strong>: enormous headroom. The component to watch is the Worker. With a target of ~20 concurrency per Worker and batches of 10,000 events every ~1-2 s, 4 Workers drain 2,400 ev/s with margin; the &lt;code>langfuse.queue.ingestion.length&lt;/code> metric should stay flat near zero. If it grows, knob 1 (more Workers) is the answer before any fine tuning.&lt;/p>
&lt;p>&lt;strong>Tail-sampling is the multiplier that changes the economics.&lt;/strong> If the Collector preserves 100 % of errors/high latencies but samples normal traffic at, say, 10 %, the 2,400 ev/s you &lt;em>store&lt;/em> in ClickHouse drop to ~240-300 effective ev/s without losing the signal that matters. The rule: &lt;strong>sample in the Collector, not in Langfuse&lt;/strong>. Langfuse should receive, already filtered, what deserves to be persisted. This is developed in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the OTel tracing post&lt;/a>; here it is enough to note that upstream sampling is, in effect, the knob 0 that multiplies all the others.&lt;/p>
&lt;h3 id="storage-estimate">Storage estimate&lt;/h3>
&lt;p>An LLM observation with full input+output weighs, compressed in ClickHouse, on the order of &lt;strong>1-3 KB&lt;/strong> (ClickHouse compresses text very well, 5-10×). With sampling at 10 % over 2,400 ev/s:&lt;/p>
$$ 240\,\tfrac{\text{ev}}{\text{s}} \times 2\,\text{KB} \times 86,400\,\tfrac{\text{s}}{\text{day}} \approx 41\ \text{GB/day (raw)} \;\xrightarrow{\text{compression}}\; \sim 5\text{–}8\ \text{GB/day in CH} $$
&lt;p>At 90 days of retention (knob 9), the permanent archive settles at around &lt;strong>500-700 GB in ClickHouse&lt;/strong>, comfortable on the NVMe of the storage node, plus the raw events in MinIO with a 30-day lifecycle. &lt;strong>Without&lt;/strong> the system log hygiene (knob 10), add easily as much again in rubbish nobody queries. The two disk knobs together are the difference between planning storage once a year and fighting a full disk every month.&lt;/p>
&lt;h3 id="maximum-use-checklist">&amp;ldquo;Maximum use&amp;rdquo; checklist&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>Sampling in the Collector&lt;/strong> (tail: 100 % errors + N % normal), before touching anything in Langfuse.&lt;/li>
&lt;li>&lt;strong>Workers scaled by queue length&lt;/strong> via KEDA (knob 1), not fixed.&lt;/li>
&lt;li>&lt;strong>Ingestion separated from UI&lt;/strong> (knob 2) so the dashboard responds during incidents.&lt;/li>
&lt;li>&lt;strong>&lt;code>SKIP_FINAL_FOR_OTEL_PROJECTS&lt;/code>&lt;/strong> enabled (knob 7) because the instrumentation is 100 % OTel.&lt;/li>
&lt;li>&lt;strong>Generous CH batching&lt;/strong> (knob 3) tuned to the throughput, accepting the loss window.&lt;/li>
&lt;li>&lt;strong>Retention + TTL + system log hygiene&lt;/strong> (knobs 9 and 10) configured on day 1, not when the disk screams.&lt;/li>
&lt;li>&lt;strong>ClickHouse with ≥16 GiB and every query filtering by &lt;code>projectId&lt;/code>+time&lt;/strong> (knob 8 in its on-premise version: vertical scaling).&lt;/li>
&lt;li>&lt;strong>Langfuse isolated from the GPUs&lt;/strong> by &lt;code>taints&lt;/code>/&lt;code>nodeSelector&lt;/code>: not a MB of VRAM, no memory bandwidth contention with vLLM.&lt;/li>
&lt;/ol>
&lt;h2 id="traps-and-things-that-are-not-what-they-look-like">Traps and things that are not what they look like&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Langfuse guarantees me full traceability.&amp;rdquo;&lt;/strong> No: the design is &lt;strong>high-performance best-effort&lt;/strong>, not a ledger. Between the HTTP 207 and the row in ClickHouse there are volatile buffers (Redis without hard persistence, the Worker&amp;rsquo;s in-memory buffer, ClickHouse&amp;rsquo;s server-side &lt;code>async_insert&lt;/code>). There is a &lt;a href="https://github.com/langfuse/langfuse/issues/13468">known bug&lt;/a> where the writer discards rows with no dead-letter queue after exhausting retries. For operational observability, losing 0.01 % of spans is irrelevant. For &lt;strong>ENS/EU AI Act audit evidence&lt;/strong>, where the trace &lt;em>is&lt;/em> the proof, Langfuse should not be the only record; the regulatory audit log needs durability guarantees this pipeline does not promise. A distinction covered in &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical controls for ENS/42001/EU AI Act&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Raising ClickHouse batching &amp;ldquo;to go faster&amp;rdquo; and nothing else.&lt;/strong> Knob 3 improves throughput at the cost of enlarging the loss window and the latency of data appearing in the dashboard. Batches of 50,000 every 10 s perform wonderfully… until the Worker restarts with 50,000 events in the buffer. Tune with awareness of the trade-off, not by blind maximisation.&lt;/p>
&lt;p>&lt;strong>Putting ClickHouse on the same node as vLLM with no limits.&lt;/strong> ClickHouse is voracious with memory bandwidth during merges. Sharing a node with vLLM without &lt;code>resources.limits&lt;/code> or NUMA isolation means an ingestion spike can degrade inference TTFT, exactly the original sin this whole architecture wanted to avoid. Isolate.&lt;/p>
&lt;p>&lt;strong>Forgetting the time filter in your own queries.&lt;/strong> The ClickHouse tables are indexed by &lt;code>projectId&lt;/code> and time. A custom dashboard or an API query without a time filter scans the whole history and takes performance down for everyone. It is not Langfuse &amp;ldquo;being slow&amp;rdquo;: it is a badly written query.&lt;/p>
&lt;p>&lt;strong>Applying lifecycle to the media bucket.&lt;/strong> It breaks the files referenced in traces and blocks future uploads (state is tracked by hash in Postgres). The media bucket is managed &lt;strong>only&lt;/strong> with the Langfuse retention feature, never with blind S3 rules.&lt;/p>
&lt;p>&lt;strong>Treating queue sharding as a routine optimisation.&lt;/strong> It is an advanced knob for a genuinely drowning Redis, irreversible (do not reduce shards) and with &lt;em>per-shard&lt;/em> concurrency semantics that are easy to misread. In the vast majority of on-premise deployments it is not needed; if you enable it &amp;ldquo;just in case&amp;rdquo;, you complicate your life without gaining anything.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Langfuse v3 solved the structural problem of LLM observability, that the observer should not suffocate the observed, by moving from a monolith on Postgres to a six-service sorting centre with asynchronous ingestion. That design is what allows a cluster serving thousands of tokens per second to be instrumented end to end without the app ever waiting for a trace to be filed. But the design is a necessary condition, not a sufficient one: it performs if the right levers are tuned. Of the ten knobs, three decide almost everything in a typical on-premise deployment, &lt;strong>scaling Workers by queue length (1), retention + TTL (9), and system log hygiene (10)&lt;/strong>; the rest are refinements that show up when the load bites. And above them all lives knob 0, which is not a Langfuse one: &lt;strong>sampling in the Collector&lt;/strong>, which decides how much reaches the pipeline before any internal setting matters. Maximising Langfuse on the 4×H100 cluster is not about squeezing its peak throughput: it is about putting it off the GPUs, feeding it already-sampled traffic, sizing the Worker by the queue, and configuring retention on day one, so that the tool that came to tell the story does not end up being the chapter about the incident.&lt;/p>
&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/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the &lt;code>SDK → Collector → backend&lt;/code> pipeline that feeds Langfuse. There Langfuse is treated as a destination; here it is opened up. The two-layer sampling of that post is the knob 0 that multiplies the ten in this one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the prompt management layer that lives in Postgres (not in ClickHouse). The &lt;code>prompt_id@version&lt;/code> that post propagates as a span attribute lands in the tracing tables described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> — the Langfuse datasets and evaluators rest on this same backend; the stored traces are the input of continuous eval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — the Langfuse entry alongside Phoenix and the rest of the observability ecosystem.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The on-premise LLM inference stack in seven layers&lt;/a> — where Langfuse fits (layer 5, LLM-aware observability) in the whole building and how it is sized on the same 4×H100 cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — the concrete mechanism for scaling the Langfuse Workers by &lt;code>langfuse.queue.ingestion.length&lt;/code> (knob 1), the same pattern as for vLLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">Technical controls: ENS, ISO 42001 and the EU AI Act&lt;/a> — why Langfuse is best-effort observability and does not replace the regulatory audit log with durability guarantees.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — the cable between the gateway and this backend: integration routes, &lt;code>traceparent&lt;/code> propagation and the four bounded queues that drop events before they reach the ingestion pipeline described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">Langfuse v4: what goes into a trace&lt;/a> — version 4 changed the data model underneath this architecture: there is no trace table any more. It opens the series of eight about operations.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Langfuse, &lt;em>Scaling Langfuse Deployments&lt;/em> (doc oficial de sizing y todos los env vars de este post): &lt;a href="https://langfuse.com/self-hosting/configuration/scaling">https://langfuse.com/self-hosting/configuration/scaling&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Self-host Langfuse&lt;/em> y &lt;em>Configuration via Environment Variables&lt;/em>: &lt;a href="https://langfuse.com/self-hosting">https://langfuse.com/self-hosting&lt;/a> · &lt;a href="https://langfuse.com/self-hosting/configuration">https://langfuse.com/self-hosting/configuration&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>ClickHouse (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse">https://langfuse.com/self-hosting/deployment/infrastructure/clickhouse&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>From Zero to Scale: Langfuse&amp;rsquo;s Infrastructure Evolution&lt;/em> (el porqué del rediseño v2→v3): &lt;a href="https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution">https://langfuse.com/blog/2024-12-langfuse-v3-infrastructure-evolution&lt;/a>.&lt;/li>
&lt;li>Langfuse, &lt;em>Migrate v2 to v3 (self-hosted)&lt;/em>: &lt;a href="https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3">https://langfuse.com/self-hosting/upgrade/upgrade-guides/upgrade-v2-to-v3&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>Langfuse and ClickHouse: A new data stack for modern LLM applications&lt;/em>: &lt;a href="https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications">https://clickhouse.com/blog/langfuse-and-clickhouse-a-new-data-stack-for-modern-llm-applications&lt;/a>.&lt;/li>
&lt;li>Langfuse, issue #13468 — &lt;em>ClickhouseWriter drops rows after max flush attempts with no DLQ&lt;/em> (la ventana de pérdida documentada): &lt;a href="https://github.com/langfuse/langfuse/issues/13468">https://github.com/langfuse/langfuse/issues/13468&lt;/a>.&lt;/li>
&lt;li>ClickHouse, &lt;em>TTL for tables and columns&lt;/em>: &lt;a href="https://clickhouse.com/docs/guides/developer/ttl">https://clickhouse.com/docs/guides/developer/ttl&lt;/a>.&lt;/li>
&lt;li>OpenTelemetry, &lt;em>Semantic Conventions for Generative AI&lt;/em> (&lt;code>gen_ai.*&lt;/code>): &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Mixed NVIDIA + Intel environments for LLM inference: from the central H100 cluster to the NUC in the branch office</title><link>https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/</link><pubDate>Tue, 02 Jun 2026 04:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> (which assumed a pure NVIDIA cluster), &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the stack&lt;/a> (which also did not get into hardware heterogeneity) and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> (where capability routing makes full sense once there is mixed hardware). It is the missing piece for talking about &amp;ldquo;hardware sovereignty&amp;rdquo; without reducing it to &amp;ldquo;which vendor do I pick&amp;rdquo;.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A production LLM inference cluster in 2026 can stop being monolithically NVIDIA if it accepts heterogeneity as an architectural decision. The motivation is not theory but &lt;strong>three measurable operational advantages&lt;/strong>. (1) &lt;strong>Cost&lt;/strong>: an Intel Xeon 6 with AMX (Advanced Matrix Extensions) delivers 7B INT4 at ~80 tok/s while serving embeddings and reranking at a fraction of the cost of dedicating an H100 to that task; the &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> adds up better with an Intel CPU handling the cheap work and an NVIDIA H100 the big LLM. (2) &lt;strong>Sovereignty and supply chain diversification&lt;/strong>: NVIDIA holds roughly 94 % of the AI accelerator market (November 2025), single-vendor dependency with all its risks; Intel manufactures in Europe (Leixlip operational, Magdeburg planned) against a design-only NVIDIA with TSMC as foundry, which for a Spanish or European organisation subject to ENS / NIS2 / EU AI Act is a real hedging argument. (3) &lt;strong>Edge&lt;/strong>: an Intel NUC with a Lunar Lake CPU (48 TOPS NPU) or Panther Lake (50 TOPS NPU + 120 TOPS Xe3 = 180 TOPS platform) runs 7B INT4 models at usable speed, which opens up the &amp;ldquo;branch office with local inference + central DC for complex cases&amp;rdquo; pattern. Relevant Intel hardware in June 2026: &lt;strong>Intel Gaudi 3&lt;/strong> (128 GB HBM2e, 1,835 TFLOPS BF16/FP8, 3.67 TB/s; a direct competitor to the H100, Intel claims +20 % on Llama 2 70B but Signal65 published H200 9× over Gaudi 3 on Llama 3.1 405B, you have to cite both; Falcon Shores cancelled in January 2025, Jaguar Shores 2026 as the restart bet, &lt;strong>Gaudi 4 confirmed as never happening&lt;/strong>); &lt;strong>Intel Xeon 6 with AMX&lt;/strong> (up to 288 E-cores on Sierra Forest or 86 P-cores on Granite Rapids, 1,024 FLOPS BF16/cycle/core with AMX, Intel claims 2.7× tok/s vs EPYC 9965 on the vLLM CPU backend); &lt;strong>Intel Arc Pro B60&lt;/strong> (Battlemage, 24 GB GDDR6, 456 GB/s, 197 TOPS INT8, launched September 2025, with a dual-GPU 48 GB variant and a &amp;ldquo;Battlematrix&amp;rdquo; rack with 8× = 192 GB VRAM); &lt;strong>Intel NUC with NPU&lt;/strong> (Lunar Lake 48 TOPS, Arrow Lake similar, Panther Lake 50 TOPS at CES 2026; realistic for 7-13B INT4, not for the 30-70B Intel claims in its marketing). Software: &lt;strong>OpenVINO 2025.3&lt;/strong> with the GenAI API and vLLM-OpenVINO; &lt;strong>IPEX-LLM&lt;/strong> with integrations into llama.cpp, vLLM, HF, LangChain; &lt;strong>vLLM CPU backend&lt;/strong> with AMX; &lt;strong>llama.cpp SYCL&lt;/strong> (better than Vulkan on Arc). Four canonical patterns: embeddings + reranker on Intel alongside the LLM on NVIDIA; guardrails + PII redaction on a near-edge NUC; speculative drafter on a NUC near the user and target on the H100; NUC dev workstations. Unified observability via DCGM + habana-metric-exporter + intel-gpu-exporter + Intel PCM federated in Prometheus. Pitfalls: tokenizer mismatch between engines, edge↔central round-trip latency, FP8 Hopper ≠ INT8 AMX in quality, version synchronisation. Applied to a generic cluster: central DC 4×H100 SXM + Xeon 6 AMX sidecar + 6-12 Intel NUCs in branch offices. &lt;strong>Critical disclaimer&lt;/strong>: as of June 2026 there are no publicly verifiable cases of a mixed NVIDIA + Intel deployment in European banking or government; the pattern is emergent architecture and worth recommending, not established practice with an industrial track record.&lt;/p>
&lt;h2 id="you-are-here-deploy-with-heterogeneity-as-a-decision">You are here: DEPLOY (with heterogeneity as a decision)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy with heterogeneous hardware">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#mxm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mxm)}&lt;/style>
&lt;defs>&lt;marker id="mxm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · heterogeneous NVIDIA + Intel hardware as an architectural decision&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-factory-with-several-different-machines">The analogy: the factory with several different machines&lt;/h2>
&lt;p>A serious factory has several machines with different purposes, not one universal machine. A 200-tonne hydraulic press for heavy stamping; a bench lathe for turned parts; a 3D printer for quick prototypes; a pick-and-place robot for SMD. Each machine does what it does better than the others in its niche, and the plant manager sizes the mix according to the real product portfolio, not according to fashion. Buying three hydraulic presses because &amp;ldquo;they are the most impressive&amp;rdquo; when 60 % of the work is turned parts wastes capital: the lathe is cheaper, faster in its niche and frees the press for what genuinely needs it.&lt;/p>
&lt;p>An LLM inference cluster with an NVIDIA H100 dedicated to computing embeddings for a RAG corpus is using a hydraulic press to drill bolts. The H100 is magnificent for a 70B LLM in BF16 with concurrency of 40+; for embeddings of an 800-token document with bge-m3, what you need is an Intel Xeon 6 with AMX at a fraction of the cost and power draw. A cluster that wants to serve light guardrails (Llama Guard 4 8B) on every request, with a 50 ms budget, does not need that guardrail on an H100 either: an Intel NUC with a 48 TOPS NPU covers the case with room to spare.&lt;/p>
&lt;p>The heterogeneous factory is not theoretical elegance: it is &lt;strong>maximising the useful utilisation of fixed capital&lt;/strong>. Nor is the heterogeneous LLM inference cluster.&lt;/p>
&lt;h2 id="three-operational-reasons-for-heterogeneity">Three operational reasons for heterogeneity&lt;/h2>
&lt;h3 id="reason-1--cost">Reason 1 — cost&lt;/h3>
&lt;p>An H100 SXM 80 GB running 24/7 draws ~700 W (real measurement at the wall ~697 W with vLLM Llama 3.1 405B batch=4) and represents between 25,000 € and 35,000 € of amortised hardware. An Intel Xeon 6 with AMX (Granite Rapids 86 cores or Sierra Forest 288 E-cores) draws 350-500 W for the socket and costs a fraction. The operational split: the H100 is reserved for the big LLM (Llama 70B BF16 or FP8, where its HBM3 and FP8 tensor cores are worth their weight); the Xeon AMX absorbs embeddings (bge-m3, e5-large), reranking (bge-reranker-v2-m3), small models (Llama 3.2 1B / 3B INT4) and offline batch processing. It is the same logic as the &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> taken one step further: instead of budgeting KV cache VRAM only on the H100, budget each workload on the silicon where its arithmetic intensity fits best.&lt;/p>
&lt;h3 id="reason-2--sovereignty-and-supply-chain-diversification">Reason 2 — sovereignty and supply chain diversification&lt;/h3>
&lt;p>As of November 2025, NVIDIA holds approximately &lt;strong>94 % of the AI accelerator market&lt;/strong>. That concentration is risk. For an organisation subject to ENS / NIS2 / EU AI Act, depending on a single supplier whose foundry is concentrated in Taiwan (TSMC) introduces supply chain vulnerabilities that recent regulations (NIS2, supply chain provisions) are starting to require you to document and mitigate. Intel diversifies: it has its own fabs in Europe (Leixlip operational in Ireland; Magdeburg planned in Germany, with EU Chips Act funding), which for a Spanish or European public sector client is a real contractual argument, not marketing.&lt;/p>
&lt;p>Mandatory disclaimer: &lt;strong>the Intel roadmap after Falcon Shores is unstable&lt;/strong>. Intel cancelled Falcon Shores in January 2025 and relegated Gaudi 4 to &amp;ldquo;will never exist&amp;rdquo;; the restart bet is Jaguar Shores in 2026 as a rack-scale platform, still with no confirmed public specs. Diversification is strategically correct, &lt;strong>but assuming continuity of the Intel roadmap at NVIDIA&amp;rsquo;s level in 2026 would be naive&lt;/strong>. The operational strategy: Intel for workloads where lock-in is lower (CPU for embeddings, NUC for light edge, replaceable by AMD/Apple/SiFive if Intel pivots again), NVIDIA for the big LLM where the maturity of the software stack still has no rival.&lt;/p>
&lt;h3 id="reason-3--edge">Reason 3 — edge&lt;/h3>
&lt;p>The &amp;ldquo;everything travels to the central DC&amp;rdquo; pattern breaks in three cases: latency (branch office 100+ ms away from the DC, unacceptable for chat), data sovereignty (prompts with personal or classified data that must not leave the local perimeter), and offline operation (branch office with intermittent connectivity). The Intel NUC with a modern CPU (Lunar Lake / Arrow Lake / Panther Lake) brings 48-50 TOPS NPU + 100-180 TOPS Xe2/Xe3 iGPU in a box of 0.5-1.5 L volume drawing 30-65 W. 7B INT4 models run at usable speed; with more aggressive quantisation (Q3_K) a Llama 13B fits. For branch offices with RAG over a local corpus + a 7B LLM + guardrails, the NUC is perfect.&lt;/p>
&lt;h2 id="relevant-intel-hardware-june-2026">Relevant Intel hardware (June 2026)&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Part&lt;/th>
&lt;th>Memory&lt;/th>
&lt;th>Key performance&lt;/th>
&lt;th>Launch&lt;/th>
&lt;th>Status&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Intel Gaudi 3&lt;/td>
&lt;td>128 GB HBM2e, 3.67 TB/s&lt;/td>
&lt;td>1,835 TFLOPS BF16/FP8; 1,200 GB/s networking&lt;/td>
&lt;td>Apr 2024&lt;/td>
&lt;td>Active; successor Jaguar Shores 2026 (not Gaudi 4)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel Xeon 6 (Granite Rapids)&lt;/td>
&lt;td>DDR5 + MRDIMM&lt;/td>
&lt;td>86 P-cores, AMX 1,024 FLOPS BF16/cycle/core&lt;/td>
&lt;td>2024-2025&lt;/td>
&lt;td>Active&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel Xeon 6 (Sierra Forest)&lt;/td>
&lt;td>DDR5&lt;/td>
&lt;td>288 E-cores&lt;/td>
&lt;td>2024&lt;/td>
&lt;td>Active&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel Arc Pro B60 (Battlemage)&lt;/td>
&lt;td>24 GB GDDR6, 456 GB/s&lt;/td>
&lt;td>197 TOPS INT8; 12.28 TFLOPS FP32&lt;/td>
&lt;td>Sep 2025&lt;/td>
&lt;td>Active; dual 48 GB variant, rack 8× = 192 GB&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel Data Center GPU Max&lt;/td>
&lt;td>128 GB HBM&lt;/td>
&lt;td>successor to Ponte Vecchio&lt;/td>
&lt;td>discontinued&lt;/td>
&lt;td>&lt;strong>Discontinued&lt;/strong> Jan 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel NUC (Lunar Lake)&lt;/td>
&lt;td>DDR5x&lt;/td>
&lt;td>NPU 48 TOPS + Xe2 67 TOPS = 120 TOPS platform&lt;/td>
&lt;td>2024&lt;/td>
&lt;td>Active&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel NUC (Arrow Lake)&lt;/td>
&lt;td>DDR5&lt;/td>
&lt;td>NPU 13 TOPS + Xe iGPU&lt;/td>
&lt;td>2024&lt;/td>
&lt;td>Active (less NPU than Lunar)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel NUC (Panther Lake)&lt;/td>
&lt;td>DDR5x&lt;/td>
&lt;td>NPU 50 TOPS + Xe3 120 TOPS = 180 TOPS platform&lt;/td>
&lt;td>CES Jan 2026&lt;/td>
&lt;td>Rolling out&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="intel-gaudi-3--the-critical-note-on-the-marketing">Intel Gaudi 3 — the critical note on the marketing&lt;/h3>
&lt;p>Intel publishes that Gaudi 3 delivers &lt;strong>+20 % throughput vs H100 on Llama 2 70B inference&lt;/strong> and 2× price/performance. The figure appears in the official whitepaper and in launch presentations. However, &lt;strong>Signal65 (an independent firm)&lt;/strong> published in 2025 that &lt;strong>the H200 beats Gaudi 3 by a factor of 9× on Llama 3.1 405B&lt;/strong>. The discrepancy matters: both numbers can be true for their specific benchmarks (Llama 2 70B FP16 vs Llama 3.1 405B FP8) but the operational conclusion changes radically depending on which one you keep.&lt;/p>
&lt;p>This post&amp;rsquo;s recommendation: treat Gaudi 3 as a valid option for Llama-class 70B in BF16/FP8 where Intel claims parity or advantage, not for frontier 200B+ models where NVIDIA keeps a clear margin. And consider the roadmap risk: Gaudi 4 will never exist; the line&amp;rsquo;s successor is Jaguar Shores 2026 with a completely new rack-scale architecture, a discontinuity rather than an evolution.&lt;/p>
&lt;h3 id="intel-xeon-6-with-amx--the-cpu-workhorse">Intel Xeon 6 with AMX — the CPU workhorse&lt;/h3>
&lt;p>The &lt;strong>Advanced Matrix Extensions (AMX)&lt;/strong> are the non-obvious piece. Each Granite Rapids P-core executes up to 1,024 BF16 FLOPS per cycle via AMX, which turns a Xeon 6 with 64-86 cores into a respectable matrix accelerator for small and mid-sized models. Real reported figures: &lt;strong>Llama 3.2 INT4 at ~57 tok/s with AMX vs 28 tok/s without AMX&lt;/strong> (a clean 2× factor). For serving 7B INT4 with the vLLM CPU backend + AMX, Intel claims &lt;strong>2.7× tok/s vs EPYC 9965&lt;/strong>, a figure with Intel bias but qualitatively corroborated by LMSYS in their DeepSeek R1 671B deployment on Xeon 6 + SGLang.&lt;/p>
&lt;p>Operational use case: embeddings and reranking on a Xeon 6 sidecar next to the H100 cluster. Models such as &lt;code>bge-m3&lt;/code> (multilingual embedding) or &lt;code>bge-reranker-v2-m3&lt;/code> run at acceptable throughput on CPU AMX; they do not deserve a dedicated H100. Freeing the H100 for the 70B LLM raises the cluster&amp;rsquo;s effective RPS without buying more GPUs.&lt;/p>
&lt;h3 id="intel-arc-pro-b60-and-battlematrix">Intel Arc Pro B60 and Battlematrix&lt;/h3>
&lt;p>Launched in September 2025, the Arc Pro B60 (Battlemage) brings 24 GB GDDR6 with 456 GB/s of bandwidth and 197 TOPS INT8 at 200 W. Maxsun has a dual-GPU 48 GB variant. The &amp;ldquo;Battlematrix&amp;rdquo; rack configuration combines 8 units = &lt;strong>192 GB of aggregate VRAM&lt;/strong>. The interesting point: at a cost far below an H100 SXM 80 GB, which makes it a candidate for 30-70B INT4-INT8 LLMs served via OpenVINO or llama.cpp SYCL.&lt;/p>
&lt;p>Phoronix verified that on SYCL the Arc Pro B70 reaches parity with the Radeon PRO W7900 (previous AMD generation) on DeepSeek R1 Llama 8B &lt;code>pp512&lt;/code>. The Vulkan backend loses heavily (~1/4 of SYCL performance); for Arc Pro, always SYCL.&lt;/p>
&lt;h3 id="intel-nuc-with-npu--the-edge-node">Intel NUC with NPU — the edge node&lt;/h3>
&lt;p>Intel NUCs with a Lunar Lake CPU (Core Ultra Series 2) bring NPU 4 with 48 TOPS and a platform total of 120 TOPS adding the Xe2 iGPU and CPU AVX. Panther Lake (CES January 2026) goes up to NPU 5 = 50 TOPS + Xe3 120 TOPS = 180 TOPS platform.&lt;/p>
&lt;p>Intel states that Panther Lake &amp;ldquo;runs 30-70B models locally&amp;rdquo;. Reality check: &lt;strong>it is marketing&lt;/strong>. A 30-70B INT4 fits in RAM (DDR5x 32-64 GB) but sustained speed with Q4_K_M quantisation on a NUC hovers around 2-8 tok/s; comfortable for occasional use, not for serving traffic. &lt;strong>The NUC&amp;rsquo;s real sweet spot is 7B INT4 at 20-40 tok/s&lt;/strong> on the iGPU/NPU, perfect for a client branch office with casual queries.&lt;/p>
&lt;h2 id="intel-software--the-relevant-stack">Intel software — the relevant stack&lt;/h2>
&lt;p>&lt;strong>OpenVINO 2025.3&lt;/strong> (June 2026) is the central piece. It supports single-command deployment via the OVMS CLI with automatic download from the HF Hub; it integrates &lt;code>OpenVINO GenAI&lt;/code> with a C++/Python API for generative pipelines; it exposes a vLLM v1-compatible API (&lt;code>vLLM-OpenVINO&lt;/code>). GGUF model support: DeepSeek Distill, Qwen 2/2.5, Llama 3. Optimisations: Sage Attention (first token with long prompts), per-channel KV-cache compression.&lt;/p>
&lt;p>&lt;strong>Intel Extension for PyTorch (IPEX)&lt;/strong>, XPU version 2.8.10+xpu, adds Intel backends to PyTorch. &lt;strong>IPEX-LLM&lt;/strong> is the subproject that integrates with llama.cpp, Ollama, HuggingFace, LangChain, LlamaIndex, vLLM and DeepSpeed. May 2025: it ran DeepSeek V3/R1 671B and Qwen3MoE 235B on 1-2 Arc A770/B580 cards with FlashMoE.&lt;/p>
&lt;p>&lt;strong>vLLM CPU backend&lt;/strong>, the CPU branch of vLLM with AMX optimisations. For 7B INT4 on a 4th-gen Xeon with AMX: 12-50 tok/s; with a Xeon Gold 6530 + INT4: ~80 tok/s. Academic figures (arXiv 2410.04466).&lt;/p>
&lt;p>&lt;strong>llama.cpp SYCL&lt;/strong>, the recommended backend for Arc; Vulkan works but hovers around 1/4 of SYCL performance on an Arc B580. SYCL reaches parity with the previous AMD generation.&lt;/p>
&lt;p>&lt;strong>Habana SynapseAI&lt;/strong>, the Gaudi 3 stack. The PyTorch bridge &lt;code>habana_frameworks.torch&lt;/code> registers the &lt;code>hpu&lt;/code> device; it integrates with &lt;code>torch.compile&lt;/code>. It is &lt;strong>not&lt;/strong> a complete port to oneAPI but a partial integration via oneMKL. That implies the Gaudi ecosystem keeps a certain separation from Intel&amp;rsquo;s general oneAPI, relevant in view of the hypothetical Jaguar Shores and future unification.&lt;/p>
&lt;h2 id="the-four-canonical-patterns">The four canonical patterns&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="four canonical NVIDIA + Intel patterns">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.e{fill:#dfe9f5;stroke:#356}.g{fill:#eef0d0;stroke:#7a3}.s{fill:#f4e3cf;stroke:#a63}.d{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.l{font:11px sans-serif;fill:#222}.n{font:italic 10px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Four canonical patterns of mixed NVIDIA + Intel use&lt;/text>
&lt;rect x="20" y="40" width="380" height="120" class="b e"/>
&lt;text x="30" y="62" class="h">1 · EMBEDDINGS + RERANKER ON INTEL&lt;/text>
&lt;text x="30" y="82" class="l">A Xeon 6 AMX sidecar (or Arc Pro B60) serves bge-m3 +&lt;/text>
&lt;text x="30" y="98" class="l">bge-reranker-v2-m3 next to the H100 with the 70B LLM.&lt;/text>
&lt;text x="30" y="118" class="n">Frees the H100 from cheap work; improves effective RPS&lt;/text>
&lt;text x="30" y="132" class="n">with no extra GPU. The most mature pattern.&lt;/text>
&lt;rect x="420" y="40" width="380" height="120" class="b g"/>
&lt;text x="430" y="62" class="h">2 · GUARDRAILS + PII ON A NEAR-EDGE NUC&lt;/text>
&lt;text x="430" y="82" class="l">A Lunar/Panther Lake NUC in the branch runs&lt;/text>
&lt;text x="430" y="98" class="l">Llama Guard 4 + Presidio before the round-trip.&lt;/text>
&lt;text x="430" y="118" class="n">PII never leaves the local perimeter;&lt;/text>
&lt;text x="430" y="132" class="n">latency 50-150ms instead of 200-500ms.&lt;/text>
&lt;rect x="20" y="170" width="380" height="120" class="b s"/>
&lt;text x="30" y="192" class="h">3 · SPECULATIVE DRAFTER ON A NUC&lt;/text>
&lt;text x="30" y="212" class="l">Llama 3.2 1B INT4 on a NUC near the user;&lt;/text>
&lt;text x="30" y="228" class="l">the target Llama 70B on the central H100 accepts/rejects.&lt;/text>
&lt;text x="30" y="248" class="n">TTFT falls ~50% if the acceptance rate is &amp;gt; 60%.&lt;/text>
&lt;text x="30" y="262" class="n">Requires a tokenizer-identical drafter.&lt;/text>
&lt;rect x="420" y="170" width="380" height="120" class="b d"/>
&lt;text x="430" y="192" class="h">4 · NUC DEV WORKSTATIONS&lt;/text>
&lt;text x="430" y="212" class="l">Dev/CI runs tests on Llama 3.2 3B on a NUC;&lt;/text>
&lt;text x="430" y="228" class="l">prod deploys to the H100 cluster after green CI.&lt;/text>
&lt;text x="430" y="248" class="n">Iteration 10× cheaper; validates end-to-end logic&lt;/text>
&lt;text x="430" y="262" class="n">without spending production GPU.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="pattern-1--embeddings--reranker-on-intel">Pattern 1 — embeddings + reranker on Intel&lt;/h3>
&lt;p>The most mature and the easiest to adopt. In a typical RAG system, each user request invokes:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Query embedding&lt;/strong> (50 ms on an H100, 80 ms on a Xeon AMX, 30 ms on an Arc Pro B60).&lt;/li>
&lt;li>&lt;strong>Vector search&lt;/strong> (Qdrant / Milvus / Chroma; latency ~10-30 ms).&lt;/li>
&lt;li>&lt;strong>Reranker over the top-k candidates&lt;/strong> (60 ms on an H100, 100-150 ms on a Xeon AMX).&lt;/li>
&lt;li>&lt;strong>LLM&lt;/strong> over the augmented prompt (200-500 ms TTFT, 30-50 ms/token).&lt;/li>
&lt;/ol>
&lt;p>Steps 1 and 3 are memory-bound and relatively small (100M-1B models): a Xeon 6 with AMX (the Arc Pro B60 is faster but is already a dedicated GPU) does the job at a hardware cost that is a fraction of a dedicated H100. Step 4 stays on NVIDIA because that is where its tensor architecture + HBM3 + FP8 is worth what it costs.&lt;/p>
&lt;p>&lt;strong>Operational implication&lt;/strong>: a Xeon 6 sidecar (~40 cores, ~10-15 k€) serving embeddings + reranking frees the equivalent of 1-2 H100s of &amp;ldquo;cheap&amp;rdquo; load, recovering that capacity for the big LLM. Clear ROI in sizing.&lt;/p>
&lt;h3 id="pattern-2--guardrails--pii-redaction-on-a-near-edge-nuc">Pattern 2 — guardrails + PII redaction on a near-edge NUC&lt;/h3>
&lt;p>A bank branch, a medical practice or a law office generates prompts with personal or classified data. Sending those prompts to the central DC (even if it is corporate on-premise) can clash with local retention policies or with specific compliance (GDPR, professional privilege).&lt;/p>
&lt;p>The pattern: the &lt;strong>NUC in the branch office&lt;/strong> runs two critical steps before the round-trip:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>PII redaction&lt;/strong> with Presidio (CPU-only, fast) or Llama Guard 4 8B on the NUC&amp;rsquo;s NPU + iGPU. It replaces names, tax IDs and account numbers with placeholders.&lt;/li>
&lt;li>&lt;strong>Light guardrails&lt;/strong> (PromptGuard 2 86M, Llama Guard 4 8B) on the NPU + iGPU. It filters prompt injection, jailbreaks and prohibited content.&lt;/li>
&lt;/ol>
&lt;p>Only afterwards does the redacted prompt travel to the central DC so the big LLM can answer. The response comes back to the NUC, which rehydrates the placeholders with the real values before showing it to the user. The sensitive data never leaves the branch office.&lt;/p>
&lt;p>Costs: a Panther Lake NUC is ~1,500-2,500 € per unit, scalable to dozens of branch offices with no additional central GPU cost. Latency: 50-150 ms for the edge step before the DC&amp;rsquo;s 200-500 ms round-trip.&lt;/p>
&lt;h3 id="pattern-3--speculative-decoding-drafter-on-a-nuc">Pattern 3 — speculative decoding drafter on a NUC&lt;/h3>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> uses a &lt;strong>small drafter&lt;/strong> that proposes γ tokens and a &lt;strong>large target&lt;/strong> that accepts or rejects them in a single forward pass. If the drafter is geographically close to the user (a NUC in the branch office) and the target is in the central DC, the client&amp;rsquo;s perceived latency falls further still.&lt;/p>
&lt;p>&lt;strong>Setup&lt;/strong>: drafter Llama 3.2 1B INT4 on a NUC + target Llama 3.1 70B FP8 on the central H100. The NUC generates γ=4 tokens in ~50 ms locally; the target verifies them in one pass (40-80 ms including round-trip); if the acceptance rate is &amp;gt; 60 %, &lt;strong>effective TTFT falls ~50 %&lt;/strong> compared with Llama 70B without speculative decoding.&lt;/p>
&lt;p>An important constraint: &lt;strong>drafter and target must share a tokenizer&lt;/strong>. Llama 3.2 1B and Llama 3.1 70B have compatible tokenizers. Mixing a Llama drafter with a Qwen target breaks the pattern.&lt;/p>
&lt;h3 id="pattern-4--nuc-dev-workstations">Pattern 4 — NUC dev workstations&lt;/h3>
&lt;p>Dev / CI iterating on prompts, evals and retrieval logic does not need a production GPU to validate correctness. A NUC with Llama 3.2 3B INT4 runs the end-to-end functional tests (including embeddings + retrieval + LLM + guardrails) at a tenth of the cost of iterating on an H100. &lt;strong>Only the last pre-prod smoke test uses the production cluster&lt;/strong>.&lt;/p>
&lt;p>A mature pattern in organisations with many developers and scarce production GPU. Iteration that is 10× faster and cheaper translates into feature delivery speed.&lt;/p>
&lt;h2 id="unified-observability-in-a-heterogeneous-cluster">Unified observability in a heterogeneous cluster&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability post&lt;/a> covered DCGM Exporter for NVIDIA. In a mixed cluster you need more:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Hardware part&lt;/th>
&lt;th>Exporter&lt;/th>
&lt;th>Key metrics&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>NVIDIA H100/A100&lt;/td>
&lt;td>&lt;code>nvidia/dcgm-exporter&lt;/code>&lt;/td>
&lt;td>DCGM_FI_DEV_* + DCGM_FI_PROF_*&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel Gaudi 3&lt;/td>
&lt;td>&lt;code>HabanaAI/habana-metric-exporter&lt;/code>&lt;/td>
&lt;td>habana_hpu_utilization, habana_hbm_used&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel Arc Pro&lt;/td>
&lt;td>&lt;code>intel/intel-gpu-exporter&lt;/code> (unofficial; alternatives exist)&lt;/td>
&lt;td>xe_engine_utilization, xe_memory_used&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel Xeon CPU + AMX&lt;/td>
&lt;td>&lt;code>prometheus/node-exporter&lt;/code> + Intel PCM&lt;/td>
&lt;td>cpu_amx_utilization (via PCM)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Intel NUC (NPU+iGPU)&lt;/td>
&lt;td>&lt;code>intel/intel-gpu-exporter&lt;/code> + custom NPU exporter&lt;/td>
&lt;td>npu_utilization, xe_iGPU&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>All federated into a single Prometheus + Grafana. The dashboards are organised by hardware family (NVIDIA, Intel server, Intel edge) plus an aggregate &amp;ldquo;heterogeneous cluster&amp;rdquo; view with per-tenant SLOs combining all four.&lt;/p>
&lt;p>Cardinality: ~1.5-2× that of a pure NVIDIA cluster. Manageable with Thanos / Mimir for long retention.&lt;/p>
&lt;h2 id="capability-routing--from-the-l7-router-to-the-heterogeneous-one">Capability routing — from the L7 router to the heterogeneous one&lt;/h2>
&lt;p>The &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">LLM inference router&lt;/a> stops being a version selector for the same model and becomes a &lt;strong>capability dispatcher&lt;/strong>:&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="nt">models&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;llama-70b-chat&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">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;vllm-llama70b.inference.svc:8000&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">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia-h100&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">chat, tool_use, json_mode]&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;embedding-multilingual&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">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ipex-bge-m3.inference.svc:8080&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">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">intel-xeon-amx&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">embeddings]&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;reranker-multilingual&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">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ipex-bge-reranker.inference.svc:8080&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">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">intel-xeon-amx&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">reranking]&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;guardrail-prompt-injection&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">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;openvino-llama-guard.edge-suc01.local:8080&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">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">intel-nuc-edge&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">guardrails, redact-pii]&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">region&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sucursal-01&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;llama-3b-draft&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">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;openvino-llama-3b.edge-suc01.local:8080&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">backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">intel-nuc-edge&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">speculative-drafter]&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">region&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sucursal-01&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">target_model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;llama-70b-chat&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The router resolves &lt;code>model=embedding-multilingual&lt;/code> → Intel Xeon; &lt;code>model=llama-70b-chat&lt;/code> → H100; &lt;code>model=guardrail-prompt-injection&lt;/code> with &lt;code>region=sucursal-01&lt;/code> → local NUC. If the branch office NUC goes down, &lt;strong>failover&lt;/strong> to an equivalent replica in the central DC, accepting the latency cost.&lt;/p>
&lt;p>LiteLLM Proxy, NVIDIA Dynamo and Envoy AI Gateway support this capability routing. The non-obvious piece: the router has to know the compatible tokenizer between drafter and target for pattern 3, which is modelled in extra catalogue metadata.&lt;/p>
&lt;h2 id="specific-pitfalls">Specific pitfalls&lt;/h2>
&lt;p>&lt;strong>Tokenizer mismatch between engines.&lt;/strong> OpenVINO with a Llama 3.2 GGUF and vLLM with the same nominal Llama 3.2 can use slightly different tokenizers (chat template, special tokens). Validate token identity with &lt;code>tokenizer.encode(&amp;quot;hola&amp;quot;)&lt;/code> on both sides before assuming interchangeability. For speculative decoding, &lt;strong>a single different token breaks the pattern&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Edge ↔ central round-trip latency.&lt;/strong> Patterns 2 and 3 assume the NUC and the DC are on the same corporate WAN with controlled latency. If the branch office is on 4G/5G with 100-200 ms of jitter, the speculative drafter compensates for nothing; on the contrary, it adds latency. Measure before promising.&lt;/p>
&lt;p>&lt;strong>FP8 Hopper ≠ INT8 AMX in output quality.&lt;/strong> The operator assumes a request that runs FP8 on an H100 and INT8 on a Xeon AMX will produce the same output. &lt;strong>That is not true&lt;/strong>: the two quantisations have different degradation profiles. If the system expects idempotence (for example, evals with golden output), validate offline that the Intel version reproduces the expected behaviour within tolerance.&lt;/p>
&lt;p>&lt;strong>Model version synchronisation between sites.&lt;/strong> The model in the central DC gets updated, but the branch office NUCs keep the old version of the drafter or the guardrail for weeks. Result: divergent behaviour between branches with no easy diagnosis. Policy: &lt;strong>the central model and the edge model advance together&lt;/strong> or within a documented window; the &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary&lt;/a> extends to the NUC fleet.&lt;/p>
&lt;p>&lt;strong>Unstable Intel roadmap.&lt;/strong> Falcon Shores cancelled, Gaudi 4 will never exist, Jaguar Shores 2026 still with no confirmed public specs. Buying Gaudi 3 today is reasonable if the use case justifies the 18-24 months of amortisation; committing an architecture for 5+ years on an Intel accelerator is a riskier bet than the NVIDIA equivalent, at least until Jaguar Shores materialises with a mature software stack.&lt;/p>
&lt;p>&lt;strong>Absence of public production deployments.&lt;/strong> As of June 2026, the confirmed Gaudi 3 deployments are IBM Cloud, Dell AI Factory and a handful of early adopters (Bharti Airtel, Bosch, Naver). &lt;strong>There is no publicly verifiable case of a mixed NVIDIA + Intel cluster in European banking or government&lt;/strong>. This pattern is recommended emergent architecture, not practice with an industrial track record. The first adopter takes on a validation cost that a second adopter avoids.&lt;/p>
&lt;h2 id="applied-to-a-generic-on-premise-cluster">Applied to a generic on-premise cluster&lt;/h2>
&lt;p>For an organisation with a generic heterogeneous LLM inference cluster:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Central DC&lt;/strong>: 4 nodes × 4×H100 SXM 80 GB with intra-node NVLink = 16 H100s. Serves the big LLMs (Llama 70B, Mixtral 8×22B, Qwen 72B) in BF16 or FP8.&lt;/li>
&lt;li>&lt;strong>Xeon 6 sidecar&lt;/strong>: 2-4 Xeon 6 servers (Granite Rapids 64-86 cores) with AMX, 512 GB DDR5, in the same rack as the H100 cluster. Serves embeddings (bge-m3), reranking (bge-reranker-v2-m3) and small models (Llama 3.2 1B/3B) on the vLLM CPU backend with AMX.&lt;/li>
&lt;li>&lt;strong>Arc Pro sidecar&lt;/strong> (optional): 1-2 servers with 4-8× Arc Pro B60 24 GB each (Battlematrix), for 13-30B INT8 models via OpenVINO. Useful if the cost per mid-sized LLM has to come down from the H100.&lt;/li>
&lt;li>&lt;strong>Edge NUCs in branch offices&lt;/strong>: 1-2 Panther Lake NUCs per branch, with a 50 TOPS NPU + Xe3 120 TOPS, serving Llama Guard 4 + Presidio + a Llama 3.2 1B INT4 drafter via OpenVINO. Corporate WAN connectivity with latency &amp;lt; 80 ms to the DC.&lt;/li>
&lt;/ul>
&lt;p>Estimated envelope: central cluster ~120 kW of GPU peak + ~10-15 kW of Intel sidecars. Edge: ~50 W per NUC, negligible compared with office costs.&lt;/p>
&lt;p>Observability: federated Prometheus in the DC + pull scrape from the NUCs (via corporate VPN). Dashboards &amp;ldquo;NVIDIA GPU fleet&amp;rdquo;, &amp;ldquo;Intel server fleet&amp;rdquo;, &amp;ldquo;Intel edge fleet&amp;rdquo; plus a &amp;ldquo;consolidated SLO&amp;rdquo; view.&lt;/p>
&lt;p>Router: LiteLLM Proxy or NVIDIA Dynamo in the DC, with an extended model catalogue to include Intel backends and regions (sucursal-01, sucursal-02, &amp;hellip;). Documented edge→central failover.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Reproducible benchmarks&lt;/strong> of Llama 70B on Gaudi 3 vs H100 SXM on equivalent hardware, the missing material for making decisions with your own data rather than Intel&amp;rsquo;s or Signal65&amp;rsquo;s.&lt;/li>
&lt;li>&lt;strong>AMD ROCm in the mix&lt;/strong>: how MI300X / MI355X fit into this heterogeneous pattern and what changes in the router catalogue.&lt;/li>
&lt;li>&lt;strong>Apple Silicon as edge&lt;/strong>: M3/M4 Max with a Neural Engine of ~38 TOPS + a 40-core GPU, hardware equivalent to the Panther Lake NUC but with a different software stack (MLX).&lt;/li>
&lt;li>&lt;strong>Energy cost optimisation&lt;/strong>: how &lt;code>nvidia-smi -pl 500W&lt;/code> + an Intel TDP cap on Xeon 6 cuts the bill by 25-30 % with a 15-20 % throughput loss.&lt;/li>
&lt;li>&lt;strong>Model CI/CD for an edge fleet&lt;/strong>: how a rolling update of a Llama Guard reaches 50 branch office NUCs without any of them losing service.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">Serving embeddings and rerankers with TEI in production&lt;/a> — TEI on CPU for batch ingest and on GPU for query time, the concrete heterogeneous case.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">Taking RAG to the CPU: separating the data plane from the generation plane&lt;/a> — the concrete case: the RAG data plane on the CPU nodes, generation on the GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the sizing that this heterogeneity lets you optimise task by task, instead of everything on the H100.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — the seven layers apply just the same on heterogeneous hardware; the backends are interchangeable as long as the OpenAI-compatible contract is respected.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the capability router is the central piece of the heterogeneous pattern.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — extends to Gaudi, Arc, Xeon AMX and edge NPUs.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — FP8 Hopper, INT8 AMX, INT4 GGUF, the basis for why mixed hardware demands cross-validation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — pattern 3 of this post; how the near-edge drafter closes the latency gap.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a> — the models that live on the NUC in pattern 2.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS catalogue for LLMOps&lt;/a> — entries for OpenVINO, IPEX-LLM and the vLLM CPU backend.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers&lt;/a> — the parallel lock-in analysis that underpins the diversification argument.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;strong>Intel Gaudi 3&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Intel — &lt;em>Gaudi 3 AI Accelerator White Paper&lt;/em>. &lt;a href="https://cdrdv2-public.intel.com/817486/gaudi-3-ai-accelerator-white-paper.pdf">https://cdrdv2-public.intel.com/817486/gaudi-3-ai-accelerator-white-paper.pdf&lt;/a>&lt;/li>
&lt;li>Intel — Hot Chips 2024 Gaudi 3 deep dive. &lt;a href="https://hc2024.hotchips.org/assets/program/conference/day1/60_HC2024.Intel.RomanKaplan.Gaudi3-0826.pdf">https://hc2024.hotchips.org/assets/program/conference/day1/60_HC2024.Intel.RomanKaplan.Gaudi3-0826.pdf&lt;/a>&lt;/li>
&lt;li>Signal65 / DataCenterDynamics — &lt;em>NVIDIA H200 outperforms Intel Gaudi 3 by factor of 9× across first Llama 3.1 405B benchmark test&lt;/em>. &lt;a href="https://www.datacenterdynamics.com/en/news/nvidia-h200-outperforms-intel-gaudi-3-by-factor-of-nine-across-first-llama-31-405b-benchmark-test-exclusive/">https://www.datacenterdynamics.com/en/news/nvidia-h200-outperforms-intel-gaudi-3-by-factor-of-nine-across-first-llama-31-405b-benchmark-test-exclusive/&lt;/a>&lt;/li>
&lt;li>IEEE Spectrum — &lt;em>Intel Gaudi 3 review&lt;/em>. &lt;a href="https://spectrum.ieee.org/intel-gaudi-3">https://spectrum.ieee.org/intel-gaudi-3&lt;/a>&lt;/li>
&lt;li>Tom&amp;rsquo;s Hardware — &lt;em>Intel cancels Falcon Shores GPU; Jaguar Shores to be successor&lt;/em>. &lt;a href="https://www.tomshardware.com/tech-industry/artificial-intelligence/intel-cancels-falcon-shores-gpu-for-ai-workloads-jaguar-shores-to-be-successor">https://www.tomshardware.com/tech-industry/artificial-intelligence/intel-cancels-falcon-shores-gpu-for-ai-workloads-jaguar-shores-to-be-successor&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Intel Xeon 6 + AMX&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Intel — &lt;em>Xeon 6 (Granite Rapids) Product Brief&lt;/em>. &lt;a href="https://www.intel.com/content/dam/www/central-libraries/us/en/documents/2025-02/xeon-6-granite-rapids-product-brief.pdf">https://www.intel.com/content/dam/www/central-libraries/us/en/documents/2025-02/xeon-6-granite-rapids-product-brief.pdf&lt;/a>&lt;/li>
&lt;li>OpenMetal — &lt;em>Intel AMX AI Inference Performance&lt;/em>. &lt;a href="https://openmetal.io/resources/blog/intel-amx-ai-inference-performance/">https://openmetal.io/resources/blog/intel-amx-ai-inference-performance/&lt;/a>&lt;/li>
&lt;li>LMSYS — &lt;em>Intel Xeon 6 + SGLang for DeepSeek R1 671B&lt;/em>. &lt;a href="https://www.lmsys.org/blog/2025-07-14-intel-xeon-optimization/">https://www.lmsys.org/blog/2025-07-14-intel-xeon-optimization/&lt;/a>&lt;/li>
&lt;li>arXiv 2410.04466 — &lt;em>CPU-LLM benchmarks with AMX&lt;/em>.&lt;/li>
&lt;li>Intel community blog — &lt;em>Accelerating vLLM Inference on Intel Xeon 6 Processor&lt;/em>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Intel Arc Pro Battlemage&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Intel — &lt;em>Arc Pro B60 Graphics Specifications&lt;/em>. &lt;a href="https://www.intel.com/content/www/us/en/products/sku/243916/intel-arc-pro-b60-graphics/specifications.html">https://www.intel.com/content/www/us/en/products/sku/243916/intel-arc-pro-b60-graphics/specifications.html&lt;/a>&lt;/li>
&lt;li>StorageReview — &lt;em>Intel Arc Pro B60 Battlematrix Preview: 192GB VRAM for On-Premise AI&lt;/em>. &lt;a href="https://www.storagereview.com/review/intel-arc-pro-b60-battlematrix-preview-192gb-of-vram-for-on-premise-ai">https://www.storagereview.com/review/intel-arc-pro-b60-battlematrix-preview-192gb-of-vram-for-on-premise-ai&lt;/a>&lt;/li>
&lt;li>Phoronix — &lt;em>Intel Arc Pro B-series review&lt;/em>. &lt;a href="https://www.phoronix.com/review/intel-arc-pro-b-series">https://www.phoronix.com/review/intel-arc-pro-b-series&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Intel NUC / NPU&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>HotHardware — &lt;em>Intel CES 2026 Panther Lake is a Go&lt;/em>. &lt;a href="https://hothardware.com/news/intel-ces-2026-panther-lake-is-a-go">https://hothardware.com/news/intel-ces-2026-panther-lake-is-a-go&lt;/a>&lt;/li>
&lt;li>TechPowerUp — &lt;em>Intel Panther Lake Technical Deep Dive&lt;/em>.&lt;/li>
&lt;li>arXiv 2412.11053 — &lt;em>NITRO: LLM inference on laptop NPU&lt;/em>.&lt;/li>
&lt;li>Intel — &lt;em>AI PC brings larger LLM development to your desk&lt;/em>.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Software&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>OpenVINO — &lt;em>Release Notes 2025.3&lt;/em>. &lt;a href="https://www.intel.com/content/www/us/en/developer/articles/release-notes/openvino/2025-3.html">https://www.intel.com/content/www/us/en/developer/articles/release-notes/openvino/2025-3.html&lt;/a>&lt;/li>
&lt;li>HuggingFace — &lt;em>Deploy with OpenVINO&lt;/em>. &lt;a href="https://huggingface.co/blog/deploy-with-openvino">https://huggingface.co/blog/deploy-with-openvino&lt;/a>&lt;/li>
&lt;li>Intel — &lt;em>Intel Extension for PyTorch XPU 2.8.10&lt;/em>. &lt;a href="https://intel.github.io/intel-extension-for-pytorch/xpu/latest/tutorials/releases.html">https://intel.github.io/intel-extension-for-pytorch/xpu/latest/tutorials/releases.html&lt;/a>&lt;/li>
&lt;li>IPEX-LLM — &lt;code>github.com/intel/ipex-llm&lt;/code>.&lt;/li>
&lt;li>Habana — &lt;em>SynapseAI PyTorch Theory of Operations&lt;/em>. &lt;a href="https://docs.habana.ai/en/latest/PyTorch/PyTorch_Gaudi_Theory_of_Operations.html">https://docs.habana.ai/en/latest/PyTorch/PyTorch_Gaudi_Theory_of_Operations.html&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Market context&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>MLCommons — &lt;em>MLPerf Inference v6.0 benchmark results&lt;/em>. &lt;a href="https://www.spheron.network/blog/mlperf-inference-v6-benchmark-results-2026/">https://www.spheron.network/blog/mlperf-inference-v6-benchmark-results-2026/&lt;/a>&lt;/li>
&lt;li>Intel newsroom — &lt;em>Gaudi 3 Expanded Availability&lt;/em>. &lt;a href="https://newsroom.intel.com/artificial-intelligence/intel-gaudi-3-expands-availability-drive-ai-innovation-scale">https://newsroom.intel.com/artificial-intelligence/intel-gaudi-3-expands-availability-drive-ai-innovation-scale&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Sources: the full URLs are linked inline on each reference.&lt;/p></description></item><item><title>The LLM inference router: the L7 switchboard we called a LoadBalancer in the canary post</title><link>https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/</link><pubDate>Tue, 02 Jun 2026 03:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/</guid><description>&lt;blockquote>
&lt;p>This post is the natural continuation of &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a>. There the promotion mechanics dumped all the traffic-splitting complexity into a box we called &amp;ldquo;LoadBalancer&amp;rdquo;. The description was operational, good enough to understand the choreography, but structurally vague: what actually does that splitting is an L7 inference router with LLM awareness, a piece of the stack in its own right (&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">layer 1 of the seven layers&lt;/a>) that deserves its own post.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>In the previous post on &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary&lt;/a> we called &lt;strong>LoadBalancer&lt;/strong> the piece that splits traffic between the stable v1 and candidate v2 pools. The description was enough to understand the flow, but technically it was blurry: neither an L4 LoadBalancer (kube-proxy, MetalLB, IPVS) nor a generic L7 HTTP LoadBalancer (NGINX or HAProxy with no extension) knows what a model is, what a version is, how many tokens a request costs, what prefix the prompt has or which replica has which KV cache hot. The right piece is an &lt;strong>LLM inference router&lt;/strong>: an L7 proxy with explicit knowledge of the domain. It combines four functions: &lt;strong>model catalogue&lt;/strong> (resolving &lt;code>model=llama-70b@v2&lt;/code> → &lt;code>service.namespace:port&lt;/code>), &lt;strong>traffic splitting&lt;/strong> (applying the canary weight with a deterministic hash or deliberate stickiness for A/B), &lt;strong>cross-cutting policy&lt;/strong> (OIDC auth, per-tenant rate limit and quota, pre-prompt PII redaction, lightweight inline guardrails, &lt;code>gen_ai.*&lt;/code> tracing propagation) and &lt;strong>failover/degradation&lt;/strong> (if v2 falls over, redirect to v1; if the whole cluster is saturated, return 503 with &lt;code>Retry-After&lt;/code> instead of queueing forever). The &lt;strong>non-obvious&lt;/strong> piece that justifies its technical existence beyond the operational one is &lt;strong>prefix-aware routing&lt;/strong>: the router decides which fleet replica each request goes to based on the prompt prefix, so that a RAG system with the same system prompt plus the same block of retrieved documents systematically hits the prefix cache (RadixAttention in SGLang, PrefixCaching in vLLM, KV reuse in TensorRT-LLM) of the &lt;strong>same&lt;/strong> replica, multiplying the hit rate from &lt;strong>5–15 %&lt;/strong> (blind round-robin) to &lt;strong>60–85 %&lt;/strong> (prefix affinity). The concrete pieces in May 2026 are LiteLLM Proxy (the simplest option, OpenAI-compatible, declarative YAML catalogue), vLLM Production Stack router (specific to vLLM fleets, aware of the KV cache and the prefix), Envoy AI Gateway (LLM-aware Envoy filters, integrable with Istio), Kong AI Gateway (enterprise alternative with a plugin ecosystem), KGateway (CNCF, still incubating) and the NVIDIA Dynamo router (production-grade, aware of &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated prefill/decode serving&lt;/a>). In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a> it lives in &lt;strong>layer 1&lt;/strong> (gateway); in the &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">five maturity levels&lt;/a> it appears from &lt;strong>level 3&lt;/strong> onwards; in the &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven-phase deployment cycle&lt;/a> it is the last piece that &lt;strong>F6&lt;/strong> closes. This post includes a minimal manifest applicable to a generic 4×H100 SXM cluster.&lt;/p>
&lt;h2 id="you-are-here-deploy-layer-1-of-the-stack">You are here: DEPLOY (layer 1 of the stack)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy, layer 1 of the stack">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#rim)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#rim)}&lt;/style>
&lt;defs>&lt;marker id="rim" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · layer 1 of the stack (gateway / inference router)&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-background-what-the-canary-post-called-a-loadbalancer">The background: what the canary post called a &amp;ldquo;LoadBalancer&amp;rdquo;&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> we described the flow like this: &lt;em>&amp;ldquo;the LoadBalancer progressively splits traffic following a schedule: 1 % → 5 % → 25 % → 100 %&amp;rdquo;&lt;/em>. It was a correct &lt;strong>operational&lt;/strong> description, and the reader understood the choreography without needing more. But &lt;strong>technically&lt;/strong> it left unnamed a piece that deserves explicit treatment, because neither of the two usual senses of &amp;ldquo;LoadBalancer&amp;rdquo; does what that paragraph assumed:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>An L4 LoadBalancer&lt;/strong> (kube-proxy with iptables/IPVS, MetalLB, F5 BIG-IP in TCP mode) splits IP packets without looking inside the payload. It does not know which model is being requested, nor which version, nor how many tokens it carries, nor whether the client has quota. It cannot apply the canary weight &amp;ldquo;for model X version 2&amp;rdquo;: to it, every packet heading for the &lt;code>vllm-llama70b&lt;/code> VIP is indistinguishable.&lt;/li>
&lt;li>&lt;strong>A generic L7 HTTP LoadBalancer&lt;/strong> (NGINX or HAProxy in HTTP mode with no extension, a &lt;code>ClusterIP&lt;/code> Service with multiple backends) does split by URL and can route by header, but &lt;strong>it does not understand the OpenAI-compatible body&lt;/strong> of the request. It does not know that &lt;code>{&amp;quot;model&amp;quot;: &amp;quot;llama-70b&amp;quot;, &amp;quot;messages&amp;quot;: [...]}&lt;/code> carries the routing key in the &lt;code>model&lt;/code> field; it does not count tokens; it does not apply policies over LLM structures; it does not do prefix-aware routing because that requires parsing &lt;code>messages&lt;/code> and hashing the common prefix.&lt;/li>
&lt;/ul>
&lt;p>The piece the canary post assumed was doing this work is an &lt;strong>L7 inference router with LLM awareness&lt;/strong>. A layer in its own right, with its own configuration, its own CI/CD, its own metrics and its own pitfalls. This post names it and takes it apart.&lt;/p>
&lt;h2 id="the-analogy-the-switchboard-and-triage-of-a-hospital-with-multiple-specialities">The analogy: the switchboard and triage of a hospital with multiple specialities&lt;/h2>
&lt;p>A large hospital receives patients who arrive at A&amp;amp;E through different doors and who need different specialities: trauma, cardiology, paediatrics, oncology. There are three possible models of &amp;ldquo;front door&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>A single door with no triage.&lt;/strong> All patients wait in the same room and are sent in arrival order to the first free doctor, whatever their speciality. It works in a village surgery with a single general practitioner. When there are 200 patients a day and 12 specialities, it falls into dysfunction fast: the cardiologist treats sprains, the paediatrician treats heart attacks, specialised resources are wasted. This is the equivalent of the L4 LoadBalancer, which distributes bodies without understanding what they bring.&lt;/p>
&lt;p>&lt;strong>A door with a receptionist who asks about the symptom.&lt;/strong> Someone at the front desk asks &amp;ldquo;what&amp;rsquo;s wrong?&amp;rdquo; and directs the patient to the right corridor. The cardiologist sees only cardiology, the paediatrician only children. Better, but the receptionist is slow, does not gauge urgency and does not know the state of the rooms: they may send you to the cardiologist in corridor A when the one in B is free. This is the equivalent of a generic L7 HTTP proxy with &lt;code>path-based routing&lt;/code>, which distributes by category but with no information about internal state.&lt;/p>
&lt;p>&lt;strong>Professional triage with full awareness.&lt;/strong> A trained triage nurse who knows the catalogue of specialities, knows which bay is busy and which is free, remembers the recurring patient whose file is already open in the system (and sends them to the same doctor for continuity), applies cross-cutting policy (checks insurance cover, records allergens, redirects to paediatric A&amp;amp;E if the patient is a minor) and, if the cardiology room goes down because the electrocardiograph has broken, redirects to the hospital on the other side of the city. This is the piece a large hospital needs. In LLM terms it is called an &lt;strong>inference router&lt;/strong>.&lt;/p>
&lt;p>The analogy holds down to the last detail, including the &amp;ldquo;file already open&amp;rdquo; one: the patient who returns to the same doctor is exactly the client whose prompt shares a prefix with the one from five minutes ago. If the router sends it to the &lt;strong>same replica&lt;/strong>, that replica still has the KV cache hot and the request hits the prefix cache. If it sends it to a different replica because that one was &amp;ldquo;next in round-robin&amp;rdquo;, the KV cache has to be rebuilt from scratch and TTFT doubles. The triage nurse knows this. The blind LoadBalancer does not.&lt;/p>
&lt;h2 id="the-four-functions-of-the-inference-router">The four functions of the inference router&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="four functions of the LLM inference router">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.c{fill:#dfe9f5;stroke:#356}.t{fill:#eef0d0;stroke:#7a3}.p{fill:#f4e3cf;stroke:#a63}.f{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.h{font:700 12px sans-serif;fill:#222}.l{font:11px sans-serif;fill:#222}.n{font:italic 10px sans-serif;fill:#444}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Four functions the inference router combines&lt;/text>
&lt;rect x="20" y="40" width="380" height="120" class="b c"/>
&lt;text x="30" y="62" class="h">1 · MODEL CATALOGUE&lt;/text>
&lt;text x="30" y="82" class="l">Resolve `model=llama-70b@v2` → service:port&lt;/text>
&lt;text x="30" y="102" class="l">Versioning, aliases, lifecycle (preview/stable/deprecated)&lt;/text>
&lt;text x="30" y="125" class="n">What stops the client from knowing the topology.&lt;/text>
&lt;text x="30" y="145" class="n">Without it, every client knows internal IPs/ports.&lt;/text>
&lt;rect x="420" y="40" width="380" height="120" class="b t"/>
&lt;text x="430" y="62" class="h">2 · TRAFFIC SPLITTING&lt;/text>
&lt;text x="430" y="82" class="l">Canary / blue-green / shadow weight&lt;/text>
&lt;text x="430" y="102" class="l">Deterministic per-request hash or deliberate stickiness&lt;/text>
&lt;text x="430" y="125" class="n">The splits from the canary post are applied here,&lt;/text>
&lt;text x="430" y="145" class="n">not in the inference engine.&lt;/text>
&lt;rect x="20" y="170" width="380" height="120" class="b p"/>
&lt;text x="30" y="192" class="h">3 · CROSS-CUTTING POLICY&lt;/text>
&lt;text x="30" y="212" class="l">OIDC auth · rate limit · per-tenant quota&lt;/text>
&lt;text x="30" y="232" class="l">Pre-prompt PII redaction · lightweight inline guardrails&lt;/text>
&lt;text x="30" y="252" class="l">Propagated gen_ai.* tracing · semantic cache&lt;/text>
&lt;text x="30" y="275" class="n">What is applied once across all models.&lt;/text>
&lt;rect x="420" y="170" width="380" height="120" class="b f"/>
&lt;text x="430" y="192" class="h">4 · FAILOVER · DEGRADATION&lt;/text>
&lt;text x="430" y="212" class="l">If v2 falls over → redirect to v1&lt;/text>
&lt;text x="430" y="232" class="l">If everything saturated → 503 with Retry-After&lt;/text>
&lt;text x="430" y="252" class="l">Circuit breaker · active health probes&lt;/text>
&lt;text x="430" y="275" class="n">What stops requests queueing forever.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="function-1--model-catalogue">Function 1 — Model catalogue&lt;/h3>
&lt;p>The router keeps a declarative catalogue mapping model identity to a concrete deployment:&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="nt">models&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;llama-70b&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># stable alias&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">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;v2&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># canary version&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">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 5% of the traffic&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;vllm-llama70b-v2.inference.svc.cluster.local:8000&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">chat, tool_use]&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">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">canary&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;llama-70b&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">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;v1&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">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">95&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;vllm-llama70b-v1.inference.svc.cluster.local:8000&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">chat, tool_use]&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">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">stable&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;embedding-multilingual&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">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;v1&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">weight&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">100&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;tei-bge-m3.inference.svc.cluster.local:8080&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">capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">embeddings]&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">lifecycle&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">stable&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The client sends &lt;code>{&amp;quot;model&amp;quot;: &amp;quot;llama-70b&amp;quot;, &amp;quot;messages&amp;quot;: [...]}&lt;/code> without knowing there are two replica pools behind it. The router resolves. If tomorrow you migrate from vLLM to SGLang for a specific version, the client never notices; you change the &lt;code>endpoint&lt;/code> in the catalogue and that is it.&lt;/p>
&lt;p>What this decoupling buys you is the freedom to move topology without breaking clients. What it costs is keeping the naming convention disciplined (&lt;code>llama-70b&lt;/code> is always the stable alias; &lt;code>llama-70b@v2&lt;/code> is the specific version for canary). Without that discipline, the aliases get dirty with &lt;code>llama-70b-prod-fixed-real-final-v3&lt;/code> and the catalogue stops being navigable within a few weeks.&lt;/p>
&lt;h3 id="function-2--traffic-splitting">Function 2 — Traffic splitting&lt;/h3>
&lt;p>The splits from the &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">canary post&lt;/a> (1 % → 5 % → 25 % → 100 %) materialise &lt;strong>here&lt;/strong>, not in the inference engine. The router computes a deterministic hash of the &lt;code>request_id&lt;/code> (or of the &lt;code>user_id&lt;/code>, if you want stickiness) and maps it to the weight range in the catalogue. For a weight of &lt;code>[v1: 95, v2: 5]&lt;/code>, 5 % of the hash space falls in v2 and 95 % in v1.&lt;/p>
&lt;p>Three design decisions that matter:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hash by random &lt;code>request_id&lt;/code>&lt;/strong> = independent sampling. Each request is an independent observation of the v1 vs v2 distribution. It is the right setting for statistically comparable canaries.&lt;/li>
&lt;li>&lt;strong>Hash by &lt;code>user_id&lt;/code>&lt;/strong> = per-user stickiness. The same client always sees the same pool. Useful for A/B testing with persisted conversational memory, but it &lt;strong>breaks the statistical comparability of the canary&lt;/strong> because user populations are not symmetric, a pitfall explained in the &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">previous post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Hash by &lt;code>tenant_id&lt;/code>&lt;/strong> = hard partitioning. Tenant A goes to v1, tenant B to v2. This is the pattern for clients with different SLAs, or for validating v2 in an internal tenant before exposing it to external clients.&lt;/li>
&lt;/ul>
&lt;h3 id="function-3--cross-cutting-policy">Function 3 — Cross-cutting policy&lt;/h3>
&lt;p>Once, across all models, the router applies:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Auth&lt;/strong>: OIDC with JWT tokens validated against Keycloak / Authentik. &lt;code>Authorization: Bearer ...&lt;/code> headers translated into &lt;code>tenant_id&lt;/code> and &lt;code>roles&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Rate limit&lt;/strong>: token bucket per tenant (&lt;code>X req/min&lt;/code>) or per model (&lt;code>Y req/min&lt;/code> for llama-70b because it is expensive).&lt;/li>
&lt;li>&lt;strong>Quota&lt;/strong>: monthly quota of tokens consumed per tenant. The router counts &lt;code>gen_ai.usage.input_tokens&lt;/code> + &lt;code>gen_ai.usage.output_tokens&lt;/code> and rejects with &lt;code>429 Quota exceeded&lt;/code> when it runs out.&lt;/li>
&lt;li>&lt;strong>Pre-prompt PII redaction&lt;/strong>: Presidio or Llama Guard inline before the prompt touches the model. What the model does not see is not trained on, not logged, not leaked.&lt;/li>
&lt;li>&lt;strong>Lightweight inline guardrails&lt;/strong>: PromptGuard 2, Llama Guard 4, Granite Guardian, the ones covered in &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a>, run in the router because their latency (30–150 ms) fits within the TTFT budget.&lt;/li>
&lt;li>&lt;strong>&lt;code>gen_ai.*&lt;/code> tracing propagation&lt;/strong>: the router starts the parent span, propagates &lt;code>traceparent&lt;/code> to the engine and emits the &lt;code>gen_ai.system&lt;/code>, &lt;code>gen_ai.request.model&lt;/code> and &lt;code>gen_ai.request.version&lt;/code> attributes that &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel GenAI tracing&lt;/a> consumes.&lt;/li>
&lt;li>&lt;strong>Semantic cache&lt;/strong>: for prompts that repeat exactly or with high semantic similarity (embedding cosine &amp;gt; 0.97 against the previous cache), it returns the cached answer without touching the engine. Typical saving in RAG with frequently asked questions: 20–40 % of requests.&lt;/li>
&lt;/ul>
&lt;h3 id="function-4--failover-and-degradation">Function 4 — Failover and degradation&lt;/h3>
&lt;p>The router knows the health state of each endpoint (active health probes every 5–15 s, recent TTFT latency) and decides:&lt;/p>
&lt;ul>
&lt;li>If v2 returns persistent 5xx or does not respond, the &lt;strong>circuit breaker&lt;/strong> opens: the router redirects the traffic that was going to v2 towards v1 until the probes go green again. This is automatic canary rollback in its simplest form.&lt;/li>
&lt;li>If the whole cluster is saturated (all replicas reporting &lt;code>num_requests_waiting &amp;gt; N&lt;/code> for T seconds), the router returns &lt;strong>&lt;code>503 Service Unavailable&lt;/code>&lt;/strong> with &lt;code>Retry-After: 30&lt;/code> instead of queueing forever. It is better to tell the client &amp;ldquo;come back in 30 seconds&amp;rdquo; than to keep them waiting four minutes and then time out.&lt;/li>
&lt;li>If there is multi-region or multi-cluster, &lt;strong>cross-cluster failover&lt;/strong> via DNS or L7: the primary region goes down and the secondary router takes over.&lt;/li>
&lt;/ul>
&lt;h2 id="the-non-obvious-piece-prefix-aware-routing">The non-obvious piece: prefix-aware routing&lt;/h2>
&lt;p>This is the function a conventional LoadBalancer cannot do and the one that justifies an LLM-specific router beyond the four generic ones.&lt;/p>
&lt;p>The KV cache in vLLM, SGLang and TensorRT-LLM can &lt;strong>reuse common prefixes across requests&lt;/strong>, see &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>. Specifically:&lt;/p>
&lt;ul>
&lt;li>vLLM with &lt;code>--enable-prefix-caching&lt;/code>: it detects that the current request shares a prefix (of a length that is a multiple of the block size, 16 tokens by default) with a previous request whose pages are still in HBM, and reuses those pages instead of reprocessing them.&lt;/li>
&lt;li>SGLang with RadixAttention: it structures the cache as a radix tree indexed by tokens; each request hits the common path of the tree and only computes the tail.&lt;/li>
&lt;li>TensorRT-LLM: a similar feature, called &lt;em>KV cache reuse&lt;/em>.&lt;/li>
&lt;/ul>
&lt;p>The prefix cache hit rate is the key metric: every token that hits is a token that is &lt;strong>not processed in prefill&lt;/strong>, reducing TTFT in direct proportion. For a typical RAG system, with a 400-token system prompt plus 2,000 tokens of retrieved documents plus a 50-token user question, the common prefix (&lt;code>system_prompt + docs&lt;/code>) is 2,400 of the 2,450 total tokens. If the cache hits, &lt;strong>prefill only processes 50 tokens instead of 2,450&lt;/strong>: TTFT falls to roughly a &lt;strong>twentieth&lt;/strong>.&lt;/p>
&lt;p>But the cache lives &lt;strong>per replica&lt;/strong>, not globally. If two requests with the same 2,400-token prefix land on different replicas, both do the full prefill: the first one&amp;rsquo;s cache is no use to the second. The second pays the full cost.&lt;/p>
&lt;p>With &lt;strong>blind round-robin&lt;/strong> (any conventional LB), requests are spread uniformly across N replicas. For a cluster of 4 replicas and 1,000 requests with the same &lt;code>system_prompt + docs&lt;/code>, &lt;strong>each replica receives ~250 requests&lt;/strong>, but all 4 do their own &amp;ldquo;first prefill&amp;rdquo; and the following 249 benefit within their replica. The global hit rate is decent but not optimal. For traffic with many different system prompts and little intra-prefix repetition, the hit rate hovers around &lt;strong>5–15 %&lt;/strong>.&lt;/p>
&lt;p>With &lt;strong>prefix-aware routing&lt;/strong>, the router computes a hash of the prompt prefix (the first N tokens, or the &lt;code>system_prompt&lt;/code> declared in &lt;code>messages[0]&lt;/code>) and keeps an &lt;strong>affinity table&lt;/strong> of &lt;code>hash → replica&lt;/code>. All requests with the same prefix land on the &lt;strong>same&lt;/strong> replica. The first pays the full prefill; the following 999 hit the cache. Global hit rate: &lt;strong>60–85 %&lt;/strong>.&lt;/p>
&lt;p>The cost of implementing it: the router has to parse the request body (not just the HTTP header), apply a lightweight tokeniser or a byte-based hash, and maintain an LRU/consistent-hash affinity table that rebalances when a replica comes in or out. That is server work, not generic proxy work. &lt;strong>vLLM Production Stack router&lt;/strong> implements it natively. &lt;strong>NVIDIA Dynamo&lt;/strong> does too. LiteLLM has a beta in its enterprise version. Envoy AI Gateway is adding it as an experimental filter.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Correction (September 2026).&lt;/strong> The previous sentence about LiteLLM is false: prefix-aware routing does not exist in any version, open or paid, verified against the code of 1.102.0. The detail, what LiteLLM does have, and the real state of the field are in &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>.&lt;/p>
&lt;/blockquote>
&lt;p>The operational difference for a production RAG system: with prefix-aware routing, the same cluster serves &lt;strong>2–4× more requests&lt;/strong> without adding GPUs, simply because prefill disappears in most cases.&lt;/p>
&lt;h2 id="token-aware-load-balancing">Token-aware load balancing&lt;/h2>
&lt;p>The second non-obvious piece. Classic round-robin splits by number of requests; but a 50-token prompt and an 8,000-token one cost radically different amounts (a factor of ~160× in prefill). Splitting equally by count severely unbalances the real load.&lt;/p>
&lt;p>&lt;strong>Token-aware load balancing&lt;/strong> adds up the expected prefill tokens (prompt length) and expected decode tokens (the client&amp;rsquo;s max_tokens) per active replica, and sends the new request to the replica with the lowest accumulated load. It is what both vLLM Production Stack and NVIDIA Dynamo implement as the default strategy when it is enabled.&lt;/p>
&lt;p>The metric feeding the calculation is, once again, &lt;code>vllm:num_requests_running&lt;/code> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, see &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a>, ideally complemented with an estimator of the incoming prompt&amp;rsquo;s tokens. Mature routers use &lt;code>tiktoken&lt;/code> or the model&amp;rsquo;s real tokeniser to count prompt tokens before choosing a replica.&lt;/p>
&lt;h2 id="comparison-of-concrete-pieces-may-2026">Comparison of concrete pieces (May 2026)&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>LLM awareness&lt;/th>
&lt;th>Prefix-aware&lt;/th>
&lt;th>Token-aware LB&lt;/th>
&lt;th>Multi-model&lt;/th>
&lt;th>Semantic cache&lt;/th>
&lt;th>Plug &amp;amp; play&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>LiteLLM Proxy&lt;/td>
&lt;td>High&lt;/td>
&lt;td>Beta (enterprise)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Yes (Redis)&lt;/td>
&lt;td>Very high&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>vLLM Production Stack router&lt;/td>
&lt;td>vLLM-specific&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>vLLM only&lt;/td>
&lt;td>No (external)&lt;/td>
&lt;td>Medium&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA Dynamo router&lt;/td>
&lt;td>High + disagg-aware&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>vLLM/SGLang/TRT-LLM&lt;/td>
&lt;td>No (external)&lt;/td>
&lt;td>Low&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Envoy AI Gateway&lt;/td>
&lt;td>Medium (filters)&lt;/td>
&lt;td>Experimental&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Via filter&lt;/td>
&lt;td>Medium&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kong AI Gateway&lt;/td>
&lt;td>Medium (plugins)&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes (plugin)&lt;/td>
&lt;td>Medium&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KGateway&lt;/td>
&lt;td>Medium&lt;/td>
&lt;td>Roadmap&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Roadmap&lt;/td>
&lt;td>Low (CNCF incubating)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NGINX + custom Lua&lt;/td>
&lt;td>Manual&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Manual&lt;/td>
&lt;td>Manual&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Low (build it yourself)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>LiteLLM Proxy&lt;/strong> is the default option to start with. OpenAI-compatible, simple YAML, supports the commercial providers plus any self-hosted OpenAI-compatible backend. The OSS version covers the four basic functions and semantic cache; prefix-aware routing and the enterprise version add advanced multi-tenancy.&lt;/p>
&lt;p>&lt;strong>vLLM Production Stack router&lt;/strong> is the right option if the fleet is 100 % vLLM. Aware of the KV cache, the prefix and the LoRA loaded per replica. It integrates better with native vLLM metrics.&lt;/p>
&lt;p>&lt;strong>NVIDIA Dynamo router&lt;/strong> is the most complete production-grade option, especially if you operate &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving&lt;/a> (separate prefill workers and decode workers). It requires an NVIDIA-aligned stack.&lt;/p>
&lt;p>&lt;strong>Envoy AI Gateway&lt;/strong> and &lt;strong>Kong AI Gateway&lt;/strong> are the options if the organisation already has Envoy or Kong as its corporate gateway and wants to extend it with LLM awareness without introducing another new piece.&lt;/p>
&lt;h2 id="minimal-manifest-litellm-proxy-on-a-generic-cluster">Minimal manifest: LiteLLM Proxy on a generic cluster&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ConfigMap&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">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: litellm-config, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&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">data&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.yaml&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> model_list:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> - model_name: llama-70b
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> litellm_params:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> model: openai/llama-70b
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> api_base: http://vllm-llama70b-v1.inference.svc:8000/v1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> weight: 95
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> model_info:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> version: v1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> lifecycle: stable
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> - model_name: llama-70b
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> litellm_params:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> model: openai/llama-70b
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> api_base: http://vllm-llama70b-v2.inference.svc:8000/v1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> weight: 5
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> model_info:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> version: v2
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> lifecycle: canary
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> - model_name: embedding-multilingual
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> litellm_params:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> model: openai/bge-m3
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> api_base: http://tei-bge-m3.inference.svc:8080
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> router_settings:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> routing_strategy: least-busy # basic token-aware
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> num_retries: 1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> timeout: 60
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> general_settings:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> master_key: &amp;#34;os.environ/LITELLM_MASTER_KEY&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> database_url: &amp;#34;os.environ/DATABASE_URL&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> litellm_settings:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> cache: true
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> cache_params:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> type: redis
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> host: redis.inference.svc
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> port: 6379
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> similarity_threshold: 0.97
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> success_callback: [&amp;#34;langfuse&amp;#34;]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> failure_callback: [&amp;#34;langfuse&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="nn">---&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&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">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: litellm-router, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&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">spec&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">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&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">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm } }&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">template&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">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm } }&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">spec&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">containers&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm&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">ghcr.io/berriai/litellm:v1.55.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="nt">args&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;--config=/config/config.yaml&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--port=4000&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;--num_workers=4&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 class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">containerPort: 4000, name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http }, { containerPort: 4000, name: metrics }]&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">env&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="w"> &lt;/span>&lt;span class="nt">name: LITELLM_MASTER_KEY, valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: litellm-secret, key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">master_key } } }&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="w"> &lt;/span>&lt;span class="nt">name: DATABASE_URL, valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: litellm-secret, key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">db_url } } }&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="w"> &lt;/span>&lt;span class="nt">name: LANGFUSE_PUBLIC_KEY, valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: langfuse-keys, key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">public } } }&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="w"> &lt;/span>&lt;span class="nt">name: LANGFUSE_SECRET_KEY, valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: langfuse-keys, key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">secret } } }&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">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: config, mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/config }]&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">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">path: /health, port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4000&lt;/span>&lt;span class="w"> &lt;/span>}&lt;span class="w"> &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="w"> &lt;/span>&lt;span class="nt">name: config, configMap&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm-config } }]&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="nn">---&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&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">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: litellm-router, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&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">spec&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">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm }&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">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: http, port: 80, targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4000&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="nn">---&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">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodMonitor&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">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">name: litellm-metrics, namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference }&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">spec&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">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">litellm } }&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">podMetricsEndpoints&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">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&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">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/metrics&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">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;/code>&lt;/pre>&lt;/div>&lt;p>The end client points at &lt;code>litellm-router.inference.svc:80/v1/chat/completions&lt;/code>, sets &lt;code>model=llama-70b&lt;/code>, and the router decides on each request whether it goes to v1 (95 %) or v2 (5 %), applies the rate limit, looks in the semantic cache, propagates tracing to Langfuse, and translates from OpenAI-compatible to the OpenAI-compatible API of the destination vLLM. Three router replicas for HA and so the gateway itself can scale horizontally with KEDA if needed, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a>.&lt;/p>
&lt;h2 id="four-operational-pitfalls">Four operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — the router becomes a SPoF if it is not replicated.&lt;/strong> Three or more replicas of the router itself, behind a &lt;code>LoadBalancer&lt;/code> Service (this one genuinely L4) with healthchecks. A single router replica means every configuration deploy closes the whole service for a few seconds.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — the router&amp;rsquo;s latency adds to the model&amp;rsquo;s.&lt;/strong> Each function adds milliseconds: body parsing (5–10 ms), JWT auth (2–5 ms), rate limit (1–2 ms), PII redaction with Presidio (20–80 ms), inline guardrails with Llama Guard (50–150 ms), prefix hash (5–10 ms), token counting with a tokeniser (10–30 ms). In total, &lt;strong>100–300 ms&lt;/strong> of overhead before touching the engine. If the model&amp;rsquo;s TTFT is 400 ms and the router&amp;rsquo;s is 200 ms, the client sees &lt;strong>600 ms&lt;/strong>, so it is worth measuring what each function costs and disabling the non-critical ones in the low-latency path.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — the catalogue drifts from the real state of the cluster.&lt;/strong> The router believes &lt;code>vllm-llama70b-v2&lt;/code> exists because it is in its YAML, but the deployment was withdrawn three days ago and nobody updated the config. The router returns 502 on 5 % of the traffic. Fix: validate the catalogue against &lt;code>kubectl get svc&lt;/code> in CI; no &lt;code>endpoint&lt;/code> in the catalogue may point at a non-existent Service. Or better: the router discovers the available endpoints dynamically via a label selector (&lt;code>app=vllm,model=llama-70b&lt;/code>) and applies the catalogue weights over the ones that are alive.&lt;/p>
&lt;p>&lt;strong>Pitfall 4 — semantic cache with an outdated embedding.&lt;/strong> The semantic cache compares the new prompt&amp;rsquo;s embedding against the embeddings of cached prompts. If you update the embedding model (see &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>), the distances are computed in a different space and the cache stops working correctly (false hits or false misses). Policy: the cache is &lt;strong>invalidated&lt;/strong> when the embedding model changes; generations are never mixed.&lt;/p>
&lt;h2 id="fit-within-the-stack-and-maturity">Fit within the stack and maturity&lt;/h2>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">seven-layer stack&lt;/a>, the router is &lt;strong>layer 1&lt;/strong>: the front door that precedes the inference engine (layer 2), the KV cache + PagedAttention (layer 3) and the rest. It is the only piece that sees &lt;strong>all the traffic&lt;/strong> from the outside; any policy not applied here gets duplicated N times in the engines.&lt;/p>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">five maturity levels&lt;/a>, the router appears from &lt;strong>level 3&lt;/strong> (MANAGED) onwards: without OIDC + RBAC + cert-manager + default-deny NetworkPolicy, the router has nobody to authenticate and nobody to apply quotas to; before level 3, what you should build is a minimal proxy with no pretension of a catalogue. Platforms that try to have a polished router at level 1 end up with a large YAML nobody maintains.&lt;/p>
&lt;p>In the &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven deployment phases&lt;/a>, the router is what closes &lt;strong>F6&lt;/strong>: the last atomic step that puts the cluster into production. Without a router, F6 does not finish, because the catalogue, the quotas, the canaries and the failovers are a necessary condition for opening production traffic.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4 nodes × 4×H100 SXM 80 GB&lt;/strong>, the inference router consumes modest resources: 3 replicas of the router pod (2 CPU cores, 4 GiB of memory each) are enough to sustain thousands of RPS because its work is light (parsing, hashing, routing, no inference). The router lives on non-GPU nodes of the cluster (control plane nodes or general workload nodes), and never consumes &lt;code>nvidia.com/gpu&lt;/code>.&lt;/p>
&lt;p>Traffic volume that a LiteLLM with 3 replicas and 4 workers each will sustain: &lt;strong>2,000–5,000 RPS&lt;/strong> routing to a vLLM backend, with &lt;strong>80–150 ms&lt;/strong> of overhead on the full path (auth + rate limit + cache check + propagation). If more is needed, scaling the router with KEDA on &lt;code>litellm:requests_per_second&lt;/code> is trivial.&lt;/p>
&lt;p>For larger clusters (16+ GPU nodes), consider the vLLM Production Stack router or the NVIDIA Dynamo router, which are more complex but squeeze out the prefix-aware routing and token-aware LB that LiteLLM OSS does not cover. For multi-region clusters, Envoy AI Gateway with Istio Service Mesh is the standard choice.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Deep comparison of LiteLLM vs vLLM PStack vs Dynamo&lt;/strong>, with prefix-aware benchmarks on a real on-premise cluster.&lt;/li>
&lt;li>&lt;strong>Semantic cache with Redis Stack + RedisVL&lt;/strong>: hit rate, false positives, TTL policy.&lt;/li>
&lt;li>&lt;strong>Multi-region routing&lt;/strong>: how the router decides between the DC1 and DC2 clusters based on latency, health and load.&lt;/li>
&lt;li>&lt;strong>AI Gateway specific features&lt;/strong>: token-bucket cost-based rate limiting (penalising long prompts), guardrails policy engine in the router.&lt;/li>
&lt;li>&lt;strong>Migration path&lt;/strong>: how to introduce a router into a cluster that already has clients pointing straight at the vLLM service, with no downtime.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster: who pays for what&lt;/a> — the gateway&amp;rsquo;s virtual keys as the basis for per-team chargeback.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the previous post where we called this piece a &amp;ldquo;LoadBalancer&amp;rdquo;; this one names it and takes it apart.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — the router is layer 1 of the stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — the router appears from level 3 onwards.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases&lt;/a> — the router is what closes F6.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — the router can scale with KEDA on its own metrics; it coexists with engine autoscaling.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the token-aware LB consumes &lt;code>vllm:num_requests_running&lt;/code> and &lt;code>vllm:gpu_cache_usage_perc&lt;/code> to choose a replica.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> — what prefix-aware routing caches and why it multiplies the hit rate.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated prefill/decode serving&lt;/a> — production-grade routers (Dynamo) are aware of disaggregation and route prefill and decode to different pools.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the router emits the &lt;code>gen_ai.*&lt;/code> parent spans and propagates &lt;code>traceparent&lt;/code> to the engines.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety in LLMs&lt;/a> — lightweight inline guardrails typically run in the router.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments for LLM inference&lt;/a> — capability-based routing makes full sense when there are heterogeneous backends (NVIDIA for the large LLM, Intel for embeddings/reranker, NUC for edge); the catalogue is extended with &lt;code>backend&lt;/code> and &lt;code>region&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the switchboard: which OSS gateway to put in front&lt;/a> — the decision companion: this post explains &lt;em>what&lt;/em> a router is; that one picks &lt;em>which&lt;/em> with verified licences (LiteLLM, Envoy AI Gateway + Inference Extension, Higress, APISIX, Kong) and a recommendation for a K8s-native RKE2 + vLLM stack.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">LiteLLM and Langfuse: the operational pair&lt;/a> — day 2 of the router: cost per token on self-hosted models, trace correlation down to vLLM, content redaction and the measured overhead of the proxy.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">LiteLLM on day 2: high availability&lt;/a> — operating the router once it is up, with the latency each routing strategy adds.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>LiteLLM project — &lt;code>litellm.ai&lt;/code> (documentación de Proxy, routing strategies, semantic cache).&lt;/li>
&lt;li>vLLM Production Stack — &lt;code>github.com/vllm-project/production-stack&lt;/code> (router con prefix-aware nativo).&lt;/li>
&lt;li>NVIDIA Dynamo — &lt;code>developer.nvidia.com/blog/nvidia-dynamo-1-production-ready/&lt;/code> (router production-grade con disaggregated-aware).&lt;/li>
&lt;li>Envoy AI Gateway — &lt;code>gateway.envoyproxy.io/docs/tasks/ai-gateway/&lt;/code> (proyecto en gestación dentro de Envoy).&lt;/li>
&lt;li>Kong AI Gateway — &lt;code>konghq.com/products/kong-ai-gateway&lt;/code> (proxy enterprise con plugin LLM).&lt;/li>
&lt;li>KGateway — &lt;code>kgateway.dev&lt;/code> (alternativa CNCF en gestación).&lt;/li>
&lt;li>Zheng et al. — &lt;em>SGLang: Efficient Execution of Structured Language Model Programs&lt;/em> (NeurIPS 2024) — RadixAttention y prefix caching.&lt;/li>
&lt;li>vLLM project — &lt;em>Automatic Prefix Caching&lt;/em> (&lt;code>docs.vllm.ai/en/latest/features/automatic_prefix_caching.html&lt;/code>).&lt;/li>
&lt;li>Patel et al. — &lt;em>SplitWise: Efficient Generative LLM Inference Using Phase Splitting&lt;/em> (ISCA 2024) — base teórica del routing prefill/decode aware.&lt;/li>
&lt;/ul></description></item><item><title>GPU observability for LLM inference: the twelve DCGM and vLLM metrics that dictate the health of your production</title><link>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</link><pubDate>Mon, 01 Jun 2026 15:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> (the tracing layer above the metrics), &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning&lt;/a> (what was sized and what must be watched) and &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (the mechanism that explains several of the engine metrics).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Observability for an LLM inference cluster is built on &lt;strong>two complementary sources&lt;/strong>: the GPU hardware metrics exposed by the &lt;strong>DCGM (Data Center GPU Manager) Exporter&lt;/strong>, part of the NVIDIA GPU Operator, and the metrics from the &lt;strong>inference engine&lt;/strong> (vLLM, SGLang, TensorRT-LLM) exposed on a Prometheus-compatible &lt;code>/metrics&lt;/code>. Neither is enough on its own. The classic &lt;code>nvidia-smi&lt;/code> metric called &lt;em>GPU utilization&lt;/em> is misleading for LLMs: it reads high whenever &lt;strong>any kernel&lt;/strong> is running, without distinguishing tensor cores burning from SMs waiting on HBM. The full cockpit has &lt;strong>twelve DCGM metrics in four families&lt;/strong> (compute: &lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>, &lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>, &lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>; memory: &lt;code>DCGM_FI_DEV_FB_USED&lt;/code>, &lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>, &lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>; thermal-power: &lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>, &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>, &lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>; health: &lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>, &lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>, &lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>) and &lt;strong>five vLLM engine metrics&lt;/strong> (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:num_requests_waiting&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:time_to_first_token_seconds&lt;/code>, &lt;code>vllm:time_per_output_token_seconds&lt;/code>). Each one has a defensible green/amber/red threshold, a PromQL query for alerting, and at least one common false reading that confuses the junior operator. The &lt;strong>six critical alerts&lt;/strong> that any production cluster must fire are: HBM &amp;gt; 92 %, thermal or power throttle, XID error, ECC double-bit, KV cache pool &amp;gt; 95 %, and TTFT P95 outside SLO for 5 minutes. The point of having this panel: that the operator on shift diagnoses the origin of a degradation in &lt;strong>under five minutes&lt;/strong>, without opening an SSH console to the GPUs. When that holds, the cluster has moved to professional operation; until then, it is operated on intuition.&lt;/p>
&lt;h2 id="you-are-here-observe-the-other-half-of-tracing">You are here: OBSERVE (the other half of tracing)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#c9a8e9;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#obm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#obm)}&lt;/style>
&lt;defs>&lt;marker id="obm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: OBSERVE · metrics (DCGM + engine) complement tracing&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box idle"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box active"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;p>Tracing, already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>, answers &lt;em>what happened in this specific request&lt;/em>. Metrics answer &lt;em>what is happening across the cluster in aggregate&lt;/em>. They are complementary: an alert on the metrics side tells you &amp;ldquo;the cluster is degrading&amp;rdquo;, tracing tells you &amp;ldquo;and here is the specific trace that proves it&amp;rdquo;. A cluster without tracing but with metrics operates; a cluster without metrics but with tracing &lt;strong>does not operate, it debugs&lt;/strong>.&lt;/p>
&lt;h2 id="the-analogy-the-cockpit-of-a-modern-aircraft">The analogy: the cockpit of a modern aircraft&lt;/h2>
&lt;p>In a modern commercial aircraft, the pilot&amp;rsquo;s instrument panel has more than 70 active indicators. If there were only one, the altimeter, say, the aircraft would fly into the ground at the first moment of low visibility. You need the altimeter &lt;strong>and&lt;/strong> the attitude indicator, &lt;strong>and&lt;/strong> the airspeed indicator, &lt;strong>and&lt;/strong> the turn indicator, &lt;strong>and&lt;/strong> the fuel gauge, &lt;strong>and&lt;/strong> the oil pressure gauges for each engine, &lt;strong>and&lt;/strong> the turbine exit temperatures. Each answers a different question. And together they cover the operational question: &lt;em>is the aircraft healthy, is it where it should be, and is it going where we want?&lt;/em>&lt;/p>
&lt;p>Observability for an LLM inference cluster works the same way. A single metric, &amp;ldquo;GPU utilization 99 %&amp;rdquo;, answers nothing useful. It is like looking only at the car&amp;rsquo;s odometer to diagnose why the engine is making a noise. The full cockpit is &lt;strong>twelve instruments on the hardware side plus five on the inference engine side&lt;/strong>, organised into families that answer different questions:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compute and efficiency&lt;/strong>: &lt;em>are the tensor cores doing the work we expect, or are they waiting?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Memory&lt;/strong>: &lt;em>is there VRAM left for new requests, or are we on the edge of OOM?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Thermal and power&lt;/strong>: &lt;em>is the hardware healthy, or is it silently limiting throughput?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Health and errors&lt;/strong>: &lt;em>is there hardware degradation under way (ECC, XID, NVLink)?&lt;/em>&lt;/li>
&lt;li>&lt;strong>Inference engine&lt;/strong>: &lt;em>is the queue growing, is the KV pool saturated, is the SLO being met?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>The first four answer &amp;ldquo;is the GPU fine?&amp;rdquo;. The fifth answers &amp;ldquo;is it delivering the service we promised?&amp;rdquo;. The two questions are different and both must have an answer at a glance.&lt;/p>
&lt;h2 id="why-nvidia-smi-gpu-util-misleads-with-llms">Why &lt;code>nvidia-smi&lt;/code> &lt;code>GPU-Util&lt;/code> misleads with LLMs&lt;/h2>
&lt;p>The classic metric that appears in &lt;code>nvidia-smi&lt;/code> as &lt;code>GPU-Util&lt;/code> corresponds to &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code>. Its official definition: &amp;ldquo;percentage of time during which one or more kernels were executing on the GPU&amp;rdquo;. The problem with LLMs: the decode phase is &lt;strong>memory-bound&lt;/strong>, not compute-bound. When the inference engine decodes token by token, the GPU spends 90 % of the time waiting for the HBM to finish delivering the model weights and the KV cache. There is a kernel running (the HBM read), so &lt;code>GPU-Util&lt;/code> reports values close to 100 %. But the tensor cores are idle: the bottleneck is memory, not compute.&lt;/p>
&lt;p>Practical result: the operator sees &amp;ldquo;GPU-Util 99 %&amp;rdquo; in Grafana and assumes &amp;ldquo;GPU saturated, no more load can go in&amp;rdquo;. But the reality may be &amp;ldquo;compute at 25 %, HBM saturated at 95 %&amp;rdquo;, which changes the operational decisions (quantisation, batch size, parallelism). The classic metric lies by oversimplifying.&lt;/p>
&lt;p>The right move is to look at the &lt;strong>three DCGM profiling metrics&lt;/strong> of the &lt;code>_FI_PROF_*&lt;/code> subsystem:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>: ratio of active warps over the maximum per SM. &lt;em>Is there parallel work?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>: % of cycles with tensor cores effectively active. &lt;em>Is compute working?&lt;/em>&lt;/li>
&lt;li>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>: % of cycles with HBM transferring. &lt;em>Is memory saturated?&lt;/em>&lt;/li>
&lt;/ul>
&lt;p>A typical decode-bound GPU running Llama 70B on an H100 shows: SM occupancy 35–55 %, tensor active 15–30 %, DRAM active 80–95 %. That is the real &amp;ldquo;saturated GPU&amp;rdquo; for LLMs. The three together tell the regimes apart; none of them alone says anything actionable.&lt;/p>
&lt;h2 id="how-they-are-wired-up-in-production">How they are wired up in production&lt;/h2>
&lt;p>The platform side is covered in &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> (level 4, the GPU plane) and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">Seven deployment phases&lt;/a> (phase F5). For the observer, the key pieces are:&lt;/p>
&lt;p>&lt;strong>NVIDIA GPU Operator.&lt;/strong> Helm manifests that deploy on each GPU node: drivers, container toolkit, MIG manager and &lt;strong>DCGM Exporter&lt;/strong>. The last one exposes &lt;code>/metrics&lt;/code> in Prometheus format with all the &lt;code>DCGM_FI_*&lt;/code> listed above. It is scraped from the cluster&amp;rsquo;s internal Prometheus.&lt;/p>
&lt;p>&lt;strong>Inference engine.&lt;/strong> vLLM exposes &lt;code>/metrics&lt;/code> on port 8000 (default) with &lt;code>vllm:*&lt;/code> metrics. SGLang exposes it too, with the &lt;code>sglang:&lt;/code> prefix. TensorRT-LLM exposes it through Triton Inference Server with the &lt;code>nv_inference:&lt;/code> prefix. The basic naming convention is similar across the three engines; the thresholds and queries in this post assume vLLM, but they translate.&lt;/p>
&lt;p>&lt;strong>ServiceMonitor / PodMonitor.&lt;/strong> The Prometheus operator resource that says what to scrape. Minimal example:&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="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">monitoring.coreos.com/v1&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">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PodMonitor&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">metadata&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-inference&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">spec&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">selector&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">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">app&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">podMetricsEndpoints&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">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&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">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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Dashboards.&lt;/strong> The NVIDIA operator publishes reference Grafana dashboards for DCGM in &lt;code>nvidia/dcgm-exporter&lt;/code> (the official repo). vLLM publishes one in &lt;code>vllm-project/vllm&lt;/code> (the &lt;code>examples/&lt;/code> folder). Both work as a base; each team adds the panels specific to its own SLO.&lt;/p>
&lt;h2 id="the-twelve-dcgm-metrics-organised-by-family">The twelve DCGM metrics organised by family&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="twelve DCGM metrics in four families">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.fc{fill:#dfe9f5;stroke:#356}.fm{fill:#eef0d0;stroke:#7a3}.ft{fill:#f4e3cf;stroke:#a63}.fs{fill:#f6e2e2;stroke:#a33}.title{font:600 13px sans-serif;fill:#222}.fam{font:700 11px sans-serif;fill:#222}.met{font:10px monospace;fill:#222}.note{font:italic 10px sans-serif;fill:#555}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">DCGM cockpit: 12 metrics in 4 families&lt;/text>
&lt;rect x="20" y="40" width="195" height="290" class="b fc"/>
&lt;text x="117" y="60" text-anchor="middle" class="fam">COMPUTE&lt;/text>
&lt;text x="30" y="90" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="105" class="met">SM_OCCUPANCY&lt;/text>
&lt;text x="30" y="135" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="150" class="met">PIPE_TENSOR_ACTIVE&lt;/text>
&lt;text x="30" y="180" class="met">DCGM_FI_PROF_&lt;/text>&lt;text x="30" y="195" class="met">DRAM_ACTIVE&lt;/text>
&lt;text x="30" y="240" text-anchor="start" class="note">Is compute working or&lt;/text>
&lt;text x="30" y="254" text-anchor="start" class="note">waiting on HBM?&lt;/text>
&lt;rect x="220" y="40" width="195" height="290" class="b fm"/>
&lt;text x="317" y="60" text-anchor="middle" class="fam">MEMORY&lt;/text>
&lt;text x="230" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="105" class="met">FB_USED&lt;/text>
&lt;text x="230" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="230" y="150" class="met">FB_FREE&lt;/text>
&lt;text x="230" y="180" class="met">DCGM_FI_DEV_NVLINK_&lt;/text>&lt;text x="230" y="195" class="met">BANDWIDTH_TOTAL&lt;/text>
&lt;text x="230" y="240" class="note">Is there VRAM left for&lt;/text>
&lt;text x="230" y="254" class="note">new requests?&lt;/text>
&lt;rect x="420" y="40" width="195" height="290" class="b ft"/>
&lt;text x="517" y="60" text-anchor="middle" class="fam">THERMAL · POWER&lt;/text>
&lt;text x="430" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="105" class="met">GPU_TEMP&lt;/text>
&lt;text x="430" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="430" y="150" class="met">POWER_USAGE&lt;/text>
&lt;text x="430" y="180" class="met">DCGM_FI_DEV_CLOCK_&lt;/text>&lt;text x="430" y="195" class="met">THROTTLE_REASONS&lt;/text>
&lt;text x="430" y="240" class="note">Healthy hardware or&lt;/text>
&lt;text x="430" y="254" class="note">silently limiting?&lt;/text>
&lt;rect x="620" y="40" width="180" height="290" class="b fs"/>
&lt;text x="710" y="60" text-anchor="middle" class="fam">HEALTH&lt;/text>
&lt;text x="630" y="90" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="105" class="met">XID_ERRORS&lt;/text>
&lt;text x="630" y="135" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="150" class="met">ECC_DBE_VOL_TOTAL&lt;/text>
&lt;text x="630" y="180" class="met">DCGM_FI_DEV_&lt;/text>&lt;text x="630" y="195" class="met">RETIRED_DBE&lt;/text>
&lt;text x="630" y="240" class="note">Is the silicon&lt;/text>
&lt;text x="630" y="254" class="note">degrading right now?&lt;/text>
&lt;text x="410" y="350" text-anchor="middle" class="note">Each family answers a different question · none suffices alone&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="family-1--compute">Family 1 — Compute&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/strong>: ratio of active warps per SM over the maximum possible. Value between 0 and 1.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 0.30–0.70 (typical LLM decode regime).&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;lt; 0.20 sustained (batch too small, GPU under-used in parallelism).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: 0.95 sustained with low DRAM_ACTIVE (pathological kernel saturating the SMs).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/strong>: % of cycles with tensor cores executing. The key metric for &amp;ldquo;is compute producing?&amp;rdquo;.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in prefill&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 15–30 % (decode is memory-bound, this is not a symptom of a problem).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;lt; 5 % sustained in prefill, or the engine is not using the tensor cores at all (bad config, incompatible format).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/strong>: % of cycles with HBM transferring data. The key metric for detecting memory saturation.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green in decode&lt;/strong>: 60–85 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: &amp;gt; 90 % sustained (HBM is a firm bottleneck, which explains high TPOT).&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 95 % sustained with KV cache pool &amp;lt; 70 % (something other than the engine is asking for HBM; investigate leaks).&lt;/li>
&lt;/ul>
&lt;h3 id="family-2--memory">Family 2 — Memory&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_USED&lt;/code>&lt;/strong>: Frame Buffer (HBM) used, in MiB.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 70–85 % of total.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 86–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of OOM on the next paged-attention allocation).&lt;/li>
&lt;/ul>
&lt;p>PromQL for the cluster-wide percentage: &lt;code>100 * sum(DCGM_FI_DEV_FB_USED) / sum(DCGM_FI_DEV_FB_TOTAL)&lt;/code>.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_FB_FREE&lt;/code>&lt;/strong>: free Frame Buffer. Complementary to the previous one; useful for absolute alerts (&lt;code>&amp;lt; 4096 MiB free&lt;/code>).&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/strong>: aggregate NVLink bandwidth in MB/s. For TP (tensor parallel) topologies that cross GPUs over NVLink, this metric reveals whether the parallelism split is saturating the bus.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: varies with topology. On 4×H100 SXM with NVLink 4.0, theoretical capacity is 450 GB/s per GPU. A typical TP=4 regime: 50–150 GB/s sustained.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 90 % of capacity sustained (check whether the model would fit with lower TP or with pipeline parallel).&lt;/li>
&lt;/ul>
&lt;h3 id="family-3--thermal-and-power">Family 3 — Thermal and power&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/strong>: die temperature in °C.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: &amp;lt; 75 °C.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 75–82 °C.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 83 °C (close to the H100&amp;rsquo;s automatic thermal throttle; check ventilation, air flow, rack inlet temperature).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/strong>: draw in watts. For the H100 SXM, nominal TDP is 700 W. Useful for three things: spotting an unusually low workload (suspect idle or a stall), billing real energy cost, and firing alerts if the draw approaches the PDU limit.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/strong>: encoded bitmap with the active throttle reasons. This is the metric that &lt;strong>silently explains&lt;/strong> TPOT degradations.&lt;/p>
&lt;p>Relevant bits:&lt;/p>
&lt;ul>
&lt;li>&lt;code>0x0000000000000001&lt;/code>: Idle (not a problem).&lt;/li>
&lt;li>&lt;code>0x0000000000000002&lt;/code>: App clocks setting.&lt;/li>
&lt;li>&lt;code>0x0000000000000004&lt;/code>: SW Power Cap (software limit, e.g. from &lt;code>nvidia-smi -pl&lt;/code>).&lt;/li>
&lt;li>&lt;code>0x0000000000000008&lt;/code>: HW Slowdown.&lt;/li>
&lt;li>&lt;code>0x0000000000000010&lt;/code>: Sync Boost (NVIDIA Sync).&lt;/li>
&lt;li>&lt;code>0x0000000000000020&lt;/code>: SW Thermal Slowdown (software thermal limit).&lt;/li>
&lt;li>&lt;code>0x0000000000000040&lt;/code>: HW Thermal Slowdown (hardware thermal limit, an emergency).&lt;/li>
&lt;li>&lt;code>0x0000000000000080&lt;/code>: HW Power Brake Slowdown (PSU voltage drop).&lt;/li>
&lt;li>&lt;code>0x0000000000000100&lt;/code>: Display Clock Setting.&lt;/li>
&lt;/ul>
&lt;p>Any throttle other than &lt;code>Idle&lt;/code> with a sustained value &amp;gt; 0 &lt;strong>is an alert&lt;/strong>. TPOT degradation with &lt;code>DRAM_ACTIVE&lt;/code> already high and thermal throttle active is the classic &amp;ldquo;the rack is badly ventilated, it is not the engine&amp;rsquo;s fault&amp;rdquo;.&lt;/p>
&lt;h3 id="family-4--health">Family 4 — Health&lt;/h3>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_XID_ERRORS&lt;/code>&lt;/strong>: cumulative counter of driver XID errors. XIDs are critical event codes that NVIDIA documents exhaustively (XID 13: graphics engine exception; XID 31: GPU memory page fault; XID 43: reset channel verif error; XID 79: GPU has fallen off the bus; XID 95: uncontained ECC error; and so on). &lt;strong>Any increment is an immediate alert&lt;/strong>: many XIDs require a node reset or an RMA of the GPU.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/strong>: volatile double-bit ECC errors (uncorrectable). Unlike single-bit ones (which ECC corrects silently and which are counted in &lt;code>DCGM_FI_DEV_ECC_SBE_*&lt;/code>), double-bit errors &lt;strong>corrupt data&lt;/strong>. Any value &amp;gt; 0 is a critical alert: the GPU must be drained and inspected.&lt;/p>
&lt;p>&lt;strong>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/strong>: physical HBM pages retired because of accumulated double-bit errors. NVIDIA retires faulty pages automatically to prevent future corruption. More than 4–8 retired pages on one GPU suggests silicon degradation: document it and plan a replacement in the next maintenance window.&lt;/p>
&lt;h2 id="the-five-inference-engine-metrics-vllm">The five inference engine metrics (vLLM)&lt;/h2>
&lt;p>The DCGM metrics answer &amp;ldquo;is the GPU healthy?&amp;rdquo;. The engine metrics answer &amp;ldquo;is the service meeting the SLO?&amp;rdquo;. Without them, you know the hardware works but you do not know whether the clients are happy.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_running&lt;/code>&lt;/strong>: requests currently in the batch. If it reaches the configured &lt;code>--max-num-seqs&lt;/code> and does not come down, the engine is saturated on concurrency (check VRAM and rebalance through the autoscaler, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>).&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/strong>: requests queued, not yet in the batch. Any value &amp;gt; 0 sustained over minutes means the cluster is not scaling with the load. &lt;strong>This is the primary metric for HPA&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/strong>: % of the KV cache pool in use.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Green&lt;/strong>: 50–80 %.&lt;/li>
&lt;li>&lt;strong>Amber&lt;/strong>: 80–92 %.&lt;/li>
&lt;li>&lt;strong>Red&lt;/strong>: &amp;gt; 92 % (risk of &lt;strong>preempt-on-OOM&lt;/strong>: vLLM will drop requests to free memory, which raises TTFT visibly).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/strong>: histogram of TTFT per request. Consumed as &lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_to_first_token_seconds_bucket[5m])))&lt;/code>. Compared against the TTFT P95 SLO, it fires the primary service alert.&lt;/p>
&lt;p>&lt;strong>&lt;code>vllm:time_per_output_token_seconds&lt;/code>&lt;/strong>: histogram of TPOT. Equivalent to the previous one but for streaming smoothness. Compared against the TPOT P95 SLO, it fires the secondary alert.&lt;/p>
&lt;h2 id="the-six-alerts-that-must-page-in-production">The six alerts that must page in production&lt;/h2>
&lt;p>Any serious production cluster fires these six alerts to a channel with an on-call rotation. Without them, the SLO is met by luck, not by process.&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="nt">groups&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">gpu-llm-critical&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">rules&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuHbmNearOom&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">100&lt;/span>&lt;span class="w"> &lt;/span>*&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL) &amp;gt; 92&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">2m&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;HBM on {{ $labels.gpu }} at {{ $value }}% — OOM risk&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuThermalOrPowerThrottle&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">(DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 0) and ignoring(reason) (DCGM_FI_DEV_CLOCK_THROTTLE_REASONS != 1)&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">1m&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">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;GPU {{ $labels.gpu }} throttling (reasons={{ $value }})&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuXidErrorDetected&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">increase(DCGM_FI_DEV_XID_ERRORS[5m]) &amp;gt; 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="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;XID error on GPU {{ $labels.gpu }} — investigate immediately&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GpuEccDoubleBit&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DCGM_FI_DEV_ECC_DBE_VOL_TOTAL &amp;gt; 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="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">critical }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ECC double-bit on GPU {{ $labels.gpu }} — drain node&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmKvCachePoolNearFull&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm:gpu_cache_usage_perc &amp;gt; 0.95&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">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">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="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;KV cache pool &amp;gt; 95% on {{ $labels.instance }}&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">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">VllmTtftP95OutOfSlo&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">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">histogram_quantile(0.95, sum by(le, instance)(rate(vllm:time_to_first_token_seconds_bucket[5m]))) &amp;gt; 1.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="nt">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">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="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning }&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">annotations&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">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;TTFT P95 over SLO ({{ $value }}s &amp;gt; 1.5s)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These six cover 80 % of the incidents that affect the SLO. The remaining 20 % demand investigation with tracing (see &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>).&lt;/p>
&lt;h2 id="master-table-thresholds-and-queries">Master table: thresholds and queries&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Green&lt;/th>
&lt;th>Amber&lt;/th>
&lt;th>Red&lt;/th>
&lt;th>Base query (PromQL)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SM occupancy&lt;/td>
&lt;td>0.30–0.70&lt;/td>
&lt;td>0.15–0.30&lt;/td>
&lt;td>&amp;lt; 0.10 sustained&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_SM_OCCUPANCY&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tensor active (decode)&lt;/td>
&lt;td>15–30 %&lt;/td>
&lt;td>&amp;lt; 10 %&lt;/td>
&lt;td>&amp;lt; 3 %&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_PIPE_TENSOR_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DRAM active&lt;/td>
&lt;td>60–85 %&lt;/td>
&lt;td>85–95 %&lt;/td>
&lt;td>&amp;gt; 95 % with low KV&lt;/td>
&lt;td>&lt;code>DCGM_FI_PROF_DRAM_ACTIVE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FB used&lt;/td>
&lt;td>70–85 %&lt;/td>
&lt;td>86–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>100 * DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVLink BW&lt;/td>
&lt;td>&amp;lt; 70 % cap&lt;/td>
&lt;td>70–90 % cap&lt;/td>
&lt;td>&amp;gt; 90 % cap&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_NVLINK_BANDWIDTH_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU temp&lt;/td>
&lt;td>&amp;lt; 75 °C&lt;/td>
&lt;td>75–82 °C&lt;/td>
&lt;td>&amp;gt; 83 °C&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_GPU_TEMP&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Power usage&lt;/td>
&lt;td>&amp;lt; 90 % TDP&lt;/td>
&lt;td>90–98 % TDP&lt;/td>
&lt;td>&amp;gt; 98 % TDP&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throttle reasons&lt;/td>
&lt;td>0 or Idle&lt;/td>
&lt;td>App/SW&lt;/td>
&lt;td>HW Therm/Power&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_CLOCK_THROTTLE_REASONS&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>XID errors&lt;/td>
&lt;td>no change&lt;/td>
&lt;td>—&lt;/td>
&lt;td>any delta&lt;/td>
&lt;td>&lt;code>increase(DCGM_FI_DEV_XID_ERRORS[5m])&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ECC DBE&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;td>&amp;gt; 0&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_ECC_DBE_VOL_TOTAL&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Retired pages&lt;/td>
&lt;td>&amp;lt; 4&lt;/td>
&lt;td>4–8&lt;/td>
&lt;td>&amp;gt; 8&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_RETIRED_DBE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>KV cache used&lt;/td>
&lt;td>50–80 %&lt;/td>
&lt;td>80–92 %&lt;/td>
&lt;td>&amp;gt; 92 %&lt;/td>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Requests waiting&lt;/td>
&lt;td>0&lt;/td>
&lt;td>1–5 sustained&lt;/td>
&lt;td>&amp;gt; 10 sustained&lt;/td>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>see alert query above&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT P95&lt;/td>
&lt;td>&amp;lt; SLO&lt;/td>
&lt;td>80–100 % SLO&lt;/td>
&lt;td>&amp;gt; SLO&lt;/td>
&lt;td>&lt;code>histogram_quantile(0.95, sum by(le)(rate(vllm:time_per_output_token_seconds_bucket[5m])))&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="three-pitfalls-that-confuse-the-junior-operator">Three pitfalls that confuse the junior operator&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — &amp;ldquo;GPU-Util at 99 % = saturated&amp;rdquo;.&lt;/strong> As explained at the start, &lt;code>DCGM_FI_DEV_GPU_UTIL&lt;/code> lights up with any kernel. The right move is to look at the three &lt;code>_PROF_*&lt;/code> metrics (SM occupancy, tensor active, DRAM active) together. &lt;em>GPU util 99 % + tensor active 8 % + DRAM active 92 %&lt;/em> means &amp;ldquo;saturated by memory, not compute&amp;rdquo;; &lt;em>GPU util 99 % + tensor active 75 % + DRAM active 50 %&lt;/em> means &amp;ldquo;saturated by compute, prefill heavy&amp;rdquo;. The two situations call for different levers.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — confusing single-bit ECC (SBE) with double-bit (DBE).&lt;/strong> Single-bit errors are corrected silently and are &lt;strong>unavoidable&lt;/strong> in any HBM under load (cosmic radiation, voltage fluctuations). An SBE counter growing slowly is not an alert, it is physics. DBE is: it corrupts data. Telling the two metrics apart avoids false alarms and false negatives in equal measure.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — alerting on &lt;code>num_requests_waiting &amp;gt; 0&lt;/code> without context.&lt;/strong> An instantaneous value of 1 or 2 during a spike is normal. What matters is a &lt;strong>sustained&lt;/strong> queue: use &lt;code>for: 5m&lt;/code> with a threshold of 3–5. Without that window, the system floods the alert channel with noise.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB with intra-node NVLink&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>DCGM Exporter deployed through the NVIDIA GPU Operator, one DaemonSet per GPU node.&lt;/li>
&lt;li>Internal Prometheus with 30-day retention for high-frequency metrics, 1 year for downsampled ones (Thanos/Mimir if the volume justifies it).&lt;/li>
&lt;li>Grafana with three standard dashboards: GPU hardware (DCGM), engine (vLLM), SLO (TTFT/TPOT/RPS against written targets).&lt;/li>
&lt;li>Alertmanager with an on-call rotation and rate limiting through silences grouped by node.&lt;/li>
&lt;li>Cardinality kept under control: &lt;code>gpu&lt;/code> (local id), &lt;code>node&lt;/code>, &lt;code>pod&lt;/code>, &lt;code>model&lt;/code>. Do not add &lt;code>request_id&lt;/code> or other high-cardinality labels to metrics; that is tracing&amp;rsquo;s job.&lt;/li>
&lt;/ul>
&lt;p>Estimated volume for a 16-GPU cluster scraped every 15 s: around 2 million samples/min, around 25 GB/day of raw Prometheus. Manageable with one Prometheus per cluster plus retention; if the team scales beyond 64 GPUs, consider a Thanos sidecar or VictoriaMetrics. See &lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">OSS LLMOps tooling catalogue&lt;/a> for equivalent alternatives.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Tracing of LLM workloads&lt;/strong>: already covered in &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Autoscaling&lt;/strong> based on these metrics: see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Incident response runbooks&lt;/strong>: how each of these alerts translates into concrete action (drain, restart, RMA, scaling, rollback).&lt;/li>
&lt;li>&lt;strong>Cost accounting&lt;/strong>: using &lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> and &lt;code>vllm:request_success_total&lt;/code> for per-tenant cost showback.&lt;/li>
&lt;li>&lt;strong>Multi-tenant fairness monitoring&lt;/strong>: when several tenants share a cluster, which metrics detect that one of them is hogging the KV cache.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy on the GPU cluster: who pays for what&lt;/a> — DCGM utilisation as the cluster&amp;rsquo;s FinOps governance metric.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the other half of observability.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — what was sized and, therefore, which thresholds are defensible here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — explains why &lt;code>num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> are the engine&amp;rsquo;s operational metrics.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels&lt;/a> — LLM-aware observability lives at level 4.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — DCGM Exporter is a piece of the platform layer.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a> — uses these metrics as input.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">Anatomy of the twelve DCGM and five vLLM metrics&lt;/a> — a deeper dive with an analogy and a documented production anomaly for each metric, with figures from public incidents (Meta Llama 3, &lt;em>Story of Two GPUs&lt;/em>, vLLM issues, Dell/Lenovo KBs).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks for LLM with Keep + Kafka&lt;/a> — the translation of each critical alert into concrete action (drain, reset, RMA, rollback) with workflow YAML, a WORM Kafka schema and the fit with ISO 27035, ENS, NIS2 and EU AI Act art. 73.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token travels through&lt;/a> — the NVLink counters (&lt;code>nvidia-smi nvlink -e&lt;/code>, TX/RX bytes per link, CRC errors) that these DCGM metrics expose: a slow all-reduce shows up in a cable error counter before it shows up in API latency.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — from the DCGM metrics (GPU_UTIL vs SM_ACTIVE, MFU) to the cost of idle and the cost per token.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>DCGM Exporter&lt;/em> (repo &lt;code>nvidia/dcgm-exporter&lt;/code>, documented metrics and units).&lt;/li>
&lt;li>NVIDIA — &lt;em>DCGM Field Identifiers reference&lt;/em> (complete list of &lt;code>DCGM_FI_*&lt;/code>).&lt;/li>
&lt;li>NVIDIA — &lt;em>XID Errors documentation&lt;/em> (catalogue of XID codes and remediation procedures).&lt;/li>
&lt;li>NVIDIA — &lt;em>NVIDIA GPU Operator&lt;/em> (official Helm chart).&lt;/li>
&lt;li>vLLM project — &lt;code>examples/production_monitoring/&lt;/code> (reference PromQL and Grafana dashboards).&lt;/li>
&lt;li>Prometheus — &lt;em>Histogram and summary best practices&lt;/em> (for building defensible percentile queries).&lt;/li>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU datasheet&lt;/em> (TDP, HBM bandwidth, NVLink capacities).&lt;/li>
&lt;/ul></description></item><item><title>Capacity planning for on-premise LLM inference: how to size GPUs from an SLO</title><link>https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/</link><pubDate>Mon, 01 Jun 2026 15:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/</guid><description>&lt;blockquote>
&lt;p>This post complements the ones on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> (the piece that dominates the VRAM budget), &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> (what defines the effective utilisation of the compute) and &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the stack&lt;/a> (the pieces the sizing takes for granted). Before reading this one, make sure your team has written down the SLOs it is going to chase; without that input the calculation is not defensible.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM inference capacity planning does not answer &amp;ldquo;how many tokens per second does a GPU give&amp;rdquo; — that question has no universal answer, because throughput depends on concurrency, the prefill/decode split, context length, the inference engine and quantisation. The right question has three inputs (&lt;strong>SLO&lt;/strong>: TTFT P95, TPOT P95, sustained RPS), a hardware reference (GPU model, VRAM, HBM bandwidth, effective FLOPs) and a model (parameters, GQA/MHA/MoE architecture, weight format). The calculation resolves into two coupled budgets that cross each other. &lt;strong>VRAM budget&lt;/strong>: from the GPU&amp;rsquo;s total you subtract model weights and activations, what remains is the &lt;strong>KV cache budget&lt;/strong>, and from there you derive the &lt;strong>maximum concurrency&lt;/strong> possible at the average context you expect. &lt;strong>Time budget&lt;/strong>: the engine (vLLM, SGLang, TensorRT-LLM) has a tokens/s ceiling in decode given by HBM bandwidth and another in prefill given by useful FLOPs; from there you derive the &lt;strong>expected TPOT&lt;/strong> and, dividing prefill_tokens by prefill throughput, the &lt;strong>expected TTFT&lt;/strong>. Both budgets must meet the SLO &lt;strong>simultaneously&lt;/strong>: whichever is tighter dictates the sizing. On the Llama 70B BF16 example with tensor parallel 4 on 4×H100 SXM, a single replica saturates at ~28 concurrent requests and delivers ~3,200 tokens/s of aggregate decode with a median TPOT of 35 ms; for 200 sustained RPS at a profile of 800 prompt tokens + 250 of output, you need between 4 and 5 replicas with a 25 % cushion over the observed peak. Quantisation (FP8 → INT4) divides VRAM cost and decode time by between 1.5 and 4×, but it degrades quality measurably — it is not assumed free, it is validated with evals. The five habitual traps: confusing the mean with P95, ignoring the prefill/decode split of the real workload, sizing without head-room for retraining or rollback, forgetting that a GPU at 100 % SM util means nothing if the HBM is saturated, and not documenting the assumptions behind the calculation (a sizing with no written assumptions is a throwaway calculation).&lt;/p>
&lt;h2 id="you-are-here-deploy-with-one-foot-in-observe">You are here: DEPLOY (with one foot in OBSERVE)&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy with one foot in Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ffb347;stroke-width:3}.semiactive{fill:#ffe1b3;stroke-width:2}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#cpm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#cpm)}&lt;/style>
&lt;defs>&lt;marker id="cpm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: capacity planning · closes DEPLOY and opens the conversation with OBSERVE&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box semiactive"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;p>Capacity planning is a piece with dual residency. It lives in &lt;strong>DEPLOY&lt;/strong> because without a valid sizing you do not buy hardware or configure the inference engine. But its &lt;strong>input is real observations&lt;/strong>: the distribution of prompt and output lengths, the prefill/decode mix of the workload, the real P95s already being seen in pre-production. Without that data the calculation is a napkin sketch, defensible only until the first customer arrives who does not fit the assumed average.&lt;/p>
&lt;h2 id="the-analogy-the-hotel-with-variable-sized-rooms">The analogy: the hotel with variable-sized rooms&lt;/h2>
&lt;p>Imagine a hotel where the rooms have no fixed size: each guest pays for the square metres they need, and the building&amp;rsquo;s floor plan reorganises itself dynamically to accommodate whoever arrives. Management wants to maximise occupancy, but it has two real constraints and one quality metric.&lt;/p>
&lt;p>&lt;strong>Constraint 1 — physical space.&lt;/strong> The floor has 1,000 m² in total. If a family comes in needing 200 m², that family occupies that surface and it cannot be handed to the next guest. The largest room limits how many simultaneous guests fit.&lt;/p>
&lt;p>&lt;strong>Constraint 2 — service staff.&lt;/strong> There are 10 receptionists. Each can handle the check-in of one guest every two minutes. When 60 guests arrive within an hour, the last ones wait in a queue; the time from walking into reception to receiving their key depends on how many are ahead of them.&lt;/p>
&lt;p>&lt;strong>Quality metric — a promise about time.&lt;/strong> The brochure says &amp;ldquo;check-in in under 15 minutes&amp;rdquo;. If too many guests arrive at once, that promise breaks even if there is physical space free.&lt;/p>
&lt;p>The physical space is the GPU&amp;rsquo;s VRAM. Each room is a request with its KV cache (larger the longer the conversation). The receptionists are the compute units (Streaming Multiprocessors + Tensor Cores). The check-in is the prefill phase; the nights the guest spends afterwards are the decode steps. The 15-minute promise is the TTFT P95 SLO.&lt;/p>
&lt;p>The hotel&amp;rsquo;s capacity planning is exactly this: given the expected guest profile (how many arrive per hour, how much space they ask for on average, how many minutes of waiting they tolerate), work out how many floors and how many receptionists are needed. It is not done by estimating &amp;ldquo;rooms per hour&amp;rdquo; in the abstract; it is done by crossing the two budgets with the promise about time. The analogy holds up the calculation all the way to the end.&lt;/p>
&lt;h2 id="the-three-slo-inputs">The three SLO inputs&lt;/h2>
&lt;p>Before putting a single number on the sheet, you have to write down the three dimensions of the SLO. Without this, the calculation is aesthetics, not engineering.&lt;/p>
&lt;p>&lt;strong>TTFT P95 (Time-To-First-Token).&lt;/strong> The time from the client sending the request until it receives the first token. It is dominated by the prefill phase (processing the whole prompt in one go) plus the scheduler queue. For conversational chat, a reasonable target sits between 0.5 and 2 seconds P95. For coding assistants with large prompts (5–10 K tokens of context), between 2 and 4 s P95. Below 500 ms you enter UX territory for voice-style conversations, but that demands serious architectural compromises.&lt;/p>
&lt;p>&lt;strong>TPOT P95 (Time-Per-Output-Token).&lt;/strong> The time between consecutive tokens during decode. It dominates the &amp;ldquo;perceived fluency&amp;rdquo; of streaming. Above 80 ms/token the human reader perceives pauses; below 30 ms/token the output flows faster than it can be read. The usual industry target: 40–60 ms P95.&lt;/p>
&lt;p>&lt;strong>Sustained RPS meeting the SLO.&lt;/strong> The throughput the system must support &lt;strong>without violating&lt;/strong> TTFT or TPOT. This is DistServe&amp;rsquo;s key metric, called &lt;strong>goodput&lt;/strong> (see &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a>). &amp;ldquo;200 RPS peak&amp;rdquo; is not the same as &amp;ldquo;200 RPS with TTFT P95 ≤ 1.5 s&amp;rdquo;. Without the SLO condition, the RPS number means nothing.&lt;/p>
&lt;p>These three dimensions come with a &lt;strong>workload profile&lt;/strong>: the distribution of prompt and output lengths. Medians are not enough; you need P50, P95, P99. A badly measured profile is the main reason sizings fail.&lt;/p>
&lt;h2 id="the-central-formula-two-budgets-that-cross">The central formula: two budgets that cross&lt;/h2>
&lt;p>The calculation resolves into two independent sums that are then crossed. The smaller of the two rules.&lt;/p>
&lt;h3 id="vram-budget">VRAM budget&lt;/h3>
&lt;p>For a GPU with total VRAM $V$, the space available for KV cache is:&lt;/p>
$$V_{\text{kv}} = V - V_{\text{model}} - V_{\text{activations}} - V_{\text{overhead}}$$
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>$V_{\text{model}}$ is the size of the weights: for a model of $P$ parameters in a format of $b$ bytes/parameter, $V_{\text{model}} = P \cdot b$. Llama 70B BF16 = $70 \times 10^9 \times 2 = 140$ GB. Under tensor parallel TP=4, each GPU carries $140 / 4 = 35$ GB.&lt;/li>
&lt;li>$V_{\text{activations}}$ are the intermediate buffers of the forward pass. For vLLM with a reasonable batch, between 2 and 6 GB per GPU depending on batch size and maximum length.&lt;/li>
&lt;li>$V_{\text{overhead}}$ is the CUDA context, NCCL buffers, the PagedAttention pool, reserved paged blocks. 2–4 GB typical.&lt;/li>
&lt;/ul>
&lt;p>The KV cache budget per GPU is what is left over. For an H100 SXM 80 GB with Llama 70B TP=4 BF16:&lt;/p>
$$V_{\text{kv}} = 80 - 35 - 4 - 3 = 38 \text{ GB per GPU} = 152 \text{ GB aggregated over TP=4}$$
&lt;p>The KV cache cost per token for a model with $L$ layers, $H_{\text{kv}}$ KV heads (GQA), per-head dimension $d_h$, in a format of $b$ bytes:&lt;/p>
$$\text{kv per token} = 2 \cdot L \cdot H_{\text{kv}} \cdot d_h \cdot b$$
&lt;p>The factor of 2 is because both K and V are stored. For Llama 70B (L=80, $H_{\text{kv}}$=8 with GQA, $d_h$=128, BF16 = 2 bytes):&lt;/p>
$$\text{kv per token} = 2 \cdot 80 \cdot 8 \cdot 128 \cdot 2 = 327,680 \text{ bytes} = 320 \text{ KB/token}$$
&lt;p>And the maximum concurrency at average context $C$:&lt;/p>
$$N_{\text{max}} = \frac{V_{\text{kv}}}{C \cdot \text{kv per token}}$$
&lt;p>With an aggregated $V_{\text{kv}}$ of 152 GB and an average context of 1,500 tokens (800 prompt + 700 generated at the worst moment of the conversation):&lt;/p>
$$N_{\text{max}} = \frac{152 \times 10^9}{1,500 \cdot 320 \times 10^3} \approx 316 \text{ concurrent requests}$$
&lt;p>This is the &lt;strong>physical ceiling&lt;/strong> of concurrency for that replica. It is not what you are going to use; it is what you &lt;strong>cannot exceed&lt;/strong> without OOM. The operational number sits well below it (head-room for spikes).&lt;/p>
&lt;h3 id="time-budget">Time budget&lt;/h3>
&lt;p>Two sub-calculations come in here: decode (memory-bound) and prefill (compute-bound).&lt;/p>
&lt;p>&lt;strong>Decode TPOT.&lt;/strong> For every token generated, the model weights (the ones relevant to that request) have to be walked through and the accumulated KV cache has to be read. The bottleneck is HBM bandwidth. For a GPU with bandwidth $B$ GB/s and a model of $V_{\text{model per gpu}}$ GB of weights:&lt;/p>
$$\text{tpot}_{\text{theoretical}} \approx \frac{V_{\text{model per gpu}}}{B}$$
&lt;p>For an H100 SXM with HBM3 at 3.35 TB/s and Llama 70B TP=4 BF16 (35 GB/GPU):&lt;/p>
$$\text{tpot}_{\text{theoretical}} \approx \frac{35}{3,350} \approx 10.4 \text{ ms/token}$$
&lt;p>This is the &lt;strong>best theoretical case&lt;/strong> with batch=1 and 100 % HBM efficiency. In practice vLLM on H100 with Llama 70B TP=4 reaches 12–18 ms/token at low batch and 30–45 ms/token at high batch (at concurrency 32, tokens compete for the shared HBM). The defensible operational number: &lt;strong>35 ms/token&lt;/strong> at concurrency 24–32.&lt;/p>
&lt;p>&lt;strong>Prefill throughput.&lt;/strong> Prefill processes N prompt tokens in a single forward pass. It is compute-bound: the bottleneck is FLOPs. For an H100 SXM with 989 sustained BF16 TFLOPs and Llama 70B (each forward pass costs roughly $2 \cdot P \cdot N$ FLOPs per sequence of length N):&lt;/p>
$$\text{prefill tps} = \frac{4 \cdot \text{TFLOPs} \cdot \eta}{2 \cdot P} = \frac{4 \cdot 989 \times 10^{12} \cdot 0.5}{2 \cdot 70 \times 10^9} \approx 14,000 \text{ tokens/s}$$
&lt;p>(the factor of 4 is the GPUs in TP, $\eta$ is real efficiency between 0.4 and 0.6 on H100). An 800-token prompt takes this long in prefill:&lt;/p>
$$\text{prefill time} = \frac{800}{14,000} \approx 57 \text{ ms}$$
&lt;p>Adding a typical queue of 100–300 ms at high concurrency, &lt;strong>TTFT P95 ≈ 350–500 ms&lt;/strong> for that profile. Well below the 1.5 s target; there is margin.&lt;/p>
&lt;h3 id="the-crossing">The crossing&lt;/h3>
&lt;p>The real operational concurrency $N_{\text{op}}$ is the minimum of &lt;strong>the VRAM ceiling&lt;/strong>, the concurrency at which &lt;strong>TPOT starts to degrade&lt;/strong> past the SLO, and the concurrency at which &lt;strong>TTFT starts to degrade&lt;/strong> past the SLO (prefill queue). For the example:&lt;/p>
&lt;ul>
&lt;li>VRAM ceiling: 316.&lt;/li>
&lt;li>TPOT degrades to 80 ms (SLO) at around concurrency ~80–100 (measured empirically with a benchmark, not a closed formula).&lt;/li>
&lt;li>TTFT degrades to 1.5 s at around concurrency ~40–60 because of the prefill queue.&lt;/li>
&lt;/ul>
&lt;p>The replica&amp;rsquo;s operational concurrency is ~50. Applying 25 % head-room for spikes and rebalancing, &lt;strong>target concurrency per replica ≈ 35–40&lt;/strong>.&lt;/p>
&lt;h2 id="step-by-step-worksheet-llama-70b-bf16-on-4h100-sxm">Step-by-step worksheet: Llama 70B BF16 on 4×H100 SXM&lt;/h2>
&lt;p>The exercise&amp;rsquo;s input:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SLO&lt;/strong>: TTFT P95 ≤ 1.5 s; TPOT P95 ≤ 60 ms; &lt;strong>200 sustained RPS&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Workload&lt;/strong>: prompt P50=600, P95=1,200, P99=2,500; output P50=180, P95=500, P99=900. Average prompt 800, output 250.&lt;/li>
&lt;li>&lt;strong>Generic hardware&lt;/strong>: 4×H100 SXM 80 GB with NVLink, vLLM v1 engine, tensor parallel 4, BF16.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Step 1 — VRAM per GPU.&lt;/strong> Weights 35 GB, activations 4 GB, overhead 3 GB → KV budget 38 GB/GPU = 152 GB aggregated. KV/token for Llama 70B GQA = 320 KB. Ceiling of live tokens in cache: $152 \times 10^9 / 320 \times 10^3 \approx 475,000$ tokens. At the operational average context (800 prompt + 200 already generated = 1,000 live tokens per request), concurrency ceiling $\approx 475$.&lt;/p>
&lt;p>&lt;strong>Step 2 — average duration of a request.&lt;/strong> Prefill 800 tokens / 14,000 tps = 57 ms. Decode 250 tokens × 35 ms/token = 8,750 ms. Total $\approx 8.8$ s per request.&lt;/p>
&lt;p>&lt;strong>Step 3 — replica throughput.&lt;/strong> If the replica sustains operational concurrency 40 and each request lasts 8.8 s, the replica delivers roughly $40 / 8.8 \approx 4.5$ requests/s in steady state.&lt;/p>
&lt;p>&lt;strong>Step 4 — number of replicas.&lt;/strong> For a 200 RPS target: $200 / 4.5 \approx 45$ replicas. That is 45 × 4 = 180 GPUs. Too much: this sizing does not work because the cost per request is high.&lt;/p>
&lt;p>&lt;strong>Step 5 — review the levers.&lt;/strong> Before buying more hardware, there are three levers to explore, in this order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Quantisation.&lt;/strong> Dropping to FP8 reduces weights to 17.5 GB/GPU (leaving more VRAM for KV cache → more concurrency), roughly doubles decode tokens/s (HBM saturated by half), and degrades MMLU quality typically by 0.5–1.5 points on models like Llama 70B. Rewriting the calculation in FP8: TPOT drops to ~18 ms, total time per request to 4.7 s, RPS per replica rises to ~8.5, &lt;strong>replicas needed ≈ 24, equivalent to 96 GPUs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Speculative decoding.&lt;/strong> With a small drafter and 60–70 % acceptance, effective TPOT falls 30–40 %. RPS per replica rises to ~12, &lt;strong>replicas ≈ 17 = 68 GPUs&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Disaggregated serving.&lt;/strong> Separating prefill workers and decode workers lets each be scaled to the real mix of the workload (see &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>). It usually shaves off another 20–40 % under asymmetric workloads.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Step 6 — recommended sizing.&lt;/strong> For the example, with FP8 + speculative decoding and 25 % head-room: &lt;strong>20 vLLM replicas at TP=4 over 80 H100 SXM&lt;/strong>. If the team does not want to depend on aggressive quantisation (pure BF16 for maximum fidelity), the calculation rises to &lt;strong>30 replicas = 120 GPUs&lt;/strong> and forces a renegotiation of the SLO or the budget.&lt;/p>
&lt;p>&lt;strong>Step 7 — write down the assumptions.&lt;/strong> This is the part no valid sizing skips. In the team&amp;rsquo;s repo, next to the calculation:&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"># sizing/llama70b-prod.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">fecha&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="ld">2026-06-01&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">slo&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">ttft_p95_ms&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1500&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">tpot_p95_ms&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">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="nt">rps_target&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">200&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">workload&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">prompt_tokens_p50&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">600&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">prompt_tokens_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1200&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">output_tokens_p50&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">180&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">output_tokens_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">500&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">asunto&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">chat productivo con RAG ligero&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">modelo&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">arquitectura&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-70b-instruct&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">formato_pesos&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fp8&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">motor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-v1&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">hardware&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">gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">H100-SXM-80GB&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">topologia&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">TP=4 con NVLink intra-nodo&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">red_inter_replica&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">25&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GbE&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">optimizaciones&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="l">paged_attention&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="l">chunked_prefill&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="l">speculative_decoding (drafter llama-1.1b, aceptación esperada 65%)&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">asunciones_criticas&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">utilizacion_hbm_eficiente&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.55&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">head_room_pico_sobre_p95&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.25&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">aceptacion_speculative_min&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.55&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">plan_validacion&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="l">benchmark vllm bench serve antes de procurement&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="l">canary 10% durante 7 días post-deploy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without this YAML, the calculation is not reproducible a month later.&lt;/p>
&lt;h2 id="moe-case-mixtral-822b-141-b-total-39-b-active">MoE case: Mixtral 8×22B (~141 B total, 39 B active)&lt;/h2>
&lt;p>MoEs change the calculation along one key dimension: the total weights are large but the active weights per token are small. For Mixtral 8×22B with top-2 routing:&lt;/p>
&lt;ul>
&lt;li>Weight VRAM: $141 \times 2 = 282$ GB BF16. With TP=4 → 70 GB/GPU. It does not fit in an H100 80 GB alongside KV cache + activations. You need TP=8 (~35 GB/GPU) or FP8 with TP=4 (~35 GB/GPU).&lt;/li>
&lt;li>Decode TPOT: dominated by the weights read per token, which are $\sim 39 / 8 \cdot 2 \approx 9.75$ GB/GPU with TP=4 (one top-2 expert per token, divided across 4 GPUs). On an H100 with HBM at 3.35 TB/s, &lt;strong>theoretical TPOT ≈ 3 ms/token&lt;/strong>. In practice, 10–20 ms at reasonable concurrency.&lt;/li>
&lt;li>Prefill: similar to the dense model of the active weights, ~39 B FLOPs/token.&lt;/li>
&lt;/ul>
&lt;p>MoE sizing usually delivers more RPS per GPU than an equivalent dense model: the low cost per token compensates for the extra VRAM. See &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a> for the detail of routing and why a high batch is decisive so that each expert sees enough tokens.&lt;/p>
&lt;h2 id="sensitivity-table-context-and-quantisation">Sensitivity table: context and quantisation&lt;/h2>
&lt;p>For Llama 70B on 4×H100 SXM (TP=4), operational concurrency per replica with an SLO of TTFT 1.5 s / TPOT 60 ms:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Average context&lt;/th>
&lt;th>BF16&lt;/th>
&lt;th>FP8&lt;/th>
&lt;th>INT4 (AWQ)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>500 tokens&lt;/td>
&lt;td>55&lt;/td>
&lt;td>110&lt;/td>
&lt;td>180&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1,000 tokens&lt;/td>
&lt;td>40&lt;/td>
&lt;td>80&lt;/td>
&lt;td>130&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2,000 tokens&lt;/td>
&lt;td>24&lt;/td>
&lt;td>50&lt;/td>
&lt;td>85&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4,000 tokens&lt;/td>
&lt;td>12&lt;/td>
&lt;td>26&lt;/td>
&lt;td>48&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8,000 tokens&lt;/td>
&lt;td>6&lt;/td>
&lt;td>13&lt;/td>
&lt;td>25&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Approximate numbers from public vLLM benchmarks as of June 2026, with ±20 % variation depending on engine version and the headroom adopted. To validate on your own hardware: &lt;code>vllm bench serve&lt;/code> with your profile of real prompts.&lt;/p>
&lt;h2 id="the-five-habitual-traps">The five habitual traps&lt;/h2>
&lt;p>&lt;strong>Trap 1 — confusing the mean with P95.&lt;/strong> Average throughput over an hour may be 50 RPS while the 5-minute peak reaches 180 RPS. Sizing against the mean guarantees breaking the SLO at every peak. Rule: size against the hourly P95, with 20–30 % head-room over P95.&lt;/p>
&lt;p>&lt;strong>Trap 2 — not measuring the real prefill/decode split.&lt;/strong> A &amp;ldquo;RAG with short answers&amp;rdquo; workload spends 70–80 % of GPU time in prefill; a &amp;ldquo;writing assistant generating essays&amp;rdquo; spends 80 % in decode. The useful optimisations (chunked prefill vs speculative decoding) change radically. Without measuring it, you buy badly balanced hardware.&lt;/p>
&lt;p>&lt;strong>Trap 3 — sizing with no head-room for retraining or rollback.&lt;/strong> The production cluster is not just the inference engine: there is a re-embedding batch when the embeddings model changes, continuous canary evals (see &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a>), light fine-tuning, hot stand-by for rollback. Reserve 15–25 % of capacity for those non-negotiable workloads.&lt;/p>
&lt;p>&lt;strong>Trap 4 — &amp;ldquo;GPU at 100 % SM utilization&amp;rdquo; as a target.&lt;/strong> SM occupancy of 95 % with saturated HBM produces the same throughput as SM at 60 % with saturated HBM. The bottleneck in decode is the HBM. Optimising for &amp;ldquo;GPU usage 100 %&amp;rdquo; without looking at HBM utilisation and arithmetic intensity makes you spend more on GPU without gaining throughput. See &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> for which metrics to actually watch.&lt;/p>
&lt;p>&lt;strong>Trap 5 — not documenting the assumptions.&lt;/strong> A sizing with no reproducible YAML (workload, model, engine, head-room, critical assumptions) leaves the team with no way of knowing what changed when the cluster stops meeting the SLO six months later. Documenting is cheap; losing a quarter to debugging is not.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;p>For a generic cluster of &lt;strong>4×H100 SXM 80 GB with intra-node NVLink and 25 GbE between nodes&lt;/strong>, the recurring configurations in May 2026 are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Format&lt;/th>
&lt;th>TP&lt;/th>
&lt;th>Replicas that fit&lt;/th>
&lt;th>Typical RPS per node (ctx 1K)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama 8B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>1&lt;/td>
&lt;td>4 (one per GPU)&lt;/td>
&lt;td>240–320&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 8B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>4&lt;/td>
&lt;td>450–600&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>30–45&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>60–90&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 70B&lt;/td>
&lt;td>INT4 AWQ&lt;/td>
&lt;td>2&lt;/td>
&lt;td>2&lt;/td>
&lt;td>90–130&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8×22B&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>90–140&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen 72B&lt;/td>
&lt;td>BF16&lt;/td>
&lt;td>4&lt;/td>
&lt;td>1&lt;/td>
&lt;td>28–42&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These numbers are &lt;strong>orders of magnitude to start the conversation&lt;/strong>, not commitments. The definitive sizing is validated with &lt;code>vllm bench serve&lt;/code> or &lt;code>genai-perf&lt;/code> (NVIDIA) using the customer&amp;rsquo;s real prompt/output profile. The prefill/decode asymmetry of each case&amp;rsquo;s workload can move these numbers 30–50 % up or down.&lt;/p>
&lt;p>For clusters of 8×H100 SXM (typical of DGX servers or equivalent replicas), the options open up to TP=8 for 405B-class models or multi-replica TP=2 for 70B models with greater density. The metric that decides is always the same: tokens meeting SLO per kW and per euro of amortised hardware.&lt;/p>
&lt;h2 id="how-the-sizing-is-validated-before-buying">How the sizing is validated before buying&lt;/h2>
&lt;p>The spreadsheet sizing is the first half. The second is the validation benchmark.&lt;/p>
&lt;p>&lt;strong>Stage 1 — back-of-the-envelope sizing.&lt;/strong> This post&amp;rsquo;s formulas applied to the SLO and the expected workload. Output: an approximate number of replicas and a topology.&lt;/p>
&lt;p>&lt;strong>Stage 2 — synthetic micro-benchmark.&lt;/strong> On a borrowed GPU or one rented by the day, bring up the engine with the chosen model and run &lt;code>vllm bench serve&lt;/code> with prompts of representative lengths. Validate TPOT, prefill TPS and the concurrency ceiling. Calibrate the HBM efficiency factor ($\eta$) used in the formulas.&lt;/p>
&lt;p>&lt;strong>Stage 3 — load test with realistic traffic.&lt;/strong> Generate traffic following the real distribution of the customer&amp;rsquo;s workload (not Poisson, not constant: the real trace). Measure P50/P95/P99 of TTFT, TPOT, throughput. Confirm the head-room.&lt;/p>
&lt;p>&lt;strong>Stage 4 — canary in production.&lt;/strong> With the cluster sized, route 5–10 % of real traffic for 7–14 days before closing the procurement of additional hardware. See &lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a> for the mechanics.&lt;/p>
&lt;p>Jumping from Stage 1 to full procurement is the most frequent cause of a cluster that is 40 % oversized and 60 % undersized at the same time, in different regions of the workload. Four weeks of validation done well save four months of refactoring.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The observability metrics&lt;/strong> that close the sizing loop in production, see &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a>.&lt;/li>
&lt;li>Autoscaling that adjusts replicas to the real traffic curve, see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLM on Kubernetes&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Detailed cost accounting&lt;/strong> per tenant (showback / chargeback) over the sized hardware.&lt;/li>
&lt;li>&lt;strong>Sizing for continuous fine-tuning&lt;/strong> (PEFT and light training) that shares a cluster with inference.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster: who pays for what&lt;/a> — translating the sizing into €/token and attributing cost per team.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — the component that dominates the VRAM budget.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — what defines the effective utilisation of the compute and the goodput metric.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated prefill/decode serving&lt;/a> — an advanced lever for asymmetric workloads.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a> — how the sums change with MoE models.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for inference&lt;/a> — what each format costs and what it saves.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Seven layers of the on-premise LLM inference stack&lt;/a> — the pieces the sizing takes for granted.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments for LLM inference&lt;/a> — the sizing closes better once heterogeneity is accepted: embeddings and reranker on Intel Xeon AMX free up H100s for the large LLM, without buying more GPU.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">RKE2 resource managers&lt;/a> — why the sizing comes to be reasoned per NUMA node rather than per node: you have to discount the reserved housekeeping cores and check that the pod fits on a single NUMA &amp;ldquo;table&amp;rdquo;.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of the on-premise GPU cluster: amortisation, energy and infrastructure&lt;/a> — the €/GPU-hour that closes the sizing&amp;rsquo;s economic model: once you know how many GPUs you need, here is the real cost of owning them.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/">Sizing and justifying the investment in GPU&lt;/a> — the full roadmap for turning this sizing into an investment proposal defensible before management: ROI, break-even and sensitivity analysis.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">Sizing for agents&lt;/a> — the same method with the inputs changed, when the caller is an agent and not a person.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kwon et al. — &lt;em>vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention&lt;/em> (SOSP 2023).&lt;/li>
&lt;li>Zhong et al. — &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>Agrawal et al. — &lt;em>Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>NVIDIA — &lt;em>H100 Tensor Core GPU Architecture Whitepaper&lt;/em> (HBM3 memory, bandwidth, sustained FLOPs).&lt;/li>
&lt;li>vLLM project — &lt;code>vllm bench serve&lt;/code> reference (benchmarking CLI included in the repo).&lt;/li>
&lt;li>NVIDIA — &lt;code>genai-perf&lt;/code> (official tool for benchmarking LLM services).&lt;/li>
&lt;/ul></description></item><item><title>Seven greenfield deployment phases for an on-premise LLM platform: from hardware in the room to the first production token</title><link>https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/</link><pubDate>Sun, 31 May 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The two previous posts in this architectural trilogy settled the pieces: &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">the seven layers of the LLM inference stack&lt;/a> describe the components above the cluster, and &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">the five maturity levels of the platform&lt;/a> describe the strata below. This post settles the &lt;strong>when&lt;/strong>: in what order each thing gets deployed when you start from zero, hardware bought, racks installed, physical cabling done, and you want to reach a cluster serving the first production token to a customer. Seven nominal phases &lt;strong>F0 to F6&lt;/strong> with no calendar commitments, organised by &lt;strong>technical dependencies&lt;/strong> (you do not enter F3 without the F2 gate) and with an identifiable &lt;strong>critical path&lt;/strong>. F0 hardware inventory and electrical/network connectivity. F1 bare metal OS + drivers + container runtime. F2 Kubernetes cluster with CNI and Ceph storage operational. F3 GitOps and infrastructure observability. F4 identity, TLS, secrets and policies. F5 GPU platform with LLM-aware observability. F6 the LLM stack operational and open to production traffic. For each phase: what gets built, what has to be ready first (dependencies between phases), the &lt;strong>gate&lt;/strong> that validates its closure, and the typical trap that delays the critical path. The thesis: an on-premise LLM platform sinks far more often from &lt;strong>sequencing badly&lt;/strong> than from &lt;strong>choosing badly&lt;/strong>. The tools have all been invented; the order is the only thing every team rediscovers.&lt;/p>
&lt;h2 id="you-are-here-the-seven-phases-and-their-dependencies">You are here: the seven phases and their dependencies&lt;/h2>
&lt;p>The phases do not run in pure series. F2 and F3 can be started at the same time to speed things up (installing Kubernetes and preparing the GitOps repo in parallel). F4 can overlap with the tail end of F3. F5 waits for F4 to close because GPU pods demand NetworkPolicy and RBAC from day one. F6 is &lt;strong>a single atomic step&lt;/strong>: the cluster goes into production or it does not.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="dag of phases F0 to F6 with dependencies and critical path">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.f0{fill:#f6e2e2;stroke:#a33}.f1{fill:#f4e3cf;stroke:#a63}.f2{fill:#eef0d0;stroke:#7a3}.f3{fill:#dfe9f5;stroke:#356}.f4{fill:#d8eecf;stroke:#373}.f5{fill:#f5e3d8;stroke:#763}.f6{fill:#ead8f5;stroke:#634}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}.crit{stroke:#c33;stroke-width:2.4;fill:none;marker-end:url(#ac)}&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;marker id="ac" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c33"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">Phase DAG · critical path marked in red&lt;/text>
&lt;rect x="40" y="50" width="110" height="48" class="b f0"/>&lt;text x="95" y="68" text-anchor="middle" class="tiny">F0&lt;/text>&lt;text x="95" y="84" text-anchor="middle" class="sm">Hardware&lt;/text>&lt;text x="95" y="98" text-anchor="middle" class="note">Inventory · network&lt;/text>
&lt;rect x="180" y="50" width="110" height="48" class="b f1"/>&lt;text x="235" y="68" text-anchor="middle" class="tiny">F1&lt;/text>&lt;text x="235" y="84" text-anchor="middle" class="sm">Bare metal&lt;/text>&lt;text x="235" y="98" text-anchor="middle" class="note">OS · drivers&lt;/text>
&lt;rect x="320" y="50" width="110" height="48" class="b f2"/>&lt;text x="375" y="68" text-anchor="middle" class="tiny">F2&lt;/text>&lt;text x="375" y="84" text-anchor="middle" class="sm">k8s cluster&lt;/text>&lt;text x="375" y="98" text-anchor="middle" class="note">Cilium · Ceph&lt;/text>
&lt;rect x="460" y="50" width="110" height="48" class="b f3"/>&lt;text x="515" y="68" text-anchor="middle" class="tiny">F3&lt;/text>&lt;text x="515" y="84" text-anchor="middle" class="sm">GitOps + obs&lt;/text>&lt;text x="515" y="98" text-anchor="middle" class="note">Flux · VM/Loki&lt;/text>
&lt;rect x="600" y="50" width="110" height="48" class="b f4"/>&lt;text x="655" y="68" text-anchor="middle" class="tiny">F4&lt;/text>&lt;text x="655" y="84" text-anchor="middle" class="sm">Identity&lt;/text>&lt;text x="655" y="98" text-anchor="middle" class="note">OIDC · Kyverno&lt;/text>
&lt;rect x="320" y="170" width="110" height="48" class="b f5"/>&lt;text x="375" y="188" text-anchor="middle" class="tiny">F5&lt;/text>&lt;text x="375" y="204" text-anchor="middle" class="sm">GPU plane&lt;/text>&lt;text x="375" y="218" text-anchor="middle" class="note">NVIDIA op · DCGM&lt;/text>
&lt;rect x="600" y="170" width="110" height="48" class="b f6"/>&lt;text x="655" y="188" text-anchor="middle" class="tiny">F6&lt;/text>&lt;text x="655" y="204" text-anchor="middle" class="sm">LLM stack live&lt;/text>&lt;text x="655" y="218" text-anchor="middle" class="note">7 layers active&lt;/text>
&lt;path class="crit" d="M150,74 L180,74"/>
&lt;path class="crit" d="M290,74 L320,74"/>
&lt;path class="crit" d="M430,74 L460,74"/>
&lt;path class="crit" d="M570,74 L600,74"/>
&lt;path class="arr" d="M655,98 L655,170"/>
&lt;path class="arr" d="M375,98 L375,170"/>
&lt;path class="crit" d="M430,194 L600,194"/>
&lt;text x="410" y="262" text-anchor="middle" class="sm" fill="#c33">Critical path: F0 → F1 → F2 → F3 → F4 → F5 → F6&lt;/text>
&lt;text x="410" y="282" text-anchor="middle" class="note">Possible overlaps: F2 ↔ F3 (prepare repo while the cluster is built) · F3 ↔ F4 (policies in audit before enforce)&lt;/text>
&lt;text x="410" y="304" text-anchor="middle" class="note">Not overlappable: F4 before F5 (GPU without RBAC = a bomb) · F5 before F6 (the LLM stack does not start without a GPU plane)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The red arrows are the &lt;strong>critical path&lt;/strong>: the sequential bottleneck that no parallelism can shorten. The grey arrows are dependencies that admit partial overlap. Recognising where to overlap and where not is the difference between a three-month deployment and a six-month one for the same perimeter.&lt;/p>
&lt;h2 id="the-analogy-the-expedition-to-an-eight-thousand-metre-summit">The analogy: the expedition to an eight-thousand-metre summit&lt;/h2>
&lt;p>An expedition to a high alpine summit is not a long trek. It is a series of &lt;strong>camps&lt;/strong> that are set up in order, each with its altitude, its function and its validation gate: if you do not acclimatise properly at base camp, you cannot go up to C1 without risk; if C2 does not have its kitchen and its radio running, you cannot send people higher; if the summit push is attempted without porters in the high camps, there is no safe descent.&lt;/p>
&lt;p>Greenfield deployment of an LLM platform works identically. &lt;strong>F0&lt;/strong> is the arrival of the material at base camp: boxes, sponsors, permits, first review. &lt;strong>F1&lt;/strong> is setting up an operational base camp: kitchen, tents, generator. &lt;strong>F2&lt;/strong> is the climb to C1: there is real altitude now (a k8s cluster running) and you breathe differently. &lt;strong>F3&lt;/strong> is C2: it adds communications, planning and operational acclimatisation. &lt;strong>F4&lt;/strong> is C3, the last night before the push: team roped up, oxygen ready, every protocol verified. &lt;strong>F5&lt;/strong> is the day of the summit push, intense effort, thin margins. &lt;strong>F6&lt;/strong> is the summit and the start of a safe descent: from here on the expedition is in day-to-day operation, no longer in construction.&lt;/p>
&lt;p>The analogy holds two useful lessons: &lt;strong>you do not skip a camp&lt;/strong> (going straight from base camp to the summit kills the team), and &lt;strong>the gates are technical, not emotional&lt;/strong> (if the barometer says storm, you do not set out, however much enthusiasm there is). The platform team that follows those two rules reaches the summit. The one that negotiates them does not.&lt;/p>
&lt;h2 id="f0--hardware-in-the-room-base-camp">F0 — Hardware in the room: base camp&lt;/h2>
&lt;p>&lt;strong>What gets built in this phase.&lt;/strong> An inventory of the hardware received (servers, switches, PDUs, BMC), racks assembled, electrical and data cabling terminated, physical labelling of each machine (rack/U/function), connectivity to the corporate network, management IPs assigned, BMC reachable over VPN with MFA, first ping to each node from the bastion.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> None technical, this is the phase &lt;strong>before the software&lt;/strong>. There are procurement dependencies (servers bought, switches bought), civil works dependencies (a room with enough cooling, a raised floor) and administrative ones (datacenter access for the technicians).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Every node appears in the inventory with &lt;code>(hostname, MAC, management IP, data IP, rack, U, function, owner)&lt;/code>.&lt;/li>
&lt;li>Each node&amp;rsquo;s BMC responds to &lt;code>ipmitool power status&lt;/code> and to the HTTPS UI from the management VPN.&lt;/li>
&lt;li>The top-of-rack switch has its configuration versioned in git (even if there is no cluster GitOps yet, the switch configs are).&lt;/li>
&lt;li>A command &lt;code>for h in $(cat hosts); do ping -c1 -W1 $h.mgmt; done&lt;/code> returns 100 % success.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Cabling &amp;ldquo;however it comes out&amp;rdquo; with no physical labelling and no diagram. When F4 arrives and a NetworkPolicy has to be troubleshot, not knowing which physical interface carries which VLAN doubles the diagnosis time of every incident for ever.&lt;/p>
&lt;p>&lt;strong>Why F0 does not overlap with F1.&lt;/strong> Until every server has a management IP and a live BMC, OS bootstrap cannot be automated. Every hour invested in F0 saves hours in every later phase. It is the phase with the best ROI in the project and the only one that admits no shortcuts.&lt;/p>
&lt;h2 id="f1--bare-metal-the-operational-base-camp">F1 — Bare metal: the operational base camp&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> The operating system image (stable Debian or Ubuntu LTS) provisioned via PXE or cloud-init with the &lt;code>cloud-config&lt;/code> versioned in git. Each node has: a coherent hostname, LVM partitions, kernel ≥ 6.6, the &lt;code>containerd&lt;/code> container runtime, NVIDIA drivers on the GPU nodes, &lt;code>chrony&lt;/code> synchronising against your own servers, the operator&amp;rsquo;s SSH key as the only way in, and &lt;code>nvidia-smi&lt;/code> passing a smoke test on the GPU nodes.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F0 closed. It needs the management network working so PXE responds and so the bastion can reach every node.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F1.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>ansible -i inventory all -m ping&lt;/code> returns 100 % success (or the equivalent with Salt / Pulumi / etc).&lt;/li>
&lt;li>Every GPU node passes &lt;code>nvidia-smi&lt;/code> showing the expected GPUs with a consistent driver across nodes.&lt;/li>
&lt;li>Each node&amp;rsquo;s clock drifts by less than 50 ms from the reference NTP.&lt;/li>
&lt;li>A physical reboot of a node leaves it in exactly the same state after boot (idempotency).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> NVIDIA drivers installed by hand with &lt;code>apt install&lt;/code> or with NVIDIA&amp;rsquo;s &lt;code>.run&lt;/code> script. It works on day one and breaks the day of the first kernel update. The operational rule already established in &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">the post on the five levels&lt;/a>: the drivers end up managed by the GPU Operator in F5; whatever is done now is only so that &lt;code>nvidia-smi&lt;/code> passes the smoke test, not for production.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F1 can start for some nodes while F0 is still being finished on others (real greenfield rarely delivers every server on the same day). The F1 gate is per cluster, not per individual node.&lt;/p>
&lt;h2 id="f2--kubernetes-cluster-operational">F2 — Kubernetes cluster operational&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> RKE2 installed with three HA control plane nodes, all workers joined (CPU and GPU), Cilium as CNI with &lt;code>kubeProxyReplacement&lt;/code> enabled and a BGP control plane pointing at the ToR switches from F0, Rook-Ceph deployed on the storage nodes to cover block (RBD), filesystem (CephFS) and object (S3-compatible RGW), &lt;code>kubectl get nodes&lt;/code> returning every node &lt;code>Ready&lt;/code>, a first test pod with a PVC mounting and data persisting after the pod restarts.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F1 closed (drivers + container runtime). Switches with BGP configured (closed in F0). NVMe disks partitioned or available raw for Ceph OSDs.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F2.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl get nodes -o wide&lt;/code> shows every node &lt;code>Ready&lt;/code> with the expected Kubernetes version.&lt;/li>
&lt;li>A Deployment with replicas=3 and per-node antiAffinity starts and the pods land on different nodes.&lt;/li>
&lt;li>An RWO PVC (RBD) creates a volume, the pod writes data, the pod is deleted, another pod mounts it and reads the data.&lt;/li>
&lt;li>An RWX PVC (CephFS) does the same with two pods writing simultaneously.&lt;/li>
&lt;li>An RGW bucket via &lt;code>s3cmd&lt;/code> or &lt;code>mc&lt;/code> accepts &lt;code>put&lt;/code> and &lt;code>get&lt;/code> over TLS.&lt;/li>
&lt;li>Hubble (the read side of the CNI) shows flow logs between two pods in different namespaces.&lt;/li>
&lt;li>Chaos test: draining a non-GPU worker node; the workloads are rescheduled automatically.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Starting to &lt;code>kubectl apply&lt;/code> real workloads in F2 without GitOps. The backlog of things-applied-by-hand grows faster than the capacity to migrate it to git afterwards. The rule: in F2 only the cluster &lt;strong>prerequisites&lt;/strong> are applied (CNI, CSI, default storage class). Any application workload waits for F3.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F2 ↔ F3. While the cluster is being built, the GitOps repo is prepared in parallel (directory structure, first Helm releases). When F2 closes, Flux is plugged into the repo and everything that was going to be &lt;code>kubectl apply&lt;/code> is already a reconciled manifest.&lt;/p>
&lt;h2 id="f3--gitops-and-infrastructure-observability">F3 — GitOps and infrastructure observability&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> Forgejo deployed first (it is a prerequisite for everything that follows). A &lt;code>gitops-infra&lt;/code> repo with the initial structure (&lt;code>apps/&lt;/code>, &lt;code>infrastructure/&lt;/code>, &lt;code>tenants/&lt;/code>, &lt;code>clusters/&lt;/code>). Flux installed and reconciling that repo. The prerequisite workloads applied by hand in F2 are moved to the repo and reconciled (no operational &lt;code>kubectl apply&lt;/code> remains). VictoriaMetrics + vmagent scraping metrics. Grafana with initial dashboards (USE/RED + cluster + Ceph + Cilium). Loki receiving logs via vector/fluent-bit. Alertmanager + Keep routing alerts to a chat channel. Barman Cloud backups for Postgres (future CNPG) and scheduled Ceph snapshots.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F2 closed. An RGW bucket to store backups (covered by the Ceph from F2).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F3.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A change applied to the repo is reflected in the cluster in under 5 minutes with no manual intervention.&lt;/li>
&lt;li>A change applied with &lt;code>kubectl edit&lt;/code> directly to the cluster is detected by Flux and reverted (binding drift detection, not merely observational).&lt;/li>
&lt;li>Grafana shows dashboards for the cluster, Ceph, Cilium and GPU nodes (DCGM does not arrive until F5, but basic node metrics do).&lt;/li>
&lt;li>A test alert sent to Alertmanager reaches the chat channel in under 1 minute.&lt;/li>
&lt;li>Restoring a Postgres backup on a temporary cluster returns coherent data (the test defines the real RPO).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Having Helm charts in git but still applying them with &lt;code>helm install&lt;/code> from the terminal. That is level 1 dressed up as level 2. F3 only closes when Flux is &lt;strong>the only authority&lt;/strong> that applies changes and humans edit the repo, not the cluster.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> F3 ↔ F4. While F3 is being closed, the Defguard and cert-manager manifests can be prepared in the repo. When they reconcile they have somewhere to land.&lt;/p>
&lt;h2 id="f4--identity-certificates-secrets-policies">F4 — Identity, certificates, secrets, policies&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> Defguard deployed with its dedicated Postgres (CNPG). An initial realm with the platform operators enrolled with MFA and WireGuard. OIDC integrated into kube-apiserver (&lt;code>--oidc-issuer-url&lt;/code>, &lt;code>--oidc-client-id&lt;/code>, &lt;code>--oidc-username-claim&lt;/code>), into Forgejo, into Grafana, into Alertmanager, one single SSO. cert-manager installed with an internal CA issuing internal certs for mTLS and with Let&amp;rsquo;s Encrypt ACME for edge certs. SOPS configured with a KMS (it can be a physical HSM, an age key in a vault, or an external Vault) and External Secrets Operator syncing secrets into the cluster. Kyverno deployed with initial policies in &lt;code>audit&lt;/code> mode for a week, then promoted to &lt;code>enforce&lt;/code>. A default-deny NetworkPolicy applied to every existing namespace. Tetragon enabled for runtime security. The kube-apiserver audit log sent to Loki with long retention.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F3 closed (Flux applies the manifests, VM/Loki ingest metrics and logs).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F4.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl&lt;/code> with a shared admin &lt;code>kubeconfig&lt;/code> &lt;strong>stops working&lt;/strong>; every operator uses their own OIDC token with MFA.&lt;/li>
&lt;li>A secret in plain &lt;code>data:&lt;/code> in a commit is rejected by the pre-commit hook (or by Kyverno admission).&lt;/li>
&lt;li>A pod without &lt;code>securityContext.runAsNonRoot=true&lt;/code> is rejected by Kyverno at admission.&lt;/li>
&lt;li>A deliberately wrong NetworkPolicy (allow-all) in a tenant namespace is rejected.&lt;/li>
&lt;li>An audit of the last day returns the complete list of actors and changes (minimum regulatory footprint).&lt;/li>
&lt;li>Basic internal pen-test: an attacker with a forged &lt;code>kubeconfig&lt;/code> fails MFA and is recorded.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Kyverno in permanent &lt;code>audit&lt;/code> mode because &amp;ldquo;we do not want to break production workloads&amp;rdquo;. F4 closes when the policies are in &lt;code>enforce&lt;/code>. Until then, you are still in F3 wearing an F4 face.&lt;/p>
&lt;p>&lt;strong>Why F4 does not overlap with F5.&lt;/strong> F5 introduces GPU pods that move a lot of VRAM and a lot of compute. Without default-deny NetworkPolicy, without OIDC RBAC, without Kyverno blocking insecure configurations, the GPU pods are the juiciest attack surface in the cluster. Any compromise in F5 with F4 not closed is near-total access to the expensive hardware.&lt;/p>
&lt;h2 id="f5--gpu-platform-with-llm-aware-observability">F5 — GPU platform with LLM-aware observability&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> NVIDIA GPU Operator via Flux with the driver version decided in F1 (no longer touched by hand). DCGM Exporter exposing GPU metrics to VictoriaMetrics. MIG manager configured for the nodes where it makes sense (for example, on a 4×H100 SXM cluster: two GPUs with full passthrough for the general LLM at TP=4, two GPUs partitioned into 2×3g.40gb each for small LLMs and embeddings). Topology Manager with the &lt;code>single-numa-node&lt;/code> policy. KEDA with the Prometheus scaler installed and an example ScaledObject pointing at a vLLM metric (&lt;code>vllm:num_requests_running&lt;/code>). OpenTelemetry Collector with OTLP receivers, &lt;code>attributes&lt;/code> processors (enriching spans with &lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>), exporters to Langfuse and to Tempo. LeaderWorkerSet API enabled for tensor parallel topologies. OME (Operator Model Engine) or vLLM Production Stack deployed as a controller, still with no models loaded.&lt;/p>
&lt;p>&lt;strong>Dependencies.&lt;/strong> F4 closed (the GPU pods inherit default-deny NetworkPolicy, OIDC RBAC and Kyverno policies).&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F5.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A test pod requesting &lt;code>nvidia.com/gpu: 1&lt;/code> is scheduled on the right node and &lt;code>nvidia-smi&lt;/code> from inside the container sees the right GPU (whole or a MIG slice).&lt;/li>
&lt;li>DCGM Exporter exposes metrics in Grafana (utilisation, VRAM, temperature, NVLink bandwidth) for every GPU.&lt;/li>
&lt;li>A test vLLM Deployment starts with a small model (for example, a 7B FP16) loaded from Ceph RGW.&lt;/li>
&lt;li>An OpenTelemetry span generated by that vLLM reaches Langfuse with correct &lt;code>gen_ai.*&lt;/code> attributes.&lt;/li>
&lt;li>KEDA scales the test Deployment from 1 to N replicas under synthetic load and back to 1 when it stops.&lt;/li>
&lt;li>A GPU Operator upgrade to a new version drains and reschedules the GPU pods with no loss of service.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Loading the large model &amp;ldquo;just to try&amp;rdquo; before DCGM and OTel are green. When something fails, there will be no metrics to distinguish OOM, thermal throttling, a driver mismatch or a network problem, and you diagnose blind. The rule: &lt;strong>small model first&lt;/strong>, golden path green, &lt;strong>then&lt;/strong> the large model.&lt;/p>
&lt;p>&lt;strong>Possible overlap.&lt;/strong> None with F6. F6 is atomic.&lt;/p>
&lt;h2 id="f6--llm-stack-in-production">F6 — LLM stack in production&lt;/h2>
&lt;p>&lt;strong>What gets built.&lt;/strong> The &lt;strong>seven layers of the inference stack&lt;/strong> described in the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">corresponding post&lt;/a>, deployed in this order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Vector store + relational data&lt;/strong> (Qdrant, PostgreSQL CNPG, Ceph RGW for weights and adapters, CephFS for datasets). Some components already existed from F3 as data; here they specialise for RAG with their initial collections and schemas.&lt;/li>
&lt;li>&lt;strong>Embeddings + reranker&lt;/strong> (Infinity with &lt;code>multilingual-e5-large&lt;/code>, TEI with &lt;code>bge-reranker-v2-m3&lt;/code>). This is the layer that must be green before any large model, because RAG depends on it.&lt;/li>
&lt;li>&lt;strong>LLM inference&lt;/strong> (vLLM Production Stack with the general LLM and the code LLM). It loads models from Ceph RGW. The initial multi-LoRA pool is empty.&lt;/li>
&lt;li>&lt;strong>Gateway&lt;/strong> (Envoy AI Gateway) with Defguard OAuth, routing by &lt;code>body.model&lt;/code>, rate-limit per tenant. This is the point that &lt;strong>opens traffic to the outside&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>LLM-aware observability&lt;/strong> (Langfuse plugged into the OTel from F5).&lt;/li>
&lt;li>&lt;strong>GitOps control plane&lt;/strong> and &lt;strong>dependency tracking&lt;/strong> were already active since F3 and F4 respectively; here you simply add the catalogue of the new LLM services to them.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Dependencies.&lt;/strong> All the previous phases closed.&lt;/p>
&lt;p>&lt;strong>Validation gate that closes F6.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A curl to the public endpoint with a Defguard bearer token receives a correct chat completion response in technical Spanish, with &lt;code>trace_id&lt;/code> propagated.&lt;/li>
&lt;li>The trace appears in Langfuse with complete &lt;code>gen_ai.*&lt;/code> attributes, a latency breakdown and its own &lt;code>tenant_id&lt;/code>.&lt;/li>
&lt;li>A 5 % traffic canary to the new model over 24 h degrades neither quality nor latency metrics.&lt;/li>
&lt;li>A controlled traffic burst triggers KEDA, the replicas scale, and P95 latency stays within budget.&lt;/li>
&lt;li>A deliberate failure of one vLLM pod does not affect endpoint availability (replicas + reschedule).&lt;/li>
&lt;li>The internal operator demonstrates the complete path for revoking a tenant&amp;rsquo;s access in under 5 minutes (Defguard → Kyverno → closing the NetworkPolicy).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Typical trap.&lt;/strong> Opening real customer traffic before having the incident runbook signed off, the SLO negotiated and the continuity plan tested. F6 is technically closed; operationally, the platform is still an experiment until the first real postmortem proves the team knows how to respond.&lt;/p>
&lt;h2 id="the-maths-that-matter-relative-weight-of-effort-per-phase">The maths that matter: relative weight of effort per phase&lt;/h2>
&lt;p>Without committing to calendar weeks, we can quantify the &lt;strong>relative weight&lt;/strong> of the engineering effort per phase in a typical greenfield. The curve is not uniform:&lt;/p>
$$
\text{effort}_{F_i} \approx \text{base}_i \cdot (1 + \epsilon_i)
$$
&lt;p>where $\text{base}_i$ is the nominal effort and $\epsilon_i$ is the &lt;strong>surprises&lt;/strong> factor (wrong cabling, incompatible drivers, badly issued certificates, version conflicts). The table below gives the nominal relative weight and the typical surprise factor observed:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Nominal weight&lt;/th>
&lt;th>Typical surprise factor ε&lt;/th>
&lt;th>Mean effective weight&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>F0 — Hardware&lt;/td>
&lt;td>8 %&lt;/td>
&lt;td>0.5 (1× to 2×)&lt;/td>
&lt;td>&lt;strong>12 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F1 — Bare metal&lt;/td>
&lt;td>6 %&lt;/td>
&lt;td>0.3&lt;/td>
&lt;td>8 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F2 — k8s cluster&lt;/td>
&lt;td>12 %&lt;/td>
&lt;td>0.4&lt;/td>
&lt;td>17 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F3 — GitOps + obs&lt;/td>
&lt;td>14 %&lt;/td>
&lt;td>0.5&lt;/td>
&lt;td>21 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F4 — Identity + policies&lt;/td>
&lt;td>18 %&lt;/td>
&lt;td>0.7&lt;/td>
&lt;td>&lt;strong>31 %&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F5 — GPU plane&lt;/td>
&lt;td>10 %&lt;/td>
&lt;td>0.4&lt;/td>
&lt;td>14 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>F6 — LLM stack live&lt;/td>
&lt;td>8 %&lt;/td>
&lt;td>0.3&lt;/td>
&lt;td>10 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Buffer / integration&lt;/td>
&lt;td>24 %&lt;/td>
&lt;td>—&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two operational observations. &lt;strong>F4 concentrates more surprises than any other phase&lt;/strong> (OIDC federation across four or five apps with different configurations, Kyverno policies that knock over legitimate workloads, secrets broken by poorly tested encryption). &lt;strong>F0 has a high surprise coefficient relative to its size&lt;/strong> because any cabling or labelling error is discovered late and paid for dearly. The two practical consequences: plan &lt;strong>F4 with generous margin&lt;/strong> and do not skimp on time in &lt;strong>F0&lt;/strong>, because every hour saved there costs five later.&lt;/p>
&lt;p>&lt;strong>Critical path and slack.&lt;/strong> The critical path is linear F0 → F1 → F2 → F3 → F4 → F5 → F6. The only real slack is the overlaps already identified:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>F2 ↔ F3 (slack ~30 %)&lt;/strong>: prepare the repo and initial dashboards while the cluster is being built.&lt;/li>
&lt;li>&lt;strong>F3 ↔ F4 (slack ~20 %)&lt;/strong>: identity manifests ready as F3 closes, applied immediately.&lt;/li>
&lt;li>&lt;strong>Within F4&lt;/strong>: policies in &lt;code>audit&lt;/code> mode running in parallel with the Defguard setup.&lt;/li>
&lt;/ul>
&lt;p>Nothing shortens the critical path by more than about 15 % of the total. Anyone promising a productive greenfield in half a reasonable timescale is selling something else: probably skipping F4, or loading F6 with an F5 that is green-but-not-validated.&lt;/p>
&lt;h2 id="final-diagram-the-complete-deployment-schedule">Final diagram: the complete deployment schedule&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete phased deployment schedule with pieces and gates">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.bg{fill:#fafafa;stroke:#bbb;rx:8}.f0{fill:#f6e2e2;stroke:#a33}.f1{fill:#f4e3cf;stroke:#a63}.f2{fill:#eef0d0;stroke:#7a3}.f3{fill:#dfe9f5;stroke:#356}.f4{fill:#d8eecf;stroke:#373}.f5{fill:#f5e3d8;stroke:#763}.f6{fill:#ead8f5;stroke:#634}.gate{fill:#fffbe0;stroke:#a90}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">Complete schedule: pieces per phase and validation gates&lt;/text>
&lt;rect x="40" y="40" width="740" height="68" class="b f0"/>&lt;text x="60" y="58" class="tiny">F0 · HARDWARE IN THE ROOM&lt;/text>
&lt;text x="60" y="76" class="sm">Inventory · cabling · BMC TLS+MFA · management IPs · BGP switches versioned&lt;/text>
&lt;text x="60" y="92" class="note">Gate: `for h in hosts; ping HOST.mgmt` 100 % success · complete inventory&lt;/text>
&lt;rect x="40" y="116" width="740" height="68" class="b f1"/>&lt;text x="60" y="134" class="tiny">F1 · BARE METAL&lt;/text>
&lt;text x="60" y="152" class="sm">PXE/cloud-init · LTS OS · kernel ≥6.6 · containerd · NVIDIA drivers · chrony · LVM&lt;/text>
&lt;text x="60" y="168" class="note">Gate: `ansible all -m ping` 100 % · `nvidia-smi` smoke OK · idempotent reboot&lt;/text>
&lt;rect x="40" y="192" width="740" height="68" class="b f2"/>&lt;text x="60" y="210" class="tiny">F2 · KUBERNETES CLUSTER&lt;/text>
&lt;text x="60" y="228" class="sm">RKE2 HA · Cilium (kube-proxy replacement + BGP) · Rook-Ceph (RBD + CephFS + RGW)&lt;/text>
&lt;text x="60" y="244" class="note">Gate: RWO/RWX PVCs OK · RGW bucket OK · node drain with no downtime&lt;/text>
&lt;rect x="40" y="268" width="740" height="68" class="b f3"/>&lt;text x="60" y="286" class="tiny">F3 · GITOPS + INFRA OBSERVABILITY&lt;/text>
&lt;text x="60" y="304" class="sm">Forgejo · Flux · VictoriaMetrics + Grafana + Loki · Alertmanager + Keep · backups&lt;/text>
&lt;text x="60" y="320" class="note">Gate: repo change → cluster in &amp;lt;5min · drift reverted · backup restore OK&lt;/text>
&lt;rect x="40" y="344" width="740" height="68" class="b f4"/>&lt;text x="60" y="362" class="tiny">F4 · IDENTITY + POLICIES&lt;/text>
&lt;text x="60" y="380" class="sm">Defguard OIDC+MFA+WG · cert-manager · SOPS+ESO · Kyverno enforce · NP default deny · Tetragon&lt;/text>
&lt;text x="60" y="396" class="note">Gate: shared admin kubeconfig no longer works · policies in enforce · complete audit log&lt;/text>
&lt;rect x="40" y="420" width="740" height="68" class="b f5"/>&lt;text x="60" y="438" class="tiny">F5 · GPU PLATFORM + LLM-AWARE OBSERVABILITY&lt;/text>
&lt;text x="60" y="456" class="sm">NVIDIA GPU Operator · DCGM · MIG manager · KEDA with vLLM metrics · OTel gen_ai.* · OME&lt;/text>
&lt;text x="60" y="472" class="note">Gate: GPU pod scheduled · DCGM green · vLLM smoke with a small model · KEDA scales&lt;/text>
&lt;rect x="40" y="496" width="740" height="38" class="b f6"/>&lt;text x="60" y="514" class="tiny">F6 · LLM STACK LIVE&lt;/text>
&lt;text x="60" y="528" class="sm">7 layers active · first model green · canary OK · runbook signed · first customer with an SLA&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The schedule is not decorative: each row defines what gets built in its phase &lt;strong>and the gate that closes it&lt;/strong>. A phase is not considered finished until its gate is green. A phase with a yellow gate drags every later one; trying to jump to the next with a partially met gate is what produces, several weeks later, the incident that forces you to &amp;ldquo;go back to F4 with production running&amp;rdquo;, the most expensive situation in the whole cost matrix of the &lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">post on the five levels&lt;/a>.&lt;/p>
&lt;h2 id="typical-planning-mistakes">Typical planning mistakes&lt;/h2>
&lt;p>Patterns that delay or sink the greenfield deployment, regardless of the tools chosen:&lt;/p>
&lt;p>&lt;strong>1. Buying the LLM before the cluster.&lt;/strong> Starting the project with &amp;ldquo;which model are we going to serve&amp;rdquo; instead of &amp;ldquo;what platform can sustain any reasonable model&amp;rdquo;. The model is an interchangeable parameter; the platform is not.&lt;/p>
&lt;p>&lt;strong>2. Underestimating F0.&lt;/strong> &amp;ldquo;The network team does that.&amp;rdquo; Yes, but the output of F0 is consumed by every later phase. If the network team delivers late, the whole project is late, and nobody had marked it as the critical path.&lt;/p>
&lt;p>&lt;strong>3. Overlapping F4 with F5 &amp;ldquo;to save time&amp;rdquo;.&lt;/strong> It is the one dependency with no slack. If you try to overlap, F5 ends up operating with policies in permanent &lt;code>audit&lt;/code> (you are not in F4) or without OIDC integrated (operators with a shared kubeconfig touching GPUs). Both antipatterns stay in production.&lt;/p>
&lt;p>&lt;strong>4. Skipping the small model smoke test in F5.&lt;/strong> &amp;ldquo;Let&amp;rsquo;s go straight for the 70B.&amp;rdquo; When something fails (and something will), there will be no baseline to diagnose against.&lt;/p>
&lt;p>&lt;strong>5. Treating F6 as &amp;ldquo;turning on vLLM&amp;rdquo;.&lt;/strong> F6 includes gateway, LLM-aware observability, runbook, SLO, continuity plan. Turning on vLLM takes five minutes; closing F6 is weeks of validation and sign-off.&lt;/p>
&lt;p>&lt;strong>6. Not defining gates in writing.&lt;/strong> If the gates are not written down, they are negotiable after the fact. &amp;ldquo;This already counts as F4&amp;rdquo; is the sentence that precedes the next six months of retrofit.&lt;/p>
&lt;p>&lt;strong>7. Assigning a phase to a single owner.&lt;/strong> Every phase needs at least two people who understand it. Staff turnover on long projects destroys knowledge; written gates + cross review preserve it.&lt;/p>
&lt;p>&lt;strong>8. Forgetting the descent route.&lt;/strong> The post focuses on going up. Day-to-day operation (the descent, in the analogy) is another story that also deserves planning: runbooks, on-call, upgrade capacity, end-of-life plan. Teams that only plan the ascent reach the summit and stay there with no oxygen.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-4h100-sxm">Applied to typical on-premise hardware: 4×H100 SXM&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM per GPU node, 3 control plane nodes, 3-5 CPU worker nodes, 2 GPU worker nodes), the &lt;strong>temporal&lt;/strong> split of the work is distributed like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">F0 (hardware)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ 8 physical servers racked + switches + BMC + management IPs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 3 nodes cp-01..03 — control plane (no GPU)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 3 nodes worker-cpu-01..03 — CPU plane (Forgejo, Ceph, observability)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ 2 nodes worker-gpu-01..02 — GPU plane (4×H100 SXM each)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F1 (bare metal)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ OS + drivers + containerd on all 8 nodes alike
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> (NVIDIA drivers only on the 2 GPU nodes, `nvidia-smi` smoke)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F2 (k8s cluster)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ RKE2 control plane on cp-01..03 (HA with embedded etcd)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> workers joining: 3 CPU + 2 GPU
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Ceph OSDs on the 3 CPU worker nodes
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> default pools: RBD-replicated-3, CephFS-replicated-3, RGW
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F3 (GitOps + obs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ Forgejo + Flux + VM/Grafana/Loki + Keep on the CPU plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first `gitops-infra` repo reconciles what came from F2
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F4 (identity)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ Defguard on the CPU plane (StatefulSet with Postgres CNPG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> OIDC in kube-apiserver, Forgejo, Grafana, Alertmanager
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Kyverno as a Deployment on the control plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F5 (GPU plane)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ NVIDIA GPU Operator targets the GPU workers
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> MIG manager: 1st GPU MIG 7g.80gb (= passthrough), 2nd 2×3g.40gb
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> OTel Collector as a DaemonSet on the GPU plane + CPU plane
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first vLLM with a 7B FP16 model green
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">F6 (LLM stack)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─ The 7 layers reconcile via Flux from a second repo `gitops-llm`
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first public endpoint with Defguard OAuth
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> first production customer enrolled under an SLA
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The physical distribution of the cluster exploits the isolation between planes defined in F0: the control plane never touches a GPU, the CPU plane concentrates relevant state (Forgejo, Ceph, Postgres CNPG, Langfuse, Defguard) and the GPU plane specialises as much as possible. That separation, decided in F0 before the first server is installed, conditions the success of the remaining phases. It is another reminder of why F0 matters more than it looks.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post walks the &lt;strong>ascent route&lt;/strong> to the summit. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The safe descent&lt;/strong>: day-to-day operation, runbooks per component, on-call, continuous capacity planning, the cluster upgrade cycle with no downtime.&lt;/li>
&lt;li>&lt;strong>Multi-site (the second summit)&lt;/strong>: how two clusters are federated with Cilium Cluster Mesh and what extra phases that introduces. F3.5 (Cluster Mesh) and F4.5 (cross-site replication) are the missing phases.&lt;/li>
&lt;li>&lt;strong>The brownfield route&lt;/strong>: what changes when there is already a cluster with workloads. The phases stay the same, but the gates are applied retroactively and every step requires migration planning.&lt;/li>
&lt;li>&lt;strong>The real calendar cost&lt;/strong>: typical ranges in weeks for a platform team of 2-3 people, split by phase, with uncertainty bands.&lt;/li>
&lt;li>&lt;strong>The handoff to operations&lt;/strong>: how the platform is handed from the deployment team to the operations team, which documents are signed, what is inherited and what is renegotiated.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how to turn these phases into a reproducible, reconciled GitOps deployment.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of an on-premise LLM inference stack&lt;/a> — the seven layers built in F6. The components, not the schedule.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the platform under the LLM&lt;/a> — the levels corresponding to phases F1→F5. The strata, not the sequence.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — individual cards for the pieces cited here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel of F5 with detail on the &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — when F6 closes, the immediate operational question is how many replicas fit and how many customers they support; the sizing that ends the conversation with procurement.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the concrete cockpit of DCGM and vLLM that F5 enables: the twelve metrics that decide whether the cluster is operated or operated on by intuition.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — KEDA and HPA with custom metrics, the first real deliverable of F6 when traffic starts to move.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — the mechanics of promoting models with no downtime that F6 must support from day one.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the last piece F6 closes: catalogue, L7 traffic splitting, cross-cutting policy, failover and prefix-aware routing. Without the router, F6 does not finish even with all seven layers running.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents: from workstation to cluster&lt;/a> — the runtime hardening that fits into F4 (identity, policies) and F5 (platform): Kata &lt;code>RuntimeClass&lt;/code> and Tetragon &lt;code>TracingPolicy&lt;/code> for agent workloads; its &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">runbook&lt;/a> brings the files.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>RKE2 Documentation — &lt;a href="https://docs.rke2.io/">docs.rke2.io&lt;/a>&lt;/li>
&lt;li>Cilium documentation — &lt;a href="https://docs.cilium.io/">docs.cilium.io&lt;/a>&lt;/li>
&lt;li>Rook documentation — &lt;a href="https://rook.io/">rook.io/docs&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;li>Forgejo — &lt;a href="https://forgejo.org/">forgejo.org&lt;/a>&lt;/li>
&lt;li>cert-manager — &lt;a href="https://cert-manager.io/">cert-manager.io&lt;/a>&lt;/li>
&lt;li>External Secrets Operator — &lt;a href="https://external-secrets.io/">external-secrets.io&lt;/a>&lt;/li>
&lt;li>Kyverno — &lt;a href="https://kyverno.io/">kyverno.io&lt;/a>&lt;/li>
&lt;li>NVIDIA GPU Operator — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">docs.nvidia.com/datacenter/cloud-native/gpu-operator&lt;/a>&lt;/li>
&lt;li>DCGM Exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>KEDA — &lt;a href="https://keda.sh/">keda.sh&lt;/a>&lt;/li>
&lt;li>LeaderWorkerSet API — &lt;a href="https://github.com/kubernetes-sigs/lws">github.com/kubernetes-sigs/lws&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Five maturity levels of the platform under the LLM: from a Linux server to a cluster ready for vLLM</title><link>https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/</link><pubDate>Sun, 31 May 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The post on &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">the seven layers of the LLM inference stack&lt;/a> took many pieces for granted: an operational Kubernetes cluster, GitOps reconciling, identities resolved, GPUs visible to the scheduler, observability capable of carrying &lt;code>gen_ai.*&lt;/code>. Before vLLM makes any sense, &lt;strong>you have to reach that starting point&lt;/strong>, and you reach it by levels. This post defines &lt;strong>five maturity levels&lt;/strong> of the platform that lives under the LLM, from a bare metal server with Linux installed (level 0) to a cluster ready to run the inference layer (level 4) and the handoff to the previous post (level 5). Each level &lt;strong>unlocks a concrete capability&lt;/strong>, running containers reproducibly, rebuilding the cluster from git, authenticating humans via OIDC, scheduling GPUs with MIG and DCGM metrics, demonstrating compliance with no manual intervention, and each has a &lt;strong>validation test&lt;/strong> that decides whether you are really there or just telling yourself you are. For each level: which OSS pieces cover it in 2026 (Cilium, RKE2, Flux, cert-manager, Defguard, NVIDIA GPU Operator, KEDA, Trivy, Kyverno…), &lt;strong>the deployment order&lt;/strong> within the level, the decisions that are expensive to skip, and the antipatterns that drop you a level when you thought you were higher. The thesis: &lt;strong>moving up a level costs little effort if you do it in time, and a lot of refactoring if you try to skip it&lt;/strong>. LLM inference demands at least level 4; anyone trying to serve LLMs from level 1 or 2 ends up paying in night-time incidents what they saved on the platform.&lt;/p>
&lt;h2 id="you-are-here-the-five-levels-at-a-glance">You are here: the five levels at a glance&lt;/h2>
&lt;p>Before the detail, the ladder. Each rung adds a capability absent from the one below. The level&amp;rsquo;s test is the question whose honest answer says whether you are already there.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="five maturity levels of the platform under the LLM">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.l0{fill:#f6e2e2;stroke:#a33}.l1{fill:#f4e3cf;stroke:#a63}.l2{fill:#eef0d0;stroke:#7a3}.l3{fill:#d8eecf;stroke:#373}.l4{fill:#dfe9f5;stroke:#356}.l5{fill:#ead8f5;stroke:#634}.title{font:600 13px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}&lt;/style>
&lt;text x="410" y="20" text-anchor="middle" class="title">Five maturity levels (plus the handoff to the LLM stack at level 5)&lt;/text>
&lt;rect x="40" y="38" width="740" height="44" class="b l0"/>&lt;text x="60" y="56" class="tiny">LEVEL 0 · CHAOTIC&lt;/text>&lt;text x="60" y="74" class="sm">Bare metal with Linux · ad-hoc docker / podman · no orchestrator · manual changes over SSH&lt;/text>
&lt;rect x="40" y="90" width="740" height="44" class="b l1"/>&lt;text x="60" y="108" class="tiny">LEVEL 1 · REPEATABLE&lt;/text>&lt;text x="60" y="126" class="sm">k8s cluster installed (RKE2 / kubeadm) · CNI · CSI · kubectl apply / Helm from the terminal · pods running&lt;/text>
&lt;rect x="40" y="142" width="740" height="44" class="b l2"/>&lt;text x="60" y="160" class="tiny">LEVEL 2 · DEFINED&lt;/text>&lt;text x="60" y="178" class="sm">GitOps (Flux) · internal registry · infra observability · backups · the cluster rebuilds from the repo&lt;/text>
&lt;rect x="40" y="194" width="740" height="44" class="b l3"/>&lt;text x="60" y="212" class="tiny">LEVEL 3 · MANAGED&lt;/text>&lt;text x="60" y="230" class="sm">OIDC + RBAC · cert-manager · External Secrets · Kyverno · NetworkPolicy default deny · auditing&lt;/text>
&lt;rect x="40" y="246" width="740" height="44" class="b l4"/>&lt;text x="60" y="264" class="tiny">LEVEL 4 · OPTIMISED FOR GPU&lt;/text>&lt;text x="60" y="282" class="sm">NVIDIA GPU Operator · DCGM Exporter · MIG / time-slicing · KEDA with LLM metrics · OTel ready for gen_ai.*&lt;/text>
&lt;rect x="40" y="298" width="740" height="32" class="b l5"/>&lt;text x="60" y="318" class="sm">&lt;tspan font-weight="700">LEVEL 5 · HANDOFF&lt;/tspan> — the cluster is ready for the LLM stack (the 7 layers) to make sense&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The levels are &lt;strong>not interchangeable&lt;/strong>. A cluster at level 2 cannot run LLMs in production with any guarantee: technically the vLLM pod loads, but at the first night-time incident you discover there is no TLS, no identities, no alerting, no GPU metrics, and no way to know who changed what. Moving up a level after LLMs are already in production costs &lt;strong>orders of magnitude&lt;/strong> more than moving up while the cluster is still empty.&lt;/p>
&lt;h2 id="the-analogy-from-the-street-stall-to-the-starred-restaurant">The analogy: from the street stall to the starred restaurant&lt;/h2>
&lt;p>Picture the scale of a hospitality business. &lt;strong>Level 0&lt;/strong> is the street stall: a griddle, a gas bottle, a cook improvising. It can sell food, it works, but anything that deviates from a normal day (a health inspection, an allergic customer, an order for 200 portions) sinks the business. &lt;strong>Level 1&lt;/strong> is the tapas bar: a properly sized kitchen, a short repeatable menu, several shifts. The cook no longer improvises daily; he works from a written menu, even if the recipes live in the head of the boss. &lt;strong>Level 2&lt;/strong> is the restaurant with a set lunch menu: written procedures, fixed suppliers, stock control, an incident book. If the head cook falls ill, the second can run the service without carnage. &lt;strong>Level 3&lt;/strong> is the restaurant with an à la carte menu and formal service: traceability of every ingredient, allergens on the menu, health certification, contracts with suppliers, mandatory staff training. &lt;strong>Level 4&lt;/strong> is the kitchen specialised in a complex product (sushi, haute cuisine, artisan bakery): specific tools the normal restaurant does not need (wood-fired oven, special knives, proving chamber), calibrated processes, quality metrics. &lt;strong>Level 5&lt;/strong> is the restaurant with a Michelin star: the whole system works, &lt;strong>the dish is the result of the organisation, not of one person&amp;rsquo;s talent&lt;/strong>.&lt;/p>
&lt;p>The analogy holds to the end, including the most interesting detail: &lt;strong>you can operate at any level&lt;/strong>, but the promises you can keep are different. The street stall cannot promise a consistent experience to 80 diners with a booking. The cluster at level 1 cannot promise productive multi-tenant LLM service with an SLA. In both cases the problem is not the &lt;strong>technical capability of the last component&lt;/strong> (the griddle cooks; the pod starts); it is the &lt;strong>organisational capability of the whole system&lt;/strong>.&lt;/p>
&lt;p>Let us go level by level.&lt;/p>
&lt;h2 id="level-0--chaotic-the-server-with-linux-and-nothing-else">Level 0 — Chaotic: the server with Linux and nothing else&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Running containers with &lt;code>docker&lt;/code>/&lt;code>podman&lt;/code>, running binaries, connecting the server to the network. The operator can SSH in, do things, and see results.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I reinstall the server from scratch, can I leave it identical to how it was in one afternoon, using only saved notes?&amp;rdquo;&lt;/em>. If the answer is no (because the steps are in the head of whoever built it, in &lt;code>.bash_history&lt;/code>, in an out-of-date wiki), you are at level 0.&lt;/p>
&lt;p>&lt;strong>Minimum pieces to settle before moving up to level 1.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why it matters when moving up&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Linux distribution&lt;/td>
&lt;td>Stable Debian or Ubuntu LTS&lt;/td>
&lt;td>Long, predictable support, recent kernel available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kernel&lt;/td>
&lt;td>Recent LTS (≥ 6.6) with BPF and modern schedulers&lt;/td>
&lt;td>Cilium/eBPF and recent NVIDIA drivers demand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NVIDIA drivers&lt;/td>
&lt;td>The version that matches the CUDA of the LLM engine you will serve&lt;/td>
&lt;td>A driver/CUDA mismatch blocks vLLM before you start&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Container runtime&lt;/td>
&lt;td>&lt;code>containerd&lt;/code>&lt;/td>
&lt;td>CNCF standard, integrated with RKE2/kubeadm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Root filesystem&lt;/td>
&lt;td>XFS or ext4 + LVM thin pools&lt;/td>
&lt;td>Snapshots, online expansion&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Time synchronisation&lt;/td>
&lt;td>&lt;code>chrony&lt;/code> with your own servers&lt;/td>
&lt;td>TLS, correlated logs and short certificates demand it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Management network&lt;/td>
&lt;td>Dedicated VLAN, ACLs on the switch&lt;/td>
&lt;td>Isolate the control plane from workload traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cluster network&lt;/td>
&lt;td>LACP + jumbo frames + BGP (if you are going with Cilium)&lt;/td>
&lt;td>Intra-node NVLink does not rescue the service network&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BMC / IPMI&lt;/td>
&lt;td>Out-of-band access with TLS and MFA&lt;/td>
&lt;td>Recovery when the operating system does not boot&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that pin you at level 0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Pet&lt;/strong> servers (with their own names, configured by hand, not replaceable).&lt;/li>
&lt;li>Changes applied with &lt;code>vi&lt;/code> directly over &lt;code>/etc/...&lt;/code> with no commit to a repo.&lt;/li>
&lt;li>Deployment with &lt;code>docker-compose&lt;/code> with no healthchecks and no automatic restart.&lt;/li>
&lt;li>An inventory that lives in an Excel sheet nobody updates.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> System image from PXE/cloud-init with the initial configuration (LVM, hostname, network, SSH key, chrony) → bastion/jump host bootstrap → inventory in Ansible (or an equivalent declarative tool, even if it is later replaced) → NVIDIA drivers + container runtime → smoke test (a CUDA container passes &lt;code>nvidia-smi&lt;/code>). At this point, the server is ready for Kubernetes to come in.&lt;/p>
&lt;h2 id="level-1--repeatable-an-operational-kubernetes-cluster">Level 1 — Repeatable: an operational Kubernetes cluster&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Scheduling containers with a scheduler, network abstraction between pods, persistent volumes, workload lifecycle, manual horizontal scaling.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;Can I lose a node and have the workloads rescheduled without human intervention?&amp;rdquo;&lt;/em>. If yes, you are at level 1. If not, because the pods are pinned to nodes, because there are no replicas, because the PVCs do not reattach, you are still at 0 with Kubernetes on top.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Main alternative&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>k8s distribution&lt;/td>
&lt;td>&lt;strong>RKE2&lt;/strong> (CIS-hardened by default, no commercial surcharge)&lt;/td>
&lt;td>k3s for very small edge, plain kubeadm for custom cases&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CNI&lt;/td>
&lt;td>&lt;strong>Cilium&lt;/strong> with kube-proxy replacement, BGP, Gateway API&lt;/td>
&lt;td>Calico (without BGP it does not compete with Cilium in 2026)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CSI block + filesystem + object&lt;/td>
&lt;td>&lt;strong>Rook-Ceph&lt;/strong> (RBD + CephFS + S3-compatible RGW)&lt;/td>
&lt;td>OpenEBS Mayastor + Garage for small deployments&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ingress&lt;/td>
&lt;td>Cilium Gateway API (better to unify with the CNI)&lt;/td>
&lt;td>NGINX Ingress, Traefik&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Basic certs&lt;/td>
&lt;td>Self-signed bootstrap&lt;/td>
&lt;td>(cert-manager comes in at level 3)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Workload handling&lt;/td>
&lt;td>&lt;code>kubectl apply&lt;/code> + Helm from the terminal&lt;/td>
&lt;td>No GitOps yet&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Container registry&lt;/td>
&lt;td>Any internal registry (or a trusted external one) with TLS&lt;/td>
&lt;td>(a managed internal registry comes in at level 2)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that drop you to level 0.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Services deployed with &lt;code>kubectl apply&lt;/code> from one person&amp;rsquo;s terminal and &lt;strong>with the YAML saved nowhere&lt;/strong>.&lt;/li>
&lt;li>Persistent volumes with no backup policy.&lt;/li>
&lt;li>A &amp;ldquo;single-node cluster&amp;rdquo; as permanent production, an architectural single point of failure.&lt;/li>
&lt;li>A CNI with no NetworkPolicy available or no BGP when the network requires it.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> RKE2 installed on at least three nodes for an HA control plane → Cilium installed in kube-proxy replacement mode + BGP control plane → Rook-Ceph on at least three nodes covering block (RBD) + filesystem (CephFS) + object (S3-compatible RGW) with 3× replication or Erasure Coding depending on the pool → smoke test (a Deployment with a PVC starts, the pods reschedule when a node is cordoned, the data persists).&lt;/p>
&lt;h2 id="level-2--defined-the-cluster-rebuilds-from-git">Level 2 — Defined: the cluster rebuilds from git&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> The cluster state lives in a repository. Every change goes through a commit. Anyone can rebuild the cluster (or an equivalent one) from the repo and the backups. Basic observability warns when something breaks.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I lose the whole cluster, can I recreate it in X hours from the repo + the backups, with no manual intervention beyond the bootstrap?&amp;rdquo;&lt;/em>. The hours are negotiable; what defines the level is that &lt;strong>the repo + the backups are enough&lt;/strong>, not that the person-who-knows is available.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Forge&lt;/td>
&lt;td>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE)&lt;/td>
&lt;td>Self-hosted OSS, community fork of Gitea, open governance&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GitOps reconciler&lt;/td>
&lt;td>&lt;strong>Flux&lt;/strong>&lt;/td>
&lt;td>CNCF graduated, native multi-tenancy, lightweight&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Image registry&lt;/td>
&lt;td>&lt;strong>Forgejo Container Registry&lt;/strong>&lt;/td>
&lt;td>Next to the code, no extra piece&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Metrics TSDB&lt;/td>
&lt;td>&lt;strong>VictoriaMetrics&lt;/strong> + vmagent&lt;/td>
&lt;td>Higher throughput than plain Prometheus, long retention, PromQL compatible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Visualisation&lt;/td>
&lt;td>&lt;strong>Grafana&lt;/strong>&lt;/td>
&lt;td>The de facto standard&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Logs&lt;/td>
&lt;td>&lt;strong>Loki&lt;/strong> or &lt;strong>Vector&lt;/strong>&lt;/td>
&lt;td>OSS, integrated with Grafana&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Alerting&lt;/td>
&lt;td>&lt;strong>Alertmanager&lt;/strong> + &lt;strong>Keep&lt;/strong> (OSS orchestrator)&lt;/td>
&lt;td>Keep adds multi-channel routing with no lock-in&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DB backups&lt;/td>
&lt;td>&lt;strong>Barman Cloud&lt;/strong> (Postgres)&lt;/td>
&lt;td>The standard for CNPG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Object / dataset backups&lt;/td>
&lt;td>&lt;strong>Ceph RGW multisite&lt;/strong> + CephFS snapshots&lt;/td>
&lt;td>Cross-pool and cross-site&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Antipatterns that drop you to level 1.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;code>kubectl apply&lt;/code> run in production &lt;strong>outside&lt;/strong> the repo (undetected drift).&lt;/li>
&lt;li>&lt;code>main&lt;/code> branches with write permissions for humans without review.&lt;/li>
&lt;li>A monolithic repo with no tenant/infra/apps separation (cross changes that cannot be audited).&lt;/li>
&lt;li>Metrics kept no longer than 7 days (no SLO observable over a month).&lt;/li>
&lt;li>Alerting that fires for everything (fatigue) or for nothing (silence).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> Forgejo deployed first (it is a prerequisite for everything else) → Flux installed and pointing at the manifests repo → an initial repository with the Cilium and Rook-Ceph Helm releases reconciled by Flux (replacing the level 1 &lt;code>kubectl apply&lt;/code>) → VictoriaMetrics + Grafana + Loki via Helm/Flux → Postgres backups and Ceph snapshots scheduled → smoke test (tear the cluster down, restore from repo + backup, the services come back).&lt;/p>
&lt;h2 id="level-3--managed-identities-certificates-secrets-and-policies">Level 3 — Managed: identities, certificates, secrets and policies&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> Every human operating the cluster does so with their own identity (no shared &lt;code>kubeconfig&lt;/code>), with MFA and with limited permissions. Automatic internal TLS. Encrypted versioned secrets. Policies that &lt;strong>reject&lt;/strong> insecure configurations before they reach the cluster. A complete audit of who did what.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If an attacker gets hold of an administrator&amp;rsquo;s laptop, what can they do in production?&amp;rdquo;&lt;/em>. At level 3 the answer is &lt;em>&amp;ldquo;little&amp;rdquo;&lt;/em>: MFA blocks the second factor, Kyverno policies block destructive changes without approval, the NetworkPolicies prevent lateral movement, the secrets are encrypted with an external KMS, and the audit log remains. At level 2, &lt;em>&amp;ldquo;everything&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>IdP / OIDC&lt;/td>
&lt;td>&lt;strong>Defguard&lt;/strong>&lt;/td>
&lt;td>Spanish OSS, WireGuard + OIDC + 2FA, multi-org&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Federation with the cluster&lt;/td>
&lt;td>OIDC in kube-apiserver, OIDC in Forgejo, OIDC in Grafana&lt;/td>
&lt;td>Consistent SSO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Internal PKI&lt;/td>
&lt;td>&lt;strong>cert-manager&lt;/strong> + Trust Manager&lt;/td>
&lt;td>De facto standard, ACME and internal CA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>External ACME&lt;/td>
&lt;td>Let&amp;rsquo;s Encrypt for edge certs&lt;/td>
&lt;td>No cost, automated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Secrets in git&lt;/td>
&lt;td>&lt;strong>SOPS&lt;/strong> + age or an external KMS&lt;/td>
&lt;td>Versionable, encrypted in the repo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Secret syncing&lt;/td>
&lt;td>&lt;strong>External Secrets Operator&lt;/strong>&lt;/td>
&lt;td>Pull from KMS / Vault into the cluster&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Policy as code&lt;/td>
&lt;td>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper)&lt;/td>
&lt;td>Kyverno has a shallower learning curve&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NetworkPolicy&lt;/td>
&lt;td>&lt;strong>Cilium NetworkPolicy&lt;/strong> + L7&lt;/td>
&lt;td>Default deny per namespace&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runtime security&lt;/td>
&lt;td>&lt;strong>Tetragon&lt;/strong> (Cilium)&lt;/td>
&lt;td>eBPF, complements NetworkPolicy with detection&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vulnerability scanning&lt;/td>
&lt;td>&lt;strong>Trivy&lt;/strong> in the CI pipeline + admission&lt;/td>
&lt;td>SBOM per image, blocking of critical CVEs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Audit log&lt;/td>
&lt;td>kube-apiserver with &lt;code>--audit-policy-file&lt;/code> sent to Loki&lt;/td>
&lt;td>Regulatory traceability&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Minimum Kyverno policies to keep alive.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Deny &lt;code>:latest&lt;/code> images or images without a sha digest.&lt;/li>
&lt;li>Deny pods without &lt;code>securityContext.runAsNonRoot=true&lt;/code>.&lt;/li>
&lt;li>Deny pods without &lt;code>resources.limits&lt;/code> (CPU + memory).&lt;/li>
&lt;li>Deny Services without an &lt;code>owner=&amp;lt;team&amp;gt;&lt;/code> label.&lt;/li>
&lt;li>Deny changes in critical namespaces (&lt;code>kube-system&lt;/code>, &lt;code>flux-system&lt;/code>) without an approval label.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Antipatterns that drop you to level 2.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>A &lt;code>kubeconfig&lt;/code> shared between administrators.&lt;/li>
&lt;li>Secrets in plain &lt;code>data:&lt;/code> in a manifest committed to the repo.&lt;/li>
&lt;li>NetworkPolicy absent from new namespaces by default (implicit allow-all).&lt;/li>
&lt;li>&lt;code>kubectl edit&lt;/code> or &lt;code>kubectl patch&lt;/code> in production without going through the repo.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> Defguard deployed and enrolled with WireGuard / OIDC → OIDC integration with kube-apiserver, Forgejo and Grafana → cert-manager installed and issuing internal certificates (your own CA for mTLS, Let&amp;rsquo;s Encrypt for the edge) → SOPS configured and External Secrets Operator installed → migration of secrets from plain to encrypted → Kyverno with initial policies in &lt;em>audit&lt;/em> mode, then &lt;em>enforce&lt;/em> → default-deny NetworkPolicy per namespace → Tetragon enabled → smoke test (try to bypass each policy and check that admission rejects it).&lt;/p>
&lt;h2 id="level-4--optimised-for-gpu-the-cluster-now-knows-what-an-h100-is">Level 4 — Optimised for GPU: the cluster now knows what an H100 is&lt;/h2>
&lt;p>&lt;strong>The capability it gives.&lt;/strong> The Kubernetes scheduler sees the GPUs, distinguishes them, can partition them (MIG) or multiplex them (time-slicing), expose DCGM metrics, autoscale with KEDA using metrics from the LLM workload itself (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>), and carry traces with the GenAI semantic conventions. Everything needed for the LLM inference stack to rest on a platform that understands its nature.&lt;/p>
&lt;p>&lt;strong>The test for the level.&lt;/strong> &lt;em>&amp;ldquo;If I deploy a pod asking for &lt;code>nvidia.com/gpu: 1&lt;/code>, does it get scheduled on the right GPU, with the right slice, with DCGM metrics exposed, with GenAI observability ready to receive spans?&amp;rdquo;&lt;/em>. If yes, you are at level 4. If the answer requires &amp;ldquo;it depends which node and who deploys it&amp;rdquo;, not yet.&lt;/p>
&lt;p>&lt;strong>Minimum pieces for the level.&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>Suggested decision in 2026&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPU device plugin&lt;/td>
&lt;td>&lt;strong>NVIDIA GPU Operator&lt;/strong>&lt;/td>
&lt;td>Deploys drivers, container toolkit, DCGM and MIG manager with one operator&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HW partitioning&lt;/td>
&lt;td>&lt;strong>MIG&lt;/strong> (Multi-Instance GPU) on the H100 where it applies&lt;/td>
&lt;td>Real hardware isolation, not time-slicing&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU metrics&lt;/td>
&lt;td>&lt;strong>DCGM Exporter&lt;/strong>&lt;/td>
&lt;td>SM utilisation, VRAM, temperature, throttling, NVLink bandwidth&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM metrics&lt;/td>
&lt;td>&lt;strong>vLLM Prometheus&lt;/strong> endpoint + scrape&lt;/td>
&lt;td>TTFT, TPOT, KV cache, prefix hit rate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autoscaling&lt;/td>
&lt;td>&lt;strong>KEDA&lt;/strong> with a Prometheus ScaledObject&lt;/td>
&lt;td>Scales on LLM metrics, not on CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM operators&lt;/td>
&lt;td>&lt;strong>vLLM Production Stack&lt;/strong> / &lt;strong>OME&lt;/strong> (Operator Model Engine)&lt;/td>
&lt;td>Declarative handling of models / adapters&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Traces&lt;/td>
&lt;td>&lt;strong>OpenTelemetry Collector&lt;/strong> with OTLP receivers + processors + exporters&lt;/td>
&lt;td>&lt;code>gen_ai.*&lt;/code> semantic conventions (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">post&lt;/a>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LeaderWorkerSet&lt;/td>
&lt;td>LeaderWorkerSet API (k8s 1.30+)&lt;/td>
&lt;td>Tensor parallel topology coherent with NVLink&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Topology Manager&lt;/td>
&lt;td>enabled with &lt;code>single-numa-node&lt;/code>&lt;/td>
&lt;td>Pinning GPU pods to the right NUMA node&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>The key decision: MIG, time-slicing or passthrough.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MIG&lt;/strong> divides an H100 into 1g.10gb, 2g.20gb, 3g.40gb, 7g.80gb (slices with real HW isolation). Useful for serving several small models or reserving capacity per tenant with a guarantee. Limitation: up to 7 instances per GPU, predefined profiles.&lt;/li>
&lt;li>&lt;strong>Time-slicing&lt;/strong> shares a GPU between several pods with no HW isolation. Useful for dev/test, not for multi-tenant production with an SLA.&lt;/li>
&lt;li>&lt;strong>Passthrough&lt;/strong> assigns the whole GPU to one pod. Useful for tensor parallel across several GPUs on the same node (a large LLM with TP=4).&lt;/li>
&lt;/ul>
&lt;p>For a productive LLM platform, the practical rule: &lt;strong>passthrough for the large models with TP&lt;/strong>, &lt;strong>MIG for embeddings and small models that cohabit&lt;/strong>, &lt;strong>never time-slicing in production&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Antipatterns that drop you to level 3.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Installing NVIDIA drivers by hand outside the GPU Operator (silent breakage when Kubernetes is upgraded).&lt;/li>
&lt;li>Serving an LLM with &lt;code>requests.gpu: 1&lt;/code> without having decided on MIG / passthrough (you end up with idle GPUs from fragmentation or pods stepping on each other).&lt;/li>
&lt;li>KEDA autoscaling on CPU (the classic &lt;code>HorizontalPodAutoscaler&lt;/code>) on pods that are almost always at 10 % CPU but 95 % KV cache.&lt;/li>
&lt;li>OpenTelemetry deployed but without the &lt;code>gen_ai.*&lt;/code> semantic conventions (the traces are not LLM-aware).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Deployment order within the level.&lt;/strong> NVIDIA GPU Operator installed via Helm/Flux with the driver version that matches the chosen LLM engine → DCGM Exporter enabled and metrics visible in Grafana (NVIDIA dashboards imported) → MIG manager configured for the nodes where it makes sense (a typical mix on a 4×H100 SXM cluster: two GPUs with full passthrough for the general LLM at TP=4, two GPUs partitioned into 2×3g.40gb each for small LLMs + embeddings) → OpenTelemetry Collector with &lt;code>attributes&lt;/code> processors to enrich spans with your own labels (&lt;code>tenant_id&lt;/code>, &lt;code>priority_tier&lt;/code>) + exporters to Langfuse and to Tempo → KEDA installed with an example ScaledObject pointing at &lt;code>vllm:num_requests_running&lt;/code> → vLLM Production Stack or OME to declare models as a CRD → smoke test (a vLLM Deployment declared via CRD starts, serves a token, exposes metrics, the trace reaches Langfuse, KEDA scales under synthetic load).&lt;/p>
&lt;h2 id="level-5--handoff-the-cluster-is-an-llm-platform-the-seven-layers-go-on-top">Level 5 — Handoff: the cluster is an LLM platform, the seven layers go on top&lt;/h2>
&lt;p>Once at level 4, the cluster meets the contract that the &lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">post on the seven layers&lt;/a> assumed as its starting point. Level 5 adds no infrastructure: it adds the &lt;strong>LLM stack&lt;/strong> proper. For completeness, the seven components of level 5 are:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Gateway&lt;/strong> (Envoy AI Gateway) — it comes first, directing traffic to LLM inference and embeddings.&lt;/li>
&lt;li>&lt;strong>LLM inference&lt;/strong> (vLLM Production Stack or OME with vLLM) — on the GPUs already discovered by the level 4 GPU Operator.&lt;/li>
&lt;li>&lt;strong>Embeddings + reranker&lt;/strong> (Infinity, TEI) — a pod separate from the LLM, already covered in the previous post.&lt;/li>
&lt;li>&lt;strong>Vector store + relational data&lt;/strong> (Qdrant, PostgreSQL CNPG, Ceph RGW for weights and adapters, CephFS for datasets) — most of it already existed at level 2 as data; now it specialises for RAG.&lt;/li>
&lt;li>&lt;strong>LLM-aware observability&lt;/strong> (Langfuse) — it plugs into the level 4 OTel chain.&lt;/li>
&lt;li>&lt;strong>GitOps control plane&lt;/strong> — the one from level 2 remains the only legitimate authority.&lt;/li>
&lt;li>&lt;strong>Dependency tracking&lt;/strong> (Hubble flows + Otterize) — on the Cilium that already existed at level 1.&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>The criterion for promoting from level 4 to level 5&lt;/strong> is not technical: it is contractual. The cluster already supports LLMs; the decision is when to open real customer traffic. Promotion demands: a green golden eval of the model, a signed incident runbook, negotiated SLOs, a continuity plan, and mapping to ENS / NIS2 / 42001 where it applies.&lt;/p>
&lt;h2 id="the-maths-that-matter-what-skipping-a-level-costs">The maths that matter: what skipping a level costs&lt;/h2>
&lt;p>To quantify the thesis of the post, an order-of-magnitude estimate of the &lt;strong>cost of moving up each level in time&lt;/strong> versus &lt;strong>moving up after production is running&lt;/strong>. The figures are engineering time with a small platform team (2-3 people), assuming templates and prior experience.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>Time to build on an empty cluster&lt;/th>
&lt;th>Time to retrofit with production running&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0 → 1&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>1-2 weeks (little downstream refactoring)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1 → 2&lt;/td>
&lt;td>2-3 weeks&lt;/td>
&lt;td>4-8 weeks (migrating everything to git)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2 → 3&lt;/td>
&lt;td>2-4 weeks&lt;/td>
&lt;td>8-16 weeks (image rebuilds, secret migration, retroactive RBAC)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3 → 4&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>4-8 weeks (reconfiguring GPUs, moving models to MIG, instrumenting &lt;code>gen_ai.*&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4 → 5&lt;/td>
&lt;td>1-2 weeks&lt;/td>
&lt;td>2-4 weeks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total 0 → 5&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~10-15 weeks&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~20-40 weeks if done in the wrong order&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The typical multiplier observable in practice: &lt;strong>2× to 3×&lt;/strong> the cost if it is done in the wrong order. And that assumes it &lt;strong>gets done at all&lt;/strong>, since many projects never get past level 2 because &amp;ldquo;the identity thing&amp;rdquo; can always wait for another sprint. By the time the incident arrives, it is too late to start.&lt;/p>
&lt;p>Beyond time, the &lt;strong>operational&lt;/strong> cost (night-time incidents, security escapes, invisible debt) grows exponentially with the gap between the actual level and the required level. A cluster at level 2 serving productive LLMs to regulated customers is a time bomb: technically it works, organisationally it does not.&lt;/p>
&lt;h2 id="final-diagram-the-complete-ladder-with-pieces">Final diagram: the complete ladder with pieces&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 520" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="final diagram of the five levels with their OSS pieces and the handoff to the LLM stack">
&lt;style>.b{stroke:#333;stroke-width:1.4;rx:6}.bg{fill:#fafafa;stroke:#bbb;rx:8}.l0{fill:#f6e2e2;stroke:#a33}.l1{fill:#f4e3cf;stroke:#a63}.l2{fill:#eef0d0;stroke:#7a3}.l3{fill:#d8eecf;stroke:#373}.l4{fill:#dfe9f5;stroke:#356}.l5{fill:#ead8f5;stroke:#634}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#222}.tiny{font:600 10px sans-serif;fill:#222}.note{font:italic 10px sans-serif;fill:#555}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">Five maturity levels · OSS pieces · handoff to the LLM stack&lt;/text>
&lt;rect x="40" y="38" width="740" height="76" class="b l0"/>&lt;text x="60" y="56" class="tiny">LEVEL 0 · CHAOTIC · a server with Linux&lt;/text>
&lt;text x="60" y="74" class="sm">Debian / Ubuntu LTS · kernel ≥6.6 · containerd · NVIDIA drivers · LVM · chrony · BMC TLS+MFA&lt;/text>
&lt;text x="60" y="92" class="sm">Network: management VLAN, LACP, jumbo frames, BGP on the switch&lt;/text>
&lt;text x="60" y="108" class="note">Test: can I rebuild the server from notes?&lt;/text>
&lt;rect x="40" y="122" width="740" height="76" class="b l1"/>&lt;text x="60" y="140" class="tiny">LEVEL 1 · REPEATABLE · operational Kubernetes cluster&lt;/text>
&lt;text x="60" y="158" class="sm">RKE2 (CIS-hardened) · Cilium (kube-proxy replacement + BGP) · Rook-Ceph (RBD + CephFS + RGW)&lt;/text>
&lt;text x="60" y="176" class="sm">Gateway API · kubectl/Helm from the terminal · pods running with HA&lt;/text>
&lt;text x="60" y="192" class="note">Test: does losing a node require no human action?&lt;/text>
&lt;rect x="40" y="206" width="740" height="76" class="b l2"/>&lt;text x="60" y="224" class="tiny">LEVEL 2 · DEFINED · the cluster rebuilds from git&lt;/text>
&lt;text x="60" y="242" class="sm">Forgejo + Flux · Forgejo Container Registry · VictoriaMetrics + Grafana + Loki&lt;/text>
&lt;text x="60" y="260" class="sm">Barman Cloud backups + Ceph snapshots/RGW multisite · Alertmanager + Keep&lt;/text>
&lt;text x="60" y="276" class="note">Test: can I recreate the cluster from repo + backups?&lt;/text>
&lt;rect x="40" y="290" width="740" height="76" class="b l3"/>&lt;text x="60" y="308" class="tiny">LEVEL 3 · MANAGED · identity, certs, secrets, policies&lt;/text>
&lt;text x="60" y="326" class="sm">Defguard (OIDC + WireGuard) · cert-manager · SOPS + ESO · Kyverno · Trivy&lt;/text>
&lt;text x="60" y="344" class="sm">NetworkPolicy default deny · Tetragon · audit log&lt;/text>
&lt;text x="60" y="360" class="note">Test: what can an attacker do with an admin laptop?&lt;/text>
&lt;rect x="40" y="374" width="740" height="86" class="b l4"/>&lt;text x="60" y="392" class="tiny">LEVEL 4 · OPTIMISED FOR GPU · the scheduler understands the H100&lt;/text>
&lt;text x="60" y="410" class="sm">NVIDIA GPU Operator · DCGM Exporter · MIG manager · Topology Manager NUMA&lt;/text>
&lt;text x="60" y="428" class="sm">KEDA with vLLM metrics · OTel Collector with gen_ai.* · LeaderWorkerSet · OME&lt;/text>
&lt;text x="60" y="446" class="sm">Decision: passthrough TP=4 for the large LLM, MIG for small LLMs + embeddings&lt;/text>
&lt;text x="60" y="460" class="note" fill="#373">Test: does a pod with nvidia.com/gpu:1 schedule with metrics and traces ready?&lt;/text>
&lt;rect x="40" y="468" width="740" height="44" class="b l5"/>&lt;text x="60" y="486" class="tiny">LEVEL 5 · HANDOFF&lt;/text>&lt;text x="60" y="504" class="sm">The LLM stack (7 layers from the previous post) goes on top · gateway, vLLM, embeddings, Qdrant, Langfuse...&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The ladder is not decorative: each level &lt;strong>enables&lt;/strong> the next. You cannot have LLM-aware observability (level 4) without OTel deployed via Flux (level 2). You cannot have automatic internal TLS (level 3) without a root PKI that lives somewhere (registry and certificates managed from level 2). You cannot have KEDA scaling on vLLM metrics (level 4) without Prometheus / VictoriaMetrics scraping (level 2). The levels are &lt;strong>not a conceptual hierarchy&lt;/strong>: they are a hierarchy of &lt;strong>installation dependencies&lt;/strong>.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-progress">Typical design decisions that break progress&lt;/h2>
&lt;p>Mistakes seen repeatedly that drag the cluster back a level:&lt;/p>
&lt;p>&lt;strong>1. Jumping from level 1 straight to level 4.&lt;/strong> &amp;ldquo;We are in a hurry to serve the LLM, we will do the identity and GitOps stuff later.&amp;rdquo; Later is always two orders of magnitude more expensive and always arrives after the first incident.&lt;/p>
&lt;p>&lt;strong>2. Confusing Helm with GitOps.&lt;/strong> Having Helm charts is not level 2. It is level 1 with templates. Level 2 demands that a reconciler (Flux/ArgoCD) &lt;strong>applies&lt;/strong> the charts from a repo, &lt;strong>detects drift&lt;/strong> and &lt;strong>warns&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>3. cert-manager with no usage policy.&lt;/strong> Having auto-renewed certificates but using TLS only at the ingress, with no internal mTLS between services, leaves the TLS promise lame and turns level 3 into a cosplay of level 3.&lt;/p>
&lt;p>&lt;strong>4. NVIDIA drivers by hand.&lt;/strong> It works on day one and breaks the day of the first kernel upgrade. The rule: drivers &lt;strong>always via the GPU Operator&lt;/strong>, never operating system packages.&lt;/p>
&lt;p>&lt;strong>5. Prometheus metrics but 7 days of retention.&lt;/strong> Without long retention (≥ 90 days) there is no honest SLO. VictoriaMetrics with a year of retention costs little more than Prometheus with 7 days, and it unlocks compliance and serious postmortems.&lt;/p>
&lt;p>&lt;strong>6. OIDC only for kube-apiserver.&lt;/strong> If Forgejo, Grafana, Defguard and vLLM each have their own auth system, you do not have SSO, you have islands. An honest level 3 demands &lt;strong>federation&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>7. Kyverno in permanent &lt;em>audit&lt;/em> mode.&lt;/strong> Policies that do not reject are not policies, they are alerts. At some point you have to move to &lt;em>enforce&lt;/em>. Until then, you are still at level 2 wearing a level 3 face.&lt;/p>
&lt;p>&lt;strong>8. MIG with no conscious decision on the profile.&lt;/strong> Configuring MIG with the default profile without having measured the size of the models that will cohabit leaves fragmented GPUs with slices nobody uses. The rule: MIG only if you have measured and decided the profiles in advance.&lt;/p>
&lt;p>They all share one root: &lt;strong>declaring the level without passing the level&amp;rsquo;s test&lt;/strong>. Saying &amp;ldquo;we already did GitOps&amp;rdquo; when things are still applied with &lt;code>kubectl edit&lt;/code> in prod. Saying &amp;ldquo;we already did identity&amp;rdquo; when there is a shared admin &lt;code>kubeconfig&lt;/code>. Saying &amp;ldquo;we are ready for LLMs&amp;rdquo; when there is no DCGM Exporter and no Langfuse plugged in.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), a reasonable setup after passing the five levels distributes the components like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">control plane (3 nodes without GPU, hostnames cp-01..03)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── kube-apiserver, etcd, controller-manager, scheduler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Flux, Forgejo, cert-manager, External Secrets, Kyverno
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── Tetragon (DaemonSet here too)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">worker plane (≥ 3 nodes without GPU, hostnames worker-cpu-01..03)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Cilium agent (DaemonSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Rook-Ceph OSDs + MONs + MDS (CephFS) + RGW (S3)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── VictoriaMetrics + Grafana + Loki
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Defguard (StatefulSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── Langfuse + OTel Collector
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">GPU worker plane (≥ 2 nodes with 4×H100 SXM, hostnames worker-gpu-01..02)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── NVIDIA GPU Operator (driver + container toolkit)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── DCGM Exporter (DaemonSet)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── MIG manager (configuring the chosen profile)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vLLM (Deployment) — general LLM TP=4 occupies 4 GPUs (passthrough)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vLLM (Deployment) — code LLM TP=2 occupies 2 GPUs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── Infinity (embeddings) — 2 replicas cohabit on 2 MIG slices
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── KEDA scaler listening to vLLM metrics
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The operational rule: &lt;strong>the control plane and the CPU plane are separated from the GPU plane&lt;/strong>. An incident on the GPU plane must not take the control plane with it (the control plane is what recovers the cluster). And the CPU plane concentrates everything that moves relevant state (Forgejo, Rook-Ceph, Postgres CNPG, Langfuse): it is the heart to protect.&lt;/p>
&lt;p>The GPU hardware specialises as much as possible: GPU pods run &lt;strong>only&lt;/strong> on GPU nodes, and GPU nodes run &lt;strong>nothing&lt;/strong> CPU-bound beyond the operational overhead (Cilium, GPU Operator, DCGM). This is enforced with &lt;code>nodeSelector&lt;/code> + taints/tolerations + a Kyverno policy that rejects pods with no GPU requests being scheduled onto GPU nodes.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post walks the vertical path upwards. Some horizontal and cross-cutting pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Multi-site active/standby&lt;/strong>: how two clusters are federated with Cilium Cluster Mesh and what changes at each level when there are two sites instead of one.&lt;/li>
&lt;li>&lt;strong>Migration between levels with real traffic&lt;/strong>: how a cluster already in production is retrofitted to the next level with no downtime.&lt;/li>
&lt;li>&lt;strong>Day-to-day operation&lt;/strong>: runbooks per level, which dashboards to look at each morning, which SLOs to define per component.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: what each level costs in hardware, energy, engineering hours, optional OSS licences (commercial support from Rancher, Cilium Enterprise, etc.) and when each expense is justified.&lt;/li>
&lt;li>&lt;strong>Operationalised compliance&lt;/strong>: how levels 3 and 4 map to ENS High, NIS2 and ISO/IEC 42001 controls without turning the cluster into a paperwork exercise.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — the maturity level at which the platform is operated as code with Flux.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">Anatomy of an on-premise LLM inference stack&lt;/a> — what gets built &lt;strong>on top&lt;/strong> of a level 4 cluster. This post is its architectural prequel.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — individual cards for many of the pieces cited here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the OTel of level 4 with detail on the &lt;code>gen_ai.*&lt;/code> semantic conventions.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the operational framework that lives above a level 5 cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the twelve DCGM and vLLM metrics that turn level 4 into a real cockpit.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — KEDA with custom metrics is the piece that activates level 4&amp;rsquo;s &amp;ldquo;OPTIMISED FOR GPU&amp;rdquo; when the first traffic peak arrives.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow for LLM models&lt;/a> — progressive rollout is the first thing level 5 must be able to do without the operator having to watch the screen.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">The LLM inference router&lt;/a> — the piece that appears from level 3 onwards (it needs level 3&amp;rsquo;s OIDC + cert-manager + NetworkPolicy to make sense) and that connects level 4&amp;rsquo;s pools to external clients.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>RKE2 Documentation — &lt;a href="https://docs.rke2.io/">docs.rke2.io&lt;/a>&lt;/li>
&lt;li>Cilium documentation — &lt;a href="https://docs.cilium.io/">docs.cilium.io&lt;/a>&lt;/li>
&lt;li>Rook-Ceph — &lt;a href="https://rook.io/">rook.io&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;li>Forgejo — &lt;a href="https://forgejo.org/">forgejo.org&lt;/a>&lt;/li>
&lt;li>cert-manager — &lt;a href="https://cert-manager.io/">cert-manager.io&lt;/a>&lt;/li>
&lt;li>External Secrets Operator — &lt;a href="https://external-secrets.io/">external-secrets.io&lt;/a>&lt;/li>
&lt;li>Kyverno — &lt;a href="https://kyverno.io/">kyverno.io&lt;/a>&lt;/li>
&lt;li>NVIDIA GPU Operator — &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">docs.nvidia.com/datacenter/cloud-native/gpu-operator&lt;/a>&lt;/li>
&lt;li>DCGM Exporter — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>KEDA — &lt;a href="https://keda.sh/">keda.sh&lt;/a>&lt;/li>
&lt;li>LeaderWorkerSet API — &lt;a href="https://github.com/kubernetes-sigs/lws">github.com/kubernetes-sigs/lws&lt;/a>&lt;/li>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai/en/latest/serving/production_stack.html&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>CIS Kubernetes Benchmark&lt;/li>
&lt;li>NIST SP 800-207 — Zero Trust Architecture&lt;/li>
&lt;/ul></description></item><item><title>Anatomy of an on-premise LLM inference stack: the seven layers that have to hold each other up</title><link>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</link><pubDate>Sat, 30 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;strong>on-premise LLM inference stack is not a model server&lt;/strong>: it is a building of &lt;strong>seven layers&lt;/strong> that hold each other up. The inference layer (vLLM / SGLang) serves tokens, but without a dedicated embeddings layer RAG does not work; without the gateway layer the client couples its SDK to the specific engine; without an LLM-aware observability layer (Langfuse + OpenTelemetry GenAI) any quality degradation goes unnoticed; without a GitOps control plane layer (Flux + Forgejo) any manual change leaves invisible debt; and without a &lt;strong>dependency tracking&lt;/strong> layer (Hubble + intent-based policies) decommissioning a Service silently breaks applications nobody remembers were using it. This post is born from a concrete incident: a pipeline that reported &lt;code>status: completed&lt;/code> and &lt;code>matched_jobs: 0&lt;/code> for days because it kept calling an Ollama that had already been scaled to zero, while a badly written &lt;code>except&lt;/code> labelled the generic &lt;code>ConnectError&lt;/code> as &amp;ldquo;ChromaDB indexing error&amp;rdquo; and the vector store, innocent, took the blame. Each of the three symptoms was the cry of a layer that was missing or badly designed. The body of the post walks the seven layers with their canonical OSS piece, the design decisions that break them, the sizing maths on a generic &lt;strong>4×H100 SXM cluster (320 GB of VRAM, NVLink)&lt;/strong> and a final diagram of the connected stack. The thesis: a stack that passes the incident test is not measured by its peak throughput, it is measured by &lt;strong>how long it takes to shout when something drifts from the design&lt;/strong>.&lt;/p>
&lt;h2 id="you-are-here-the-seven-layers-seen-from-above">You are here: the seven layers seen from above&lt;/h2>
&lt;p>Before going into detail, the map. The seven layers are not seven servers: they are seven responsibilities the stack has to cover. A layer can collapse into one pod (gateway) or spread across several components (observability = traces + metrics + flow logs). What it cannot do is &lt;strong>be missing&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="the seven layers of the on-premise LLM inference stack">&lt;style>
.layer{stroke:#333;stroke-width:1.4;rx:6}
.l1{fill:#ffd9b8;stroke:#a44}
.l2{fill:#ffe6c2;stroke:#a55}
.l3{fill:#fff0d0;stroke:#a66}
.l4{fill:#dfe9f5;stroke:#356}
.l5{fill:#d8eecf;stroke:#373}
.l6{fill:#f5e3d8;stroke:#763}
.l7{fill:#ead8f5;stroke:#634}
.title{font:600 13px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.note{font:italic 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
&lt;/style>
&lt;defs>&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="title">On-premise LLM inference stack — seven layers, one responsibility each&lt;/text>
&lt;rect x="40" y="40" width="740" height="38" class="layer l1"/>
&lt;text x="60" y="64" class="sm">&lt;tspan font-weight="700">1 · Gateway&lt;/tspan> — Envoy AI Gateway · Cilium Gateway API · LiteLLM &lt;tspan class="note">OpenAI-compatible · JWT auth · routing by model · rate limit&lt;/tspan>&lt;/text>
&lt;rect x="40" y="86" width="365" height="50" class="layer l2"/>
&lt;text x="60" y="106" class="sm">&lt;tspan font-weight="700">2 · LLM inference&lt;/tspan>&lt;/text>
&lt;text x="60" y="122" class="tiny">vLLM · SGLang · TensorRT-LLM &lt;tspan class="note" fill="#555">multi-model · multi-LoRA · FP8 KV&lt;/tspan>&lt;/text>
&lt;rect x="415" y="86" width="365" height="50" class="layer l3"/>
&lt;text x="435" y="106" class="sm">&lt;tspan font-weight="700">3 · Embeddings&lt;/tspan>&lt;/text>
&lt;text x="435" y="122" class="tiny">Infinity · TEI · OVMS · sentence-transformers &lt;tspan class="note" fill="#555">fixed dim · separate from the LLM&lt;/tspan>&lt;/text>
&lt;rect x="40" y="144" width="740" height="50" class="layer l4"/>
&lt;text x="60" y="164" class="sm">&lt;tspan font-weight="700">4 · Vector store + relational data&lt;/tspan>&lt;/text>
&lt;text x="60" y="180" class="tiny">Qdrant · pgvector · MinIO (weights · adapters · corpus) &lt;tspan class="note" fill="#555">collection versioned by dim · bucket per tenant&lt;/tspan>&lt;/text>
&lt;rect x="40" y="202" width="740" height="50" class="layer l5"/>
&lt;text x="60" y="222" class="sm">&lt;tspan font-weight="700">5 · LLM-aware observability + infrastructure + network&lt;/tspan>&lt;/text>
&lt;text x="60" y="238" class="tiny">Langfuse · OTel GenAI · VictoriaMetrics · Grafana · Hubble · DCGM &lt;tspan class="note" fill="#555">end-to-end trace_id&lt;/tspan>&lt;/text>
&lt;rect x="40" y="260" width="740" height="40" class="layer l6"/>
&lt;text x="60" y="284" class="sm">&lt;tspan font-weight="700">6 · GitOps control plane&lt;/tspan> — Flux · Forgejo · cert-manager · External Secrets &lt;tspan class="note" fill="#555">binding drift detection&lt;/tspan>&lt;/text>
&lt;rect x="40" y="308" width="740" height="40" class="layer l7"/>
&lt;text x="60" y="332" class="sm">&lt;tspan font-weight="700">7 · Dependency tracking&lt;/tspan> — Hubble flow logs · Otterize · NetworkPolicy as code &lt;tspan class="note" fill="#555">who calls what, observed and declared&lt;/tspan>&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Layers 1–4 sit on the &lt;strong>request path&lt;/strong>: if they fall, the client notices in seconds. Layers 5–7 sit on the path of &lt;strong>design and operations&lt;/strong>: if they fall, there is no immediately visible error, and that is why they are the ones that produce silent incidents, which is how most serious incidents end up. This post argues that &lt;strong>the quality of the stack is measured in layers 5, 6 and 7&lt;/strong>, because 1–4 are commodities where everyone picks roughly the same pieces.&lt;/p>
&lt;h2 id="the-analogy-the-office-building-with-shared-services">The analogy: the office building with shared services&lt;/h2>
&lt;p>Picture a twelve-storey office building with several tenants. It has a &lt;strong>concierge desk&lt;/strong> (gateway: filters who comes in), it has &lt;strong>lifts&lt;/strong> (LLM inference: they move the heavy load), it has &lt;strong>stairs and a goods lift&lt;/strong> (embeddings: light but constant traffic, hardly anyone notices them until they break), it has &lt;strong>plumbing and water tanks&lt;/strong> (vector store: what actually holds the state), it has a &lt;strong>distribution board and sensors&lt;/strong> (observability: what warns you when something is drawing more than planned), it has a &lt;strong>building manager&lt;/strong> (GitOps control plane: the only legitimate authority to move anything), and it has a &lt;strong>tenant register&lt;/strong> (dependency tracking: who is connected to which shared service).&lt;/p>
&lt;p>When a new client moves into the third floor and asks to install a server that needs more amperage than planned, the problem is not electrical: it is &lt;strong>administrative&lt;/strong>. If the client can go straight to the board and plug in whatever they like, the building survives for a while and then a breaker trips at three in the morning. If the client has to go through the manager, the manager consults the tenant register (is anyone else hanging off that same circuit?), reviews the electrical planning (are we at the limit?) and either authorises or redirects. The building stays standing not because of its electrical installation but because of the &lt;strong>discipline of going through the manager&lt;/strong>.&lt;/p>
&lt;p>The LLM inference stack works identically. The physical layers (1–4) are the visible ones, the ones marketing people put on the slide. The governance layers (5–7) are the ones that distinguish a platform from a pile of pods with good luck.&lt;/p>
&lt;p>Now to the incident that motivates the whole post.&lt;/p>
&lt;h2 id="the-hook-the-log-that-lied-for-six-days">The hook: the log that lied for six days&lt;/h2>
&lt;p>The application is called &lt;em>jobhunter&lt;/em> internally. It is a cron pipeline that every six hours sweeps public job-ad sources, filters by EU geography, embeds the new ads, indexes them in a vector store and matches them against search profiles. The last step fires notifications.&lt;/p>
&lt;p>For six days the pipeline reported the same thing on every run:&lt;/p>
&lt;pre tabindex="0">&lt;code>status: completed
total_found: 756
new: 23
matched_jobs: 0 ← zero, run after run
&lt;/code>&lt;/pre>&lt;p>And in the logs:&lt;/p>
&lt;pre tabindex="0">&lt;code>[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/delete → 200 OK
[INFO] matcher: Purged 12 expired jobs from ChromaDB
[ERROR] pipeline: ChromaDB indexing error: All connection attempts failed
[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/get → 200 OK
[ERROR] pipeline: Matching error: All connection attempts failed
&lt;/code>&lt;/pre>&lt;p>This is a log that invites you to blame ChromaDB. And in fact the first post-mortem written internally pointed at a version incompatibility between the Python client and the v2 server. A reasonable hypothesis, technically plausible, completely false.&lt;/p>
&lt;p>The real cause: weeks earlier the general LLM on the platform had been migrated from &lt;strong>Ollama&lt;/strong> to &lt;strong>vLLM&lt;/strong>. The migration was clean for the two applications that depended directly on the large model, their manifests were pointed at the new endpoint. What nobody did was look at who else was calling the Service &lt;code>ollama.ollama.svc:11434&lt;/code>. &lt;em>jobhunter&lt;/em> called it to generate the embeddings for the ads. When the Ollama deployment was scaled to zero, the Service was left empty, and any outbound connection received a generic &lt;code>ConnectError(&amp;quot;All connection attempts failed&amp;quot;)&lt;/code> from &lt;code>httpcore&lt;/code>. The &lt;code>try/except&lt;/code> wrapping the whole matching stage caught the exception and labelled it &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em>, because that was the lexical wrapper of the block, not because ChromaDB had anything to do with it. ChromaDB was answering 200 to &lt;em>delete&lt;/em> and &lt;em>get&lt;/em> in those same logs.&lt;/p>
&lt;p>Three factors kept the incident alive for six days:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>&lt;code>except&lt;/code> named the wrapper rather than the stage that failed&lt;/strong>. The log said &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> when the error was an embeddings call against a nonexistent Service.&lt;/li>
&lt;li>The pipeline returned &lt;strong>&lt;code>status: completed&lt;/code>&lt;/strong> even when there were errors. No alerting based on &lt;code>status&lt;/code> fired. The metric that would have fired (matched_jobs stuck at zero) was not instrumented.&lt;/li>
&lt;li>The pipeline image ran as &lt;strong>&lt;code>:latest&lt;/code> with no versioning&lt;/strong>, no SBOM, no reproducibility. When it started failing, it was impossible to know which Ollama client it had been built with.&lt;/li>
&lt;/ol>
&lt;p>Each of the three symptoms is the cry of a layer that was missing. The first asks for &lt;strong>LLM-aware observability&lt;/strong> that distinguishes stages (layer 5). The second asks for the &lt;strong>pipeline logic&lt;/strong> and the Prometheus metrics to behave like contracts (layer 5, SLI/SLO dimension). The third asks for &lt;strong>GitOps with pinned images&lt;/strong> and an auditable SBOM (layer 6). And the whole incident, decommissioning a Service without knowing who consumes it, shouts &lt;strong>dependency tracking&lt;/strong> (layer 7).&lt;/p>
&lt;p>The rest of the post walks the seven layers through that lens: what each one solves, which OSS piece implements it in 2026, and which typical design decision breaks it.&lt;/p>
&lt;h2 id="layer-1--gateway-the-client-sdk-must-not-couple-to-the-engine">Layer 1 — Gateway: the client SDK must not couple to the engine&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the client sends &lt;code>POST /v1/chat/completions&lt;/code> with the standard OpenAI SDK and never learns which engine (vLLM, SGLang, TensorRT-LLM), which specific model, which LoRA adapter or even which GPU pool is serving the request. Authentication, rate limit, routing by &lt;code>body.model&lt;/code> and by tenant, header injection for tracing.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Envoy AI Gateway&lt;/strong> (Envoy with GenAI extensions: routing by model, token-based rate limit, fallback chains) or &lt;strong>Cilium Gateway API&lt;/strong> with your own filters. For cases where the client wants multi-provider without distinguishing on-prem from SaaS, &lt;strong>LiteLLM Proxy&lt;/strong> is the lightweight equivalent.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Exposing the inference engine endpoint directly. If clients call &lt;code>http://vllm-prod.svc:8000&lt;/code>, any change of engine, model or pool forces you to touch the code of every app. The rule: &lt;strong>the engine changes, the contract does not&lt;/strong>. The OpenAI SDK is standard; the routing behind the gateway is where design freedom lives.&lt;/p>
&lt;p>&lt;strong>Where &lt;em>jobhunter&lt;/em> failed.&lt;/strong> There was no gateway. The app called &lt;code>ollama.ollama.svc:11434&lt;/code> directly. When Ollama died, there was no intermediate layer that could answer with a &lt;em>fallback&lt;/em>, a &lt;em>retry against another pool&lt;/em>, or at least a &lt;em>503 error with a descriptive body&lt;/em>.&lt;/p>
&lt;h2 id="layer-2--llm-inference-vllm-as-the-default-choice-sglang-when-prefix-caching-rules">Layer 2 — LLM inference: vLLM as the default choice, SGLang when prefix caching rules&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Serving tokens with throughput and latency under control: continuous batching, PagedAttention so the KV cache does not fragment the VRAM, FP8 so a 32B model fits with room to spare, multi-LoRA for per-tenant customisation without replicating the base, structured output for function calling with schema guarantees.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>vLLM&lt;/strong> is the default choice: it covers the state of the art (&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8 quantisation&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative decoding&lt;/a>). &lt;strong>SGLang&lt;/strong> comes in when the workload has high prefix caching (long chat with large system prompts, agents with repeated instructions), because its RadixAttention composes better than standard prefix caching. For very specialised inference with proprietary NVIDIA kernels, &lt;strong>TensorRT-LLM&lt;/strong>, accepting the hardware lock-in.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming a single model &amp;ldquo;does everything&amp;rdquo;. A 32B chat LLM does not serve &lt;code>/v1/embeddings&lt;/code>; if you try, vLLM answers &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code>. Assuming it did was one of the concrete wounds of the incident: the app expected an endpoint the model did not implement.&lt;/p>
&lt;p>&lt;strong>The design decision that costs more later.&lt;/strong> Serving the LLM with aggressive quantisation (INT4) without a quality eval calibrated for your corpus. INT4 with AWQ or GPTQ saves VRAM, but it degrades answers in technical or legal Spanish in a measurable way. The rule: any quantisation change goes through the same golden eval as a model change.&lt;/p>
&lt;h2 id="layer-3--embeddings-separate-from-the-llm-fixed-dimension-a-life-of-its-own">Layer 3 — Embeddings: separate from the LLM, fixed dimension, a life of its own&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Generating dense vectors for the RAG corpus and for retrieval queries. The embeddings model is &lt;strong>a different thing&lt;/strong> from the chat LLM: different architecture (encoder, not decoder), different size (hundreds of millions of parameters, not tens of thousands of millions), different API (an &lt;code>/embeddings&lt;/code> endpoint that takes text and returns a fixed-dimension vector).&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Infinity&lt;/strong> or &lt;strong>Hugging Face Text Embeddings Inference (TEI)&lt;/strong> to serve models of the &lt;code>bge-*&lt;/code>, &lt;code>multilingual-e5-*&lt;/code>, &lt;code>nomic-embed-*&lt;/code> families with high throughput and multi-model support. &lt;strong>OpenVINO Model Server&lt;/strong> when Intel hardware is available. &lt;strong>sentence-transformers&lt;/strong> as a fallback embedded in the application itself when the corpus is small and the deployment is constrained.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Treating it as &amp;ldquo;the LLM does that too&amp;rdquo;. It does not if it is chat-only; and even when it does (some models have a dual endpoint), mixing chat and embeddings serving in the same process punishes the throughput of both. The physical separation &lt;strong>is&lt;/strong> the design.&lt;/p>
&lt;p>&lt;strong>The technical fact people forget.&lt;/strong> The &lt;strong>vector store and the embeddings model form an indivisible unit&lt;/strong>. Changing the model from &lt;code>multilingual-e5-large&lt;/code> (1024 dim) to &lt;code>multilingual-e5-small&lt;/code> (384 dim) is &lt;strong>not a substitution&lt;/strong>: it is creating a new collection (&lt;code>my_corpus_v2&lt;/code>) and re-embedding the whole corpus. If you &lt;code>upsert&lt;/code> into the old collection, you hit a dim mismatch at runtime that takes the pod down. This seems obvious and is violated constantly, because the embeddings model is chosen once and then forgotten.&lt;/p>
&lt;h2 id="layer-4--vector-store--relational-data--storage-what-actually-holds-the-state">Layer 4 — Vector store + relational data + storage: what actually holds the state&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Persisting the vectors with efficient filters (&lt;code>tenant_id&lt;/code>, &lt;code>created_at&lt;/code>, &lt;code>source&lt;/code>), persisting the relational metadata (users, configs, versioned prompts, traces), and persisting the model weights, the LoRA adapters, the datasets and the original corpora.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Qdrant&lt;/strong> for large collections (&amp;gt;200 k vectors), payload-aware filters, multi-tenancy via collection or via field.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong> for small collections with mandatory relational joins (wanting to do &lt;code>WHERE doc.author = ... AND vector &amp;lt;=&amp;gt; $1&lt;/code> in the same SQL).&lt;/li>
&lt;li>&lt;strong>PostgreSQL operated by CloudNativePG (CNPG)&lt;/strong> for the relational side: Barman Cloud backups, replication, connection via pooler.&lt;/li>
&lt;li>&lt;strong>MinIO&lt;/strong> for S3-compatible objects: bucket per tenant, cross-site replication, weights and adapters versioned by sha256.&lt;/li>
&lt;li>&lt;strong>Redis&lt;/strong> for queues, rate-limit counters and a cache of frequent retrievals.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming the vector store is &lt;em>stateless ephemeral&lt;/em>. It is exactly the opposite: it is the component where losing state costs the most. Without verified backups of the vector store, an index corruption forces you to re-embed the whole corpus, and on a corpus of millions of documents that is hours or days of GPU.&lt;/p>
&lt;p>&lt;strong>The design decision that pays later.&lt;/strong> Forgetting to version the collection by &lt;strong>dimension&lt;/strong> and &lt;strong>embeddings model&lt;/strong>. Suggested convention: &lt;code>my_corpus__embed-multie5l__1024d__v3&lt;/code>. The name carries metadata; any change in any of the three attributes forces a new collection. It is ugly but it protects against an accidental &lt;code>upsert&lt;/code> with the wrong dim.&lt;/p>
&lt;h2 id="layer-5--observability-llm-aware-traces--infrastructure-metrics--flow-logs">Layer 5 — Observability: LLM-aware traces + infrastructure metrics + flow logs&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That any inference can be recovered from its &lt;code>trace_id&lt;/code>, with all the &lt;code>gen_ai.*&lt;/code> attributes (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">semantic conventions&lt;/a>) plus your own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>adapter_id&lt;/code>, &lt;code>priority_tier&lt;/code>), a breakdown of latency (queue → prefill → decode → network), tokens consumed, tools invoked, exact model and adapter. And in parallel: Prometheus metrics from vLLM (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:prefix_cache_hit_rate&lt;/code>), from the GPU (DCGM Exporter), from the network (Hubble flow logs with drops and NetworkPolicy enforcement).&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenTelemetry Collector&lt;/strong> as the single transport for traces, metrics and logs, with OTLP receivers and separate exporters per destination.&lt;/li>
&lt;li>&lt;strong>Langfuse&lt;/strong> self-hosted for the LLM-aware side: tracing, prompt versioning, evals with LLM-as-judge (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>VictoriaMetrics + Grafana&lt;/strong> for high-throughput TSDB metrics with long retention.&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for L3/L4/L7 flow logs and NetworkPolicy visualisation.&lt;/li>
&lt;li>&lt;strong>DCGM Exporter&lt;/strong> for GPU metrics.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Wrapping the whole pipeline stage in a single &lt;code>try/except&lt;/code> that labels the exception with the wrapper&amp;rsquo;s name. The operational rule: &lt;strong>one &lt;code>try/except&lt;/code> per stage&lt;/strong>, with the stage label in the message and a Prometheus metric with &lt;code>labels={&amp;quot;stage&amp;quot;: &amp;quot;&amp;lt;name&amp;gt;&amp;quot;}&lt;/code>. That way &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> would never have been the log for an embeddings failure; it would have been &lt;em>&amp;ldquo;Embeddings call failed: ConnectError(ollama:11434)&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Complementary rule.&lt;/strong> The pipeline returns &lt;code>status: completed&lt;/code> if and only if &lt;strong>there were no errors&lt;/strong>. With errors it returns &lt;code>completed_with_errors&lt;/code> or &lt;code>failed&lt;/code>, and the metric &lt;code>pipeline_errors_total{stage}&lt;/code> is incremented. An alert based on &lt;code>increase(pipeline_errors_total[1h]) &amp;gt; 0&lt;/code> fires before the second failed run. Without this discipline, observability exists but does not warn.&lt;/p>
&lt;h2 id="layer-6--gitops-control-plane-the-only-legitimate-authority">Layer 6 — GitOps control plane: the only legitimate authority&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the cluster state is the state declared in git. That any divergence between git and the cluster is visible and, in critical components, &lt;strong>auto-reconciled or auto-alerted&lt;/strong>. That every deployed image has an immutable tag (sha digest or semver pin), an SBOM (Trivy) and traceability back to the commit that introduced it.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Flux&lt;/strong> (or ArgoCD) as the reconciler.&lt;/li>
&lt;li>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE) as the self-hosted OSS forge.&lt;/li>
&lt;li>&lt;strong>cert-manager + Trust Manager&lt;/strong> for internal PKI.&lt;/li>
&lt;li>&lt;strong>External Secrets Operator + SOPS&lt;/strong> for encrypted versioned secrets.&lt;/li>
&lt;li>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper) for binding policies: deny &lt;code>:latest&lt;/code> images, deny pods without a NetworkPolicy, deny Services without an owner label.&lt;/li>
&lt;li>&lt;strong>Trivy&lt;/strong> for SBOM and vulnerability scanning of images in the CI pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Images with a mutable tag (&lt;code>:latest&lt;/code>, &lt;code>:main&lt;/code>). Any &lt;code>kubectl edit&lt;/code> in production that is not reflected in git. &lt;code>main&lt;/code> branches with write permissions for humans without review. The rule: &lt;strong>if a human can mutate the cluster without going through a signed commit, you do not have GitOps, you have somebody&amp;rsquo;s whiteboard&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>How it applies to the incident.&lt;/strong> If the pipeline image had been pinned to a sha digest (&lt;code>registry.interno.local/jobhunter@sha256:9af2...&lt;/code>), the team could have audited immediately which Ollama client it carried. With &lt;code>:latest&lt;/code>, not even that.&lt;/p>
&lt;h2 id="layer-7--dependency-tracking-the-layer-the-incident-exposed">Layer 7 — Dependency tracking: the layer the incident exposed&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Knowing who calls which Service, both &lt;strong>declaratively&lt;/strong> (what the gitops repo says) and &lt;strong>observed&lt;/strong> (what has been seen crossing the network over the last N days). And, on mature platforms, propagating that information as &lt;strong>policy&lt;/strong>: if nobody declares and nobody observes traffic to the Service &lt;code>ollama.ollama.svc&lt;/code>, decommissioning it is safe; if somebody declares it or uses it, decommissioning it opens a ticket.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for observed flow logs: &lt;code>hubble observe --to-namespace ollama --since 14d&lt;/code> gives the list of source namespaces that have talked to Ollama in the last two weeks.&lt;/li>
&lt;li>&lt;strong>Otterize&lt;/strong> for intent-based policy: each Deployment declares &lt;em>&amp;ldquo;I need to talk to &lt;code>ollama-svc&lt;/code>&amp;rdquo;&lt;/em>, and the operator generates the corresponding NetworkPolicy and maintains a browsable catalogue of who is trying to talk to what.&lt;/li>
&lt;li>&lt;strong>manual kubectl-grep&lt;/strong> as a fallback: &lt;code>kubectl get deployments,cronjobs,statefulsets -A -o yaml | grep -E 'ollama[.-]'&lt;/code> produces the declarative list.&lt;/li>
&lt;li>&lt;strong>NetworkPolicy as code&lt;/strong> reviewed in CI: every PR that touches a Service requires the associated policy to be kept or explicitly updated.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>A pre-decom checklist&lt;/strong> that the incident suggests codifying as a CI hook:&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="nv">SVC&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ollama.ollama.svc.cluster.local&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"># (a) declarative grep in the gitops repo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git -C &lt;span class="nv">$GITOPS_REPO&lt;/span> grep -l &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK declarative&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"># (b) observed grep in Hubble (last 14 days)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">hubble observe --to-fqdn &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --since 336h --output json &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq -r &lt;span class="s1">&amp;#39;.source.namespace&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> sort -u &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK observed&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"># (c) live grep in the cluster&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get all -A -o yaml &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK live&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If all three return empty, the decom is safe. If any of them has content, there is downstream debt still open. The &lt;em>jobhunter&lt;/em> incident is exactly what happens when this check does not exist: the team that decommissioned Ollama looked at the list of applications it knew depended on it directly; nobody looked at the list of those that depended on it silently.&lt;/p>
&lt;h2 id="the-maths-that-matter-sizing-the-stack-on-4h100-sxm-320-gb">The maths that matter: sizing the stack on 4×H100 SXM (320 GB)&lt;/h2>
&lt;p>The generic reference cluster for everything that follows: &lt;strong>4×H100 SXM 80 GB&lt;/strong>, &lt;strong>NVLink&lt;/strong> between the four, &lt;strong>640 GB&lt;/strong> of system RAM, &lt;strong>2×NVMe NVMe-oF&lt;/strong> for local storage of weights and caches, &lt;strong>redundant 25/100 GbE&lt;/strong> to the switch. Aggregate VRAM: &lt;strong>320 GB&lt;/strong>.&lt;/p>
&lt;p>The VRAM budget is not free. A first reasonable split for a stack that serves one large general LLM, a medium code-specialised model, embeddings and a reranker, with room for multi-LoRA and for the KV cache:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Reference model&lt;/th>
&lt;th>Quant&lt;/th>
&lt;th>Model weights&lt;/th>
&lt;th>Reserved KV cache&lt;/th>
&lt;th>Total VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>General LLM (TP=4)&lt;/td>
&lt;td>70B-instruct&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>60 GB&lt;/td>
&lt;td>&lt;strong>130 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code LLM (TP=2)&lt;/td>
&lt;td>32B-coder&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>32 GB&lt;/td>
&lt;td>28 GB&lt;/td>
&lt;td>&lt;strong>60 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings&lt;/td>
&lt;td>multilingual-e5-large&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>1.3 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>8 GB&lt;/strong> (×2 replicas)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker&lt;/td>
&lt;td>bge-reranker-v2-m3&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>0.6 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>4 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-LoRA pool (over the general LLM)&lt;/td>
&lt;td>up to 16 adapters&lt;/td>
&lt;td>bf16&lt;/td>
&lt;td>16 × 0.4 GB ≈ 6 GB&lt;/td>
&lt;td>reuses the LLM&amp;rsquo;s KV&lt;/td>
&lt;td>&lt;strong>6 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reserved for fragmentation + overhead&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~30 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total committed&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~238 GB / 320 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Free headroom&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~82 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 26 % of free headroom is not waste: it is what lets the vLLM scheduler avoid preempting requests under moderate pressure, lets continuous batching group large batches without aborting, and lets a failover from the other site promote a standby without OOM.&lt;/p>
&lt;p>&lt;strong>Expected throughput&lt;/strong>, with the 70B general LLM in FP8 and tensor parallel 4, on an H100 SXM with continuous batching active and prefix caching at 35–55 % (typical in multi-turn chat with shared system prompts):&lt;/p>
$$
\text{aggregate tokens/second} \approx 1500 \text{ to } 2500
$$
&lt;p>for concurrency between 32 and 64 requests, with sub-second TTFT P95 on short prompts (&amp;lt;2k tokens) and TPOT P95 around &lt;strong>40–60 ms/token&lt;/strong> as perceived by the client. These numbers are &lt;strong>reasonable orders of magnitude&lt;/strong>, not guarantees: real throughput depends on the prompt mix, on whether speculative decoding (EAGLE-3) is active and burnt-in, and on the network cost between gateway and inference pods.&lt;/p>
&lt;p>&lt;strong>Embeddings throughput&lt;/strong> on two replicas of &lt;code>multilingual-e5-large&lt;/code> with dynamic batching:&lt;/p>
$$
\text{embeddings/second} \approx 3000 \text{ to } 6000 \quad (\text{optimal batch} \sim 64)
$$
&lt;p>Enough to reindex a corpus of &lt;strong>1 million documents&lt;/strong> in roughly an hour, assuming chunks of 512 tokens and two chunks per document on average. For corpora of tens of millions of documents, re-embedding is done by delta via CDC over the source (covered in &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>), not by full sweep.&lt;/p>
&lt;p>&lt;strong>Retrieval latency&lt;/strong> on Qdrant with HNSW (M=16, ef_construct=200) over a collection of 5 million 1024-dim vectors filtered by &lt;code>tenant_id&lt;/code>:&lt;/p>
$$
\text{P95 latency retrieve top-50} \approx 8 \text{ to } 25 \text{ ms}
$$
&lt;p>Below the cost of cross-encoder reranking (&lt;code>bge-reranker-v2-m3&lt;/code> over top-50 = roughly 30–60 ms more), and below any call to the LLM. The bottleneck in a well sized RAG pipeline is never the vector store: it is the LLM&amp;rsquo;s decoding.&lt;/p>
&lt;h2 id="final-diagram-the-complete-connected-stack">Final diagram: the complete connected stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete diagram of the on-premise LLM inference stack with its seven layers connected">&lt;style>
.b{stroke:#333;stroke-width:1.4;rx:6}
.gw{fill:#ffd9b8;stroke:#a44}
.llm{fill:#ffe6c2;stroke:#a55}
.emb{fill:#fff0d0;stroke:#a66}
.data{fill:#dfe9f5;stroke:#356}
.obs{fill:#d8eecf;stroke:#373}
.ctrl{fill:#f5e3d8;stroke:#763}
.dep{fill:#ead8f5;stroke:#634}
.bg{fill:#fafafa;stroke:#bbb;rx:8}
.lbl{font:600 12px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
.otel{stroke:#1a73e8;stroke-width:1.4;fill:none;stroke-dasharray:3 2;marker-end:url(#ab)}
.ctrlarr{stroke:#c66;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#ac)}
&lt;/style>
&lt;defs>
&lt;marker id="a" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>
&lt;marker id="ab" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#1a73e8"/>&lt;/marker>
&lt;marker id="ac" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c66"/>&lt;/marker>
&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">On-premise LLM inference stack — seven layers, stable contracts between them&lt;/text>
&lt;!-- Client -->
&lt;rect x="340" y="36" width="140" height="32" class="b gw"/>
&lt;text x="410" y="56" text-anchor="middle" class="sm">Client · OpenAI SDK&lt;/text>
&lt;path class="arr" d="M410,68 L410,82"/>
&lt;!-- Gateway -->
&lt;rect x="80" y="86" width="660" height="40" class="b gw"/>
&lt;text x="100" y="103" class="tiny">LAYER 1 · GATEWAY&lt;/text>
&lt;text x="410" y="120" text-anchor="middle" class="sm">Envoy AI Gateway · routing by body.model · JWT (Defguard) · rate-limit per tenant · trace_id injection&lt;/text>
&lt;!-- Inference + Embeddings + Reranker -->
&lt;rect x="80" y="146" width="320" height="100" class="b llm"/>
&lt;text x="100" y="163" class="tiny">LAYER 2 · LLM INFERENCE&lt;/text>
&lt;rect x="100" y="174" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="192" text-anchor="middle" class="sm">vLLM · general LLM 70B FP8 · TP=4 · multi-LoRA&lt;/text>
&lt;rect x="100" y="208" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="226" text-anchor="middle" class="sm">vLLM · code LLM 32B FP8 · TP=2 · structured out.&lt;/text>
&lt;rect x="420" y="146" width="320" height="100" class="b emb"/>
&lt;text x="440" y="163" class="tiny">LAYER 3 · EMBEDDINGS + RERANKER&lt;/text>
&lt;rect x="440" y="174" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="192" text-anchor="middle" class="sm">Infinity · multilingual-e5-large 1024d · ×2&lt;/text>
&lt;rect x="440" y="208" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="226" text-anchor="middle" class="sm">TEI · bge-reranker-v2-m3 (top-50 rerank)&lt;/text>
&lt;path class="arr" d="M250,126 L250,146"/>
&lt;path class="arr" d="M570,126 L570,146"/>
&lt;!-- Vector store + storage -->
&lt;rect x="80" y="266" width="660" height="80" class="b data"/>
&lt;text x="100" y="283" class="tiny">LAYER 4 · VECTOR STORE + RELATIONAL DATA + STORAGE&lt;/text>
&lt;rect x="100" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="180" y="319" text-anchor="middle" class="sm">Qdrant (HNSW)&lt;/text>
&lt;rect x="280" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="360" y="319" text-anchor="middle" class="sm">PostgreSQL (CNPG)&lt;/text>
&lt;rect x="460" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="540" y="319" text-anchor="middle" class="sm">MinIO (weights · adapters)&lt;/text>
&lt;rect x="640" y="294" width="100" height="40" class="b" fill="#eef3fb"/>
&lt;text x="690" y="319" text-anchor="middle" class="sm">Redis&lt;/text>
&lt;path class="arr" d="M580,246 L580,266"/>
&lt;!-- Observability -->
&lt;rect x="80" y="366" width="660" height="60" class="b obs"/>
&lt;text x="100" y="383" class="tiny">LAYER 5 · OBSERVABILITY (LLM-aware traces · metrics · flow logs)&lt;/text>
&lt;rect x="100" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="175" y="411" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="260" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="335" y="411" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="420" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="495" y="411" text-anchor="middle" class="sm">VictoriaMetrics + Grafana&lt;/text>
&lt;rect x="580" y="394" width="160" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="660" y="411" text-anchor="middle" class="sm">Hubble + DCGM Exporter&lt;/text>
&lt;!-- OTel lines from layers to observability -->
&lt;path class="otel" d="M740,106 L770,106 L770,396 L740,396"/>
&lt;path class="otel" d="M400,196 L770,196" opacity="0.6"/>
&lt;path class="otel" d="M400,310 L770,310" opacity="0.6"/>
&lt;!-- GitOps control plane -->
&lt;rect x="80" y="446" width="380" height="40" class="b ctrl"/>
&lt;text x="100" y="463" class="tiny">LAYER 6 · GITOPS CONTROL PLANE&lt;/text>
&lt;text x="270" y="479" text-anchor="middle" class="sm">Forgejo → Flux → cert-manager · External Secrets · Kyverno&lt;/text>
&lt;!-- Dependency tracking -->
&lt;rect x="480" y="446" width="260" height="40" class="b dep"/>
&lt;text x="500" y="463" class="tiny">LAYER 7 · DEPENDENCY TRACKING&lt;/text>
&lt;text x="610" y="479" text-anchor="middle" class="sm">Hubble flows · Otterize intents&lt;/text>
&lt;!-- Control plane reconciliation → all layers -->
&lt;path class="ctrlarr" d="M270,446 L270,430 L40,430 L40,106 L80,106"/>
&lt;path class="ctrlarr" d="M610,446 L610,430 L780,430 L780,106 L740,106"/>
&lt;text x="60" y="505" class="tiny">Request flow&lt;/text>
&lt;line x1="155" y1="502" x2="180" y2="502" stroke="#666" stroke-width="1.4"/>
&lt;text x="240" y="505" class="tiny">OTel telemetry&lt;/text>
&lt;line x1="335" y1="502" x2="360" y2="502" stroke="#1a73e8" stroke-width="1.4" stroke-dasharray="3 2"/>
&lt;text x="430" y="505" class="tiny">GitOps reconciliation&lt;/text>
&lt;line x1="555" y1="502" x2="580" y2="502" stroke="#c66" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;/svg>
&lt;/div>
&lt;p>The solid lines are the request path: client → gateway → inference engine → vector store/embeddings → response. The dashed blue lines are telemetry: each component emits OTel to the collector, which routes traces to Langfuse, metrics to VictoriaMetrics and logs to Loki. The dashed red lines are reconciliation: the GitOps control plane keeps any layer in its declared state and warns of divergence.&lt;/p>
&lt;p>The diagram is not decorative: every arrow is a &lt;strong>stable contract&lt;/strong> between two layers. If one layer changes (vLLM → SGLang, multilingual-e5 → bge-m3, Qdrant → pgvector), the arrows hold. That stability of contracts is the architectural property that lets a team migrate components without breaking downstream apps.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-the-stack">Typical design decisions that break the stack&lt;/h2>
&lt;p>A short list of mistakes seen repeatedly in stacks that looked well designed on paper:&lt;/p>
&lt;p>&lt;strong>1. Coupling the client SDK to the inference engine.&lt;/strong> Dropping the gateway because &amp;ldquo;vLLM already speaks OpenAI-compatible&amp;rdquo; works on day one and hurts the day you have to add a fallback, a canary or a second model.&lt;/p>
&lt;p>&lt;strong>2. Sharing the LLM and embeddings endpoint.&lt;/strong> A &lt;code>qwen2.5-32b-Instruct&lt;/code> is chat-only; &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code> is the cry of a design that confused the two layers.&lt;/p>
&lt;p>&lt;strong>3. Reusing the vector store collection when changing the embeddings model.&lt;/strong> Different dimensions do not allow &lt;code>upsert&lt;/code>. Versioning the collection by &lt;code>(model, dim, version)&lt;/code> is ugly but it saves the day of the change.&lt;/p>
&lt;p>&lt;strong>4. A &lt;code>try/except&lt;/code> wrapping an entire pipeline with the wrapper&amp;rsquo;s label.&lt;/strong> The log lies because the label is lexical, not causal. Every stage in its own &lt;code>try/except&lt;/code> with its own label and its own metric.&lt;/p>
&lt;p>&lt;strong>5. &lt;code>status: completed&lt;/code> with errors.&lt;/strong> The pipeline has to distinguish &lt;code>completed&lt;/code>, &lt;code>completed_with_errors&lt;/code> and &lt;code>failed&lt;/code>, and the alerting has to fire on the last two. Without this, observability exists in theory and does not warn in practice.&lt;/p>
&lt;p>&lt;strong>6. Images with a mutable tag.&lt;/strong> &lt;code>:latest&lt;/code> and &lt;code>:main&lt;/code> are not tags, they are aliases. Without a sha digest there is no reproducibility and no auditable SBOM.&lt;/p>
&lt;p>&lt;strong>7. Decommissioning a Service without a pre-decom check.&lt;/strong> The three-grep check (declarative + observed + live) takes two minutes and costs six days of incident when it is skipped.&lt;/p>
&lt;p>&lt;strong>8. Default &lt;code>limits.memory&lt;/code> on pods that load models.&lt;/strong> A sidecar loading &lt;code>sentence-transformers + torch + tokenizer&lt;/code> needs 2–4 GB; with &lt;code>limits.memory: 1Gi&lt;/code> you hit OOM on the first pod restart, and sometimes with no alert because the liveness probe answers through another route.&lt;/p>
&lt;p>All of them are variants of the same principle: the stack does not fail in its most expensive layer (inference, where nobody underestimates the cost), it fails in the cheap, boring layers (gateway, observability, GitOps, dependency tracking) where it is tempting to save.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), the suggested split into pods:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">gpu-node-01 (4×H100 SXM, intra-node NVLink)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-general (TP=4) ~130 GB VRAM (4 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── (shares GPUs with the multi-LoRA pool on the same deployment)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">gpu-node-02 (4×H100 SXM, second node)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-code (TP=2) ~60 GB VRAM (2 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── infinity-embeddings (×2) ~16 GB VRAM (shared on 1 GPU with optional MIG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── tei-reranker ~4 GB VRAM (cohabitant)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── failover reserve ~120 GB VRAM free for canary / standby
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Not everything fits comfortably on a single node; two nodes with two H100 SXM each would be enough for the conservative setup, and the rest of the cluster (CPU-bound: gateway, vector store, observability, control plane) runs on GPU-free nodes.&lt;/p>
&lt;p>The operational rule: &lt;strong>inference concentrates&lt;/strong>, the rest of the stack distributes. Concentrating inference maximises the use of NVLink (cross-GPU tensor parallel without going through PCIe); distributing the rest prevents an event on the GPU node from taking the control plane down with it.&lt;/p>
&lt;p>An even more conservative configuration, for SMEs with a single 4×H100 SXM node as a starting point, serves the general LLM (TP=4) and embeddings/reranker cohabiting with MIG (Multi-Instance GPU to partition an H100 into hardware-isolated slices). The code LLM is deferred to a second phase. It is viable and cost-aware; what is not viable is doing without layers 5, 6 and 7.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post focuses on the &lt;strong>static design&lt;/strong> of the stack. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The multi-site active/standby plane&lt;/strong>: Cilium Cluster Mesh, cross-cluster Qdrant replication, realistic RTO/RPO, when active-active pays off and when it does not.&lt;/li>
&lt;li>&lt;strong>The continuous fine-tuning plane&lt;/strong>: how the LoRA pipeline closes the loop from production feedback to a promoted adapter, in the spirit of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>The safety/guardrails plane&lt;/strong>: where Llama Guard fits, Presidio for PII and XGrammar for guaranteed structured output, connected to layer 1, the gateway.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: &lt;code>gen_ai.usage.*&lt;/code> instrumentation at tenant and model level, tokens/euro dashboards, GPU elasticity decisions via KEDA.&lt;/li>
&lt;li>&lt;strong>The compliance plane&lt;/strong>: how the stack maps to ENS High, NIS2 and ISO/IEC 42001 without turning the deployment into a compliance exercise that paralyses delivery.&lt;/li>
&lt;li>&lt;strong>Below the engine&lt;/strong>: the mini-series that opens the stack&amp;rsquo;s basement — &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">the interconnect (NVLink/NCCL)&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">the host (NUMA, hugepages, CPU isolation)&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">the RKE2 resource managers&lt;/a> that pin each pod to the right NUMA node.&lt;/li>
&lt;/ul>
&lt;p>Each one falls into a different series on the blog and will be covered with the same discipline: a concrete piece, a justified decision, the typical mistake seen in practice.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the general framework this stack operates in.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — a card per tool in the stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the same architecture seen from the perspective of an individual request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers in LLMOps&lt;/a> — a comparison with the AWS/Azure/GCP stacks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the backbone of layer 5.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the piece that gives per-tenant customisation without replicating the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — everything that goes into layers 3-4 so that RAG does not degrade.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — the reason for the FP8 W8A8 in the sizing.&lt;/li>
&lt;/ul>
&lt;h3 id="relevant-official-documentation">Relevant official documentation&lt;/h3>
&lt;ul>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai&lt;/a>&lt;/li>
&lt;li>SGLang RadixAttention — &lt;a href="https://github.com/sgl-project/sglang">github.com/sgl-project/sglang&lt;/a>&lt;/li>
&lt;li>Envoy AI Gateway — &lt;a href="https://aigateway.envoyproxy.io/">aigateway.envoyproxy.io&lt;/a>&lt;/li>
&lt;li>Langfuse self-hosted — &lt;a href="https://langfuse.com/docs/self-hosting">langfuse.com/docs/self-hosting&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>Hubble flow observability — &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">docs.cilium.io/en/stable/observability/hubble&lt;/a>&lt;/li>
&lt;li>Otterize intent-based access — &lt;a href="https://docs.otterize.com/">docs.otterize.com&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>