<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Ai-Gateway on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/ai-gateway/</link><description>Recent content in Ai-Gateway on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 01 Jun 2026 05:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/ai-gateway/index.xml" rel="self" type="application/rss+xml"/><item><title>LLM Guard: the sworn translator with a notebook of equivalences — anatomy, scanners and its integration with Langfuse, vLLM and LiteLLM</title><link>https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/</link><pubDate>Mon, 01 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/</guid><description>&lt;blockquote>
&lt;p>This post is a &lt;strong>deep dive into a single piece&lt;/strong> inside the layer covered in the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">post on guardrails and LLM safety&lt;/a>. That one maps the four lines of defence (input, retrieval, tool, output) and the 2026 OSS catalogue from a bird&amp;rsquo;s-eye view; this one gets down to ground level on &lt;strong>LLM Guard&lt;/strong>, because its Anonymize/Deanonymize pattern, its composable scanner model and its four deployment modes deserve their own treatment. The analogies built up there (HACCP kitchen, four CCPs) still hold: this post zooms in on the tool that occupies the PII belt and the individual scanners within that architecture.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM Guard is the OSS tool (MIT, Protect AI) that materialises the LLM guardrail layer with a model radically different from that of NeMo Guardrails and Guardrails AI: instead of a declarative DSL (Colang) or a validator framework with external LLM-as-judge calls, it offers a &lt;strong>catalogue of specialised compact detectors&lt;/strong>, 15 input scanners and 21 output scanners, composable as a Python pipeline, with one distinctive mechanism: the &lt;strong>Anonymize → LLM → Deanonymize with Vault&lt;/strong> pattern. The Vault is a centralised store of the mapping between real entities (&lt;code>John Doe&lt;/code>, &lt;code>12345678X&lt;/code>) and placeholders (&lt;code>[REDACTED_PERSON_1]&lt;/code>, &lt;code>[REDACTED_DNI_1]&lt;/code>); on input, entities are redacted and the mapping is saved; the LLM never sees real personal data; on output, the Deanonymize scanner restores the originals before returning the answer to the user. This post takes apart: the internal anatomy (Vault + scanners + orchestrator with &lt;code>fail_fast&lt;/code> and TTL cache), the four deployment patterns with their mathematics (in-process library, FastAPI API, OTel sidecar over vLLM, AI Gateway plugin — LiteLLM, Envoy AI Gateway, Kong AI Gateway), the integration diagrams with Langfuse (via LLM Guard&amp;rsquo;s OTel HTTP exporter + &lt;code>langfuse.score()&lt;/code> from the AI Gateway), the mathematics with the project&amp;rsquo;s benchmarks (Anonymize at 177 ms CPU → 128 ms ONNX-CPU → 125 ms GPU FP16 → 38 ms GPU+ONNX, scaling ×4.6 when you combine ONNX + GPU), the ONNX pattern as the default acceleration without a dedicated GPU, the comparison with NeMo Guardrails (declarative Colang DSL oriented to conversational flow) and Guardrails AI (JSON contract-style validators with external judges), the application to on-premise hardware (which scanners hold up on CPU, which need a shared GPU) and the seven operational pitfalls specific to the tool.&lt;/p>
&lt;h2 id="the-analogy-the-sworn-translator-with-a-notebook-of-equivalences">The analogy: the sworn translator with a notebook of equivalences&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="LLM Guard as a sworn translator with a notebook of equivalences">
&lt;style>
.t-user{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.t-trad{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.t-model{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.t-vault{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:8}
.tl{font:600 13px sans-serif;fill:#222}
.ts{font:400 11px sans-serif;fill:#555}
.tn{font:italic 11px sans-serif;fill:#555}
.tar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mt1)}
.tcb{stroke:#7a5;stroke-width:1.4;fill:none;stroke-dasharray:5 3;marker-end:url(#mt2)}
&lt;/style>
&lt;defs>
&lt;marker id="mt1" 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="mt2" 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="#7a5"/>&lt;/marker>
&lt;/defs>
&lt;rect x="20" y="40" width="120" height="60" class="t-user"/>
&lt;text x="80" y="64" text-anchor="middle" class="tl">Customer&lt;/text>
&lt;text x="80" y="82" text-anchor="middle" class="ts">"My ID is 12345678X,&lt;/text>
&lt;text x="80" y="96" text-anchor="middle" class="ts">is VAT due?"&lt;/text>
&lt;rect x="180" y="40" width="160" height="60" class="t-trad"/>
&lt;text x="260" y="64" text-anchor="middle" class="tl">Translator (Anonymize)&lt;/text>
&lt;text x="260" y="82" text-anchor="middle" class="ts">redacts sensitive entities&lt;/text>
&lt;text x="260" y="96" text-anchor="middle" class="ts">+ writes them in the notebook&lt;/text>
&lt;rect x="380" y="40" width="160" height="60" class="t-model"/>
&lt;text x="460" y="64" text-anchor="middle" class="tl">LLM&lt;/text>
&lt;text x="460" y="82" text-anchor="middle" class="ts">receives sanitised text:&lt;/text>
&lt;text x="460" y="96" text-anchor="middle" class="ts">"My ID is [DNI_1], is VAT due?"&lt;/text>
&lt;rect x="580" y="40" width="160" height="60" class="t-trad"/>
&lt;text x="660" y="64" text-anchor="middle" class="tl">Translator (Deanonymize)&lt;/text>
&lt;text x="660" y="82" text-anchor="middle" class="ts">restores the originals&lt;/text>
&lt;text x="660" y="96" text-anchor="middle" class="ts">from the notebook&lt;/text>
&lt;rect x="680" y="40" width="120" height="60" class="t-user" transform="translate(-20 130)"/>
&lt;text x="720" y="194" text-anchor="middle" class="tl" transform="translate(-20 0)">Customer receives&lt;/text>
&lt;text x="720" y="212" text-anchor="middle" class="ts" transform="translate(-20 0)">an answer with&lt;/text>
&lt;text x="720" y="226" text-anchor="middle" class="ts" transform="translate(-20 0)">"12345678X" restored&lt;/text>
&lt;path class="tar" d="M140,70 L180,70"/>
&lt;path class="tar" d="M340,70 L380,70"/>
&lt;path class="tar" d="M540,70 L580,70"/>
&lt;path class="tar" d="M660,100 Q660,150 700,170"/>
&lt;rect x="280" y="220" width="220" height="80" class="t-vault"/>
&lt;text x="390" y="244" text-anchor="middle" class="tl">Vault (shared notebook)&lt;/text>
&lt;text x="390" y="262" text-anchor="middle" class="ts">[PERSON_1] = "Marta García"&lt;/text>
&lt;text x="390" y="276" text-anchor="middle" class="ts">[DNI_1] = "12345678X"&lt;/text>
&lt;text x="390" y="290" text-anchor="middle" class="ts">[IBAN_1] = "ES91 2100 0418..."&lt;/text>
&lt;path class="tcb" d="M260,100 L320,218"/>
&lt;path class="tcb" d="M460,218 L660,100"/>
&lt;text x="200" y="160" class="tn">stores mapping&lt;/text>
&lt;text x="500" y="160" class="tn">looks up to restore&lt;/text>
&lt;text x="410" y="340" text-anchor="middle" class="tn">The LLM never sees the original PII. The notebook (Vault) is the only point that knows the equivalence.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A serious sworn translator working with sensitive documents, an employment contract, a medical record, a tax return, does not send the raw text to the machine translation service in the cloud. They keep a &lt;strong>notebook of equivalences&lt;/strong> open on the desk. When the original document arrives, they open the notebook and start writing down: &amp;ldquo;Marta García&amp;rdquo; → &lt;code>[PERSON-1]&lt;/code>, &amp;ldquo;12345678X&amp;rdquo; → &lt;code>[DNI-1]&lt;/code>, &amp;ldquo;ES91 2100 0418&amp;hellip;&amp;rdquo; → &lt;code>[IBAN-1]&lt;/code>. They substitute each occurrence in the text with its label and pass the &lt;strong>anonymised&lt;/strong> text to the translation service. The service returns a translation that still contains the labels. The translator opens the notebook again, restores each label with its original value, and hands the client the final translation with the PII intact. As far as the translation service is concerned, those personal data &lt;strong>never existed&lt;/strong>: it only saw placeholders.&lt;/p>
&lt;p>This is the exact operation that defines LLM Guard&amp;rsquo;s character against the rest of the ecosystem. NeMo Guardrails solves safety with a &lt;strong>declarative graph&lt;/strong> of rules in Colang; Guardrails AI with &lt;strong>validators&lt;/strong> that invoke an LLM-as-judge to verify contracts; LLM Guard with a &lt;strong>catalogue of specialised compact detectors&lt;/strong> + the Vault pattern. All three are valid in different scenarios. The choice is not a matter of taste: it is structural, according to how the system is built and where the bottleneck is.&lt;/p>
&lt;p>The translator also checks, of course, that the text contains no other problems besides PII: insults, instructions to reprogram itself, links to hostile pages, code that should not be there. That is what the rest of the scanner catalogue is for. But the house signature, what sets it apart, is that notebook.&lt;/p>
&lt;h2 id="internal-anatomy-of-llm-guard">Internal anatomy of LLM Guard&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Internal anatomy of LLM Guard">
&lt;style>
.a-orch{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.a-in{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:6}
.a-out{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:6}
.a-vault{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:6}
.a-obs{fill:#f8a8d8;stroke:#444;stroke-width:1.4;rx:6}
.al{font:600 12px sans-serif;fill:#222}
.as{font:400 10px sans-serif;fill:#444}
.an{font:italic 10px sans-serif;fill:#555}
.aar{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#ma1)}
&lt;/style>
&lt;defs>&lt;marker id="ma1" 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;rect x="20" y="20" width="780" height="40" class="a-orch"/>
&lt;text x="410" y="42" text-anchor="middle" class="al">Orchestrator: scan_prompt() / scan_output() · fail_fast · TTL cache · timeout · OTel spans&lt;/text>
&lt;text x="410" y="56" text-anchor="middle" class="as">Iterates scanners in order, aggregates is_valid and risk_score, emits traces and Prometheus metrics&lt;/text>
&lt;text x="50" y="90" class="al">Input scanners (15)&lt;/text>
&lt;rect x="30" y="100" width="120" height="22" class="a-in"/>&lt;text x="90" y="115" text-anchor="middle" class="as">Anonymize ⓥ&lt;/text>
&lt;rect x="30" y="125" width="120" height="22" class="a-in"/>&lt;text x="90" y="140" text-anchor="middle" class="as">PromptInjection&lt;/text>
&lt;rect x="30" y="150" width="120" height="22" class="a-in"/>&lt;text x="90" y="165" text-anchor="middle" class="as">Toxicity&lt;/text>
&lt;rect x="30" y="175" width="120" height="22" class="a-in"/>&lt;text x="90" y="190" text-anchor="middle" class="as">Secrets&lt;/text>
&lt;rect x="30" y="200" width="120" height="22" class="a-in"/>&lt;text x="90" y="215" text-anchor="middle" class="as">TokenLimit&lt;/text>
&lt;rect x="30" y="225" width="120" height="22" class="a-in"/>&lt;text x="90" y="240" text-anchor="middle" class="as">BanTopics&lt;/text>
&lt;rect x="30" y="250" width="120" height="22" class="a-in"/>&lt;text x="90" y="265" text-anchor="middle" class="as">BanCompetitors&lt;/text>
&lt;rect x="30" y="275" width="120" height="22" class="a-in"/>&lt;text x="90" y="290" text-anchor="middle" class="as">BanCode / Code&lt;/text>
&lt;rect x="30" y="300" width="120" height="22" class="a-in"/>&lt;text x="90" y="315" text-anchor="middle" class="as">Sentiment&lt;/text>
&lt;rect x="30" y="325" width="120" height="22" class="a-in"/>&lt;text x="90" y="340" text-anchor="middle" class="as">Gibberish&lt;/text>
&lt;rect x="30" y="350" width="120" height="22" class="a-in"/>&lt;text x="90" y="365" text-anchor="middle" class="as">Language&lt;/text>
&lt;rect x="30" y="375" width="120" height="22" class="a-in"/>&lt;text x="90" y="390" text-anchor="middle" class="as">InvisibleText&lt;/text>
&lt;rect x="30" y="400" width="120" height="22" class="a-in"/>&lt;text x="90" y="415" text-anchor="middle" class="as">Regex · BanSubstrings&lt;/text>
&lt;rect x="180" y="120" width="160" height="170" class="a-vault"/>
&lt;text x="260" y="142" text-anchor="middle" class="al">Vault&lt;/text>
&lt;text x="260" y="160" text-anchor="middle" class="as">In-memory dictionary&lt;/text>
&lt;text x="260" y="174" text-anchor="middle" class="as">per session / request&lt;/text>
&lt;text x="260" y="200" text-anchor="middle" class="as">[PERSON_1]→"Marta García"&lt;/text>
&lt;text x="260" y="214" text-anchor="middle" class="as">[DNI_1]→"12345678X"&lt;/text>
&lt;text x="260" y="228" text-anchor="middle" class="as">[IBAN_1]→"ES91..."&lt;/text>
&lt;text x="260" y="252" text-anchor="middle" class="as">.placeholder() / .get()&lt;/text>
&lt;text x="260" y="266" text-anchor="middle" class="as">optional: persistence&lt;/text>
&lt;text x="260" y="280" text-anchor="middle" class="as">Redis / sticky client&lt;/text>
&lt;path class="aar" d="M150,110 L186,140"/>
&lt;text x="360" y="90" class="al">Output scanners (21)&lt;/text>
&lt;rect x="350" y="100" width="120" height="22" class="a-out"/>&lt;text x="410" y="115" text-anchor="middle" class="as">Deanonymize ⓥ&lt;/text>
&lt;rect x="350" y="125" width="120" height="22" class="a-out"/>&lt;text x="410" y="140" text-anchor="middle" class="as">Sensitive (PII out)&lt;/text>
&lt;rect x="350" y="150" width="120" height="22" class="a-out"/>&lt;text x="410" y="165" text-anchor="middle" class="as">Toxicity · Bias&lt;/text>
&lt;rect x="350" y="175" width="120" height="22" class="a-out"/>&lt;text x="410" y="190" text-anchor="middle" class="as">NoRefusal&lt;/text>
&lt;rect x="350" y="200" width="120" height="22" class="a-out"/>&lt;text x="410" y="215" text-anchor="middle" class="as">Relevance&lt;/text>
&lt;rect x="350" y="225" width="120" height="22" class="a-out"/>&lt;text x="410" y="240" text-anchor="middle" class="as">FactualConsistency&lt;/text>
&lt;rect x="350" y="250" width="120" height="22" class="a-out"/>&lt;text x="410" y="265" text-anchor="middle" class="as">JSON validator&lt;/text>
&lt;rect x="350" y="275" width="120" height="22" class="a-out"/>&lt;text x="410" y="290" text-anchor="middle" class="as">MaliciousURLs&lt;/text>
&lt;rect x="350" y="300" width="120" height="22" class="a-out"/>&lt;text x="410" y="315" text-anchor="middle" class="as">URLReachability&lt;/text>
&lt;rect x="350" y="325" width="120" height="22" class="a-out"/>&lt;text x="410" y="340" text-anchor="middle" class="as">LanguageSame&lt;/text>
&lt;rect x="350" y="350" width="120" height="22" class="a-out"/>&lt;text x="410" y="365" text-anchor="middle" class="as">ReadingTime&lt;/text>
&lt;rect x="350" y="375" width="120" height="22" class="a-out"/>&lt;text x="410" y="390" text-anchor="middle" class="as">BanCompetitors&lt;/text>
&lt;rect x="350" y="400" width="120" height="22" class="a-out"/>&lt;text x="410" y="415" text-anchor="middle" class="as">Regex · BanSubstrings&lt;/text>
&lt;path class="aar" d="M340,140 L350,140"/>
&lt;text x="540" y="90" class="al">Backend models&lt;/text>
&lt;rect x="510" y="100" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="120" text-anchor="middle" class="al">ONNX runtime&lt;/text>
&lt;text x="590" y="136" text-anchor="middle" class="as">quantised models&lt;/text>
&lt;text x="590" y="152" text-anchor="middle" class="as">CPU + GPU compatible&lt;/text>
&lt;rect x="510" y="170" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="190" text-anchor="middle" class="al">Transformers (HF)&lt;/text>
&lt;text x="590" y="206" text-anchor="middle" class="as">BERT NER, distilbert&lt;/text>
&lt;text x="590" y="222" text-anchor="middle" class="as">deberta, bge, etc.&lt;/text>
&lt;rect x="510" y="240" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="260" text-anchor="middle" class="al">Presidio Analyzer&lt;/text>
&lt;text x="590" y="276" text-anchor="middle" class="as">spaCy / flair / regex&lt;/text>
&lt;text x="590" y="292" text-anchor="middle" class="as">~50 base entities&lt;/text>
&lt;rect x="510" y="310" width="160" height="60" class="a-obs"/>
&lt;text x="590" y="330" text-anchor="middle" class="al">Pure validators&lt;/text>
&lt;text x="590" y="346" text-anchor="middle" class="as">regex, JSON schema,&lt;/text>
&lt;text x="590" y="362" text-anchor="middle" class="as">stdlib URL parsing&lt;/text>
&lt;text x="700" y="90" class="al">Telemetry&lt;/text>
&lt;rect x="690" y="100" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="120" text-anchor="middle" class="al">OTel exporter&lt;/text>
&lt;text x="747" y="136" text-anchor="middle" class="as">traces (HTTP)&lt;/text>
&lt;text x="747" y="152" text-anchor="middle" class="as">metrics (HTTP)&lt;/text>
&lt;rect x="690" y="170" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="190" text-anchor="middle" class="al">Prometheus&lt;/text>
&lt;text x="747" y="206" text-anchor="middle" class="as">/metrics endpoint&lt;/text>
&lt;text x="747" y="222" text-anchor="middle" class="as">counters + histograms&lt;/text>
&lt;rect x="690" y="240" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="260" text-anchor="middle" class="al">structured logs&lt;/text>
&lt;text x="747" y="276" text-anchor="middle" class="as">stdout JSON,&lt;/text>
&lt;text x="747" y="292" text-anchor="middle" class="as">Loki/ELK parseable&lt;/text>
&lt;rect x="690" y="310" width="115" height="60" class="a-obs"/>
&lt;text x="747" y="330" text-anchor="middle" class="al">FastAPI&lt;/text>
&lt;text x="747" y="346" text-anchor="middle" class="as">/analyze/prompt&lt;/text>
&lt;text x="747" y="362" text-anchor="middle" class="as">/analyze/output&lt;/text>
&lt;text x="410" y="445" text-anchor="middle" class="an">The Vault is the unique piece: Anonymize (input) and Deanonymize (output) share it in the same request or session. Without it, PII would leak to the LLM.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The three structural pieces are:&lt;/p>
&lt;p>&lt;strong>1. The orchestrator&lt;/strong> (&lt;code>scan_prompt&lt;/code>, &lt;code>scan_output&lt;/code>). It receives an ordered list of scanners and runs them sequentially over the text. It returns the triple &lt;code>(sanitized_text, results_valid, results_score)&lt;/code> where:&lt;/p>
&lt;ul>
&lt;li>&lt;code>sanitized_text&lt;/code> is the text transformed by the scanners that mutate it (Anonymize, BanSubstrings with redaction).&lt;/li>
&lt;li>&lt;code>results_valid&lt;/code> is a &lt;code>{scanner_name: bool}&lt;/code> dict indicating which scanners passed.&lt;/li>
&lt;li>&lt;code>results_score&lt;/code> is a &lt;code>{scanner_name: float}&lt;/code> dict with the reported risk score (0 clean, 1 maximum violation).&lt;/li>
&lt;/ul>
&lt;p>It supports &lt;code>fail_fast=True&lt;/code> to stop after the first failure. It supports a per-scanner &lt;code>timeout&lt;/code> so it does not block on a slow detector. When exposed as a FastAPI API, it supports a TTL cache to avoid rescanning repeated prompts (the case of bots with identical questions).&lt;/p>
&lt;p>&lt;strong>2. The scanner catalogue.&lt;/strong> Fifteen input scanners and twenty-one output scanners, each with its own backend model and its configurable threshold:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Input&lt;/th>
&lt;th>Output&lt;/th>
&lt;th>Dominant backend&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>PII&lt;/strong>&lt;/td>
&lt;td>Anonymize&lt;/td>
&lt;td>Deanonymize, Sensitive&lt;/td>
&lt;td>Presidio + BERT-NER&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Injection and jailbreak&lt;/strong>&lt;/td>
&lt;td>PromptInjection&lt;/td>
&lt;td>—&lt;/td>
&lt;td>Fine-tuned DeBERTa (Protect AI&amp;rsquo;s own)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Toxicity and bias&lt;/strong>&lt;/td>
&lt;td>Toxicity, Sentiment&lt;/td>
&lt;td>Toxicity, Bias, Sentiment&lt;/td>
&lt;td>Fine-tuned RoBERTa / BERT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Banned topics&lt;/strong>&lt;/td>
&lt;td>BanTopics, BanCompetitors&lt;/td>
&lt;td>BanTopics, BanCompetitors&lt;/td>
&lt;td>Zero-shot BART-MNLI classifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Substrings and regex&lt;/strong>&lt;/td>
&lt;td>BanSubstrings, Regex&lt;/td>
&lt;td>BanSubstrings, Regex&lt;/td>
&lt;td>string matching + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Secrets&lt;/strong>&lt;/td>
&lt;td>Secrets&lt;/td>
&lt;td>—&lt;/td>
&lt;td>detect-secrets (Yelp) + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structure&lt;/strong>&lt;/td>
&lt;td>TokenLimit, Language, InvisibleText, Gibberish&lt;/td>
&lt;td>JSON, Language, LanguageSame, Gibberish, ReadingTime&lt;/td>
&lt;td>tokeniser, lang-detect, JSON schema&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Code&lt;/strong>&lt;/td>
&lt;td>BanCode, Code&lt;/td>
&lt;td>BanCode, Code&lt;/td>
&lt;td>language classifier + regex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>URLs&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>MaliciousURLs, URLReachability&lt;/td>
&lt;td>block list + DNS lookup&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Answer quality&lt;/strong>&lt;/td>
&lt;td>—&lt;/td>
&lt;td>NoRefusal, Relevance, FactualConsistency&lt;/td>
&lt;td>NLI cross-encoder + cosine similarity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each scanner is imported and instantiated individually, with its own threshold:&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">llm_guard.input_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Secrets&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.vault&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Vault&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">vault&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Vault&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.5&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.85&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">threshold&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.7&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Secrets&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>&lt;strong>3. The Vault.&lt;/strong> A unique piece not found in NeMo Guardrails or Guardrails AI with the same model. It is an in-memory dictionary per session or request that stores the &lt;code>placeholder → original_value&lt;/code> mapping. The &lt;code>Anonymize&lt;/code> scanner writes it on input and the &lt;code>Deanonymize&lt;/code> scanner reads it on output. If the Vault is shared across multiple requests from the same user, the mapping persists (useful for multi-turn conversations). If it is per request, it is discarded after the answer.&lt;/p>
&lt;p>The basic Vault is a Python &lt;code>dict&lt;/code>; for distributed environments with multiple pods, it is replaced by a sticky Redis (same user → same pod) or by a custom Vault that reads from and writes to an external Redis, discarded after a TTL. This is operational, not part of the core library.&lt;/p>
&lt;h2 id="the-anonymize--llm--deanonymize-flow-in-detail">The Anonymize → LLM → Deanonymize flow in detail&lt;/h2>
&lt;p>The canonical usage pattern of LLM Guard breaks down into six exact steps:&lt;/p>
&lt;pre tabindex="0">&lt;code>1. Receive the user&amp;#39;s prompt:
&amp;#34;My name is Marta García and my IBAN is ES9121000418450200051332,
can you review the charge from 14 March?&amp;#34;
2. scan_prompt() with [Anonymize(vault), PromptInjection(), Toxicity()]
→ Anonymize redacts entities and stores them in the vault:
vault[&amp;#34;[REDACTED_PERSON_1]&amp;#34;] = &amp;#34;Marta García&amp;#34;
vault[&amp;#34;[REDACTED_IBAN_1]&amp;#34;] = &amp;#34;ES9121000418450200051332&amp;#34;
→ PromptInjection checks there is no jailbreak (there is none)
→ Toxicity checks there are no insults (there are none)
→ results_valid = {Anonymize: True, PromptInjection: True, Toxicity: True}
→ sanitized_prompt:
&amp;#34;My name is [REDACTED_PERSON_1] and my IBAN is [REDACTED_IBAN_1],
can you review the charge from 14 March?&amp;#34;
3. Call the LLM with sanitized_prompt:
→ vLLM receives the prompt with no real PII
→ generates the answer:
&amp;#34;Yes, [REDACTED_PERSON_1], I am going to review the charge on account
[REDACTED_IBAN_1]. Can you confirm the amount?&amp;#34;
4. scan_output() with [Deanonymize(vault), Toxicity(), Relevance(), Sensitive()]
→ Deanonymize replaces placeholders with values from the vault:
[REDACTED_PERSON_1] → &amp;#34;Marta García&amp;#34;
[REDACTED_IBAN_1] → &amp;#34;ES9121000418450200051332&amp;#34;
→ Toxicity checks the answer is not offensive
→ Relevance checks it answers the prompt
→ Sensitive checks no unauthorised PII appears
(in this case, the restored PII is authorised because the user
brought it themselves and the Vault signs it → the rule applies
only to new PII invented by the LLM)
→ sanitized_response:
&amp;#34;Yes, Marta García, I am going to review the charge on account
ES9121000418450200051332. Can you confirm the amount?&amp;#34;
5. Return sanitized_response to the user.
6. If the session continues, the vault persists and the next turns reuse
the same placeholders. When the session ends, the vault is discarded.
&lt;/code>&lt;/pre>&lt;p>Three details that matter operationally:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Persistent entities&lt;/strong> (&lt;code>[REDACTED_PERSON_1]&lt;/code> for &amp;ldquo;Marta García&amp;rdquo;) stay constant throughout the session. If the user mentions another person (&amp;ldquo;I spoke to Juan Pérez&amp;rdquo;), Anonymize will assign &lt;code>[REDACTED_PERSON_2]&lt;/code>. Cross-turn coherence is guaranteed by the Vault.&lt;/li>
&lt;li>&lt;strong>The LLM never sees the original data&lt;/strong> during the session. This is the key property for cases where the LLM is served from a cloud model or when the prompt is logged (Langfuse, OTel) without confidential access.&lt;/li>
&lt;li>&lt;strong>LLM Guard&amp;rsquo;s logging records the placeholders&lt;/strong>, not the original values. For audit with original values, an additional layer is needed (access to the Vault with privileged permissions), and this is by design, not by default.&lt;/li>
&lt;/ul>
&lt;h2 id="four-deployment-modes">Four deployment modes&lt;/h2>
&lt;h3 id="mode-1--in-process-python-library">Mode 1 — In-process Python library&lt;/h3>
&lt;p>The simplest: &lt;code>pip install llm-guard&lt;/code>, import the scanners in the application code, call &lt;code>scan_prompt&lt;/code>/&lt;code>scan_output&lt;/code> directly. The models load into the process. The advantage is minimum latency; the disadvantage is that each application replica loads its own models into memory.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># on the app server&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">scan_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scan_output&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.input_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Anonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.output_scanners&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Deanonymize&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Toxicity&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">OutToxicity&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Relevance&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">llm_guard.vault&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Vault&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">vault&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Vault&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">input_scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">Anonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">PromptInjection&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">Toxicity&lt;/span>&lt;span class="p">()]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">output_scanners&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">Deanonymize&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">vault&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">OutToxicity&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="n">Relevance&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"># in the request handler&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">valid_in&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">score_in&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">scan_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">input_scanners&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">user_prompt&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="nb">all&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">valid_in&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">()):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">error_response&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score_in&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">response&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">vllm_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">complete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sanitized_resp&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">valid_out&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">score_out&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">scan_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output_scanners&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sanitized_prompt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">response&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="nb">all&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">valid_out&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">values&lt;/span>&lt;span class="p">()):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">error_response&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">score_out&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">return&lt;/span> &lt;span class="n">sanitized_resp&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It fits &lt;strong>pattern A (sidecar)&lt;/strong> from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a> when the app and the sidecar share a process. And &lt;strong>pattern C (in-process)&lt;/strong> if the app is itself the inference layer.&lt;/p>
&lt;h3 id="mode-2--its-own-fastapi-api">Mode 2 — Its own FastAPI API&lt;/h3>
&lt;p>The project includes a ready-made FastAPI server (&lt;code>llm-guard-api&lt;/code>) that exposes the scanners behind two REST endpoints:&lt;/p>
&lt;pre tabindex="0">&lt;code>POST /analyze/prompt
body: {&amp;#34;prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;scanners&amp;#34;: [...] (optional)}
response: {&amp;#34;sanitized_prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;is_valid&amp;#34;: bool, &amp;#34;scanners&amp;#34;: {scanner: {is_valid, risk_score}}}
POST /analyze/output
body: {&amp;#34;prompt&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;output&amp;#34;: &amp;#34;...&amp;#34;, &amp;#34;scanners&amp;#34;: [...]}
response: analogous
&lt;/code>&lt;/pre>&lt;p>Configuration through &lt;code>config/scanners.yml&lt;/code> with environment variables (&lt;code>SCAN_FAIL_FAST&lt;/code>, &lt;code>CACHE_MAX_SIZE&lt;/code>, &lt;code>CACHE_TTL&lt;/code>, &lt;code>SCAN_PROMPT_TIMEOUT&lt;/code>…). It carries Prometheus metrics at &lt;code>/metrics&lt;/code> and OTel HTTP exporter traces by default.&lt;/p>
&lt;p>It fits &lt;strong>pattern B (centralised service behind an AI gateway)&lt;/strong> from the guardrails post.&lt;/p>
&lt;h3 id="mode-3--otel-sidecar-on-the-inference-engine-pod">Mode 3 — OTel sidecar on the inference engine pod&lt;/h3>
&lt;p>For vLLM deployments on Kubernetes, a variant of mode 2 is to deploy the LLM Guard API as a &lt;strong>sidecar container&lt;/strong> in the same vLLM pod, talking over localhost. The AI gateway in front invokes the sidecar before and after inference. The node&amp;rsquo;s OTel collector aggregates vLLM&amp;rsquo;s spans with LLM Guard&amp;rsquo;s &lt;code>gen_ai.guardrail.*&lt;/code> spans automatically because they share a &lt;code>trace_id&lt;/code> propagated via HTTP baggage.&lt;/p>
&lt;p>This fits &lt;strong>pattern A (sidecar)&lt;/strong> from the guardrails post, but with the discipline of a REST API so as not to couple languages (the AI gateway can be Envoy in C++, LLM Guard in Python).&lt;/p>
&lt;h3 id="mode-4--plugin-inside-an-ai-gateway">Mode 4 — Plugin inside an AI gateway&lt;/h3>
&lt;p>Three AI gateways support LLM Guard as a native plugin in 2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>LiteLLM Proxy&lt;/strong> (MIT, BerriAI) — &lt;code>llm_guard&lt;/code> plugin, enabled in config with &lt;code>guardrails: [&amp;quot;llm_guard&amp;quot;]&lt;/code>. It calls the API internally.&lt;/li>
&lt;li>&lt;strong>Envoy AI Gateway&lt;/strong> (CNCF, Apache 2.0) — &lt;code>ai-guardrails&lt;/code> filter with a pluggable backend pointing at the LLM Guard service.&lt;/li>
&lt;li>&lt;strong>Kong AI Gateway&lt;/strong> (Apache 2.0) — &lt;code>ai-proxy&lt;/code> plugin with a post-processor that invokes LLM Guard.&lt;/li>
&lt;/ul>
&lt;p>In all three cases, the AI gateway is the single entry point from the client app to the LLM; the gateway calls LLM Guard before and after passing to the inference engine. Advantage: zero lock-in in the application code; switching from LLM Guard to NeMo Guardrails means changing the gateway plugin, not rewriting the app. Disadvantage: the extra hop adds latency (typically 5-15 ms intra-cluster).&lt;/p>
&lt;h2 id="graphical-integration-with-langfuse-vllm-and-the-otel-stack">Graphical integration with Langfuse, vLLM and the OTel stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 460" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Integration of LLM Guard with Langfuse, vLLM and the OTel stack">
&lt;style>
.b-app{fill:#7aafff;stroke:#444;stroke-width:1.4;rx:8}
.b-gw{fill:#a8e6a3;stroke:#444;stroke-width:1.4;rx:8}
.b-lg{fill:#ffd76b;stroke:#444;stroke-width:1.4;rx:8}
.b-vllm{fill:#ff8a4c;stroke:#444;stroke-width:1.4;rx:8}
.b-otel{fill:#c8b8ff;stroke:#444;stroke-width:1.4;rx:8}
.b-langfuse{fill:#f8a8d8;stroke:#444;stroke-width:1.4;rx:8}
.b-storage{fill:#f0e8c0;stroke:#444;stroke-width:1.4;rx:8}
.bl{font:600 13px sans-serif;fill:#222}
.bs{font:400 11px sans-serif;fill:#444}
.bn{font:italic 10px sans-serif;fill:#555}
.bar{stroke:#666;stroke-width:1.6;fill:none;marker-end:url(#mb1)}
.bart{stroke:#5a5;stroke-width:1.4;fill:none;stroke-dasharray:5 3;marker-end:url(#mb2)}
&lt;/style>
&lt;defs>
&lt;marker id="mb1" 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="mb2" 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="#5a5"/>&lt;/marker>
&lt;/defs>
&lt;rect x="20" y="40" width="140" height="50" class="b-app"/>
&lt;text x="90" y="60" text-anchor="middle" class="bl">Client app&lt;/text>
&lt;text x="90" y="78" text-anchor="middle" class="bs">chatbot · backend · agent&lt;/text>
&lt;rect x="200" y="40" width="180" height="50" class="b-gw"/>
&lt;text x="290" y="60" text-anchor="middle" class="bl">AI Gateway&lt;/text>
&lt;text x="290" y="78" text-anchor="middle" class="bs">LiteLLM · Envoy AI · Kong AI&lt;/text>
&lt;rect x="430" y="20" width="160" height="40" class="b-lg"/>
&lt;text x="510" y="38" text-anchor="middle" class="bl">LLM Guard API&lt;/text>
&lt;text x="510" y="54" text-anchor="middle" class="bs">scan_prompt + scan_output&lt;/text>
&lt;rect x="430" y="70" width="160" height="40" class="b-vllm"/>
&lt;text x="510" y="88" text-anchor="middle" class="bl">vLLM&lt;/text>
&lt;text x="510" y="104" text-anchor="middle" class="bs">inference engine + adapter&lt;/text>
&lt;rect x="640" y="40" width="160" height="50" class="b-storage"/>
&lt;text x="720" y="60" text-anchor="middle" class="bl">Vault Redis&lt;/text>
&lt;text x="720" y="78" text-anchor="middle" class="bs">placeholder→PII mapping&lt;/text>
&lt;path class="bar" d="M160,65 L200,65"/>
&lt;path class="bar" d="M380,55 L430,40"/>
&lt;path class="bar" d="M380,75 L430,90"/>
&lt;path class="bar" d="M510,60 L640,65"/>
&lt;text x="170" y="55" class="bn">1: prompt&lt;/text>
&lt;text x="390" y="35" class="bn">2: pre-scan&lt;/text>
&lt;text x="390" y="105" class="bn">3: inference&lt;/text>
&lt;text x="555" y="55" class="bn">vault R/W&lt;/text>
&lt;rect x="20" y="180" width="240" height="80" class="b-otel"/>
&lt;text x="140" y="202" text-anchor="middle" class="bl">OTel Collector (DaemonSet)&lt;/text>
&lt;text x="140" y="220" text-anchor="middle" class="bs">receives gen_ai.* and&lt;/text>
&lt;text x="140" y="234" text-anchor="middle" class="bs">gen_ai.guardrail.* spans from:&lt;/text>
&lt;text x="140" y="250" text-anchor="middle" class="bs">vLLM, LLM Guard, AI Gateway&lt;/text>
&lt;path class="bart" d="M510,110 Q260,140 140,178"/>
&lt;path class="bart" d="M510,60 Q330,140 200,178"/>
&lt;path class="bart" d="M290,90 Q230,140 140,178"/>
&lt;text x="320" y="135" class="bn">OTel HTTP spans&lt;/text>
&lt;rect x="300" y="180" width="200" height="80" class="b-langfuse"/>
&lt;text x="400" y="202" text-anchor="middle" class="bl">Langfuse&lt;/text>
&lt;text x="400" y="220" text-anchor="middle" class="bs">/api/public/otel ingestion&lt;/text>
&lt;text x="400" y="236" text-anchor="middle" class="bs">+ /api/public/scores&lt;/text>
&lt;text x="400" y="252" text-anchor="middle" class="bs">+ datasets + sessions&lt;/text>
&lt;path class="bar" d="M260,220 L300,220"/>
&lt;text x="270" y="215" class="bn">OTLP&lt;/text>
&lt;rect x="540" y="180" width="120" height="40" class="b-otel"/>
&lt;text x="600" y="200" text-anchor="middle" class="bl">Tempo / Jaeger&lt;/text>
&lt;text x="600" y="216" text-anchor="middle" class="bs">trace storage&lt;/text>
&lt;rect x="540" y="225" width="120" height="40" class="b-otel"/>
&lt;text x="600" y="245" text-anchor="middle" class="bl">VictoriaMetrics&lt;/text>
&lt;text x="600" y="261" text-anchor="middle" class="bs">Prom metrics&lt;/text>
&lt;path class="bar" d="M260,210 L540,200"/>
&lt;path class="bar" d="M260,235 L540,240"/>
&lt;rect x="700" y="180" width="100" height="80" class="b-storage"/>
&lt;text x="750" y="202" text-anchor="middle" class="bl">Grafana&lt;/text>
&lt;text x="750" y="220" text-anchor="middle" class="bs">datasource&lt;/text>
&lt;text x="750" y="234" text-anchor="middle" class="bs">Tempo + VM&lt;/text>
&lt;text x="750" y="252" text-anchor="middle" class="bs">+ Langfuse&lt;/text>
&lt;path class="bar" d="M660,220 L700,220"/>
&lt;rect x="20" y="320" width="780" height="50" class="b-gw"/>
&lt;text x="410" y="340" text-anchor="middle" class="bl">Langfuse scoring plane: the AI Gateway posts langfuse.score(trace_id, name="guardrail.PromptInjection", value=risk_score)&lt;/text>
&lt;text x="410" y="356" text-anchor="middle" class="bs">for each scanner run; that lets Langfuse build "% blocks per category" dashboards and time series&lt;/text>
&lt;path class="bar" d="M290,90 Q290,290 400,320"/>
&lt;text x="305" y="200" class="bn">HTTP scores&lt;/text>
&lt;text x="410" y="400" text-anchor="middle" class="bn">Three telemetry planes mix: traces (OTel → Tempo + Langfuse), metrics (Prometheus → VictoriaMetrics), scores (Langfuse SDK).&lt;/text>
&lt;text x="410" y="418" text-anchor="middle" class="bn">Grafana joins them by trace_id; Langfuse joins them by session_id + propagated trace_id.&lt;/text>
&lt;text x="410" y="438" text-anchor="middle" class="bn">The Vault Redis has its own data plane and is NOT exported to observability — the original PII never leaves it.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The &lt;strong>three integration routes with Langfuse&lt;/strong> that matter operationally:&lt;/p>
&lt;p>&lt;strong>Route A — LLM Guard&amp;rsquo;s OTel HTTP exporter.&lt;/strong> LLM Guard has a native OTel HTTP exporter. By setting &lt;code>OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://langfuse.cluster/api/public/otel&lt;/code>, the &lt;code>gen_ai.guardrail.*&lt;/code> spans emitted by each scanner arrive directly at Langfuse and appear as child spans of the main LLM span (provided the &lt;code>trace_id&lt;/code> is propagated via HTTP baggage from the AI gateway). This is the canonical route in 2026.&lt;/p>
&lt;p>&lt;strong>Route B — Langfuse scoring API from the AI gateway.&lt;/strong> On receiving LLM Guard&amp;rsquo;s response with the per-scanner &lt;code>risk_score&lt;/code>, the AI gateway (LiteLLM, Envoy AI, Kong AI) issues a &lt;code>langfuse.score(trace_id, name=&amp;quot;guardrail.PromptInjection&amp;quot;, value=0.87, comment=&amp;quot;blocked&amp;quot;)&lt;/code> call for each scanner. In Langfuse it appears as scores attached to the same trace as the inference. It enables &amp;ldquo;blocks per category&amp;rdquo; dashboards and per-scanner time series. It is &lt;strong>complementary&lt;/strong> to route A: A brings the spans, B brings the numeric score that is easy to aggregate in SQL.&lt;/p>
&lt;p>&lt;strong>Route C — Langfuse sessions + Vault metadata.&lt;/strong> In conversational mode, the AI gateway propagates &lt;code>langfuse_session_id&lt;/code> to the Vault as its key. When a user has a multi-turn session, Langfuse shows the complete trace of the session, with the placeholders reused turn after turn. The original PII still never travels to Langfuse, only the placeholders and their categories.&lt;/p>
&lt;p>The node&amp;rsquo;s &lt;strong>OTel Collector&lt;/strong> is the glue: it receives spans from vLLM (via OpenLLMetry or native instrumentation), from LLM Guard (via its OTel exporter) and from the AI gateway (standard HTTP instrumentation), &lt;strong>joins them by trace_id&lt;/strong>, and sends them in parallel to Langfuse (via OTLP HTTP) and to Tempo/Jaeger. LLM Guard&amp;rsquo;s Prometheus metrics go to VictoriaMetrics via normal scraping. Grafana offers the unified view for cross-trace investigation; Langfuse offers the LLM-centric view with sessions and scores. The &lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">OTel GenAI tracing post&lt;/a> details the complete mechanics of the Collector.&lt;/p>
&lt;h2 id="the-mathematics-that-matter">The mathematics that matter&lt;/h2>
&lt;h3 id="latency-per-scanner--the-real-numbers">Latency per scanner — the real numbers&lt;/h3>
&lt;p>The project publishes reproducible benchmarks. For the Anonymize scanner (input length 317 chars, batch 5), the reference data are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Platform&lt;/th>
&lt;th>Backend&lt;/th>
&lt;th>Avg latency&lt;/th>
&lt;th>p99&lt;/th>
&lt;th>QPS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>AWS m5.xlarge (CPU)&lt;/td>
&lt;td>Transformers&lt;/td>
&lt;td>177 ms&lt;/td>
&lt;td>326 ms&lt;/td>
&lt;td>1,789&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS m5.xlarge (CPU)&lt;/td>
&lt;td>&lt;strong>ONNX runtime&lt;/strong>&lt;/td>
&lt;td>&lt;strong>128 ms&lt;/strong>&lt;/td>
&lt;td>180 ms&lt;/td>
&lt;td>2,464&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS r6a.xlarge (AMD CPU)&lt;/td>
&lt;td>Transformers&lt;/td>
&lt;td>244 ms&lt;/td>
&lt;td>284 ms&lt;/td>
&lt;td>1,298&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS g5.xlarge (NVIDIA A10G)&lt;/td>
&lt;td>Transformers FP16&lt;/td>
&lt;td>125 ms&lt;/td>
&lt;td>498 ms&lt;/td>
&lt;td>2,532&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AWS g5.xlarge (A10G)&lt;/td>
&lt;td>&lt;strong>ONNX + GPU&lt;/strong>&lt;/td>
&lt;td>&lt;strong>38 ms&lt;/strong>&lt;/td>
&lt;td>99 ms&lt;/td>
&lt;td>8,317&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>ONNX always wins.&lt;/strong> Even on CPU, ONNX brings the average down from 177 to 128 ms (a factor of 1.4×). On GPU with ONNX, it drops from 177 to 38 ms (a factor of 4.6×). The practical rule: &lt;strong>always export the scanner&amp;rsquo;s model to ONNX before production&lt;/strong>. The official SaaS preview uses it by default.&lt;/li>
&lt;li>&lt;strong>A GPU without ONNX does not perform as well as you might expect.&lt;/strong> An A10G without ONNX (125 ms) is comparable to an m5.xlarge with ONNX (128 ms). The GPU alone does not compensate if the inference graph is not optimised. The relevant pairing is ONNX + GPU.&lt;/li>
&lt;li>&lt;strong>p99 latency without ONNX explodes.&lt;/strong> On GPU without ONNX, the p99 of 498 ms triples the average of 125 ms — queues and batching produce high tail latencies. With ONNX, the p99/avg ratio drops to 2.6× (99/38), far more predictable.&lt;/li>
&lt;/ol>
&lt;p>For a guardrail layer with five scanners run sequentially (Anonymize, PromptInjection, Toxicity, Secrets, BanTopics), the sum of the p99s is what determines the budget for line 1 (input). Five scanners at ~100 ms p99 each = 500 ms accumulated p99, outside the budget for interactive chat. With ONNX we get down to ~50 ms each = 250 ms p99, which is manageable. &lt;strong>With &lt;code>fail_fast=True&lt;/code>&lt;/strong>, the expected time is lower (the most likely case is that the cheap ones pass and the expensive ones only fail if they run at all).&lt;/p>
&lt;p>For a finer calculation, the expected latency of the pipeline with &lt;code>fail_fast&lt;/code> is:&lt;/p>
\[
\mathbb{E}[L] = \sum_{i=1}^{N} L_i \cdot \prod_{j=1}^{i-1} p_j
\]
&lt;p>where \(L_i\) is the latency of scanner \(i\) and \(p_j\) the probability that scanner \(j\) returns valid. On well-behaved traffic (most prompts pass every scanner), \(\prod p_j \approx 1\) and the formula collapses to the direct sum. On adversarial traffic, the faster scanners at the start of the pipeline cut in earlier and the expected latency drops sharply.&lt;/p>
&lt;h3 id="computational-cost-per-scanner">Computational cost per scanner&lt;/h3>
&lt;p>The size of the backend model determines the cost and the possibility of running on CPU vs requiring a GPU:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scanner&lt;/th>
&lt;th>Typical backend model&lt;/th>
&lt;th>Parameters&lt;/th>
&lt;th>VRAM FP16 / ONNX-INT8&lt;/th>
&lt;th>CPU viable&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Anonymize (BERT-NER)&lt;/td>
&lt;td>dslim/bert-base-NER&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes (with ONNX)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Anonymize (BERT-large)&lt;/td>
&lt;td>dslim/bert-large-NER&lt;/td>
&lt;td>335 M&lt;/td>
&lt;td>670 MB / 170 MB&lt;/td>
&lt;td>Yes but slow (~500 ms CPU)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PromptInjection&lt;/td>
&lt;td>Fine-tuned DeBERTa-v3-base&lt;/td>
&lt;td>184 M&lt;/td>
&lt;td>370 MB / 90 MB&lt;/td>
&lt;td>Yes (with ONNX)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Toxicity&lt;/td>
&lt;td>unitary/toxic-bert&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sentiment&lt;/td>
&lt;td>distilbert-sst2&lt;/td>
&lt;td>67 M&lt;/td>
&lt;td>130 MB / 35 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Gibberish&lt;/td>
&lt;td>small distilbert&lt;/td>
&lt;td>67 M&lt;/td>
&lt;td>130 MB / 35 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BanTopics&lt;/td>
&lt;td>BART-MNLI zero-shot&lt;/td>
&lt;td>407 M&lt;/td>
&lt;td>815 MB / 200 MB&lt;/td>
&lt;td>Slow on CPU (~400 ms)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Bias (output)&lt;/td>
&lt;td>RoBERTa-bias&lt;/td>
&lt;td>125 M&lt;/td>
&lt;td>250 MB / 65 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FactualConsistency&lt;/td>
&lt;td>cross-encoder/nli-deberta&lt;/td>
&lt;td>184 M&lt;/td>
&lt;td>370 MB / 90 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Relevance&lt;/td>
&lt;td>sentence-transformers&lt;/td>
&lt;td>110 M&lt;/td>
&lt;td>220 MB / 55 MB&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TokenLimit, Regex, JSON, BanSubstrings, Secrets&lt;/td>
&lt;td>(no model)&lt;/td>
&lt;td>—&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Trivial&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Sensible on-premise pattern&lt;/strong>: model-free scanners (TokenLimit, Regex, BanSubstrings, Secrets) run on CPU without blinking. Anonymize, PromptInjection, Toxicity, Sentiment and Relevance run comfortably on CPU with ONNX-INT8 at ~50-150 ms p99. BanTopics and the ones based on large cross-encoders (FactualConsistency) are the candidates to live on a shared GPU if you want p99 &amp;lt; 100 ms.&lt;/p>
&lt;h3 id="api-throughput-on-a-cluster">API throughput on a cluster&lt;/h3>
&lt;p>One instance of the FastAPI API with 4 Uvicorn workers on a node with 8 vCPUs reaches ~600-1,200 RPS over a typical 5-scanner pipeline on CPU + ONNX. To scale:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Horizontally&lt;/strong>: replicate pods behind a ClusterIP Service — linear scaling, because the scanners are stateless (except the Vault, which is per session and is externalised to Redis if you want it sticky or shared).&lt;/li>
&lt;li>&lt;strong>Vertically with GPU&lt;/strong>: 1 H100 serves ~5,000-10,000 RPS with all the scanners in ONNX-GPU. It is overkill for most deployments except multi-tenant ones with thousands of sustained QPS.&lt;/li>
&lt;/ul>
&lt;p>The practical rule from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a> (1 guardrail GPU per 4-8 LLM GPUs) holds here: with a 4×H100 SXM cluster serving Llama 70B at TP=4, an L4 or RTX 4090 dedicated to the LLM Guard service covers the load.&lt;/p>
&lt;h2 id="comparison-with-nemo-guardrails-and-guardrails-ai">Comparison with NeMo Guardrails and Guardrails AI&lt;/h2>
&lt;p>The three tools solve the same problem from three different architectural models. The choice between them is not about quality, since all three are mature, it is about &lt;strong>fit with the rest of the stack&lt;/strong>:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>LLM Guard&lt;/th>
&lt;th>NeMo Guardrails&lt;/th>
&lt;th>Guardrails AI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Conceptual model&lt;/strong>&lt;/td>
&lt;td>Pipeline of compact scanners&lt;/td>
&lt;td>Declarative Colang graph (conversational flow)&lt;/td>
&lt;td>JSON contract-style validators&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Dominant detection&lt;/strong>&lt;/td>
&lt;td>Specialised ML models (BERT, DeBERTa) per category&lt;/td>
&lt;td>Rules + LLM-as-judge&lt;/td>
&lt;td>Heuristic validators + external LLM-as-judge&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PII workflow&lt;/strong>&lt;/td>
&lt;td>Anonymize + Vault + Deanonymize&lt;/td>
&lt;td>Via integrated Presidio, no built-in Vault&lt;/td>
&lt;td>PII validators, no automatic restitution&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Licence&lt;/strong>&lt;/td>
&lt;td>MIT&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0 (+ paid Hub)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Language&lt;/strong>&lt;/td>
&lt;td>Python&lt;/td>
&lt;td>Python + Colang DSL&lt;/td>
&lt;td>Python&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>API maturity&lt;/strong>&lt;/td>
&lt;td>Built-in FastAPI API, built-in OTel&lt;/td>
&lt;td>Built-in FastAPI server, partial OTel&lt;/td>
&lt;td>External API server&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cluster deployment&lt;/strong>&lt;/td>
&lt;td>Lib + API + sidecar + gateway plugins&lt;/td>
&lt;td>Lib + server&lt;/td>
&lt;td>Lib + server + SaaS Hub&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical latency (5 ONNX-GPU scanners)&lt;/strong>&lt;/td>
&lt;td>50-200 ms&lt;/td>
&lt;td>100-500 ms (more if there is an LLM judge)&lt;/td>
&lt;td>100-300 ms (depends on the validator)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When it shines&lt;/strong>&lt;/td>
&lt;td>Apps with heavy PII, multi-tenant with sessions, GDPR/HIPAA requirements&lt;/td>
&lt;td>Conversational systems with defined flows, agents with a dialogue policy&lt;/td>
&lt;td>Apps with strict JSON contracts, structured output with additional validation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When it does not fit&lt;/strong>&lt;/td>
&lt;td>If you need a declarative dialogue policy&lt;/td>
&lt;td>If you want compact detectors with no LLM judge&lt;/td>
&lt;td>If you want an automatic Vault and Deanonymize&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three are &lt;strong>complementary in large deployments&lt;/strong>. A mature pattern in 2026 is:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>NeMo Guardrails&lt;/strong> orchestrates the dialogue flow (which tools the agent can invoke, in what order, with what cooldowns).&lt;/li>
&lt;li>&lt;strong>LLM Guard&lt;/strong> occupies the PII line + compact scanners on input and output, with its Vault doing the dirty work of anonymisation.&lt;/li>
&lt;li>&lt;strong>Guardrails AI&lt;/strong> validates structured outputs (JSON Schema, function calling) with its validators.&lt;/li>
&lt;/ul>
&lt;p>The separation of responsibilities avoids overlap and allows pieces to be swapped without rewriting everything. All three expose a FastAPI API and emit OTel spans; the AI gateway orchestrates them sequentially.&lt;/p>
&lt;h2 id="applied-to-on-premise-hardware">Applied to on-premise hardware&lt;/h2>
&lt;h3 id="on-the-rtx-4090-24-gb">On the RTX 4090 (24 GB)&lt;/h3>
&lt;p>A 4090 dedicated to the LLM Guard service pod comfortably serves the complete pipeline in medium-scale production:&lt;/p>
&lt;ul>
&lt;li>Anonymize (BERT-NER ONNX-INT8): ~50 MB VRAM.&lt;/li>
&lt;li>PromptInjection (DeBERTa ONNX-INT8): ~90 MB.&lt;/li>
&lt;li>Toxicity, Sentiment, Gibberish: ~150 MB total.&lt;/li>
&lt;li>BanTopics (BART-MNLI ONNX-INT8): ~200 MB.&lt;/li>
&lt;li>Bias, Relevance, FactualConsistency (output): ~250 MB total.&lt;/li>
&lt;/ul>
&lt;p>Total ~750 MB. The rest of the VRAM is idle or usable for aggressive batching. Sustained throughput of 3,000-6,000 RPS over the complete pipeline. For deployments with &amp;lt; 500 sustained RPS, the 4090 is under-used and can be shared with another workload (RAG embeddings, BGE reranker).&lt;/p>
&lt;h3 id="on-the-4h100-sxm-cluster-320-gb-total-nvlink">On the 4×H100 SXM cluster (320 GB total, NVLink)&lt;/h3>
&lt;p>There is capacity to spare by an order of magnitude. Sensible pattern:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>3 H100s&lt;/strong> serving the main LLM at TP=3 (Llama 70B FP8).&lt;/li>
&lt;li>&lt;strong>1 H100 split into MIG instances&lt;/strong> (1g.10gb or similar) — one slice for LLM Guard (~10 GB MIG is more than enough), another for the reranker, another for embeddings.&lt;/li>
&lt;/ul>
&lt;p>Aggregate throughput for LLM Guard at that scale: 15,000-30,000 RPS. Plenty for a large multi-tenant setup with long sessions.&lt;/p>
&lt;h2 id="the-specific-operational-pitfalls">The specific operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Pitfall 1 — Vault with no TTL.&lt;/strong> The Vault grows without limit if it is not cleaned. In in-process lib mode per request there is no problem (the object is destroyed). In centralised service mode with Redis, &lt;strong>the TTL is missing&lt;/strong> and Redis fills up. A silent trap discovered when the Redis pod gets OOM-killed in production after six weeks.&lt;/p>
&lt;p>&lt;strong>Pitfall 2 — Vault not shared between pods + AI gateway with no sticky session.&lt;/strong> If the AI gateway distributes round-robin across multiple LLM Guard pods, one pod&amp;rsquo;s local Vault knows nothing of the mapping created by another. Result: on turn 2 of a session, Deanonymize does not find the placeholders from turn 1 and leaves a literal &lt;code>[REDACTED_PERSON_1]&lt;/code> in the answer. Solution: a shared Redis Vault &lt;strong>or&lt;/strong> a sticky session by user_id.&lt;/p>
&lt;p>&lt;strong>Pitfall 3 — Models not exported to ONNX in production.&lt;/strong> It is deployed with the default config (Transformers) and latency is 3-5× worse than the benchmarks report. The team assumes LLM Guard &amp;ldquo;is slow&amp;rdquo;. The solution is to export to ONNX (built into the project) and configure &lt;code>recognizer_conf&lt;/code> with the path to the model&amp;rsquo;s &lt;code>.onnx&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Pitfall 4 — &lt;code>fail_fast=False&lt;/code> with many scanners.&lt;/strong> Without &lt;code>fail_fast&lt;/code>, every scanner always runs, even if the first one already blocked. Latency is 3-5× worse on adversarial traffic. For production, barring an explicit reason (wanting complete per-scanner metrics even when blocking), &lt;code>fail_fast=True&lt;/code> is the reasonable default.&lt;/p>
&lt;p>&lt;strong>Pitfall 5 — Infinite &lt;code>cache_ttl&lt;/code> + prompts with variable PII.&lt;/strong> If the API&amp;rsquo;s cache stores the &lt;code>sanitized_prompt&lt;/code> indefinitely, two different sessions with the same prompt structure but different PII can collide if the cache key does not include the Vault hash. You have to verify that the cache key includes either the complete content (without PII) or a hash of the original prompt.&lt;/p>
&lt;p>&lt;strong>Pitfall 6 — Structured logs with original PII.&lt;/strong> LLM Guard&amp;rsquo;s JSON stdout logs record only placeholders by default. But if custom hooks are added for debugging, it is easy to leak the original PII to the log. A regulatory audit (GDPR, ENS) detects this and it is a breach. Discipline: never add hooks that read from the Vault without explicit permission.&lt;/p>
&lt;p>&lt;strong>Pitfall 7 — &lt;code>scan_output&lt;/code> without the original &lt;code>prompt&lt;/code>.&lt;/strong> The &lt;code>scan_output&lt;/code> method expects (&lt;code>prompt&lt;/code>, &lt;code>output&lt;/code>) for validators that compare both (Relevance, LanguageSame, FactualConsistency). If only the output is passed, those scanners fail silently or return &lt;code>is_valid=True&lt;/code> by default. You have to keep the &lt;code>sanitized_prompt&lt;/code> in the AI gateway and pass it to scan_output.&lt;/p>
&lt;h2 id="when-to-choose-llm-guard-and-when-not-to">When to choose LLM Guard (and when not to)&lt;/h2>
&lt;p>&lt;strong>Choose LLM Guard when&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The requirement for &lt;strong>PII anonymisation with automatic restitution&lt;/strong> is on the list. It is the number one reason to use it. Banking, healthcare, legal advice, HR — any case with heavy PII that must not reach the LLM even when it is local.&lt;/li>
&lt;li>You want a &lt;strong>Pythonic pipeline&lt;/strong> with no new DSL. If the team is pure Python and prefers composing scanners as objects to learning Colang.&lt;/li>
&lt;li>The stack already has an &lt;strong>AI gateway&lt;/strong> (LiteLLM, Envoy AI, Kong AI) and it integrates as a plugin without touching the app.&lt;/li>
&lt;li>You need &lt;strong>built-in OTel and Prometheus&lt;/strong> with no additional instrumentation.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Do not choose LLM Guard when&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>The system is a &lt;strong>conversational agent with complex dialogue flows&lt;/strong> (policies, fallbacks, escalation to a human). There NeMo Guardrails with Colang is structurally better.&lt;/li>
&lt;li>The safety layer reduces to &lt;strong>validating structured outputs&lt;/strong> (JSON, function calling). Guardrails AI with its validators is more natural.&lt;/li>
&lt;li>Your &lt;strong>latency budget is ultra-aggressive&lt;/strong> (&amp;lt; 30 ms for the whole layer). You will have to cut scanners and accept lower coverage; perhaps a single PromptGuard 2 + Presidio in a sidecar (the pattern from the &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">guardrails post&lt;/a>) is simpler.&lt;/li>
&lt;li>You do not want to carry &lt;strong>the operational weight of a distributed Vault&lt;/strong> (Redis, TTL, sticky session). For small systems with no strong PII requirement, it is over-engineered.&lt;/li>
&lt;/ul>
&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>Custom scanners&lt;/strong>: how to write your own scanner when nothing in the catalogue fits (a complex domain regex, your own fine-tuned classifier). The project supports custom scanners by inheriting from &lt;code>InputScanner&lt;/code> / &lt;code>OutputScanner&lt;/code> with three methods.&lt;/li>
&lt;li>&lt;strong>Integration with SLSA / supply chain&lt;/strong>: how to sign the LLM Guard container with cosign, SLSA attestations, and verification in the cluster before admitting it. An operational &lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">supply chain security&lt;/a> topic (OWASP LLM03).&lt;/li>
&lt;li>&lt;strong>Red teaming against LLM Guard&lt;/strong>: known techniques that evade detectors (homoglyphs, Unicode confusables, base64 encoding inside the prompt). The project publishes an adversarial test suite for doing your own benchmarking. How it is set up as a continuous gate in CI.&lt;/li>
&lt;li>&lt;strong>Comparative benchmark against Bedrock Guardrails and Azure AI Content Safety&lt;/strong>: F1 per category over real traffic across three different deployments. The &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers post&lt;/a> has the strategic comparison; the technical detection comparison is still missing.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>LLM Guard (Protect AI)&lt;/strong>: &lt;a href="https://llm-guard.com">https://llm-guard.com&lt;/a> — official documentation, scanner list, benchmarks.&lt;/li>
&lt;li>&lt;strong>Repository&lt;/strong>: &lt;a href="https://github.com/protectai/llm-guard">https://github.com/protectai/llm-guard&lt;/a>.&lt;/li>
&lt;li>&lt;strong>LLM Guard API&lt;/strong>: &lt;a href="https://github.com/protectai/llm-guard/tree/main/llm_guard_api">https://github.com/protectai/llm-guard/tree/main/llm_guard_api&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Presidio (Microsoft)&lt;/strong>: &lt;a href="https://microsoft.github.io/presidio/">https://microsoft.github.io/presidio/&lt;/a> — basis of the Anonymize scanner.&lt;/li>
&lt;li>&lt;strong>detect-secrets (Yelp)&lt;/strong>: &lt;a href="https://github.com/Yelp/detect-secrets">https://github.com/Yelp/detect-secrets&lt;/a> — basis of the Secrets scanner.&lt;/li>
&lt;li>&lt;strong>Langfuse OTel ingestion&lt;/strong>: &lt;a href="https://langfuse.com/docs/opentelemetry/get-started">https://langfuse.com/docs/opentelemetry/get-started&lt;/a>.&lt;/li>
&lt;li>&lt;strong>LiteLLM guardrails&lt;/strong>: &lt;a href="https://docs.litellm.ai/docs/proxy/guardrails">https://docs.litellm.ai/docs/proxy/guardrails&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Envoy AI Gateway&lt;/strong>: &lt;a href="https://aigateway.envoyproxy.io">https://aigateway.envoyproxy.io&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Kong AI Gateway&lt;/strong>: &lt;a href="https://docs.konghq.com/hub/kong-inc/ai-prompt-guard/">https://docs.konghq.com/hub/kong-inc/ai-prompt-guard/&lt;/a>.&lt;/li>
&lt;li>&lt;strong>OWASP Top 10 for LLM Applications 2025&lt;/strong>: &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">https://owasp.org/www-project-top-10-for-large-language-model-applications/&lt;/a>.&lt;/li>
&lt;li>&lt;strong>ONNX Runtime&lt;/strong>: &lt;a href="https://onnxruntime.ai">https://onnxruntime.ai&lt;/a> — exporting HF models to ONNX for acceleration.&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 of the sovereign LLM stack: defence in depth&lt;/a> — guardrails as one more layer of the stack&amp;rsquo;s defence in depth.&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: the four lines of defence&lt;/a> — the framework that places LLM Guard as one of the tools within the layer. That post explains the four lines (input, retrieval, tool, output), the OWASP LLM Top 10 and compares NeMo Guardrails, Llama Guard 4, ShieldGemma, Granite Guardian, PromptGuard 2 and LLM Guard from a bird&amp;rsquo;s-eye view.&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> — an extended write-up of LLM Guard among the rest of the OSS tools by pipeline stage.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation: the active librarian&lt;/a> — prevention at ingest shares the Presidio PII detector with LLM Guard; the Vault pattern is the new piece added at runtime.&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 plane over which LLM Guard emits the &lt;code>gen_ai.guardrail.*&lt;/code> spans that Langfuse and Tempo consume.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning with Langfuse and MLflow&lt;/a> — the &lt;code>prompt_id+version&lt;/code> travels as a span attribute even when the prompt content is anonymised; it complements this post&amp;rsquo;s PII shielding.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals for LLMs: the layer after tracing&lt;/a> — LLM Guard&amp;rsquo;s offline counterpart. When a scanner reports a high FP rate over real traffic, the offline exercise against an annotated golden set identifies whether to tune the threshold or change the backend model.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">Retrain: closing the feedback → dataset → adapter loop&lt;/a> — the HIGH severity incidents that LLM Guard emits with &lt;code>risk_score &amp;gt; threshold&lt;/code> feed the incident-driven retrain loop.&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 in LLMOps&lt;/a> — the OSS column of the &amp;ldquo;Guardrails&amp;rdquo; row (NeMo + Presidio + Llama Guard 4 + &lt;strong>LLM Guard&lt;/strong>) against Bedrock Guardrails, Azure AI Content Safety and Vertex Model Armor.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: function calling and constrained decoding&lt;/a> — LLM Guard&amp;rsquo;s JSON scanner validates the output structure as a safety net when the inference engine has already done constrained decoding.&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 master map where Guardrails (this post included) is the online counterpart of the Eval stage.&lt;/p>
&lt;/li>
&lt;/ul></description></item><item><title>The GPU cluster as a platform: turning a shared cluster into a multi-tenant service your teams can consume</title><link>https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/</link><pubDate>Thu, 21 May 2026 07:15:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Having an expensive GPU cluster and many different workloads that want to use it is not an infrastructure problem: it is an internal product problem. What separates &amp;ldquo;we have a cluster&amp;rdquo; from &amp;ldquo;we have an inference platform&amp;rdquo; are four layers the market consolidated in 2026: a gateway layer that centralises authentication, routing and policies (LiteLLM, Portkey, Kong AI Gateway); a GPU isolation model appropriate to the tenants&amp;rsquo; profile (MIG hardware isolation for untrusted multi-tenant, MPS for processes from the same team, time-slicing only for dev); a quota and rate limiting system with budgets per tenant/team/project (LiteLLM does this in its core at team/user/api-key level with descriptive 429s); and a multi-tenant observability plane that enables real cost attribution (showback as an intermediate step, chargeback as the destination), per-tenant tracing and differentiated dashboards. Applied to a typical mid-scale GPU cluster (one node with 4-8 H100 SXM and NVLink, a common place to start in production), this translates into concrete decisions: with ~640 GB of aggregate VRAM across 8 GPUs and two typical production models (a large 70B+ model with tensor parallel and a replicated mid-sized model), the cluster serves between &lt;strong>tens and low hundreds of simultaneous sessions&lt;/strong> depending on the mix; GPU isolation is usually resolved with MIG on smaller workloads and per-model dedication on large ones; and the platform&amp;rsquo;s success metric is effective utilisation, which in typical production sits at 30-40% and where the reasonable optimisation target is to push it to 60-70% without degrading SLAs.&lt;/p>
&lt;blockquote>
&lt;p>This is the fifth post in the MLOps for LLMs series. It is the most operationally oriented one and cuts across several pipeline stages (Deploy + Observe plus cross-cutting concerns). The &amp;ldquo;you are here&amp;rdquo; marker flags the two active stages because the notion of a multi-tenant platform does not live in just one.&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy--observe-the-cluster-as-a-product">You are here: Deploy + Observe (the cluster as a product)&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 + Observe">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7adb7a;stroke-width:3}.active2{fill:#c47aff;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(#mt1)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#mt1)}&lt;/style>
&lt;defs>&lt;marker id="mt1" 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 + OBSERVE · the cluster as a platform with tenants&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 active2"/>&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-question-that-changes-the-framing">The question that changes the framing&lt;/h2>
&lt;p>When a platform team buys expensive GPU hardware and starts building inference, the first version is almost always single-purpose: one model, one client, one target latency. It works. When the second team turns up asking for the same resource, that single purpose becomes internal politics: how many replicas do we give them? What do we do if the SLAs clash? Who pays for the tokens of team B&amp;rsquo;s experiment? And when the third one arrives, what was an SRE project becomes an internal product project.&lt;/p>
&lt;p>The distinction is not technical, it is one of framing. A cluster is infrastructure. A platform is a service with customers, contracts and success metrics. The change of framing implies:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Identifiable customers&lt;/strong> (tenants), not anonymous users.&lt;/li>
&lt;li>&lt;strong>Contracts&lt;/strong> (latency SLA, guaranteed throughput, available models), not &amp;ldquo;whatever we manage&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Success metrics&lt;/strong> that are not technical but product ones: adoption, satisfaction, cost per query per tenant, time to the first &amp;ldquo;hello world&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;p>This post walks through how that change of framing is carried out. It lands it on a mid-scale cluster (4-8 H100 SXM with NVLink in a single node), the usual configuration when starting with serious LLM inference; but the principles generalise to any topology, from a single node with two GPUs to multi-node clusters with InfiniBand.&lt;/p>
&lt;h2 id="the-four-layers-of-a-multi-tenant-inference-platform">The four layers of a multi-tenant inference platform&lt;/h2>
&lt;p>The canonical architecture that settled in 2026 has four layers that any serious multi-tenant platform implements, from the outside in:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 410" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Four layers of a multi-tenant platform">
&lt;style>.title{font:700 13px sans-serif;fill:#222}.lbl{font:600 12px sans-serif;fill:#222}.sm{font:11px sans-serif;fill:#555}.tiny{font:10px sans-serif;fill:#666}.layer{stroke:#444;stroke-width:1.5;rx:6}.gw{fill:#ffe9d6}.pol{fill:#d6eaff}.iso{fill:#d9f5d6}.obs{fill:#e9d6f5}.cluster{stroke:#666;stroke-dasharray:4 2;fill:none}.tenant{stroke:#888;stroke-width:1.4;fill:#fffce6;rx:4}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#pm1)}&lt;/style>
&lt;defs>&lt;marker id="pm1" 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="22" text-anchor="middle" class="title">The four layers of the multi-tenant platform&lt;/text>
&lt;rect x="40" y="50" width="100" height="40" class="tenant"/>&lt;text x="90" y="68" text-anchor="middle" class="sm">Tenant A&lt;/text>&lt;text x="90" y="82" text-anchor="middle" class="tiny">support chat&lt;/text>
&lt;rect x="160" y="50" width="100" height="40" class="tenant"/>&lt;text x="210" y="68" text-anchor="middle" class="sm">Tenant B&lt;/text>&lt;text x="210" y="82" text-anchor="middle" class="tiny">legal RAG&lt;/text>
&lt;rect x="280" y="50" width="100" height="40" class="tenant"/>&lt;text x="330" y="68" text-anchor="middle" class="sm">Tenant C&lt;/text>&lt;text x="330" y="82" text-anchor="middle" class="tiny">code agent&lt;/text>
&lt;rect x="400" y="50" width="100" height="40" class="tenant"/>&lt;text x="450" y="68" text-anchor="middle" class="sm">Tenant D&lt;/text>&lt;text x="450" y="82" text-anchor="middle" class="tiny">data extr.&lt;/text>
&lt;rect x="520" y="50" width="100" height="40" class="tenant"/>&lt;text x="570" y="68" text-anchor="middle" class="sm">Tenant E&lt;/text>&lt;text x="570" y="82" text-anchor="middle" class="tiny">batch ETL&lt;/text>
&lt;rect x="640" y="50" width="100" height="40" class="tenant"/>&lt;text x="690" y="68" text-anchor="middle" class="sm">notebooks&lt;/text>&lt;text x="690" y="82" text-anchor="middle" class="tiny">research&lt;/text>
&lt;rect x="40" y="120" width="700" height="60" class="layer gw"/>
&lt;text x="390" y="144" text-anchor="middle" class="lbl">Layer 1 · AI Gateway&lt;/text>
&lt;text x="55" y="166" class="sm">Auth (OIDC/API keys) · Routing by model · Failover · Caching · Logging · OTel emission · Rate limiting&lt;/text>
&lt;rect x="40" y="195" width="700" height="60" class="layer pol"/>
&lt;text x="390" y="219" text-anchor="middle" class="lbl">Layer 2 · Policy &amp;amp; Quota Plane&lt;/text>
&lt;text x="55" y="241" class="sm">RPS/TPM quotas per tenant · Monthly budgets · Model whitelist · Priority classes · Admission control&lt;/text>
&lt;rect x="40" y="270" width="700" height="60" class="layer iso"/>
&lt;text x="390" y="294" text-anchor="middle" class="lbl">Layer 3 · Isolation Plane&lt;/text>
&lt;text x="55" y="316" class="sm">MIG / MPS / time-slicing · K8s namespaces · NetworkPolicies · ResourceQuotas · Priority + preemption&lt;/text>
&lt;rect x="40" y="345" width="700" height="55" class="layer obs"/>
&lt;text x="390" y="369" text-anchor="middle" class="lbl">Layer 4 · Observability Plane (multi-tenant)&lt;/text>
&lt;text x="55" y="391" class="sm">Traces with tenant_id · Labelled metrics · Cost attribution · Per-tenant dashboards · Audit logs&lt;/text>
&lt;path class="arr" d="M90,90 L390,120"/>
&lt;path class="arr" d="M450,90 L390,120"/>
&lt;path class="arr" d="M690,90 L390,120"/>
&lt;/svg>
&lt;/div>
&lt;p>Each layer solves a concrete problem. Let us take them one by one.&lt;/p>
&lt;h2 id="layer-1--ai-gateway-the-single-front-door">Layer 1 — AI Gateway: the single front door&lt;/h2>
&lt;p>The AI Gateway is the component your tenants see. It is an HTTP/gRPC API compatible with OpenAI (typically &lt;code>/v1/chat/completions&lt;/code>, &lt;code>/v1/embeddings&lt;/code>, &lt;code>/v1/models&lt;/code>) that centralises everything that happens before touching the inference backends.&lt;/p>
&lt;h3 id="why-centralise">Why centralise&lt;/h3>
&lt;p>Without a gateway, tenants connect directly to vLLM or whatever the model is. Every change (rotating an endpoint, adding a model, changing credentials, applying a policy) means notifying every tenant. Every tenant has its own retry logic, its own logging, its own auth model. It is unworkable from the third customer onwards.&lt;/p>
&lt;p>With a gateway, the change is made in one place. Tenants have a stable URL and a set of credentials; the rest is the gateway&amp;rsquo;s problem.&lt;/p>
&lt;h3 id="the-three-dominant-options-in-2026">The three dominant options in 2026&lt;/h3>
&lt;p>&lt;strong>&lt;a href="https://docs.litellm.ai/">LiteLLM&lt;/a>&lt;/strong> is the most popular OSS option, Python-first, deployed as a proxy. It supports 100+ providers (OpenAI, Anthropic, Bedrock, self-hosted vLLM, Ollama, and so on) behind a unified OpenAI-compatible API. It has a native multi-tenant hierarchy with Organizations → Teams → Users → API Keys, each level with its own budget. The Apache 2.0 version covers the basics; RBAC, SSO, audit logs and team-level enforcement require the paid Enterprise version. Deployed on K8s with the official Helm chart.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://portkey.ai/">Portkey&lt;/a>&lt;/strong> is the most mature commercial / SaaS option. A single control plane that enforces budgets, quotas, permissions and compliance. Real-time spending tracking with alerting. RBAC, audit, workspaces and SSO included. The trade-off: dependency on an external service and a per-request pricing model.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">Kong AI Gateway&lt;/a>&lt;/strong> is the option for organisations that already have Kong as their API gateway. An AI plug-in on top of the existing Kong gateway, integrated with its plugin, consumer and rate-limit model. If your platform team already runs Kong, it is the lowest friction.&lt;/p>
&lt;h3 id="when-to-choose-each-one">When to choose each one&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Situation&lt;/th>
&lt;th>Gateway&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pure OSS, self-hosted, Python-first team&lt;/td>
&lt;td>&lt;strong>LiteLLM&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You need RBAC, SSO, audit log out of the box, and have budget&lt;/td>
&lt;td>&lt;strong>Portkey&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>You already run Kong as a corporate API gateway&lt;/td>
&lt;td>&lt;strong>Kong AI Gateway&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Greenfield enterprise with strict compliance&lt;/td>
&lt;td>Portkey (probably)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mid-sized OSS-first company without regulated compliance&lt;/td>
&lt;td>LiteLLM (typically)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="the-minimum-the-gateway-has-to-do">The minimum the gateway has to do&lt;/h3>
&lt;p>Whichever option you pick, this is what any serious deployment must enforce:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Auth and identity&lt;/strong>: every request carries an API key resolvable to a tenant + user + team.&lt;/li>
&lt;li>&lt;strong>Routing by model&lt;/strong>: the tenant asks for &lt;code>model: &amp;quot;gpt-4o&amp;quot;&lt;/code>; the gateway decides whether it goes to OpenAI, to Azure OpenAI, or to your vLLM with Qwen3 32B (a cheaper fallback), according to policy.&lt;/li>
&lt;li>&lt;strong>Rate limiting&lt;/strong>: RPS per tenant, TPM (tokens per minute), concurrency limits.&lt;/li>
&lt;li>&lt;strong>Caching of identical responses&lt;/strong>: 5-30% of RAG queries are repeats; caching saves latency and cost.&lt;/li>
&lt;li>&lt;strong>OTel emission&lt;/strong>: every call produces a span with &lt;code>gen_ai.*&lt;/code> semantic conventions and &lt;code>tenant_id&lt;/code> as an attribute. Covered in the &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Failover&lt;/strong>: if vLLM goes down, the gateway redirects to the OpenAI API. If OpenAI rate-limits, the gateway falls back to Anthropic. Configurable policy.&lt;/li>
&lt;/ul>
&lt;h3 id="example-of-a-multi-tenant-litellm-configuration">Example of a multi-tenant LiteLLM configuration&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"># litellm-config.yaml — simplified 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">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-3-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">openai/llama-3-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">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-llama3-70b.inference/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>&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">qwen3-32b&lt;/span>&lt;span class="w">
&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">openai/qwen3-32b&lt;/span>&lt;span class="w">
&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-qwen3-32b.inference/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>&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">gpt-4o&lt;/span>&lt;span class="w">
&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">openai/gpt-4o&lt;/span>&lt;span class="w">
&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/OPENAI_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>&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">usage-based-routing-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">fallbacks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&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-3-70b&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">qwen3-32b, gpt-4o] &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if vLLM goes down, fall back to the external 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>&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">master_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/LITELLM_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="nt">database_url&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">os.environ/DATABASE_URL &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># Postgres for budgets/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>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Hierarchy: Organizations → Teams → Users → API 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="c"># Created via the API, not in static YAML&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Creating a team with a budget:&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 http://litellm/team/new &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="si">${&lt;/span>&lt;span class="nv">LITELLM_MASTER_KEY&lt;/span>&lt;span class="si">}&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_alias&amp;#34;: &amp;#34;soporte-chat&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;: 500, # 500 USD/month
&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;: 100000, # 100K tokens/min
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;rpm_limit&amp;#34;: 1000, # 1000 requests/min
&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-3-70b&amp;#34;, &amp;#34;qwen3-32b&amp;#34;] # access to these
&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>And the team&amp;rsquo;s API 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 http://litellm/key/generate &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="si">${&lt;/span>&lt;span class="nv">LITELLM_MASTER_KEY&lt;/span>&lt;span class="si">}&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;&amp;lt;team-id&amp;gt;&amp;#34;,
&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;30d&amp;#34;,
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;metadata&amp;#34;: {&amp;#34;environment&amp;#34;: &amp;#34;production&amp;#34;, &amp;#34;app&amp;#34;: &amp;#34;support-bot&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>That API key is what the tenant uses. Every request made with it consumes from the team&amp;rsquo;s budget. When it runs out, LiteLLM returns HTTP 429 with a description.&lt;/p>
&lt;h2 id="layer-2--policy--quota-plane-what-each-tenant-can-do">Layer 2 — Policy &amp;amp; Quota Plane: what each tenant can do&lt;/h2>
&lt;p>The gateway is where enforcement happens. Policy is what gets enforced. Five axes of multi-tenant policy:&lt;/p>
&lt;h3 id="technical-quotas">Technical quotas&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>TPM&lt;/strong> (tokens per minute): the hard consumption limit. For a Llama 3 70B at TP=5, ~3000 sustained output tokens/s = 180K aggregate TPM. If you have 10 tenants, assign 18K each as the ceiling.&lt;/li>
&lt;li>&lt;strong>RPS / RPM&lt;/strong>: load control, not consumption control. A 4K-token session counts as one request; so does a batch of 100 mini-completions. Useful against abuse.&lt;/li>
&lt;li>&lt;strong>Concurrency&lt;/strong>: how many simultaneous active requests per tenant. Important for latency SLAs: 100 RPS with concurrency=50 works out at 2 seconds per request.&lt;/li>
&lt;/ul>
&lt;h3 id="economic-budgets">Economic budgets&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Monthly per tenant&lt;/strong>: a hard cap in USD.&lt;/li>
&lt;li>&lt;strong>Daily and hourly&lt;/strong>: soft caps to avoid a runaway in a single day.&lt;/li>
&lt;li>&lt;strong>Per project / API key&lt;/strong>: fine granularity within a single tenant.&lt;/li>
&lt;/ul>
&lt;p>LiteLLM has a &lt;code>max_budget&lt;/code> field at every level of the hierarchy (organization, team, user, api key). Budgets are inherited and constrained downwards.&lt;/p>
&lt;h3 id="model-whitelist-and-blacklist">Model whitelist and blacklist&lt;/h3>
&lt;p>Tenants with critical workloads → only stable models (&lt;code>llama-3-70b&lt;/code>, &lt;code>gpt-4o&lt;/code>). Research tenants → access to experimental models as well.&lt;/p>
&lt;h3 id="priority-classes">Priority classes&lt;/h3>
&lt;p>Not all requests are equal. Three typical classes:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Guaranteed&lt;/strong>: workloads with an SLA, latency respected even under pressure.&lt;/li>
&lt;li>&lt;strong>Best-effort&lt;/strong>: normal workloads without a strict SLA.&lt;/li>
&lt;li>&lt;strong>Spot&lt;/strong>: batches that can wait, evictable if a guaranteed one arrives.&lt;/li>
&lt;/ul>
&lt;p>The &lt;a href="https://arxiv.org/abs/2603.00356">Token Management in Multi-Tenant AI Inference Platforms&lt;/a> paper (2026) formalises this with a model of token pools per priority class that has started to be adopted in production. It maintains guaranteed P99 latency for guaranteed workloads even under overload, with selective throttling of spot.&lt;/p>
&lt;h3 id="admission-control">Admission control&lt;/h3>
&lt;p>Before accepting a request: is there capacity? If not, return 429 immediately instead of queueing and degrading everyone. It is the most underrated operational discipline. A cluster with admission control done properly has predictable latency; without it, catastrophic degradation when the peak arrives.&lt;/p>
&lt;h3 id="the-typical-pattern-in-2026">The typical pattern in 2026&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"># Conceptual policy for a &amp;#34;soporte-chat&amp;#34; tenant&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">tenant&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">soporte-chat&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">quotas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tpm&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">50000&lt;/span>&lt;span class="w">
&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">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">max_concurrency&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">budget&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">monthly_usd&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">800&lt;/span>&lt;span class="w">
&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_thresholds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="m">0.5&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.8&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.95&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warn when you get there&lt;/span>&lt;span class="w">
&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_allowed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="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">llama-3-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="l">qwen3-32b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">priority&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">guaranteed&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">fallback_on_overload&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="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">qwen3-32b &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># if guaranteed fills up, fall back&lt;/span>&lt;span class="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">gpt-4o-mini &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># last resort, external model&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="layer-3--isolation-plane-isolating-workloads-physically">Layer 3 — Isolation Plane: isolating workloads physically&lt;/h2>
&lt;p>This is the technically densest layer. You have a node with several H100 SXM GPUs interconnected by NVLink. How do you partition them between tenants?&lt;/p>
&lt;h3 id="three-nvidia-mechanisms-for-sharing-a-gpu">Three NVIDIA mechanisms for sharing a GPU&lt;/h3>
&lt;p>&lt;strong>MIG (Multi-Instance GPU)&lt;/strong> is the strongest isolation. It partitions the GPU into up to 7 instances with physically separate HBM memory and dedicated compute units (SMs). Tenants in different MIG instances cannot touch each other: one workload does not consume memory another needs, one does not degrade another&amp;rsquo;s throughput. Hardware isolation. Available on A100, H100, B100 and B200.&lt;/p>
&lt;p>&lt;strong>MPS (Multi-Process Service)&lt;/strong> is soft. Several processes share the GPU concurrently and NVIDIA distributes SMs according to usage. Good performance if all the processes are yours and you trust them. Worse for multi-tenant setups between customers who do not know each other, because a noisy process can degrade the others.&lt;/p>
&lt;p>&lt;strong>Time-slicing&lt;/strong> is the simplest: the GPU is assigned alternately, slot by slot, to different processes. Much worse latency (waits between slots); not recommended for production workloads with an SLA.&lt;/p>
&lt;h3 id="the-choice-for-multi-tenant-in-2026">The choice for multi-tenant in 2026&lt;/h3>
&lt;p>According to the enterprise adoption survey: 80% use MIG for untrusted multi-tenant (different customers who do not know each other) and MPS for trusted environments (processes from the same team) where you want to maximise throughput. Time-slicing is only used in dev/staging so that every developer can touch a GPU without the cost of exclusivity.&lt;/p>
&lt;p>An important limitation of MIG: it isolates compute and HBM memory, but the PCIe path remains shared. For PCIe-bound workloads (a lot of host↔device traffic), tenants in different MIG instances can still affect each other. For LLM inference the main path is HBM, so this is rarely a problem. But it is worth knowing.&lt;/p>
&lt;h3 id="mig-partitions-on-the-h100">MIG partitions on the H100&lt;/h3>
&lt;p>An H100 (80GB HBM3) can be partitioned into fixed profiles:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Profile&lt;/th>
&lt;th>SM&lt;/th>
&lt;th>Memory&lt;/th>
&lt;th>Max instances per GPU&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1g.10gb&lt;/td>
&lt;td>14&lt;/td>
&lt;td>10 GB&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1g.20gb&lt;/td>
&lt;td>14&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2g.20gb&lt;/td>
&lt;td>28&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3g.40gb&lt;/td>
&lt;td>42&lt;/td>
&lt;td>40 GB&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7g.80gb&lt;/td>
&lt;td>98&lt;/td>
&lt;td>80 GB&lt;/td>
&lt;td>1 (the whole GPU)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For a mid-scale cluster with NVLink, MIG has a fundamental problem: when you partition with MIG, NVLink between GPUs is disabled. An H100 in MIG mode does &lt;strong>not&lt;/strong> take part in multi-GPU tensor parallel. If you are going to serve a large model with tensor parallel (Llama 3 70B with TP=4 or TP=8, for instance), those GPUs have to be whole, without MIG.&lt;/p>
&lt;p>That defines the architectural decision. There are two main approaches:&lt;/p>
&lt;h3 id="approach-a--a-shared-large-model-with-quotas-at-the-gateway">Approach A — A shared large model with quotas at the gateway&lt;/h3>
&lt;p>All the node&amp;rsquo;s GPUs serve a single large model with tensor parallel spanning the whole node. All tenants share that instance. Isolation happens at the gateway layer (quotas, rate limiting) and the policy layer (priority classes). The cluster&amp;rsquo;s kernel is one enormous vLLM instance with &lt;code>--max-num-seqs=128&lt;/code> or similar; internally, vLLM shares GPU time between the active requests with continuous batching.&lt;/p>
&lt;p>&lt;strong>Advantages&lt;/strong>: you use all the GPUs to the full, NVLink stays active, better KV cache utilisation.
&lt;strong>Disadvantages&lt;/strong>: soft isolation. A tenant that saturates does not degrade the others directly (vLLM batches), but it does compete for batch slots. You need serious priority classes.&lt;/p>
&lt;h3 id="approach-b--dedicating-gpus-per-model--tenant">Approach B — Dedicating GPUs per model / tenant&lt;/h3>
&lt;p>You split the GPUs into pools dedicated to different models. Examples on an 8-GPU node:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>4 GPUs&lt;/strong>: a large 70B model with TP=4.&lt;/li>
&lt;li>&lt;strong>2 GPUs&lt;/strong>: a mid-sized 32B model replicated (2 independent instances) for tenants with a strict SLA.&lt;/li>
&lt;li>&lt;strong>2 GPUs&lt;/strong>: miscellaneous workloads (smaller models, experimentation).&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Advantages&lt;/strong>: physical isolation between critical models / tenants.
&lt;strong>Disadvantages&lt;/strong>: worse aggregate utilisation; some GPUs idle while others saturate.&lt;/p>
&lt;h3 id="approach-c-advanced--mig-on-some-gpus-plus-dedication-for-the-rest">Approach C (advanced) — MIG on some GPUs plus dedication for the rest&lt;/h3>
&lt;p>If you have small workloads (4B, 7B models), you can use MIG on 1-2 GPUs to serve them and dedicate the rest to tensor parallel for the large model. It combines strong isolation for the small workloads with full use of NVLink for the large model.&lt;/p>
&lt;h3 id="the-operational-choice-start-with-a-move-up-to-c-if-you-need-to">The operational choice: start with A, move up to C if you need to&lt;/h3>
&lt;p>In most deployments, Approach A (a shared large model plus quotas) is the right starting point. Utilisation is better, operation is simpler, and the gateway&amp;rsquo;s soft isolation works for reasonable workloads.&lt;/p>
&lt;p>When there is a tenant with a strict SLA that cannot tolerate competing with others, you move to Approach B for that tenant in particular (dedicating GPUs to an instance of the model just for them), keeping the rest of the cluster shared.&lt;/p>
&lt;p>Approach C is for when you have 10+ tenants with very heterogeneous profiles.&lt;/p>
&lt;h3 id="isolation-at-the-kubernetes-level">Isolation at the Kubernetes level&lt;/h3>
&lt;p>Independently of GPU isolation, pod-level isolation is applied in K8s:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Namespaces per tenant&lt;/strong>: &lt;code>tenant-soporte&lt;/code>, &lt;code>tenant-legal&lt;/code>, and so on.&lt;/li>
&lt;li>&lt;strong>ResourceQuotas and LimitRanges&lt;/strong>: CPU/memory limits per namespace.&lt;/li>
&lt;li>&lt;strong>NetworkPolicies&lt;/strong>: tenant A cannot talk to tenant B&amp;rsquo;s namespaces.&lt;/li>
&lt;li>&lt;strong>K8s PriorityClasses&lt;/strong>: classes with a numeric value defining preemption order if a more critical pod arrives.&lt;/li>
&lt;li>&lt;strong>PodDisruptionBudgets&lt;/strong>: how many pods of each deployment can go down simultaneously.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-4--observability-plane-seeing-what-happens-per-tenant">Layer 4 — Observability Plane: seeing what happens per tenant&lt;/h2>
&lt;p>The fourth layer: observability with a tenant dimension. Without it you cannot do cost attribution, you cannot debug incidents for a single tenant, you cannot show dashboards to stakeholders.&lt;/p>
&lt;h3 id="the-four-mandatory-properties">The four mandatory properties&lt;/h3>
&lt;p>&lt;strong>1. tenant_id on every span&lt;/strong>. The AI gateway resolves the API key and attributes a &lt;code>tenant_id&lt;/code>. That ID is propagated via &lt;code>params._meta&lt;/code> or OTel headers to every downstream component (vLLM, retrieval, MCP servers, tools). Any span in any system carries that label. It is what lets you reconstruct tenant-specific traces.&lt;/p>
&lt;p>&lt;strong>2. Metrics labelled per tenant&lt;/strong>. &lt;code>gen_ai.usage.input_tokens{tenant=&amp;quot;soporte-chat&amp;quot;}&lt;/code> or equivalents. Prometheus, Grafana, groupable by tenant.&lt;/p>
&lt;p>&lt;strong>3. Real cost attribution&lt;/strong>. The sum of tokens × cost/token per tenant gives the cost. For self-hosted vLLM, the cost is per GPU hour plus the proportional share of tokens (you can compute an equivalent cost per 1k tokens).&lt;/p>
&lt;p>&lt;strong>4. An immutable audit log&lt;/strong>. Every API key used, every model invoked, every quota change, every budget exceeded. For compliance.&lt;/p>
&lt;h3 id="showback-vs-chargeback">Showback vs chargeback&lt;/h3>
&lt;p>An important FinOps distinction that gained clarity in 2026:&lt;/p>
&lt;p>&lt;strong>Showback&lt;/strong>: visibility without consequence. &amp;ldquo;Support team, you have consumed 623 USD this month on LLMs&amp;rdquo;. Information, not an invoice. It lets you spot abuse without penalising anyone before the team understands what is going on.&lt;/p>
&lt;p>&lt;strong>Chargeback&lt;/strong>: the cost is charged to the team&amp;rsquo;s budget. When it runs out, it runs out. It changes behaviour.&lt;/p>
&lt;p>The practice that works: 6-18 months on showback while tags are calibrated, misattributions are identified and teams are trained. Then chargeback, once the numbers are credible. Launching chargeback on day 1 when the costs are still dirty creates an immediate political fight; launching showback prepares the ground so that chargeback lands in an orderly way.&lt;/p>
&lt;p>&lt;a href="https://spendark.com/blog/kubernetes-cost-allocation/">Only 14% of organisations have chargeback active&lt;/a> according to a recent survey, which indicates this is still mostly showback in real production.&lt;/p>
&lt;h3 id="tools">Tools&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://www.kubecost.com/">Kubecost&lt;/a>&lt;/strong>: cost allocation per namespace, deployment and pod on Kubernetes. For the cost of the shared GPU, allocate proportionally to the tokens consumed per tenant.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.finout.io/">Finout&lt;/a>&lt;/strong>: a FinOps platform that combines cloud bills and LLM API costs in a unified view with virtual tagging.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://langfuse.com/">Langfuse&lt;/a>&lt;/strong>: already covered. Cost tracking per trace, groupable by user or session metadata.&lt;/li>
&lt;li>&lt;strong>LiteLLM native tracking&lt;/strong>: LiteLLM&amp;rsquo;s master DB keeps running spend per team, user and API key, accessible via the API or the UI.&lt;/li>
&lt;/ul>
&lt;h3 id="the-minimum-multi-tenant-dashboard">The minimum multi-tenant dashboard&lt;/h3>
&lt;p>Any platform should have:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Summary per tenant&lt;/strong>: monthly spend, current RPS, TPM consumed, % of budget spent, active sessions.&lt;/li>
&lt;li>&lt;strong>Top users&lt;/strong> within each tenant (for detecting internal abuse).&lt;/li>
&lt;li>&lt;strong>p95 latency per tenant&lt;/strong>: SLA tracking.&lt;/li>
&lt;li>&lt;strong>429 / 503 errors&lt;/strong>: how many requests are being rate-limited or rejected because of overload.&lt;/li>
&lt;li>&lt;strong>Cost trend&lt;/strong>: the monthly trajectory with a projection.&lt;/li>
&lt;li>&lt;strong>Drift per tenant&lt;/strong> (from the post-tracing series): if a tenant starts getting worse results, raise an alert.&lt;/li>
&lt;/ol>
&lt;h2 id="sizing-on-mid-scale-gpu-clusters-concrete-decisions">Sizing on mid-scale GPU clusters: concrete decisions&lt;/h2>
&lt;p>Let us get down to hardware. As a reference we take a node with N H100 SXM (between 4 and 8) with NVLink/NVSwitch, 80 GB HBM3 each. That gives between 320 GB and 640 GB of aggregate VRAM. Inter-GPU connectivity is 900 GB/s (NVLink 4) or 600 GB/s (NVLink 3) depending on generation. HBM bandwidth per GPU is 3.35 TB/s.&lt;/p>
&lt;h3 id="default-decisions">Default decisions&lt;/h3>
&lt;p>Start with Approach A: all the node&amp;rsquo;s GPUs serving a single large 70B model in BF16 with tensor parallel = N. Expected real capacity (computed for a standard 8-GPU HGX node as an example; it scales roughly linearly with N):&lt;/p>
&lt;ul>
&lt;li>Model VRAM (70B BF16): ~140 GB (≈ 17.5 GB/GPU at TP=8).&lt;/li>
&lt;li>vLLM overhead + activations VRAM: ~10 GB/GPU.&lt;/li>
&lt;li>VRAM free for KV cache: ~52 GB/GPU. On an 8-GPU node that is &lt;strong>~416 GB aggregate&lt;/strong>; on a 4-GPU one, ~210 GB.&lt;/li>
&lt;li>With &lt;code>--kv-cache-dtype=fp8&lt;/code> and a 70B GQA model: ~320 KB/token.&lt;/li>
&lt;li>Aggregate cache capacity (8-GPU node): &lt;strong>~1.3M tokens&lt;/strong> to distribute between simultaneous sessions.&lt;/li>
&lt;/ul>
&lt;p>That translates into throughput and concurrency (indicative figures for an 8-GPU node):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Simultaneous sessions&lt;/th>
&lt;th style="text-align:right">Average context per session&lt;/th>
&lt;th style="text-align:right">Aggregate throughput (tokens/s)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">16K&lt;/td>
&lt;td style="text-align:right">~5000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">64&lt;/td>
&lt;td style="text-align:right">8K&lt;/td>
&lt;td style="text-align:right">~8000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">4K&lt;/td>
&lt;td style="text-align:right">~12000&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Typical latencies: TTFT ~150ms at low traffic, TPOT ~15-20 ms/tok. Under high concurrency, TTFT rises to ~500ms if the queue is saturated.&lt;/p>
&lt;h3 id="an-example-tenant-scheme">An example tenant scheme&lt;/h3>
&lt;p>A cluster with 4 tenants and a research pool:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tenant&lt;/th>
&lt;th style="text-align:right">TPM cap&lt;/th>
&lt;th style="text-align:right">RPM cap&lt;/th>
&lt;th style="text-align:right">Concurrency&lt;/th>
&lt;th style="text-align:right">Budget&lt;/th>
&lt;th>Priority&lt;/th>
&lt;th>Models&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Support chat&lt;/td>
&lt;td style="text-align:right">80K&lt;/td>
&lt;td style="text-align:right">800&lt;/td>
&lt;td style="text-align:right">50&lt;/td>
&lt;td style="text-align:right">1500 USD/month&lt;/td>
&lt;td>Guaranteed&lt;/td>
&lt;td>llama-3-70b, qwen3-32b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Legal RAG&lt;/td>
&lt;td style="text-align:right">30K&lt;/td>
&lt;td style="text-align:right">200&lt;/td>
&lt;td style="text-align:right">15&lt;/td>
&lt;td style="text-align:right">600 USD/month&lt;/td>
&lt;td>Guaranteed&lt;/td>
&lt;td>llama-3-70b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code agent&lt;/td>
&lt;td style="text-align:right">50K&lt;/td>
&lt;td style="text-align:right">300&lt;/td>
&lt;td style="text-align:right">25&lt;/td>
&lt;td style="text-align:right">1200 USD/month&lt;/td>
&lt;td>Best-effort&lt;/td>
&lt;td>llama-3-70b, qwen-coder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Data extr. batch&lt;/td>
&lt;td style="text-align:right">40K&lt;/td>
&lt;td style="text-align:right">1000&lt;/td>
&lt;td style="text-align:right">40&lt;/td>
&lt;td style="text-align:right">400 USD/month&lt;/td>
&lt;td>Spot&lt;/td>
&lt;td>llama-3-70b, qwen3-32b&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Research / notebooks&lt;/td>
&lt;td style="text-align:right">10K&lt;/td>
&lt;td style="text-align:right">100&lt;/td>
&lt;td style="text-align:right">5&lt;/td>
&lt;td style="text-align:right">200 USD/month&lt;/td>
&lt;td>Spot&lt;/td>
&lt;td>all&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>TPM total: 210K. Aggregate cluster capacity: ~180K sustained TPM. It is overcommitted by about 15%, assuming not every tenant hits the ceiling at the same time. That is normal and desirable; if they all do it at once, the priority classes degrade in an orderly way.&lt;/p>
&lt;h3 id="when-to-add-hardware">When to add hardware&lt;/h3>
&lt;p>Signals that the node has become too small:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Sustained p95 TTFT &amp;gt; 500 ms&lt;/strong> during peak hours → the queue is building up.&lt;/li>
&lt;li>&lt;strong>&lt;code>vllm:num_requests_waiting&lt;/code> constantly &amp;gt; 20&lt;/strong> → admission control starting to reject.&lt;/li>
&lt;li>&lt;strong>Sustained GPU utilisation &amp;gt; 80% at critical hours&lt;/strong> without dropping in quiet hours → there is no headroom.&lt;/li>
&lt;li>&lt;strong>429 rate on guaranteed tenants &amp;gt; 1%&lt;/strong> → the platform is breaking its SLA in production.&lt;/li>
&lt;/ul>
&lt;p>When several of these hold, the natural next step is to add another HGX node with internal NVLink and stand up a second vLLM instance of the same model. The gateway load-balances between the two instances. Aggregate throughput doubles; latency stays the same.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;h3 id="a-gateway-without-auth-a-backdoor-into-the-cluster">A gateway without auth: a backdoor into the cluster&lt;/h3>
&lt;p>Your vLLM sits on a ClusterIP Service and the main app talks to it. Some tenant discovers the endpoint directly and hits it without going through the gateway. Quotas and costs are silently evaded. Use a strict NetworkPolicy: only the gateway can talk to the vLLM Services; the rest of the cluster cannot.&lt;/p>
&lt;h3 id="mig-and-nvlink-are-incompatible">MIG and NVLink are incompatible&lt;/h3>
&lt;p>You enable MIG on a GPU thinking you will get isolation plus multi-GPU; you discover that MIG disables NVLink. Any large model with TP becomes unusable. Decide MIG vs NVLink globally per cluster, not per individual GPU.&lt;/p>
&lt;h3 id="quotas-pinned-to-the-clusters-ceiling">Quotas pinned to the cluster&amp;rsquo;s ceiling&lt;/h3>
&lt;p>You add up the TPM of all the tenants and it comes to exactly the cluster&amp;rsquo;s capacity. When two tenants peak at the same time, both wait or one gets rejected. An overcommit of 10-20% is healthy (it assumes they do not all peak at once); more than that is dangerous.&lt;/p>
&lt;h3 id="no-multi-tenant-observability-from-day-1">No multi-tenant observability from day 1&lt;/h3>
&lt;p>You launch with quotas and isolation but without tenant_id on spans. Three months later, your CFO asks &amp;ldquo;how much does the support agent cost versus the legal one?&amp;rdquo; and you cannot answer. OTel with a mandatory tenant_id from the first version, even if there are no dashboards yet; having the data is worth more than having perfect dashboards with no data.&lt;/p>
&lt;h3 id="showback-that-never-reaches-chargeback">Showback that never reaches chargeback&lt;/h3>
&lt;p>You have been on showback for 18 months, the teams know the numbers, nobody changes behaviour. Without the pressure of real chargeback, the incentive dilutes. Set an explicit calendar for the transition to chargeback, with an owner and a deadline.&lt;/p>
&lt;h3 id="non-whitelisted-models-eating-the-budget">Non-whitelisted models eating the budget&lt;/h3>
&lt;p>A team discovers that LiteLLM has &lt;code>gpt-4o&lt;/code> configured. They use it without permission. The budget burns on an external API when the idea was to use the cheap self-hosted one. Keep an explicit per-team whitelist of accessible models.&lt;/p>
&lt;h3 id="badly-calibrated-priority-classes">Badly calibrated priority classes&lt;/h3>
&lt;p>Everyone declares themselves &amp;ldquo;guaranteed&amp;rdquo;. At the first peak, there is nothing left to degrade and everything suffers. Priority classes only for critical cases, with justification. The majority should be best-effort.&lt;/p>
&lt;h3 id="no-failover-from-the-gateway">No failover from the gateway&lt;/h3>
&lt;p>Your vLLM goes down. The gateway has no fallback configured and returns 503 to every tenant. Configure a fallback to another model, ideally an external one (OpenAI) for guaranteed workloads, even if it costs more per hour. Availability is worth more than cost per hour.&lt;/p>
&lt;h2 id="an-operational-roadmap-for-getting-started">An operational roadmap for getting started&lt;/h2>
&lt;p>If you start from scratch with an empty GPU node, the minimum order is as follows. Each milestone is a day&amp;rsquo;s work with slack, not a tight one:&lt;/p>
&lt;p>&lt;strong>Day 1-2 — Base K8s infrastructure&lt;/strong>. NVIDIA GPU Operator + nvidia-device-plugin + dcgm-exporter + cluster-default NetworkPolicies. Validation: a basic pod with &lt;code>nvidia.com/gpu: 1&lt;/code> gets scheduled.&lt;/p>
&lt;p>&lt;strong>Day 3 — vLLM with a large model and tensor parallel across the whole node&lt;/strong>. The vLLM Production Stack Helm chart (or bare vLLM manifests). Model weights on a shared PVC (CephFS or NFS). Validation: a &lt;code>curl&lt;/code> request against the internal Service responds.&lt;/p>
&lt;p>&lt;strong>Day 4 — AI Gateway: LiteLLM&lt;/strong>. Helm chart, Postgres for budgets, master key, a first model_list pointing at vLLM. Validation: an OpenAI-compatible request through LiteLLM responds with the same content as vLLM directly.&lt;/p>
&lt;p>&lt;strong>Day 5 — Basic multi-tenancy&lt;/strong>. Create teams, API keys, budgets, model whitelists. Test with two teams. Validation: the second team, using the model it does not have whitelisted, gets a 403.&lt;/p>
&lt;p>&lt;strong>Day 6 — Minimum observability&lt;/strong>. Prometheus + Grafana scraping vLLM and LiteLLM. A dashboard with TTFT, TPOT, throughput, num_requests_waiting and budget_consumed_per_team. Validation: visible in Grafana with real data.&lt;/p>
&lt;p>&lt;strong>Day 7-8 — Pilot customer&lt;/strong>. One real tenant (ideally a controlled internal one) starts using it. Measure real latencies, discover the first operational incidents.&lt;/p>
&lt;p>&lt;strong>Day 9-10 — Tuning&lt;/strong>. Adjust &lt;code>--max-num-seqs&lt;/code>, &lt;code>--gpu-memory-utilization&lt;/code>, priority classes and quotas based on what the pilot taught you.&lt;/p>
&lt;p>&lt;strong>Day 11-14 — Onboarding the second tenant plus iteration&lt;/strong>. Repeat. Every new tenant onboarded reveals new cases.&lt;/p>
&lt;p>After two weeks you have an operational platform with two real tenants and data to decide whether it is ready for more. The line of progress from here on is horizontal (more tenants) until you saturate; from then on, vertical (more hardware).&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>Continuous fine-tuning in production&lt;/strong> (post 6, already decided): LoRA/QLoRA/DPO, dataset curation, eval gates, A/B versioning with real traffic between model versions.&lt;/li>
&lt;li>&lt;strong>Constitutional AI and alignment at runtime&lt;/strong>: an option still on the table.&lt;/li>
&lt;li>&lt;strong>Edge LLMs&lt;/strong>: when an H100 cluster is too expensive for a specific workload, distilled models running on NPUs or consumer GPUs.&lt;/li>
&lt;li>&lt;strong>GPU networking deep dive&lt;/strong>: NCCL, InfiniBand, GPUDirect, RDMA. For multi-node clusters with cross-host tensor parallel.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Multi-tenancy and GPU isolation:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.linuxoperatingsystem.net/multitenant-gpu-infrastructure-4-powerful-design-rules/">Multitenant GPU Infrastructure: 4 Powerful Design Rules&lt;/a> — survey of enterprise patterns.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/run-multiple-llms-one-gpu-mig-time-slicing-guide/">Run Multiple LLMs on One GPU: MIG, Time-Slicing, and MPS Guide (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://sagar-parmar.medium.com/a-practical-guide-to-gpu-partitioning-with-mig-on-on-prem-servers-and-kubernetes-797ccea7e1c7">A Practical Guide to GPU Partitioning with MIG (Medium)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.suse.com/c/kubecon-eu-2026-nvidia-mig-suse-virtualization/">GPU Partitioning for AI Workloads: NVIDIA MIG with SUSE Virtualization (KubeCon EU 2026)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2508.20274">Predictable LLM Serving on GPU Clusters (arxiv 2508.20274)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2603.00356">Token Management in Multi-Tenant AI Inference Platforms (arxiv 2603.00356)&lt;/a> — paper on priority + admission control.&lt;/li>
&lt;/ul>
&lt;p>AI Gateways:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/multi_tenant_architecture">LiteLLM — Multi-Tenant Architecture&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://docs.litellm.ai/docs/proxy/users">LiteLLM — Budgets and Rate Limits&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://portkey.ai/">Portkey AI Gateway&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">Kong AI Gateway — LLM Cost Management&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.spheron.network/blog/ai-gateway-litellm-portkey-kong-gpu-cloud/">AI Gateway Setup 2026: LiteLLM, Portkey, Kong (Spheron)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://techsy.io/en/blog/best-llm-gateway-tools">Stop Juggling LLM APIs: 8 Gateways Ranked 2026 (TECHSY)&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Multi-tenant FinOps:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://www.digiusher.com/blog/the-death-of-cost-allocation-why-chargeback-models-are-failing-in-the-kubernetes-and-ai-era/">The Death of Chargeback in the Kubernetes and AI Era (DigiUsher)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://medium.com/@nicholasthoni/how-to-actually-track-kubernetes-costs-in-2026-a-practical-guide-to-showback-chargeback-and-the-6a4c23f9cf51">How to Actually Track Kubernetes Costs in 2026 (Medium)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://konghq.com/blog/enterprise/llm-cost-management-ai-showback-and-chargeback">LLM Cost Management: AI Showback and Chargeback (Kong)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.kubecost.com/">Kubecost — cost allocation&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://www.finout.io/">Finout — FinOps + AI costs&lt;/a>.&lt;/li>
&lt;/ul>
&lt;p>Cross-references:&lt;/p>
&lt;ul>
&lt;li>Previous posts in series 4: &lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps for LLMs overview&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage pipeline&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant&lt;/a>.&lt;/li>
&lt;li>Relevant posts from the inference series: &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a> — the multi-GPU HGX node scenario we develop here. &lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM operators on K8s&lt;/a> — the vLLM Production Stack and OME that the gateway can direct.&lt;/li>
&lt;li>Observability: &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP observability&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">eBPF + drift&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>